@justprove/mobilevc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +342 -0
- package/bin/mobilevc.js +827 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) MobileVC contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
---
|
|
2
|
+
|
|
3
|
+
# 📱 MobileVC — 手机就是你的 Claude Code 控制台
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="mobile_vc/lib/logo-2.png" alt="MobileVC logo" width="220" />
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<strong>摆脱键盘和鼠标的束缚,用手机随时接管电脑上的 Claude Code。</strong>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<em>MobileVC 把 Claude Code 的等待、审批、审核和继续执行,变成一套专为移动端设计的操作闭环。</em>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img src="https://img.shields.io/badge/Go-1.21-blue" />
|
|
19
|
+
<img src="https://img.shields.io/badge/Flutter-3.13-blue" />
|
|
20
|
+
<img src="https://img.shields.io/badge/License-MIT-green" />
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 这是什么
|
|
26
|
+
|
|
27
|
+
MobileVC 不是桌面终端的镜像,也不是远程桌面的替代品。
|
|
28
|
+
|
|
29
|
+
它做的是一件更直接的事:**把电脑上的 Claude Code 变成可以被手机完整操控的工作台。**
|
|
30
|
+
|
|
31
|
+
你不在电脑前,也能继续把任务推进下去:
|
|
32
|
+
|
|
33
|
+
- 继续跟 Claude 对话
|
|
34
|
+
- 批准或拒绝权限请求
|
|
35
|
+
- 接住 Plan Mode 的多轮计划交互
|
|
36
|
+
- 审核 diff、接受或回滚修改
|
|
37
|
+
- 浏览文件、日志和运行状态
|
|
38
|
+
- 恢复历史会话
|
|
39
|
+
- 管理 Skill / Memory / Session Context
|
|
40
|
+
- 在 Claude 需要你时收到提醒
|
|
41
|
+
|
|
42
|
+
MobileVC 解决的不是“怎么远程看见电脑”,而是:
|
|
43
|
+
|
|
44
|
+
> **怎么让你只靠手机,就能完成电脑上的几乎全部 Claude Code 工作流。**
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 核心价值
|
|
49
|
+
|
|
50
|
+
### 1. 为手机重写 Claude Code 的交互
|
|
51
|
+
|
|
52
|
+
手机上的操作不该依赖键盘盲输。MobileVC 把 Claude Code 的关键等待态拆出来,做成更适合触摸屏的一键动作和可视化面板:
|
|
53
|
+
|
|
54
|
+
- 普通输入
|
|
55
|
+
- 权限确认
|
|
56
|
+
- Plan Mode 继续/推进
|
|
57
|
+
- 代码审查
|
|
58
|
+
- 会话恢复
|
|
59
|
+
- 技能 / 记忆管理
|
|
60
|
+
|
|
61
|
+
### 2. 让你离开电脑也不掉线
|
|
62
|
+
|
|
63
|
+
你不需要守在桌面前,也不需要回到键盘旁。
|
|
64
|
+
|
|
65
|
+
- 出门在外也能继续推进任务
|
|
66
|
+
- 电脑不在身边也能批准修改
|
|
67
|
+
- Claude 正在等待你时,手机能立即介入
|
|
68
|
+
- 复杂工作流不会因为离开键盘而中断
|
|
69
|
+
|
|
70
|
+
### 3. 把工作流做成“点一下就能继续”的手机体验
|
|
71
|
+
|
|
72
|
+
MobileVC 不是为了展示“能远程看见什么”,而是为了让你真正把事做完,而且做得更快、更直观:
|
|
73
|
+
|
|
74
|
+
- 看得见:skill 胶囊、memory 卡片、diff 组、日志和运行状态一目了然
|
|
75
|
+
- 点得快:启用 / 停用、允许 / 拒绝、接受 / 回滚都能直接点选
|
|
76
|
+
- 一键化:一键同步 skill / memory,一句话生成 skill,一句话修改 memory
|
|
77
|
+
- 自动化:Claude 生成结果可自动回写 catalog,并刷新管理面板
|
|
78
|
+
- 可视化:当前会话启用项、同步状态、最近同步时间都清楚展示
|
|
79
|
+
|
|
80
|
+
这是一套为手机设计的 Claude Code 控制台。
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 主要功能
|
|
84
|
+
|
|
85
|
+
### 1. 手机直接接管 Claude Code
|
|
86
|
+
|
|
87
|
+
- 在手机上连接本机 Claude Code 会话
|
|
88
|
+
- 继续当前任务,而不是重新开始
|
|
89
|
+
- 支持创建、切换、加载、删除会话
|
|
90
|
+
|
|
91
|
+
### 2. 权限确认与 Plan Mode
|
|
92
|
+
|
|
93
|
+
- 支持权限请求的允许 / 拒绝
|
|
94
|
+
- 支持 Claude 进入 Plan Mode 后的多轮计划交互
|
|
95
|
+
- 计划、权限、普通输入分流处理
|
|
96
|
+
- 移动端用按钮推进流程,不再依赖 CLI 盲输
|
|
97
|
+
|
|
98
|
+
### 3. 多文件 Diff 审核
|
|
99
|
+
|
|
100
|
+
- 按修改组查看待审内容
|
|
101
|
+
- 在同一组内切换多个文件
|
|
102
|
+
- 查看文件内容或 diff
|
|
103
|
+
- 支持 accept / revert / revise
|
|
104
|
+
- 支持一键接受全部待审核 diff
|
|
105
|
+
|
|
106
|
+
### 4. 文件、日志与运行状态查看
|
|
107
|
+
|
|
108
|
+
- 浏览项目文件树
|
|
109
|
+
- 读取文件内容
|
|
110
|
+
- 通过 HTTP 下载文件
|
|
111
|
+
- 查看终端执行日志
|
|
112
|
+
- 在不同 execution 间切换 stdout / stderr
|
|
113
|
+
- 查看 runtime info 和 session 历史
|
|
114
|
+
|
|
115
|
+
### 5. Skill / Memory / Session Context 管理
|
|
116
|
+
|
|
117
|
+
- Skill 以“胶囊”形式展示,轻点即可执行,长按即可查看详情和修改入口
|
|
118
|
+
- Memory 以“卡片”形式展示,内容、启用状态、来源和同步状态一眼可见
|
|
119
|
+
- 支持一键同步 skill / memory,和本机 Claude 目录保持一致
|
|
120
|
+
- 支持一句话生成 skill、一句话修改 skill / memory
|
|
121
|
+
- 结果可自动回写 catalog,并立即刷新管理面板
|
|
122
|
+
|
|
123
|
+
### 6. 后台提醒
|
|
124
|
+
|
|
125
|
+
- 当 Claude 需要你操作时发送提醒
|
|
126
|
+
- 覆盖继续输入、权限确认、Plan Mode、代码审核
|
|
127
|
+
- 通过 action-needed 信号去重,避免重复打扰
|
|
128
|
+
|
|
129
|
+
### 7. 可选 TTS
|
|
130
|
+
|
|
131
|
+
- 支持把 Claude 的关键信息转成语音
|
|
132
|
+
- 更适合移动中、通勤中或不方便盯屏的场景
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 系统架构
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
Mobile browser / Flutter app
|
|
140
|
+
│
|
|
141
|
+
▼
|
|
142
|
+
MobileVC Go server
|
|
143
|
+
│
|
|
144
|
+
├─ WebSocket event stream
|
|
145
|
+
├─ Claude Code runtime / PTY runner
|
|
146
|
+
├─ session + projection store
|
|
147
|
+
└─ Python ChatTTS sidecar (optional)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Go 后端
|
|
151
|
+
|
|
152
|
+
- 入口:`cmd/server/main.go`
|
|
153
|
+
- 负责 `/ws`、`/healthz`、`/download`、`/api/tts/synthesize`
|
|
154
|
+
- 通过 WebSocket 驱动完整会话状态流
|
|
155
|
+
- 管理 PTY runner、session store、Skill / Memory、文件系统与 TTS
|
|
156
|
+
|
|
157
|
+
### Flutter 客户端
|
|
158
|
+
|
|
159
|
+
- 入口:`mobile_vc/lib/main.dart` -> `mobile_vc/lib/app/app.dart`
|
|
160
|
+
- 根状态由 `SessionController` 驱动
|
|
161
|
+
- 首页是 `SessionHomePage`
|
|
162
|
+
- 负责把后端事件变成手机上可操作的 UI 状态
|
|
163
|
+
|
|
164
|
+
### 后端协议
|
|
165
|
+
|
|
166
|
+
Go 后端通过结构化事件流向前端推送状态,例如:
|
|
167
|
+
|
|
168
|
+
- `runtime_phase`
|
|
169
|
+
- `interaction_request`
|
|
170
|
+
- `session_history`
|
|
171
|
+
- `skill_catalog_result`
|
|
172
|
+
- `memory_list_result`
|
|
173
|
+
- `file_diff`
|
|
174
|
+
- `prompt_request`
|
|
175
|
+
- `agent_state`
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## 工作原理
|
|
180
|
+
|
|
181
|
+
1. Flutter 连接 Go 后端 WebSocket
|
|
182
|
+
2. Go 后端启动或恢复 Claude Code 的 PTY 会话
|
|
183
|
+
3. Claude 在执行中发出等待态、权限态、计划态等结构化信号
|
|
184
|
+
4. Flutter 将这些信号渲染成适合手机的操作界面
|
|
185
|
+
5. 用户在手机上批准、继续、回退、审核或输入
|
|
186
|
+
6. 决策再回灌给 Claude,形成完整闭环
|
|
187
|
+
|
|
188
|
+
这套设计的核心不是“远程操作一台电脑”,而是:
|
|
189
|
+
|
|
190
|
+
> **让手机成为你操控电脑上 Claude Code 的主入口。**
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## 快速开始
|
|
195
|
+
|
|
196
|
+
### 1. 安装 Node 启动器
|
|
197
|
+
|
|
198
|
+
在仓库根目录执行:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
npm i
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
安装后可以直接使用 `mobilevc` 命令。
|
|
205
|
+
|
|
206
|
+
### 2. 首次启动并配置
|
|
207
|
+
|
|
208
|
+
第一次运行会提示你输入后端端口和 `AUTH_TOKEN`:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
mobilevc
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
也可以随时重新配置:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
mobilevc setup
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### 3. 启动 Go 服务
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
mobilevc start
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### 4. 查看状态 / 日志 / 停止
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
mobilevc status
|
|
230
|
+
mobilevc logs
|
|
231
|
+
mobilevc logs --follow
|
|
232
|
+
mobilevc stop
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### 5. 健康检查
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
curl http://127.0.0.1:8001/healthz
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Smoke test:`AUTH_TOKEN=test ./scripts/test_smoke_flow.sh`,可快速验证后端、WebSocket 与会话主链路是否正常。
|
|
242
|
+
|
|
243
|
+
### 6. 打开 Web 工作台
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
http://127.0.0.1:8001/
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### 7. 启动 Claude 会话
|
|
250
|
+
|
|
251
|
+
```text
|
|
252
|
+
claude
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### 仍然支持直接启动 Go 后端
|
|
256
|
+
|
|
257
|
+
如果你想绕过 Node 启动器,原来的方式仍然可用:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
AUTH_TOKEN=test go run ./cmd/server
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Flutter 客户端
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
cd mobile_vc
|
|
269
|
+
flutter pub get
|
|
270
|
+
flutter run
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
> 确保 host / port / token 配置正确。
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## 测试
|
|
278
|
+
|
|
279
|
+
### Smoke test
|
|
280
|
+
|
|
281
|
+
Smoke test:`AUTH_TOKEN=test ./scripts/test_smoke_flow.sh`,用于快速验证后端、WebSocket 和会话流是否可用。
|
|
282
|
+
它会连接本地服务并跑一轮最小端到端流程,帮助你确认环境是否正常。
|
|
283
|
+
建议在启动 Go 服务后先跑一次,快速确认 WebSocket、会话流和鉴权都可用。
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
AUTH_TOKEN=test ./scripts/test_smoke_flow.sh
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Go
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
go test ./...
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Flutter
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
cd mobile_vc
|
|
299
|
+
flutter test
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## 项目结构
|
|
305
|
+
|
|
306
|
+
```text
|
|
307
|
+
cmd/server/ # Go 服务入口
|
|
308
|
+
internal/ # 后端编排、运行时、协议、存储
|
|
309
|
+
web/ # 浏览器工作台
|
|
310
|
+
mobile_vc/ # Flutter 客户端
|
|
311
|
+
sidecar/chattts/ # 可选 TTS 侧车
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## English Summary
|
|
317
|
+
|
|
318
|
+
MobileVC turns your phone into the control center for Claude Code running on your computer.
|
|
319
|
+
|
|
320
|
+
It is built for the moments when you are away from the keyboard but still need to keep shipping: approve permissions, handle Plan Mode, review diffs, inspect files and logs, resume sessions, and keep the workflow moving.
|
|
321
|
+
|
|
322
|
+
### What it gives you
|
|
323
|
+
|
|
324
|
+
- Mobile Claude Code control
|
|
325
|
+
- Permission confirmations
|
|
326
|
+
- Plan Mode handling
|
|
327
|
+
- Multi-file diff review
|
|
328
|
+
- File / log / runtime inspection
|
|
329
|
+
- Session resume and history
|
|
330
|
+
- Skill capsules and memory cards
|
|
331
|
+
- One-tap sync and AI-assisted authoring
|
|
332
|
+
- Skill / Memory / Context management
|
|
333
|
+
- Optional TTS notifications
|
|
334
|
+
|
|
335
|
+
### The idea
|
|
336
|
+
|
|
337
|
+
Not a terminal mirror.
|
|
338
|
+
Not a desktop clone.
|
|
339
|
+
|
|
340
|
+
**A phone-first workflow that lets you operate Claude Code on your computer almost entirely from mobile.**
|
|
341
|
+
|
|
342
|
+
---
|
package/bin/mobilevc.js
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const { spawn, spawnSync } = require('child_process');
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const net = require('net');
|
|
10
|
+
const qrcode = require('qrcode-terminal');
|
|
11
|
+
|
|
12
|
+
const PACKAGE_NAME = 'mobilevc';
|
|
13
|
+
const SERVER_BINARY_NAME = process.platform === 'win32' ? 'mobilevc-server.exe' : 'mobilevc-server';
|
|
14
|
+
const STATE_DIR = path.join(os.homedir(), '.mobilevc', 'launcher');
|
|
15
|
+
const LOG_DIR = path.join(os.homedir(), '.mobilevc', 'logs');
|
|
16
|
+
const CONFIG_PATH = path.join(STATE_DIR, 'config.json');
|
|
17
|
+
const STATE_PATH = path.join(STATE_DIR, 'state.json');
|
|
18
|
+
const DEFAULT_PORT = '8001';
|
|
19
|
+
const DEFAULT_LANGUAGE = 'zh';
|
|
20
|
+
|
|
21
|
+
const PLATFORM_PACKAGES = {
|
|
22
|
+
'darwin-arm64': '@mobilevc/server-darwin-arm64',
|
|
23
|
+
'darwin-x64': '@mobilevc/server-darwin-x64',
|
|
24
|
+
'linux-arm64': '@mobilevc/server-linux-arm64',
|
|
25
|
+
'linux-x64': '@mobilevc/server-linux-x64',
|
|
26
|
+
'win32-x64': '@mobilevc/server-win32-x64',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const MESSAGES = {
|
|
30
|
+
zh: {
|
|
31
|
+
helpTitle: '🐱 MobileVC 启动器',
|
|
32
|
+
help: [
|
|
33
|
+
'用法:',
|
|
34
|
+
' mobilevc start 启动 MobileVC 后端(默认)',
|
|
35
|
+
' mobilevc restart 重启 MobileVC 后端',
|
|
36
|
+
' mobilevc stop 停止已保存的后端进程',
|
|
37
|
+
' mobilevc status 查看保存的状态和健康检查',
|
|
38
|
+
' mobilevc config 重新配置端口、AUTH_TOKEN 和语言',
|
|
39
|
+
' mobilevc logs 打印后端日志(加 --follow 跟随)',
|
|
40
|
+
' mobilevc help 显示帮助',
|
|
41
|
+
],
|
|
42
|
+
selectLanguage: '选择语言 / Language [1=中文, 2=English]: ',
|
|
43
|
+
backendPort: (current) => `后端端口 [${current}]: `,
|
|
44
|
+
authToken: (current) => current ? 'AUTH_TOKEN [已保存]: ' : 'AUTH_TOKEN: ',
|
|
45
|
+
savedConfig: (filePath) => `🐈 已保存启动器配置到 ${filePath}`,
|
|
46
|
+
launcherStateNotFound: '🐱 未找到启动器状态',
|
|
47
|
+
noSavedBackendProcess: '🐈 没有已保存的后端进程',
|
|
48
|
+
noLogsFound: '🐱 没有找到日志',
|
|
49
|
+
invalidPort: '🐱 端口无效',
|
|
50
|
+
authRequired: '🐈 AUTH_TOKEN 是必填项',
|
|
51
|
+
startingBackend: (port) => `🐱 正在启动 MobileVC 后端,端口 ${port}`,
|
|
52
|
+
logFile: (filePath) => `🐈 日志文件:${filePath}`,
|
|
53
|
+
pid: (pid) => `PID: ${pid}`,
|
|
54
|
+
alreadyRunning: (pid, port) => `MobileVC 已在运行(pid ${pid},端口 ${port})`,
|
|
55
|
+
backendNotRunning: '后端进程未运行',
|
|
56
|
+
stoppedBackend: '已停止 MobileVC 后端',
|
|
57
|
+
statusPid: 'pid',
|
|
58
|
+
statusPort: '端口',
|
|
59
|
+
statusAlive: '存活',
|
|
60
|
+
statusHealthz: '健康检查',
|
|
61
|
+
statusStartedAt: '启动时间',
|
|
62
|
+
statusLogPath: '日志路径',
|
|
63
|
+
statusBinaryPath: 'binaryPath',
|
|
64
|
+
statusPlatformTarget: 'platformTarget',
|
|
65
|
+
statusServerVersion: 'serverVersion',
|
|
66
|
+
statusClaudeAvailable: 'Claude CLI 可用',
|
|
67
|
+
statusHomeWritable: 'HOME 可写',
|
|
68
|
+
statusPreflight: 'preflight',
|
|
69
|
+
qrTitle: '扫码连接局域网地址',
|
|
70
|
+
qrHint: '手机扫一扫,直接读取当前局域网地址和端口',
|
|
71
|
+
localAccess: '本机访问',
|
|
72
|
+
lanAccess: '局域网访问',
|
|
73
|
+
qrUnavailable: '未检测到可用的局域网 IPv4 地址,暂时无法生成二维码',
|
|
74
|
+
preflightTitle: '启动前检查',
|
|
75
|
+
preflightBlocking: '阻塞项',
|
|
76
|
+
preflightWarnings: '提示项',
|
|
77
|
+
preflightOk: '无',
|
|
78
|
+
missingBinaryForPlatform: (target) => `当前平台 ${target} 没有可用的预编译 server 包`,
|
|
79
|
+
binaryMissing: (filePath) => `未找到 server binary:${filePath}`,
|
|
80
|
+
binaryNotExecutable: (filePath) => `server binary 不可执行:${filePath}`,
|
|
81
|
+
homeNotWritable: (homePath) => `HOME 不可写:${homePath}`,
|
|
82
|
+
authTokenMissing: '未配置 AUTH_TOKEN,请先运行 mobilevc config',
|
|
83
|
+
claudeMissing: '未检测到 Claude CLI;后端仍可启动,但无法执行 Claude 会话',
|
|
84
|
+
portInUse: (port) => `端口 ${port} 已被其他进程占用`,
|
|
85
|
+
startupFailed: '启动失败,请检查日志和 preflight 提示',
|
|
86
|
+
statusUnavailable: '未知',
|
|
87
|
+
},
|
|
88
|
+
en: {
|
|
89
|
+
helpTitle: '🐱 MobileVC launcher',
|
|
90
|
+
help: [
|
|
91
|
+
'Usage:',
|
|
92
|
+
' mobilevc start Start the MobileVC backend (default)',
|
|
93
|
+
' mobilevc restart Restart the MobileVC backend',
|
|
94
|
+
' mobilevc stop Stop the saved backend process',
|
|
95
|
+
' mobilevc status Show saved launcher state and health',
|
|
96
|
+
' mobilevc config Reconfigure the backend port, AUTH_TOKEN, and language',
|
|
97
|
+
' mobilevc logs Print backend logs (use --follow to tail)',
|
|
98
|
+
' mobilevc help Show this help',
|
|
99
|
+
],
|
|
100
|
+
selectLanguage: 'Select language / Language [1=中文, 2=English]: ',
|
|
101
|
+
backendPort: (current) => `Backend port [${current}]: `,
|
|
102
|
+
authToken: (current) => current ? 'AUTH_TOKEN [saved]: ' : 'AUTH_TOKEN: ',
|
|
103
|
+
savedConfig: (filePath) => `🐈 Saved launcher config to ${filePath}`,
|
|
104
|
+
launcherStateNotFound: '🐱 Launcher state not found',
|
|
105
|
+
noSavedBackendProcess: '🐈 No saved backend process',
|
|
106
|
+
noLogsFound: '🐱 No logs found',
|
|
107
|
+
invalidPort: '🐱 Invalid port',
|
|
108
|
+
authRequired: '🐈 AUTH_TOKEN is required',
|
|
109
|
+
startingBackend: (port) => `🐱 Starting MobileVC backend on port ${port}`,
|
|
110
|
+
logFile: (filePath) => `🐈 Log file: ${filePath}`,
|
|
111
|
+
pid: (pid) => `PID: ${pid}`,
|
|
112
|
+
alreadyRunning: (pid, port) => `MobileVC is already running (pid ${pid} on port ${port})`,
|
|
113
|
+
backendNotRunning: 'Backend process is not running',
|
|
114
|
+
stoppedBackend: 'Stopped MobileVC backend',
|
|
115
|
+
statusPid: 'pid',
|
|
116
|
+
statusPort: 'port',
|
|
117
|
+
statusAlive: 'alive',
|
|
118
|
+
statusHealthz: 'healthz',
|
|
119
|
+
statusStartedAt: 'startedAt',
|
|
120
|
+
statusLogPath: 'logPath',
|
|
121
|
+
statusBinaryPath: 'binaryPath',
|
|
122
|
+
statusPlatformTarget: 'platformTarget',
|
|
123
|
+
statusServerVersion: 'serverVersion',
|
|
124
|
+
statusClaudeAvailable: 'Claude CLI available',
|
|
125
|
+
statusHomeWritable: 'HOME writable',
|
|
126
|
+
statusPreflight: 'preflight',
|
|
127
|
+
qrTitle: 'Scan to open over LAN',
|
|
128
|
+
qrHint: 'Scan with your phone to open the current LAN IP and port',
|
|
129
|
+
localAccess: 'Local access',
|
|
130
|
+
lanAccess: 'LAN access',
|
|
131
|
+
qrUnavailable: 'No available LAN IPv4 address was detected, so no QR code was generated',
|
|
132
|
+
preflightTitle: 'Preflight',
|
|
133
|
+
preflightBlocking: 'Blocking',
|
|
134
|
+
preflightWarnings: 'Warnings',
|
|
135
|
+
preflightOk: 'none',
|
|
136
|
+
missingBinaryForPlatform: (target) => `No precompiled server package is available for ${target}`,
|
|
137
|
+
binaryMissing: (filePath) => `Server binary not found: ${filePath}`,
|
|
138
|
+
binaryNotExecutable: (filePath) => `Server binary is not executable: ${filePath}`,
|
|
139
|
+
homeNotWritable: (homePath) => `HOME is not writable: ${homePath}`,
|
|
140
|
+
authTokenMissing: 'AUTH_TOKEN is missing. Run mobilevc config first.',
|
|
141
|
+
claudeMissing: 'Claude CLI was not found. The backend can start, but Claude sessions will not run.',
|
|
142
|
+
portInUse: (port) => `Port ${port} is already in use`,
|
|
143
|
+
startupFailed: 'Startup failed. Check the log and preflight output.',
|
|
144
|
+
statusUnavailable: 'unknown',
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function main() {
|
|
149
|
+
const args = process.argv.slice(2);
|
|
150
|
+
const command = args[0] && !args[0].startsWith('-') ? args[0] : 'start';
|
|
151
|
+
const options = parseOptions(args.slice(args[0] && args[0].startsWith('-') ? 0 : 1));
|
|
152
|
+
|
|
153
|
+
if (options.help || command === 'help' || command === '--help' || command === '-h') {
|
|
154
|
+
printHelp();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (command === 'setup' || command === 'config') {
|
|
159
|
+
runSetup(true).catch(exitWithError);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (command === 'status') {
|
|
164
|
+
runStatus().catch(exitWithError);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (command === 'stop') {
|
|
169
|
+
runStop().catch(exitWithError);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (command === 'restart') {
|
|
174
|
+
runRestart(options).catch(exitWithError);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (command === 'logs') {
|
|
179
|
+
runLogs(options).catch(exitWithError);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (command === 'start') {
|
|
184
|
+
runStart(options).catch(exitWithError);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
printHelp();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseOptions(args) {
|
|
192
|
+
const options = { help: false, follow: false };
|
|
193
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
194
|
+
const arg = args[i];
|
|
195
|
+
if (arg === '--help' || arg === '-h') options.help = true;
|
|
196
|
+
else if (arg === '--follow' || arg === '-f') options.follow = true;
|
|
197
|
+
}
|
|
198
|
+
return options;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function printHelp() {
|
|
202
|
+
const language = readJson(CONFIG_PATH, null)?.language || DEFAULT_LANGUAGE;
|
|
203
|
+
const bundle = MESSAGES[language] || MESSAGES[DEFAULT_LANGUAGE];
|
|
204
|
+
console.log(bundle.helpTitle);
|
|
205
|
+
console.log('');
|
|
206
|
+
console.log(bundle.help.join('\n'));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function runSetup(forcePrompt) {
|
|
210
|
+
ensureDir(STATE_DIR, 0o700);
|
|
211
|
+
ensureDir(LOG_DIR, 0o700);
|
|
212
|
+
|
|
213
|
+
const current = readJson(CONFIG_PATH, null);
|
|
214
|
+
const currentLanguage = current?.language || DEFAULT_LANGUAGE;
|
|
215
|
+
const language = await askLanguage(forcePrompt || !current, currentLanguage);
|
|
216
|
+
const port = await askPort(language, forcePrompt || !current);
|
|
217
|
+
const authToken = await askToken(language, forcePrompt || !current);
|
|
218
|
+
|
|
219
|
+
writeJson(CONFIG_PATH, { language, port, authToken });
|
|
220
|
+
console.log(message(language, 'savedConfig', CONFIG_PATH));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function runStart(_options) {
|
|
224
|
+
ensureDir(STATE_DIR, 0o700);
|
|
225
|
+
ensureDir(LOG_DIR, 0o700);
|
|
226
|
+
|
|
227
|
+
let config = readJson(CONFIG_PATH, null);
|
|
228
|
+
if (!config) {
|
|
229
|
+
await runSetup(false);
|
|
230
|
+
config = readJson(CONFIG_PATH, null);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const language = config?.language || DEFAULT_LANGUAGE;
|
|
234
|
+
const existingState = readJson(STATE_PATH, null);
|
|
235
|
+
if (existingState?.pid && isPidAlive(existingState.pid)) {
|
|
236
|
+
console.log(message(language, 'alreadyRunning', existingState.pid, existingState.port));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const platformTarget = getPlatformTarget();
|
|
241
|
+
const binaryInfo = resolveBinaryInfo(platformTarget);
|
|
242
|
+
const preflight = await runPreflightChecks({ config, language, platformTarget, binaryInfo });
|
|
243
|
+
printPreflight(language, preflight);
|
|
244
|
+
|
|
245
|
+
if (preflight.blocking.length > 0) {
|
|
246
|
+
const state = buildStateSkeleton(config, language, binaryInfo, platformTarget, preflight, null);
|
|
247
|
+
writeJson(STATE_PATH, state);
|
|
248
|
+
throw new Error(preflight.blocking[0]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const logPath = path.join(LOG_DIR, `mobilevc-${timestampForFile()}.log`);
|
|
252
|
+
fs.writeFileSync(logPath, '', { mode: 0o600 });
|
|
253
|
+
try {
|
|
254
|
+
fs.chmodSync(logPath, 0o600);
|
|
255
|
+
} catch (_) {}
|
|
256
|
+
|
|
257
|
+
const env = {
|
|
258
|
+
...process.env,
|
|
259
|
+
PORT: String(config.port),
|
|
260
|
+
AUTH_TOKEN: String(config.authToken),
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
fs.appendFileSync(logPath, `launcher starting binary=${binaryInfo.binaryPath} target=${platformTarget}\n`);
|
|
264
|
+
const child = spawn(binaryInfo.binaryPath, [], {
|
|
265
|
+
detached: true,
|
|
266
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
267
|
+
env,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (child.stdout) {
|
|
271
|
+
child.stdout.pipe(fs.createWriteStream(logPath, { flags: 'a', mode: 0o600 }));
|
|
272
|
+
}
|
|
273
|
+
if (child.stderr) {
|
|
274
|
+
child.stderr.pipe(fs.createWriteStream(logPath, { flags: 'a', mode: 0o600 }));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
child.on('error', (err) => {
|
|
278
|
+
fs.appendFileSync(logPath, `launcher error: ${err.stack || err.message}\n`);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const versionInfo = await waitForServerVersion(config.port, 10000);
|
|
282
|
+
const state = {
|
|
283
|
+
...buildStateSkeleton(config, language, binaryInfo, platformTarget, preflight, logPath),
|
|
284
|
+
pid: child.pid,
|
|
285
|
+
startedAt: new Date().toISOString(),
|
|
286
|
+
serverVersion: formatVersionInfo(versionInfo),
|
|
287
|
+
serverVersionRaw: versionInfo,
|
|
288
|
+
};
|
|
289
|
+
writeJson(STATE_PATH, state);
|
|
290
|
+
|
|
291
|
+
child.unref();
|
|
292
|
+
console.log(message(language, 'startingBackend', state.port));
|
|
293
|
+
console.log(message(language, 'logFile', logPath));
|
|
294
|
+
console.log(message(language, 'pid', child.pid));
|
|
295
|
+
if (!await checkHealth(state.port)) {
|
|
296
|
+
throw new Error(message(language, 'startupFailed'));
|
|
297
|
+
}
|
|
298
|
+
await printLanQr(language, state.port, state.authToken);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function runStatus() {
|
|
302
|
+
const state = readJson(STATE_PATH, null);
|
|
303
|
+
const config = readJson(CONFIG_PATH, null);
|
|
304
|
+
const language = state?.language || config?.language || DEFAULT_LANGUAGE;
|
|
305
|
+
if (!state) {
|
|
306
|
+
console.log(message(language, 'launcherStateNotFound'));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const platformTarget = state.platformTarget || getPlatformTarget();
|
|
311
|
+
const binaryInfo = resolveBinaryInfo(platformTarget);
|
|
312
|
+
const alive = state.pid ? isPidAlive(state.pid) : false;
|
|
313
|
+
const healthy = alive ? await checkHealth(state.port) : false;
|
|
314
|
+
const versionInfo = healthy ? await fetchServerVersion(state.port) : null;
|
|
315
|
+
const claudeAvailable = detectClaudeAvailability();
|
|
316
|
+
const homeWritable = isHomeWritable();
|
|
317
|
+
|
|
318
|
+
console.log(`${message(language, 'statusPid')}: ${state.pid || '-'}`);
|
|
319
|
+
console.log(`${message(language, 'statusPort')}: ${state.port || '-'}`);
|
|
320
|
+
console.log(`${message(language, 'statusAlive')}: ${alive}`);
|
|
321
|
+
console.log(`${message(language, 'statusHealthz')}: ${healthy}`);
|
|
322
|
+
console.log(`${message(language, 'statusStartedAt')}: ${state.startedAt || '-'}`);
|
|
323
|
+
console.log(`${message(language, 'statusLogPath')}: ${state.logPath || '-'}`);
|
|
324
|
+
console.log(`${message(language, 'statusBinaryPath')}: ${state.binaryPath || binaryInfo.binaryPath || '-'}`);
|
|
325
|
+
console.log(`${message(language, 'statusPlatformTarget')}: ${platformTarget}`);
|
|
326
|
+
console.log(`${message(language, 'statusServerVersion')}: ${formatVersionInfo(versionInfo) || state.serverVersion || '-'}`);
|
|
327
|
+
console.log(`${message(language, 'statusClaudeAvailable')}: ${claudeAvailable}`);
|
|
328
|
+
console.log(`${message(language, 'statusHomeWritable')}: ${homeWritable}`);
|
|
329
|
+
console.log(`${message(language, 'statusPreflight')}: ${summarizePreflight(state.preflight, language)}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function runStop(options = {}) {
|
|
333
|
+
const state = readJson(STATE_PATH, null);
|
|
334
|
+
const language = options.language || state?.language || DEFAULT_LANGUAGE;
|
|
335
|
+
if (!state?.pid) {
|
|
336
|
+
if (!options.silent) {
|
|
337
|
+
console.log(message(language, 'noSavedBackendProcess'));
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!isPidAlive(state.pid)) {
|
|
343
|
+
clearState();
|
|
344
|
+
if (!options.silent) {
|
|
345
|
+
console.log(message(language, 'backendNotRunning'));
|
|
346
|
+
}
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
killProcessGroup(state.pid, 'SIGTERM');
|
|
351
|
+
|
|
352
|
+
await waitForExit(state.pid, 4000);
|
|
353
|
+
if (isPidAlive(state.pid)) {
|
|
354
|
+
killProcessGroup(state.pid, 'SIGKILL');
|
|
355
|
+
await waitForExit(state.pid, 2000);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
clearState();
|
|
359
|
+
if (!options.silent) {
|
|
360
|
+
console.log(message(language, 'stoppedBackend'));
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function runRestart(options) {
|
|
366
|
+
const state = readJson(STATE_PATH, null);
|
|
367
|
+
const language = state?.language || readJson(CONFIG_PATH, null)?.language || DEFAULT_LANGUAGE;
|
|
368
|
+
await runStop({ silent: true, language });
|
|
369
|
+
await runStart(options);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function runLogs(options) {
|
|
373
|
+
ensureDir(LOG_DIR, 0o700);
|
|
374
|
+
const files = fs.readdirSync(LOG_DIR)
|
|
375
|
+
.filter((file) => file.endsWith('.log'))
|
|
376
|
+
.map((file) => path.join(LOG_DIR, file))
|
|
377
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
378
|
+
|
|
379
|
+
const state = readJson(STATE_PATH, null);
|
|
380
|
+
const language = state?.language || DEFAULT_LANGUAGE;
|
|
381
|
+
if (files.length === 0) {
|
|
382
|
+
console.log(message(language, 'noLogsFound'));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const latest = files[0];
|
|
387
|
+
if (options.follow) {
|
|
388
|
+
followFile(latest);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
process.stdout.write(fs.readFileSync(latest, 'utf8'));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function resolveBinaryInfo(platformTarget) {
|
|
396
|
+
const packageName = PLATFORM_PACKAGES[platformTarget] || null;
|
|
397
|
+
const packageRoot = packageName ? resolveInstalledPackageRoot(packageName) : null;
|
|
398
|
+
const binaryPath = packageRoot ? path.join(packageRoot, 'bin', SERVER_BINARY_NAME) : null;
|
|
399
|
+
return { packageName, packageRoot, binaryPath };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function resolveInstalledPackageRoot(packageName) {
|
|
403
|
+
try {
|
|
404
|
+
const packageJsonPath = require.resolve(`${packageName}/package.json`, { paths: [__dirname] });
|
|
405
|
+
return path.dirname(packageJsonPath);
|
|
406
|
+
} catch (_) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function getPlatformTarget() {
|
|
412
|
+
return `${process.platform}-${process.arch}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function runPreflightChecks({ config, language, platformTarget, binaryInfo }) {
|
|
416
|
+
const blocking = [];
|
|
417
|
+
const warnings = [];
|
|
418
|
+
|
|
419
|
+
if (!binaryInfo.packageName) {
|
|
420
|
+
blocking.push(message(language, 'missingBinaryForPlatform', platformTarget));
|
|
421
|
+
} else if (!binaryInfo.binaryPath || !fs.existsSync(binaryInfo.binaryPath)) {
|
|
422
|
+
blocking.push(message(language, 'binaryMissing', binaryInfo.binaryPath || `${binaryInfo.packageName}/bin/${SERVER_BINARY_NAME}`));
|
|
423
|
+
} else if (!isBinaryExecutable(binaryInfo.binaryPath)) {
|
|
424
|
+
blocking.push(message(language, 'binaryNotExecutable', binaryInfo.binaryPath));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!String(config?.authToken || '').trim()) {
|
|
428
|
+
blocking.push(message(language, 'authTokenMissing'));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!isHomeWritable()) {
|
|
432
|
+
blocking.push(message(language, 'homeNotWritable', os.homedir()));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (await isPortOccupied(String(config?.port || DEFAULT_PORT))) {
|
|
436
|
+
blocking.push(message(language, 'portInUse', String(config?.port || DEFAULT_PORT)));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!detectClaudeAvailability()) {
|
|
440
|
+
warnings.push(message(language, 'claudeMissing'));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return { blocking, warnings };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function printPreflight(language, preflight) {
|
|
447
|
+
console.log(`${message(language, 'preflightTitle')}:`);
|
|
448
|
+
console.log(` ${message(language, 'preflightBlocking')}: ${formatList(preflight.blocking, language)}`);
|
|
449
|
+
console.log(` ${message(language, 'preflightWarnings')}: ${formatList(preflight.warnings, language)}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function formatList(items, language) {
|
|
453
|
+
if (!items || items.length === 0) {
|
|
454
|
+
return message(language, 'preflightOk');
|
|
455
|
+
}
|
|
456
|
+
return items.join(' | ');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function buildStateSkeleton(config, language, binaryInfo, platformTarget, preflight, logPath) {
|
|
460
|
+
return {
|
|
461
|
+
pid: null,
|
|
462
|
+
port: String(config.port),
|
|
463
|
+
authToken: String(config.authToken),
|
|
464
|
+
language,
|
|
465
|
+
startedAt: null,
|
|
466
|
+
logPath,
|
|
467
|
+
binaryPath: binaryInfo.binaryPath,
|
|
468
|
+
platformTarget,
|
|
469
|
+
serverVersion: null,
|
|
470
|
+
preflight,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function detectClaudeAvailability() {
|
|
475
|
+
const result = spawnSync('claude', ['--version'], { stdio: 'ignore' });
|
|
476
|
+
return result.status === 0 || result.error == null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function isHomeWritable() {
|
|
480
|
+
try {
|
|
481
|
+
fs.accessSync(os.homedir(), fs.constants.W_OK);
|
|
482
|
+
return true;
|
|
483
|
+
} catch (_) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function isBinaryExecutable(filePath) {
|
|
489
|
+
try {
|
|
490
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
491
|
+
return true;
|
|
492
|
+
} catch (_) {
|
|
493
|
+
return process.platform === 'win32' && fs.existsSync(filePath);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function isPortOccupied(port) {
|
|
498
|
+
return new Promise((resolve) => {
|
|
499
|
+
const server = net.createServer();
|
|
500
|
+
server.once('error', (err) => {
|
|
501
|
+
if (err && err.code === 'EADDRINUSE') {
|
|
502
|
+
resolve(true);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
resolve(false);
|
|
506
|
+
});
|
|
507
|
+
server.once('listening', () => {
|
|
508
|
+
server.close(() => resolve(false));
|
|
509
|
+
});
|
|
510
|
+
server.listen(Number(port), '127.0.0.1');
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function waitForServerVersion(port, timeoutMs) {
|
|
515
|
+
return new Promise((resolve) => {
|
|
516
|
+
const started = Date.now();
|
|
517
|
+
const timer = setInterval(async () => {
|
|
518
|
+
if (Date.now() - started >= timeoutMs) {
|
|
519
|
+
clearInterval(timer);
|
|
520
|
+
resolve(null);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const info = await fetchServerVersion(port);
|
|
524
|
+
if (info) {
|
|
525
|
+
clearInterval(timer);
|
|
526
|
+
resolve(info);
|
|
527
|
+
}
|
|
528
|
+
}, 500);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function fetchServerVersion(port) {
|
|
533
|
+
return new Promise((resolve) => {
|
|
534
|
+
const req = http.get({ hostname: '127.0.0.1', port: Number(port), path: '/version', timeout: 1500 }, (res) => {
|
|
535
|
+
let body = '';
|
|
536
|
+
res.setEncoding('utf8');
|
|
537
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
538
|
+
res.on('end', () => {
|
|
539
|
+
if (res.statusCode !== 200) {
|
|
540
|
+
resolve(null);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
resolve(JSON.parse(body));
|
|
545
|
+
} catch (_) {
|
|
546
|
+
resolve(null);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
req.on('timeout', () => {
|
|
551
|
+
req.destroy();
|
|
552
|
+
resolve(null);
|
|
553
|
+
});
|
|
554
|
+
req.on('error', () => resolve(null));
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function formatVersionInfo(info) {
|
|
559
|
+
if (!info || !info.version) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
const extras = [];
|
|
563
|
+
if (info.commit && info.commit !== 'unknown') extras.push(info.commit);
|
|
564
|
+
if (info.buildDate && info.buildDate !== 'unknown') extras.push(info.buildDate);
|
|
565
|
+
return extras.length > 0 ? `${info.version} (${extras.join(', ')})` : info.version;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function summarizePreflight(preflight, language) {
|
|
569
|
+
if (!preflight) {
|
|
570
|
+
return message(language, 'statusUnavailable');
|
|
571
|
+
}
|
|
572
|
+
return `blocking=${preflight.blocking?.length || 0}, warnings=${preflight.warnings?.length || 0}`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function followFile(filePath) {
|
|
576
|
+
let lastSize = 0;
|
|
577
|
+
process.stdout.write(fs.readFileSync(filePath, 'utf8'));
|
|
578
|
+
lastSize = fs.statSync(filePath).size;
|
|
579
|
+
const timer = setInterval(() => {
|
|
580
|
+
if (!fs.existsSync(filePath)) return;
|
|
581
|
+
const stat = fs.statSync(filePath);
|
|
582
|
+
if (stat.size > lastSize) {
|
|
583
|
+
const stream = fs.createReadStream(filePath, { start: lastSize, end: stat.size });
|
|
584
|
+
stream.pipe(process.stdout, { end: false });
|
|
585
|
+
lastSize = stat.size;
|
|
586
|
+
}
|
|
587
|
+
}, 1000);
|
|
588
|
+
|
|
589
|
+
process.on('SIGINT', () => {
|
|
590
|
+
clearInterval(timer);
|
|
591
|
+
process.exit(0);
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function askLanguage(force, currentLanguage) {
|
|
596
|
+
if (!force && currentLanguage) {
|
|
597
|
+
return currentLanguage;
|
|
598
|
+
}
|
|
599
|
+
const selection = await promptInput(message(DEFAULT_LANGUAGE, 'selectLanguage'));
|
|
600
|
+
const normalized = String(selection || '').trim().toLowerCase();
|
|
601
|
+
if (normalized === '2' || normalized === 'en' || normalized === 'english') {
|
|
602
|
+
return 'en';
|
|
603
|
+
}
|
|
604
|
+
return 'zh';
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function askPort(language, force) {
|
|
608
|
+
const current = !force ? readJson(CONFIG_PATH, null)?.port : null;
|
|
609
|
+
const prompt = current ? message(language, 'backendPort', current) : message(language, 'backendPort', DEFAULT_PORT);
|
|
610
|
+
const value = await promptInput(prompt);
|
|
611
|
+
const port = String((value || current || DEFAULT_PORT).trim());
|
|
612
|
+
if (!/^\d+$/.test(port) || Number(port) <= 0 || Number(port) > 65535) {
|
|
613
|
+
throw new Error(message(language, 'invalidPort'));
|
|
614
|
+
}
|
|
615
|
+
return port;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function askToken(language, force) {
|
|
619
|
+
const current = !force ? readJson(CONFIG_PATH, null)?.authToken : null;
|
|
620
|
+
const prompt = current ? message(language, 'authToken', true) : message(language, 'authToken', false);
|
|
621
|
+
const value = await promptInput(prompt, true);
|
|
622
|
+
const token = String((value || current || '').trim());
|
|
623
|
+
if (!token) {
|
|
624
|
+
throw new Error(message(language, 'authRequired'));
|
|
625
|
+
}
|
|
626
|
+
return token;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function message(language, key, ...args) {
|
|
630
|
+
const bundle = MESSAGES[language] || MESSAGES[DEFAULT_LANGUAGE];
|
|
631
|
+
const value = bundle[key];
|
|
632
|
+
return typeof value === 'function' ? value(...args) : value;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function printLanQr(language, port, authToken = '') {
|
|
636
|
+
const host = await detectLanHost();
|
|
637
|
+
const localUrl = buildLaunchUrl('127.0.0.1', port, authToken);
|
|
638
|
+
console.log('');
|
|
639
|
+
console.log(`${message(language, 'localAccess')}: ${localUrl}`);
|
|
640
|
+
|
|
641
|
+
if (!host) {
|
|
642
|
+
console.log(message(language, 'qrUnavailable'));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const url = buildLaunchUrl(host, port, authToken);
|
|
647
|
+
console.log(`${message(language, 'lanAccess')}: ${url}`);
|
|
648
|
+
console.log('');
|
|
649
|
+
console.log(message(language, 'qrTitle'));
|
|
650
|
+
renderTerminalQr(url);
|
|
651
|
+
console.log(message(language, 'qrHint'));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function renderTerminalQr(text) {
|
|
655
|
+
qrcode.generate(text, { small: true }, (qr) => {
|
|
656
|
+
const output = String(qr || '').replace(/\s+$/, '');
|
|
657
|
+
const lines = output.split('\n');
|
|
658
|
+
const widenedLines = lines.map((line) => widenQrLine(line));
|
|
659
|
+
const indent = '';
|
|
660
|
+
console.log(widenedLines.map((line) => `${indent}${line}`).join('\n'));
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function widenQrLine(line) {
|
|
665
|
+
return Array.from(String(line || '')).map((char) => char.repeat(2)).join('');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function buildLaunchUrl(host, port, authToken = '') {
|
|
669
|
+
const url = new URL(`http://${host}:${port}/`);
|
|
670
|
+
if (authToken) {
|
|
671
|
+
url.searchParams.set('token', authToken);
|
|
672
|
+
}
|
|
673
|
+
return url.toString();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function detectLanHost() {
|
|
677
|
+
const interfaces = os.networkInterfaces();
|
|
678
|
+
const wifi = [];
|
|
679
|
+
const wired = [];
|
|
680
|
+
const other = [];
|
|
681
|
+
|
|
682
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
683
|
+
if (!entries) continue;
|
|
684
|
+
for (const entry of entries) {
|
|
685
|
+
if (!entry || entry.family !== 'IPv4' || entry.internal) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (isLinkLocalIpv4(entry.address)) {
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const lowered = name.toLowerCase();
|
|
693
|
+
if (/^(en0|wl|wlan|wifi|wi-?fi)/.test(lowered)) {
|
|
694
|
+
wifi.push(entry.address);
|
|
695
|
+
} else if (/^(en|eth)/.test(lowered)) {
|
|
696
|
+
wired.push(entry.address);
|
|
697
|
+
} else {
|
|
698
|
+
other.push(entry.address);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return wifi[0] || wired[0] || other[0] || null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function isLinkLocalIpv4(address) {
|
|
707
|
+
return /^169\.254\./.test(address);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function promptInput(question, silent = false) {
|
|
711
|
+
return new Promise((resolve) => {
|
|
712
|
+
const rl = readline.createInterface({
|
|
713
|
+
input: process.stdin,
|
|
714
|
+
output: process.stdout,
|
|
715
|
+
terminal: true,
|
|
716
|
+
});
|
|
717
|
+
if (silent) {
|
|
718
|
+
rl.stdoutMuted = true;
|
|
719
|
+
rl._writeToOutput = function _writeToOutput(stringToWrite) {
|
|
720
|
+
if (!rl.stdoutMuted) {
|
|
721
|
+
rl.output.write(stringToWrite);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const text = String(stringToWrite || '');
|
|
725
|
+
if (!text) return;
|
|
726
|
+
if (/\r?\n$/.test(text) || /:\s*$/.test(text) || /\]\s*$/.test(text)) {
|
|
727
|
+
rl.output.write(text);
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
rl.question(question, (answer) => {
|
|
732
|
+
rl.close();
|
|
733
|
+
process.stdout.write('\n');
|
|
734
|
+
resolve(answer);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function ensureDir(dir, mode) {
|
|
740
|
+
fs.mkdirSync(dir, { recursive: true, mode });
|
|
741
|
+
try {
|
|
742
|
+
fs.chmodSync(dir, mode);
|
|
743
|
+
} catch (_) {}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function writeJson(filePath, value) {
|
|
747
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
748
|
+
try {
|
|
749
|
+
fs.chmodSync(filePath, 0o600);
|
|
750
|
+
} catch (_) {}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function readJson(filePath, fallback) {
|
|
754
|
+
try {
|
|
755
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
756
|
+
} catch (_) {
|
|
757
|
+
return fallback;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function clearState() {
|
|
762
|
+
try {
|
|
763
|
+
fs.unlinkSync(STATE_PATH);
|
|
764
|
+
} catch (_) {}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function isPidAlive(pid) {
|
|
768
|
+
try {
|
|
769
|
+
process.kill(pid, 0);
|
|
770
|
+
return true;
|
|
771
|
+
} catch (err) {
|
|
772
|
+
return err.code !== 'ESRCH';
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function killProcessGroup(pid, signal) {
|
|
777
|
+
const targets = process.platform === 'win32' ? [pid] : [-pid, pid];
|
|
778
|
+
for (const target of targets) {
|
|
779
|
+
try {
|
|
780
|
+
process.kill(target, signal);
|
|
781
|
+
return;
|
|
782
|
+
} catch (err) {
|
|
783
|
+
if (err.code !== 'ESRCH' && err.code !== 'EINVAL') {
|
|
784
|
+
throw err;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function waitForExit(pid, timeoutMs) {
|
|
791
|
+
return new Promise((resolve) => {
|
|
792
|
+
const started = Date.now();
|
|
793
|
+
const timer = setInterval(() => {
|
|
794
|
+
if (!isPidAlive(pid) || Date.now() - started >= timeoutMs) {
|
|
795
|
+
clearInterval(timer);
|
|
796
|
+
resolve();
|
|
797
|
+
}
|
|
798
|
+
}, 250);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function checkHealth(port) {
|
|
803
|
+
return new Promise((resolve) => {
|
|
804
|
+
const req = http.get({ hostname: '127.0.0.1', port: Number(port), path: '/healthz', timeout: 1500 }, (res) => {
|
|
805
|
+
let body = '';
|
|
806
|
+
res.setEncoding('utf8');
|
|
807
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
808
|
+
res.on('end', () => resolve(res.statusCode === 200 && body.trim() === 'ok'));
|
|
809
|
+
});
|
|
810
|
+
req.on('timeout', () => {
|
|
811
|
+
req.destroy();
|
|
812
|
+
resolve(false);
|
|
813
|
+
});
|
|
814
|
+
req.on('error', () => resolve(false));
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function timestampForFile() {
|
|
819
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function exitWithError(err) {
|
|
823
|
+
console.error(err.message || err);
|
|
824
|
+
process.exit(1);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@justprove/mobilevc",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code mobile workspace launcher with precompiled MobileVC backend binaries.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mobilevc": "bin/mobilevc.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"package.json"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node ./bin/mobilevc.js",
|
|
21
|
+
"install:cmd": "npm install -g .",
|
|
22
|
+
"remove:cmd": "npm uninstall -g mobilevc",
|
|
23
|
+
"link:cmd": "npm link",
|
|
24
|
+
"unlink:cmd": "npm unlink -g mobilevc",
|
|
25
|
+
"sync:web": "node ./scripts/sync-embedded-web.js",
|
|
26
|
+
"build:binaries": "node ./scripts/build-npm-binaries.js"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"qrcode-terminal": "^0.12.0"
|
|
30
|
+
},
|
|
31
|
+
"optionalDependencies": {
|
|
32
|
+
"@mobilevc/server-darwin-arm64": "0.1.0",
|
|
33
|
+
"@mobilevc/server-darwin-x64": "0.1.0",
|
|
34
|
+
"@mobilevc/server-linux-arm64": "0.1.0",
|
|
35
|
+
"@mobilevc/server-linux-x64": "0.1.0",
|
|
36
|
+
"@mobilevc/server-win32-x64": "0.1.0"
|
|
37
|
+
}
|
|
38
|
+
}
|