# virtualShell **Repository Path**: yao_mi/virtual-shell ## Basic Information - **Project Name**: virtualShell - **Description**: 一个基于伪终端 (forkpty) 的轻量级 C++ 库,用于启动交互式 Bash 子进程,非阻塞地执行命令,并精确捕获子进程的标准输出。通过自定义的标记检测机制,自动去除命令回显与提示符,仅返回纯命令输出结果。 - **Primary Language**: C++ - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-04-25 - **Last Updated**: 2026-09-01 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # virtualShell – 跨平台持久 Shell 控制器 (C++ / C) 一个跨平台的轻量级持久 Shell 控制库,用于非阻塞地执行命令并捕获干净输出。Linux 使用 `forkpty` + Bash,Android 使用原生 PTY + `/system/bin/sh`,Windows 使用管道 + 持久化 `cmd.exe`。 提供两个等价实现,任选其一使用: - **`virtualShell.hpp`** — C++11 版本(面向对象接口)。 - **`virtualShell.h`** — 纯 C99 版本(由 C++ 版翻译而来,功能一致,接口为 `struct` + `vs_*` 函数)。 ## ✨ 特性 - **持久会话**:Linux/Android 使用交互式 PTY,Windows 使用持久化 `cmd.exe` 管道;命令之间保留工作目录和环境变量。 - **非阻塞执行**:使用 `select` + 非阻塞 I/O,不会阻塞主线程;支持 `sendCommand()` + `pollCommand()` 异步工作流程。 - **智能输出裁剪**:基于 `BracketMatcher` 状态机检测命令结束标记,自动移除命令本身的回显行和前置提示符,返回纯净的命令输出。 - **超时控制**:同步 `execute()` 提供超时参数,避免子进程永久挂起。 - **多实例并行**:每个对象拥有独立 Shell 子进程,可同时操控多个隔离环境。 - **空缓冲处理**:`drainAll(maxIdle, idleUs)` 可清空残留的历史输出,确保下次命令执行时不混入旧数据;参数可调,`execute()` 默认使用快速档(约 16ms)以降低单命令开销,初始化时则用较保守的默认值等待 bash 就绪。 - **零第三方依赖**(Linux 仅需系统 `libutil`)。 ## 📦 适用场景 - 需要精确获取 shell 命令执行结果的自动化工具或 bot。 - 需要长时间保持环境状态(如 conda activate、环境变量设置)的脚本托管。 - 依赖于交互式会话的 CLI 包装器(例如自动安装脚本、tutorial runner)。 ## 📁 文件说明 两个头文件均为**单文件、内置完整实现**,直接包含到项目中即可,无需额外的 `.c/.cpp` 源文件: | 文件 | 语言标准 | 接口风格 | |------|----------|----------| | `virtualShell.hpp` | C++11 | `class virtualShell` + 成员函数 | | `virtualShell.h` | C99 | `struct virtualShell` + `vs_*` 函数 | 两者功能完全一致,选用哪个取决于你的项目语言。C 版内部用自建的可增长字符串 `str_t` 替代 `std::string`,平台后端、标记检测和输出裁剪语义与 C++ 版一致。 C++ 与 C 接口对照: | C++ (`virtualShell.hpp`) | C (`virtualShell.h`) | |--------------------------|----------------------| | `virtualShell sh(initCmd, name);` | `virtualShell sh; vs_init(&sh, initCmd, name);` | | 析构(作用域结束自动) | `vs_free(&sh);` | | `sh.execute(cmd, timeout)` | `vs_execute(&sh, cmd, timeout)` | | `sh.sendCommand(cmd)` | `vs_sendCommand(&sh, cmd)` | | `sh.pollCommand()` | `vs_pollCommand(&sh)` | | `sh.get_cmd_out()` → `std::string` | `vs_get_cmd_out(&sh)` → `const char*` | | `sh.drainAll(maxIdle, idleUs)` | `vs_drainAll(&sh, maxIdle, idleUs)` | | `sh.setRenderCr(on)` | `vs_set_render_cr(&sh, on)` | | `sh.isOpen()` | `vs_is_open(&sh)` | > C 版没有默认参数,`vs_execute`/`vs_drainAll` 的超时与清空参数需显式传入(C++ 版对应默认值分别为 `timeout_sec=15`、`maxIdle=20, idleUs=10000`)。 > `vs_get_cmd_out()` 返回的是对象内部缓冲指针,有效期至下次调用或 `vs_free()`;需长期保存请自行拷贝。 ## 🛠️ 编译与使用 ### 编译要求 - **C++ 版**:支持 C++11 或以上版本的编译器。 - **C 版**:支持 C99 的编译器。 - **Linux**:使用 `forkpty`,链接 `libutil`(通常添加 `-lutil`)。 - **Android**:API 23 或更高,使用 NDK 的 PTY/进程 API,不需要 `libutil`。 - **Windows**:支持 Win32 桌面 API 的编译器(MSVC、MinGW 或 llvm-mingw),不需要额外库。 ### 示例编译命令 ```bash # 推荐:CMake 同时构建 C/C++ 冒烟测试 cmake -S . -B build cmake --build build ctest --test-dir build --output-on-failure # ---- C++ 版:编译文件内自带的 TEST_SELF 测试程序 ---- # 将 .hpp 重命名为 .cpp 后编译: mv virtualShell.hpp virtualShell.cpp g++ -std=c++11 -DTEST_SELF virtualShell.cpp -o virtualShell -lutil # mv virtualShell.cpp virtualShell.hpp # ---- C 版:用 -x c 直接把 .h 当 C 源文件编译,无需重命名 ---- gcc -std=c99 -D_GNU_SOURCE -DTEST_SELF -x c virtualShell.h -o virtualShell -lutil ./virtualShell ``` 交叉编译可使用 CMake 工具链文件,或直接选择目标编译器。例如: ```bash # Windows x64 (llvm-mingw) x86_64-w64-mingw32-clang++ -std=c++11 tests/smoke_cpp.cpp -o smoke.exe # Android arm64, API 23 (NDK LLVM) aarch64-linux-android23-clang++ -std=c++11 tests/smoke_cpp.cpp -o smoke-android ``` Windows 后端使用 `cmd.exe /Q /D`,能保留 `cd`、`set` 等会话状态,但不是伪终端;依赖 TTY/ANSI 交互输入的程序应在 Linux/Android PTY 后端运行。Android 应用还需确保其运行环境允许 `fork()` 和执行 `/system/bin/sh`。 ### 最小示例(C++) ```cpp #include #include "virtualShell.hpp" int main() { // 初始化 Shell,并在其中执行一条环境初始化命令 virtualShell shell("source ~/miniconda3/etc/profile.d/conda.sh && conda activate base", "myconda0"); // 同步执行命令(默认阻塞 15 秒超时) shell.execute("python --version"); std::string result = shell.get_cmd_out(); // 获取裁剪后的纯净输出,如 "Python 3.10.4" std::cout << result << std::endl; // 或使用“发送-轮询”非阻塞方式 shell.sendCommand("echo 'Hello from virtual shell'"); while (!shell.pollCommand()) { usleep(10000); // 模拟做其他事情 } result = shell.get_cmd_out(); // 复用上面的 result 变量 std::cout << result << std::endl; return 0; } ``` ### 最小示例(C) ```c #define _GNU_SOURCE #include #include #include "virtualShell.h" int main(void) { // 初始化 Shell,并在其中执行一条环境初始化命令 virtualShell shell; vs_init(&shell, "source ~/miniconda3/etc/profile.d/conda.sh && conda activate base", "myconda0"); // 同步执行命令(超时参数需显式传入,如 15 秒) vs_execute(&shell, "python --version", 15); printf("%s\n", vs_get_cmd_out(&shell)); // 获取裁剪后的纯净输出,如 "Python 3.10.4" // 或使用“发送-轮询”非阻塞方式 vs_sendCommand(&shell, "echo 'Hello from virtual shell'"); while (!vs_pollCommand(&shell)) { usleep(10000); // 模拟做其他事情 } printf("%s\n", vs_get_cmd_out(&shell)); vs_free(&shell); // 关闭 fd、终止子进程、释放内存 return 0; } ``` ## 🔬 工作原理 1. 初始化时,Linux 通过 `forkpty()` 启动 Bash,Android 通过原生 PTY 启动系统 `sh`,Windows 通过匿名管道启动 `cmd.exe /Q /D`。 2. 初始化阶段先 `drainAll()` 等待 bash 启动就绪(避免输入在 readline 就绪前被内核 termios 与 bash 重复回显),再设置可识别的提示符 `PS1`,最后**复用 `execute()`** 执行初始化命令,其纯净输出可由 `get_cmd_out()` 取出。 3. 每次 `execute()` 或 `sendCommand()` 都会生成独特的结束标记,如 `echo [__CMD_END__1__]`,并把命令写成 `cmd\necho [__CMD_END__1__]\n`。`BracketMatcher` 状态机在输出流中识别该标记两次出现(命令回显 + 实际 echo 输出),据此判断命令是否执行完毕。 4. `get_cmd_out()` 对累积的原始输出做两步处理: - **清洗**:去掉 `\r` 以及 CSI 转义序列(如 bash 括号粘贴模式开关 `\e[?2004h` / `\e[?2004l`); - **交错感知裁剪**:交互式 shell 中每行命令的回显紧跟其自身输出。按命令的每一行依次匹配其回显行(兼容 `PS1`/`PS2` 提示符前缀),收集回显行之间的内容作为输出,终点由 `echo ` 标记命令定位。由此**支持多行/含换行的命令**,单行命令为其特例;即使输出不带结尾换行(输出与下一个提示符同在一行)也能正确切分。 ## 📊 进度条 / 动态输出的处理 终端进度条(`wget`、`pip`、`dd` 等)通常靠 `\r`(回车)把光标移回行首反复重绘同一行,或用 ANSI 清行序列 `\e[2K` 覆盖,在真实终端里表现为“一根动态增长的条”。捕获这类输出时有两种处理方式,由开关 `setRenderCr` / `vs_set_render_cr` 控制: | 模式 | 开关 | 效果 | |------|------|------| | **渲染(默认)** | `on = true` | 像终端一样解释 `\r` 与清行序列,动态重绘**只保留最后一帧**(如 `Progress: [#####] 5/5`),最接近你在终端里看到的最终画面。 | | **拼接(旧行为)** | `on = false` | 直接删除 `\r` 与 CSI 转义序列,每一帧首尾相接成一长串(如 `Progress: [# ] 1/5Progress: [## ] 2/5...`),可用于查看全过程。 | 该开关只影响靠光标控制原地重绘的动态输出;用 `\n` 正常换行的多行输出在两种模式下结果完全一致。需在 `get_cmd_out()` 固化结果之前设置。 ```cpp shell.setRenderCr(false); // C++:切回“各帧拼接”旧行为 shell.execute("wget https://example.com/big.iso"); std::string out = shell.get_cmd_out(); ``` ```c vs_set_render_cr(&shell, false); // C:同上 vs_execute(&shell, "wget https://example.com/big.iso", 60); const char* out = vs_get_cmd_out(&shell); ``` 仓库内的 `test_progress.c` 是一个可直接编译运行的演示,对比了 `\r` 进度条、`\e[2K` 进度条、以及普通换行输出在开关开/关下的差异: ```bash gcc -std=c99 -D_GNU_SOURCE test_progress.c -o test_progress -lutil ./test_progress ``` ### 关于 `\r` / `\r\n` / `\n\r` 的准确语义(渲染模式下) 渲染模式**不是"遇到 `\r` 就删除内容"**。`\r` 只把光标(写入位置)移回行首,是否改变内容取决于其后**有没有新字符覆盖上来**: | 输入序列 | 输出 | 说明 | |----------|------|------| | `abc\rXY` | `XYc` | `\r` 归位,`XY` 覆盖 `ab`,`c` 未被覆盖故残留 | | `abc\r` | `abc` | `\r` 后无内容,原样保留 | | `abc\r\n` | `abc\n` | **CRLF 行尾不丢内容**:`\r` 归位后紧跟 `\n` 提交整行 | | `abc\n\rdef` | `abc\ndef` | `\n` 先提交 `abc`,`\r` 落在新空行上无副作用 | | `abcdef\rXY\n` | `XYcdef` | 新帧比旧帧短又未清行时会残留尾巴(与真实终端一致) | 要点: - **`\r\n`(PTY 正常行尾)和 `\n\r` 都不会丢内容**——`\r` 只去掉自身,不吞掉整行。这也是为什么不能简单"见 `\r` 就删":内核 termios 会把每个 `\n` 转成 `\r\n`,那样会把所有正常输出都吃掉。 - **"覆盖后变短会残留"是忠实模拟**,不是 bug。真实终端里不清行的短帧进度条也会留尾巴。规范做法是定宽格式,或配 `\e[2K` 清行(见案例2)。 ## ⚠️ 注意事项 - **请勿在短时间内发送大量命令**,以免管道/PTY 缓冲区溢出或子进程处理不及;同一实例应等待上一条命令结束。 - `drainAll()` 使用试探性读取来清空残余数据,如果 Shell 仍在持续输出长文本,可能无法一次清空干净,可连续调用多次或适当增加等待时间。 - 输出裁剪依赖结束标记的字面匹配:若命令**自身输出**恰好包含当前结束标记(如 `[__CMD_END__1__]`)的字面量,会导致误判。标记带递增计数器,正常使用几乎不会撞上。 - 多行命令已支持;但裁剪通过匹配命令各行的回显来分隔输出,若命令某行的输出**恰好等于该行命令文本**,理论上可能错配,正常使用不受影响。 - 标记计数器为 8 位(`uint8_t`),每 256 条命令循环一次,极端高频场景下相邻命令的标记可能重名,注意配合 `drainAll()` 使用。 - **非线程安全**,同一 `virtualShell` 对象(C++ 实例 / C 结构体)的操作应在同一线程内串行化,或者外部加锁。 - **C 版内存管理**:务必在对象用完后调用 `vs_free()` 释放内部缓冲并回收子进程;`vs_get_cmd_out()` 返回的指针在下次调用同一对象的方法或 `vs_free()` 后即失效,需要长期持有请自行 `strdup`/拷贝。 ## 📄 开源许可 本项目采用 **MIT 许可证**,允许自由使用、修改和分发。 *(请将以下许可证文本放入 `LICENSE` 文件)* ``` MIT License Copyright (c) [年份] [您的姓名/组织] Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: ... ``` ## 🤝 贡献 欢迎提交 Issue 或 Pull Request,共同改进这个项目。