940 字
5 分钟
如何假装输入

应该是本站第一个正经技术博客,但是离建立这个技术博客站已经过去一年了

起因#

最近在用 C++ 写一个终端放置游戏。既然是游戏,存档功能是刚需。但我很快发现了一个极度破坏体验的地方:存档路径。

肯定不能让用户每次都手写绝对路径,那样也太过分了,于是就有了预留一些输入的需求。用户觉得满意直接按回车,不满意就在此基础上修改。

terminal

之前为了实现基本的游戏功能就写了一个非阻塞的键盘监听

void InputMonitor::initInput() {
struct termios newt = {};
tcgetattr(STDIN_FILENO, &oldt);
newt = oldt;
newt.c_lflag &= ~(ICANON | ECHO);
tcsetattr(STDIN_FILENO, TCSANOW, &newt);
// set non-blocking
oldf = fcntl(STDIN_FILENO, F_GETFL, 0);
fcntl(STDIN_FILENO, F_SETFL, oldf | O_NONBLOCK);
}
void InputMonitor::resetInput() {
tcsetattr(STDIN_FILENO, TCSANOW, &oldt);
fcntl(STDIN_FILENO, F_SETFL, oldf);
}
int InputMonitor::pollInput() {
int ch = getchar();
if (ch != EOF) return ch;
return -1;
}

然后理所当然的觉得,既然输入路径是一个长字符串,那直接回归阻塞然后模拟一下输入就可以了。第一版代码大概就是这样:

game->setHalt(true);
game->monitor->pause();
...
std::string filePath;
std::filesystem::path cwd = std::filesystem::current_path();
std::string defaultPath = cwd.string();
if (!defaultPath.empty() && defaultPath.back() != std::filesystem::path::preferred_separator) {
defaultPath += std::filesystem::path::preferred_separator;
}
std::getline(std::cin, filePath);

经过一番搜索和考虑一些跨平台的需求,在unix系统上用了TIOCSTI,允许进程向终端注入字符,模拟是用户敲出来的。在windows上则使用很丑陋的模拟keyEvent

#ifdef _WIN64
HANDLE hInput = GetStdHandle(STD_INPUT_HANDLE);
for (char c : defaultPath) {
INPUT inputs[2] = {};
inputs[0].EventType = KEY_EVENT;
inputs[0].Event.KeyEvent.bKeyDown = TRUE;
inputs[0].Event.KeyEvent.uChar.AsciiChar = c;
inputs[0].Event.KeyEvent.wRepeatCount = 1;
inputs[1].EventType = KEY_EVENT;
inputs[1].Event.KeyEvent.bKeyDown = FALSE;
inputs[1].Event.KeyEvent.uChar.AsciiChar = c;
inputs[1].Event.KeyEvent.wRepeatCount = 1;
DWORD written;
WriteConsoleInputA(hInput, inputs, 2, &written);
}
#else
for (char c : defaultPath) {
ioctl(STDIN_FILENO, TIOCSTI, &c);
}
#endif

一言以蔽之TIOCSTI能直接往终端发送字符串,塞进cin的缓冲区里。不过这个设计有很多神秘的问题,比如默认路径/Users/calcite/calcite 会被读成 Users/calcite/calcite

刚开始怀疑是缓冲区没清空之类的问题,索性加了一行清空,然后就变成了/sers/calcite/calcite

只能说是非常神秘,疑似是因为 TIOCSTI 注入的速度极快,且异步于 shell 或程序的 cin 读取。当程序还没准备好接收输入,或者终端驱动在处理特殊字符(如 /)时存在竞争之类的。

经过一番搜索,没找到有类似问题的人,但是找到了这个issue 大概来说,就是这个功能实在是有够危险,所以被很多linux发行版和mac弃用了,既然OS没有明确支持,那感觉也没必要在mac上死磕了。

作者提了bash和zsh可以用shell函数,自然的想到了bash的历史命令功能,按了上键就能改之前的命令。

那么是怎么做到的呢?

搜了一下,大概来说终端其实并没有真的等你输入然后把缓冲区打印出来,只是在按下键盘之后实时的更新了一下缓冲区的字符然后

“假装是打印缓冲区的字符”

基于这个思路写了一个自定义的readLine,不再依赖内核注入,是在用户界面层“伪造”了一个输入过程:

std::string InputMonitor::readLine(const std::string& prompt, const std::string& defaultInput) {
struct termios oldt = {}, newt = {};
tcgetattr(STDIN_FILENO, &oldt);
newt = oldt;
newt.c_lflag &= ~(ICANON | ECHO);
tcsetattr(STDIN_FILENO, TCSANOW, &newt);
std::string buffer = defaultInput;
std::cout << prompt << buffer << std::flush;
while (true) {
char ch;
if (read(STDIN_FILENO, &ch, 1) > 0) {
if (ch == '\n') { // Enter
std::cout << std::endl;
break;
} else if (ch == 127 || ch == '\b') { // Backspace
if (!buffer.empty()) {
buffer.pop_back();
// Move cursor back, overwrite with space, move back again
std::cout << "\b \b" << std::flush;
}
} else if (static_cast<unsigned char>(ch) >= 32 && ch != 127) {
buffer += ch;
std::cout << ch << std::flush;
}
}
}
tcsetattr(STDIN_FILENO, TCSANOW, &oldt);
return buffer;
}

然后就好了。

如何假装输入
https://calc1te.github.io/posts/fake_type/type/
作者
Calc1te
发布于
2026-02-24
许可协议
CC BY-NC-SA 4.0