Windows 文本按键注入

SKILL开发
开发 未标注权限 许可证不明确
安装这个技能

用纯 ctypes + SendInput 把文本和按键注入到 Windows 当前焦点窗口(不打字到错误的地方、不触发输入法候选框、支持中文/emoji、支持回车 Esc Tab 方向键和 Ctrl+V 这类组合键、支持写系统剪贴板);也覆盖 PC 端常驻服务侧(.NET8 托盘程序、气泡通知、托盘闪烁、电脑剪贴板反向回传到手机、防回环)。当用户说"勾了回车却没换行""没打回车""粘贴完没回车""注入的按键不生效""莫名打开了文件菜单/菜单自己弹开""注入后记事本菜单被激活""自动输入到电脑""模拟键盘""帮我在电脑上按回车""把文字打到光标处""手机/远程控制电脑输入""剪贴板同步到电脑/回传手机""托盘通知""automation type text windows""simulate keyboard windows"时使用。

一句话选型结论

按资料字段整理

Windows 文本按键注入是开发类的 Agent Skill,安装方式未标注,权限级别未标注,许可证不明确,有可直接复制的安装命令。技能本质是给模型读的说明书,效果取决于模型与客户端;装完先用一个小任务验证,再放进正式流程。

分类开发 安装方式未标注 权限级别未标注权限 许可证许可证不明确
累计安装 未标注 本周 未标注
GitHub 星标 未标注 仓库 未标注
下载量 未标注 技能市场下载数
权限级别 未标注权限 安装前请确认风险范围
许可证 许可证不明确 未说明

安装

未标注
在支持 Skills 的客户端里执行,装完问一句「你有哪些技能」验证
skillhub install user_825f001d/win-sendinput-injection
  1. 1
    确认客户端支持

    确认你的客户端支持 Skills(Claude、Codex、Cursor 等),并升级到较新的版本。

  2. 2
    拿到技能文件

    执行上面的命令安装;也可以把仓库里的 SKILL.md 手动放进客户端的 skills 目录。

  3. 3
    刷新技能列表

    重新打开客户端,或刷新技能列表,让新放进来的技能被加载。

  4. 4
    验证是否生效

    问一句「你有哪些技能」,确认列表里出现它,再拿一个小任务试跑。

技能档案

开发
字段来自技能仓库与公开索引,中文说明由本站标注
作者 / 维护方user_825f001d仓库 未标注 · 版本 未标注
宿主客户端未标注技能可被这些客户端加载
安装方式未标注安装包 community
权限级别未标注权限安装前请确认风险范围
许可证未说明许可证不明确,商用前请自行确认条款
仓库可用性available技能仓库当前是否可访问
最近核验未标注上游最后核验时间 · 数据更新 1791621859
正文体量48.9 KBSKILL.md 原文大小,越大说明说明书写得越细

典型使用场景

3 类
按分类整理的常见用法,具体以 SKILL.md 为准
写代码与改 bug

按需求补代码、定位并修掉报错。

读仓库与生成改动

让模型读懂项目结构,再给出可落地的改动。

跑测试与排查故障

按现象排查问题,补测试用例并复跑。

选择建议

装之前先看这几条
按条目字段给出的判断,不替你决定用不用

值得选的地方

  • 有可直接复制的安装命令,不用自己翻文档找入口。

先想清楚的地方

  • 没有标注权限级别,实际能做什么要装好后确认。
  • 许可证不明确,商用或二次分发前先到仓库确认条款。
  • 没有做站内核验,内容以作者仓库为准。
  • 技能只是说明书,实际效果取决于模型与客户端,装完先用小任务试一遍。

更适合

  • 需要在开发场景里给模型补一套做法的人。
  • 愿意先读一遍 SKILL.md、确认它会让模型做什么的人。

不太适合

  • 对许可证要求很严、必须明确可商用的人。
  • 只想装完就自动变强、不打算看说明书的人。

同类对比

6 个同类
同分类里挑最接近的几条,字段以各自详情页为准
技能权限级别安装方式累计安装宿主客户端安装命令
Windows 文本按键注入当前 未标注权限 未标注 未标注 未标注 有
技能审查员 浏览器控制 ClawHub 市场 877.9 万 OpenClaw 有
Vercel React 最佳实践 浏览器控制 GitHub 仓库 849.1 万 Codex、Claude、Cursor 有
GitHub 代码协作 联网访问 ClawHub 市场 763.3 万 OpenClaw 有
网页设计指南 联网访问 GitHub 仓库 668.6 万 Codex、Claude、Cursor 有
本体论 只读 ClawHub 市场 663.7 万 OpenClaw 有

SKILL.md 全文

48.9 KB
模型实际读到的说明书原文,来自技能仓库

Windows 文本 / 按键注入(SendInput)

什么时候用

要把内容"打进"某个 Windows 程序(编辑器、聊天框、终端、浏览器)而又不想装 pyautogui / keyboard 这类第三方库时,直接用 ctypes 调 SendInput 就够了——零依赖、免安装。

典型场景:手机当电脑的输入外设、远程粘贴、自动化脚本、录屏演示。

三条铁律

  1. 目标是"当前前台窗口"。注入前先 GetForegroundWindow() + GetWindowTextW() 拿到标题,确认打对了地方再动手——打错窗口是不可撤销的。
  2. 中文/emoji 走 Unicode 注入(KEYEVENTF_UNICODE),不要用 VK 码去模拟输入法。Unicode 注入绕过 IME,不会弹候选框、不会丢字。
  3. 注入前必须确认焦点在自己可控的窗口上。自动化测试时用一个带"抢前台 + 写 marker 文件"的受控窗口做靶子;如果焦点被别人抢走就中止注入,绝不盲打用户桌面。

结构体(必须精确,否则静默失败)

class KEYBDINPUT(ctypes.Structure):
    _fields_ = [("wVk", wintypes.WORD), ("wScan", wintypes.WORD),
                ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD),
                ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong))]

class MOUSEINPUT(ctypes.Structure):
    _fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG),
                ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
                ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong))]

class HARDWAREINPUT(ctypes.Structure):
    _fields_ = [("uMsg", wintypes.DWORD), ("wParamL", wintypes.WORD), ("wParamH", wintypes.WORD)]

class _U(ctypes.Union):
    _fields_ = [("ki", KEYBDINPUT), ("mi", MOUSEINPUT), ("hi", HARDWAREINPUT)]

class INPUT(ctypes.Structure):
    _fields_ = [("type", wintypes.DWORD), ("u", _U)]

自检一行:ctypes.sizeof(INPUT) 在 64 位下必须是 40(32 位是 28)。不是就说明结构体写错了,后面全部会静默失败。

文本注入

KEYEVENTF_KEYUP = 0x0002
KEYEVENTF_UNICODE = 0x0004

def send_unicode_text(text):
    units = text.encode("utf-16-le")     # 注意:按 UTF-16 码元,不是字符
    events = []
    for i in range(0, len(units), 2):
        code = int.from_bytes(units[i:i+2], "little")
        for flags in (KEYEVENTF_UNICODE, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP):
            ev = INPUT(type=1)
            ev.u.ki = KEYBDINPUT(0, code, flags, 0, None)   # wVk=0,字符放 wScan
            events.append(ev)
    arr = (INPUT * len(events))(*events)
    return user32.SendInput(len(events), arr, ctypes.sizeof(INPUT))   # 返回成功注入的事件数

每次调用批量提交所有事件(别一个个 SendInput,慢且容易被目标程序丢事件)。

换行别指望它:Unicode 注入里的 \n 只当控制字符,多数程序不认。要换行就拆行、中间补 VK_RETURN:

def send_text_multiline(text):
    lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
    n = 0
    for i, line in enumerate(lines):
        if i:
            n += send_keys("enter"); time.sleep(0.012)
        if line:
            n += send_unicode_text(line)
    return n

按键 / 组合键

KEYEVENTF_EXTENDEDKEY = 0x0001
MAPVK_VK_TO_VSC = 0

VK = {"enter": 0x0D, "tab": 0x09, "esc": 0x1B, "backspace": 0x08,
      "delete": 0x2E, "insert": 0x2D, "space": 0x20,
      "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,
      "home": 0x24, "end": 0x23, "pageup": 0x21, "pagedown": 0x22,
      "ctrl": 0x11, "alt": 0x12, "shift": 0x10, "win": 0x5B}
for c in range(26): VK[chr(97+c)] = 0x41 + c
for c in range(10): VK[str(c)] = 0x30 + c
for i in range(1, 13): VK["f%d" % i] = 0x6F + i

EXTENDED = {0x26, 0x28, 0x25, 0x27, 0x24, 0x23, 0x21, 0x22, 0x2D, 0x2E}

def _ev(vk, up=False):
    flags = KEYEVENTF_KEYUP if up else 0
    if vk in EXTENDED:                      # 方向键/翻页/Ins/Del 必须标扩展,否则部分程序收不到
        flags |= KEYEVENTF_EXTENDEDKEY
    scan = user32.MapVirtualKeyW(vk, MAPVK_VK_TO_VSC) & 0xFF   # 真扫描码,兼容 DirectInput 类程序
    ev = INPUT(type=1); ev.u.ki = KEYBDINPUT(vk, scan, flags, 0, None)
    return ev

def send_keys(spec):
    """spec: "ctrl+v" / "ctrl+shift+t" / ["ctrl","v"]"""
    seq = spec.replace("+", " ").split() if isinstance(spec, str) else list(spec)
    vks = [VK[n.lower()] for n in seq]       # 未知名直接抛 KeyError,别静默吞
    evs = [_ev(v) for v in vks[:-1]]         # 修饰键按下
    evs += [_ev(vks[-1]), _ev(vks[-1], True)]  # 主键按下+抬起
    evs += [_ev(v, True) for v in reversed(vks[:-1])]  # 修饰键抬起(倒序)
    arr = (INPUT * len(evs))(*evs)
    return user32.SendInput(len(evs), arr, ctypes.sizeof(INPUT))

要点:修饰键必须真的按顺序按下再抬起,不能只发主键;Ctrl+V 这类组合键在多数程序里比逐字注入快得多(长文本用粘贴模式)。

写系统剪贴板(配合 Ctrl+V,长文本首选)

CF_UNICODETEXT, GMEM_MOVEABLE = 13, 0x0002

def write_clipboard_text(text):
    data = (text + "\0").encode("utf-16-le")
    for _ in range(5):                       # 剪贴板常被别人占着,一定要重试
        if user32.OpenClipboard(None):
            try:
                user32.EmptyClipboard()
                h = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(data))
                ptr = kernel32.GlobalLock(h)
                ctypes.memmove(ptr, data, len(data))
                kernel32.GlobalUnlock(h)
                if not user32.SetClipboardData(CF_UNICODETEXT, h):
                    kernel32.GlobalFree(h)   # 失败才自己释放
                    return False
                return True                  # 成功后内存归系统,**绝不能再 GlobalFree**
            finally:
                user32.CloseClipboard()
        time.sleep(0.05)
    return False

副作用要提前说:写剪贴板会覆盖用户当前剪贴板内容。如果同时有"剪贴板监听"类的程序在跑,会形成回环(自己写的又被自己读回来再注入一次)——两条路别同时开。

先 argtypes 再调用

ctypes 不写 argtypes 时,指针/句柄在 64 位下会被按 32 位截断 → 随机失败。至少给这些写上:

user32.SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int]
user32.GetForegroundWindow.restype = ctypes.c_void_p
user32.GetWindowTextW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_int]
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL

自动化测试时的靶子

要验证"真能输入",别拿用户桌面试,起一个受控 WinForms 窗口:

  • TopMost = true,定时器里循环 ShowWindow(hwnd, 9) + SetForegroundWindow(hwnd),拿到焦点后往磁盘写一个 focused.flag;
  • 测试脚本等到 flag 存在、且服务端报告的焦点窗口标题确实是靶子,才开始注入;否则直接中止;
  • 靶子把 TextBox 内容定期落盘成 out.txt,用 od -c out.txt 看 \r\n,验证回车真的进去了;
  • TextBox 要开 AcceptsReturn = true / AcceptsTab = true,否则回车和 Tab 不落。

为什么必须这么小心:Activate() 单独调用经常抢不到前台(Windows 有防抢焦点机制),焦点会留在用户正在用的浏览器/编辑器上,测试文字就打进那儿了——而且不可撤销。

环境相关

  • 本机 bash 里不能调 cmd.exe / powershell.exe(安全策略拦截),taskkill //F 这种 cmd 风格参数也不认;杀进程改用 PowerShell 工具。
  • powershell.exe 的 stdout 常不回显,需要输出的结果写文件再读。

C# / .NET 版本(做「手机 → 电脑键盘注入」服务时用)

实测工程 D:/WorkBuddy/Claw/CloudClip-Win(.NET 8 Minimal API + WinForms 托盘,/sendtext 注入、/sendclip 只写剪贴板),三个必踩的坑:

  1. 剪贴板必须 STA:ASP.NET Core 的请求线程是 MTA,Clipboard.SetText 直接抛异常。做法是每次临时起一个 STA 线程写,并带重试:
    csharp var t = new Thread(() => System.Windows.Forms.Clipboard.SetDataObject(text, true, 10, 80)); t.SetApartmentState(ApartmentState.STA); t.Start(); if (!t.Join(TimeSpan.FromSeconds(6))) throw new TimeoutException("写剪贴板超时");
  2. SendInput 的 INPUT 结构体 x64 必须 40 字节:union 里要同时放 KEYBDINPUT/MOUSEINPUT/HARDWAREINPUT(只放键盘结构会变成 32 字节,静默失败)。用 Marshal.SizeOf<INPUT>() 自检,失败时退回 keybd_event。
  3. app.MapPost("/x", Handler) 会把响应体丢掉:编译器会挑 MapPost(string, RequestDelegate) 重载,Task<IResult> 的返回值被丢弃(响应 200 但 body 空),并给 ASP0016 警告。写成 app.MapPost("/x", (Delegate)Handler); 才会绑到返回 IResult 的重载。
  4. 顺序与节奏:写剪贴板 → await Task.Delay(150) → Ctrl+V → await Task.Delay(120) → Enter。回车太快会被部分程序吞掉。
  5. 注入目标是当前前台窗口;目标程序是管理员权限而自己不是时会被 UIPI 拦掉。日志里记下注入瞬间的前台窗口(进程名 + 标题)非常好用。

受控靶子 + 焦点闸门(避免盲打用户桌面)

CloudClip-Win/tools/focus_target.py(纯 ctypes)建一个带多行 EDIT 的窗口当靶子,铁律是只有焦点真的就绪才允许注入:

  • 容器窗口的窗口过程必须处理 WM_SETFOCUS → SetFocus(edit)。只在外面调一次 SetFocus 不够:前台窗口拿到焦点了、但键盘焦点还在顶层窗口,Ctrl+V 就没有目标,表现为"注入了但什么都没发生"。
  • 就绪判据要同时满足:GetForegroundWindow() == hwnd 且 GetGUIThreadInfo(GetCurrentThreadId()).hwndFocus == edit;两者都真才写 focused.flag。
  • 靶子循环里持续重抢前台(AttachThreadInput + BringWindowToTop + SetForegroundWindow),并把 EDIT 内容每次变化写 out.txt 的 repr。
  • 测试脚本轮询 focused.flag,没有 flag 就直接放弃测试,绝不向未知窗口盲打。
  • 实测证据:靶子编辑框收到 'ABU-INJECT-7f3a\r\n' —— 说明 Ctrl+V 与 Enter 都真的落进去了。
  • 交叉验证剪贴板用同目录 clip_read.py(ctypes 直接读 CF_UNICODETEXT;注意 GetClipboardData 在 user32 里,不在 kernel32)。

托盘通知 / 闪烁 / 反向剪贴板回传(v2 追加)

手机做完"发指令"之后通常还要"看得见 + 双向同步",这几块在 CloudClip-Win 里都已验证:

  • 气泡通知:NotifyIcon.ShowBalloonTip(4000, 标题, 正文, ToolTipIcon.Info)。气泡正文有长度上限,截到 ~200 字。
    注意 NotifyIcon 是 Component 不是 Control,没有 Invoke;HTTP 处理跑在线程池线程上,
    所以要在 UI 线程建一个隐藏 Control 当封送器:_marshal = new Control(); var _ = _marshal.Handle;
    (必须在 UI 线程先摸一下 .Handle 强制建句柄),之后 BeginInvoke(...) 才有效。
  • 托盘闪烁 3 秒:System.Windows.Forms.Timer(Interval=250) 在两张预生成的 Icon 之间来回切,
    12 次 tick 正好 3 秒,结束时还原成常态图标。图标用 Bitmap.GetHicon() 运行时画,免去外挂 .ico
    (拿到 32×32 位图后 Icon.FromHandle(h) → Clone() 一份再 DestroyIcon(h),否则句柄泄漏/图标丢失)。
  • 反向:电脑剪贴板 → 手机:独立 STA 后台线程每 250ms 轮询 Clipboard.ContainsText()/GetText()(读剪贴板同样要求 STA),
    过滤空/纯空格、与上一条完全重复的内容,然后 POST http://{clientIp}:8080/receiveclip。
    两个必须做的细节:
    1. 防回环:服务自己写进剪贴板的内容(/sendtext、/sendclip 写的)要登记到 HashSet 里标记"自己写的",
    下一轮读到就跳过,否则手机发来的文字会立刻被弹回手机;
    2. HttpClient 必须 new HttpClientHandler { UseProxy = false, Proxy = null } —— 系统代理会把局域网
    http://192.168.x.x:8080 请求劫持掉,表现为莫名其妙的连接失败。
  • clientIp 从请求里自动学:ctx.Connection.RemoteIpAddress(先 IsIPv4MappedToIPv6 → MapToIPv4(),
    ::1 归一成 127.0.0.1),落到磁盘,重启后恢复;托盘菜单再给一个手动设置入口兜底。
  • 注意 AppContext.BaseDirectory:./build/CloudClip-Win.exe 跑起来时基目录是 build/,
    配置文件/日志都在 build/ 下,别去项目根目录删/找。
  • 验证回传链路:写个假手机接收端(tools/fake_phone.py,ThreadingHTTPServer 收 POST /receiveclip
    按行落盘),PC 端用 Set-Clipboard 改剪贴板即可触发,日志里能看到 回传结果 200。

自动化注入测试:发之前必须硬校验靶子在最前(否则会打到用户窗口)

只靠“靶子自己写了个 ready 标志文件”来判断就绪是不可靠的:

  • 如果 rm -rf 旧目录 失败(本机 safe-delete 会拦 trash 操作,报
    SAFE_DELETE_FAIL_CLOSED ... trash-failed),旧目录里的 ready 标志还在,
    检测循环立刻通过 → 你以为靶子起来了,其实没有 → 注入直接落到用户当前的前台窗口
    (实测两次把测试文本 + 回车打进了 WorkBuddy,用户会看到莫名其妙的一行字)。

可靠做法:三重校验,全满足才发:

import ctypes, time, os
u = ctypes.windll.user32
h = u.FindWindowW(None, "靶子窗口标题")
fg = u.GetForegroundWindow()
age = time.time() - os.path.getmtime("flag 文件")
ok = (h != 0) and (h == fg) and (age < 25)
  1. FindWindowW 能按标题拿到 hwnd —— 窗口真的存在
  2. GetForegroundWindow() == hwnd —— 它在最前,注入不会跑偏
  3. ready 标志是最近写的 —— 防止旧文件骗人

不满足就放弃注入并打印原因,不要“重试到成功”。实测加上校验后,成功拦下一次
(窗口存在但不在前台 → 不注入),避免了又一条误注入。

另外:清测试目录别用 rm -rf,改用每次全新的目录名(.tmp/t3、.tmp/t4…),
或用 python -c "import shutil; shutil.rmtree('...')"。靶子脚本本身也要在启动时
SetForegroundWindow + SetFocus 到目标编辑框,并把结果写进日志便于事后核对。

判定“误注入”是否发生

靶子目录里的 out.txt(编辑框最终内容)是铁证:如果它就是你没发过的旧文本,
说明这次注入根本没进靶子。对照 target.log 的时间戳(time.time() 秒)确认是哪一次运行。

完整可跑的托盘桥参考实现

D:/WorkBuddy/Claw/CloudClip-Win/(.NET 8 Minimal API + WinForms 托盘,972 行单文件 Program.cs)
是一份可直接复用的骨架,包含本 skill 涉及的全部要点:

  • Clipboard.WriteText 起临时 STA 线程 + 6s Join 超时 + SetDataObject(text, true, 10, 80) 重试
  • SendInput 结构体严格定义 + 失败退回 keybd_event;Native.DescribeForeground() 记录前台窗口,
    出问题时能立刻看出「粘到别的窗口去了」
  • 托盘图标运行时用 GDI+ 画(bmp.GetHicon() + DestroyIcon),免外挂 .ico
  • 跨线程弹通知:Control 当封送器(_marshal.BeginInvoke),因为 NotifyIcon 只能在 UI 线程碰
  • 托盘闪烁:System.Windows.Forms.Timer 250ms × 12 次 = 3 秒,两个图标交替
  • 剪贴板监听防回环:自己写进去的文本登记进 HashSet,监听线程读到就跳过(否则手机发来会立刻弹回去)
  • 出站 HttpClient 必须 UseProxy=false,否则系统代理会劫持局域网请求

安全地做「注入测试」(重要,否则会打到用户屏幕上)

要验证 /sendtext 真的注入成功,得有个受控窗口当靶子(tools/focus_target.py:Win32 建窗口 +
EDIT 控件 + SetForegroundWindow + SetFocus 到编辑框,把编辑框内容落到 out.txt)。
发送前必须做三重硬校验,缺一不可:

  1. FindWindowW(None, 标题) 拿到的 hwnd 非 0
  2. GetForegroundWindow() == hwnd(真的是最前窗口)
  3. 就绪标志文件的 mtime < 25 秒(防止读到上一轮残留的旧标志)

三条不满足就放弃注入,不要"赌一把"。踩过的坑:rm -rf 清靶子目录被 safe-delete 拦下 →
脚本没启动 → 但旧目录里的 focused.flag 还在 → 检测循环立刻 break 以为"就绪" → Ctrl+V+Enter
直接打进了用户前台窗口(WorkBuddy)。加上硬校验后成功拦下过一次。

本机删目录的雷(safe-delete)

rm -rf &lt;目录&gt; / shutil.rmtree() / os.remove() 都可能被 safe-delete 策略拦下,报
[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED](阈值 50 文件)或
[safe-delete][SAFE_DELETE_FAIL_CLOSED] trash-failed(回收站操作被中止)。
可控做法:① 不要清目录,直接换个新目录名(.tmp/t1 → .tmp/t2);
② 非要清,用 open(path,&#x27;w&#x27;).close() 截断内容 + os.rename() 改名(改名不触发回收站)。

局域网服务要让手机连上:防火墙 + 写「一键放行」.bat 的四个雷

托盘服务只监听 0.0.0.0:5000 是不够的 —— Windows 防火墙默认拦入站,
必须显式加规则,否则手机表现是 SocketTimeoutException(超时)。
判定技巧:超时 = 防火墙丢包;ConnectionRefused(立刻拒绝)= 服务没在跑 / 端口没开。
这两个报错指向完全不同的排查方向,先看是哪种再动手。

netsh advfirewall firewall add rule name=&quot;CloudClip-Win 5000 inbound&quot; dir=in action=allow protocol=TCP localport=5000

写这种「双击放行」的 .bat 时,下面四件事不注意,用户就会看到黑框一闪、什么反应都没有:

  1. 提权不要用裸名字 powershell。powershell -Command &quot;Start-Process -FilePath &#x27;%~f0&#x27; -Verb RunAs&quot;
    在 PATH 被裁剪 / 安全策略收紧的机器上会静默失败,UAC 都不弹。
    必须用绝对路径:
    bat set &quot;PSEXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe&quot; &quot;%PSEXE%&quot; -NoProfile -ExecutionPolicy Bypass -Command &quot;Start-Process -FilePath &#x27;%~f0&#x27; -Verb RunAs&quot;
    同理 netsh 也建议写成 %SystemRoot%\System32\netsh.exe。
  2. 换行必须是 CRLF。用代码生成 .bat 时很容易写成 LF,而批处理的
    if (...) / for (...) 多行括号块在 LF 下会解析错(报语法错或行为诡异)。
    生成后务必 s.replace(&#x27;\n&#x27;,&#x27;\r\n&#x27;) 并去掉 UTF-8 BOM(BOM 配 chcp 65001 会吐出乱码)。
  3. 失败必须可见。提权那条命令后面要接 if errorlevel 1 (...),把
    「手动 netsh 命令」直接 echo 出来让用户复制到管理员 cmd 里跑 —— 这是 100% 有效的兜底,
    比让用户猜「为什么没反应」好得多。
  4. 放桌面根目录。藏在 桌面\某子文件夹\ 里,用户按「桌面上的脚本」去找大概率找不到,
    会误判成「脚本坏了」。带个序号前缀(① xxx.bat)更好认。

自检清单(建规则后跑一遍):

# 规则到底建上没有(返回空 = 没建成功)
powershell -NoProfile -Command &quot;Get-NetFirewallRule | Where-Object { \$_.DisplayName -like &#x27;*CloudClip-Win*&#x27; } | Select DisplayName,Direction,Action,Enabled&quot;
# 三档防火墙是不是都开着(都开就必须放行)
powershell -NoProfile -Command &quot;Get-NetFirewallProfile | Select Name,Enabled,DefaultInboundAction&quot;

注意:本机 PowerShell 工具调用产出不会回显到 stdout(Write-Output 也拿不到),
要判断结果得 Out-File 写文件再 Read 那个文件。另外 Bash 里不要调 powershell.exe
(会被安全策略拦下,提示 "Invoking PowerShell from Bash bypasses PowerShell security checks"),
用 PowerShell 工具;PowerShell 工具里也不要调 cmd.exe / mshta.exe(判为 LOLBin 被拦)。

托盘程序「点退出卡死」——WinForms 消息循环 + Kestrel 的经典死锁

症状(很容易误判成"程序还有个后台进程没关"):
- 右键托盘点【退出】,程序没退
- 托盘图标还在,点也点不掉
- 5000 端口不再监听(手机连不上了)
- 但 tasklist 里进程还活着,只能手动 taskkill

日志上的铁证:停在 Application is shutting down... 之后再无输出
(如果是在 UI 线程同步等,就会看到这行然后永远不动)。

根因一:UI 线程同步等待与 Kestrel 续体互锁(主因)

托盘菜单回调跑在 UI(消息循环)线程上,而回调里往往直接写了:

// ❌ 死锁写法
app.StopAsync(TimeSpan.FromSeconds(3)).GetAwaiter().GetResult();

StopAsync() 内部要跑一堆 async 续体,其中部分需要回到 UI 线程
(存在 SynchronizationContext 时)。可此刻 UI 线程正被 GetResult() 阻塞着,
续体永远排不上 → 互相等待,真死锁。
HttpClient.GetAwaiter().GetResult()、任何 .Result、.Wait() 都有同样风险。

✅ 正确做法:UI 线程上绝不做阻塞等待,停服动作丢后台线程,UI 立刻返回继续泵消息:

// 菜单回调里只做这件事
private void RequestExit()
{
    if (Interlocked.Exchange(ref _exiting, 1) != 0) return;   // 防重复点击
    ClipWatcher.Stop();

    // 停服放后台线程 —— 这里等多久都不会卡住 UI
    var stopper = new Thread(() =&gt;
    {
        try { _appStop(TimeSpan.FromSeconds(2)); }
        finally { try { ExitThread(); } catch { } }           // 停完再关消息循环
    }) { IsBackground = true, Name = &quot;shutdown&quot; };
    stopper.Start();

    // 看门狗兜底:4 秒还没退干净就硬退
    var watchdog = new Thread(() =&gt;
    {
        if (!stopper.Join(TimeSpan.FromSeconds(4))) Log(&quot;看门狗:强制退出&quot;);
        FinalizeAndExit(0);
    }) { IsBackground = true, Name = &quot;exit-watchdog&quot; };
    watchdog.Start();
}

/// &lt;summary&gt;原子收尾,保证只执行一次(优雅退出与看门狗会竞争)。&lt;/summary&gt;
private static int _finalized;
private static void FinalizeAndExit(int code)
{
    if (Interlocked.Exchange(ref _finalized, 1) != 0) return;
    Log(&quot;已退出。&quot;);
    Environment.Exit(code);
}

根因二:ApplicationContext.ExitThread() 不会 Dispose 你的 NotifyIcon

TrayUi : ApplicationContext 时,ExitThread() 只让消息循环退出,
不负责释放 NotifyIcon —— 所以图标残留在通知区点不掉,
用户看到的就是「卡在没退」。
✅ 靠 using (var tray = new TrayUi(...)) { Application.Run(tray); },
在 Application.Run 返回后走 Dispose 收掉图标。
另外别忘了 ClipWatcher 这类后台线程要提供 WaitStop(timeout) 并真正 Join,
只置一个 _run = false 不等于线程结束了。

怎么自动化回归(别靠手点托盘图标)

给程序加一个隐藏参数,走和点菜单完全相同的代码路径:

var selftestExit = Array.IndexOf(args, &quot;--selftest-exit&quot;) &gt;= 0;
// 启动 2 秒后(等 Kestrel 完全就绪)自动触发
if (selftestExit)
    new Thread(() =&gt; { Thread.Sleep(2000); tray.RequestExitForTest(); })
        { IsBackground = true }.Start();

然后测试脚本断言四件事(tools/test_exit.py):
1. 进程在限定时间内真的退出(proc.poll() 不为 None),不是残留
2. 5000 端口被释放
3. 日志里出现完整轨迹(退出开始 → Web 服务已停止 → 已退出。),
没有出现看门狗字样(出现说明只是被硬杀,优雅退出仍失败)
4. 全程耗时在合理范围

再补一个高负载版(tools/test_exit_under_load.py):起 6 个线程猛打接口
(让 Kestrel 线程池、剪贴板监听、UI 的 BeginInvoke 队列全忙起来)再退出。
这个版本最有价值 —— 空载往往测不出死锁。实测结论:

场景 结果
空载退出 2.64 秒干净退出,端口释放,看门狗未触发
6 线程并发(191 次真实请求)下退出 2.97 秒干净退出,看门狗未触发

顺带一个排查教训:想确认服务到底退没退,别只看任务管理器里「进程还在」,
要同时看 端口是否还在监听。老版本的现象是「进程在、端口没了」——
这说明它已经走到 StopAsync 但卡在里面,而不是「压根没收到退出指令」。
这两种情况的排查方向完全不同。


通知策略:高频同步用「托盘闪烁」,别用气泡通知

托盘常驻程序要提示事件时,先分清事件频率和是否用户主动触发:

事件 建议做法 理由
用户主动操作的结果(点了发送、下了指令) 气泡通知 ShowBalloonTip 低频、用户正等着反馈,通知有意义
后台高频同步(剪贴板同步、文件同步、心跳) 只闪托盘图标,不弹通知 通知会盖住用户正在看的窗口内容,几十秒一次会让人想卸掉这个程序
程序启动 一次气泡 顺带让图标在 Win10/11 的溢出区里「露面」,不然第一次可能看不见

「只闪烁」也要留一个不打扰的反馈通道:闪烁期间把 NotifyIcon.Text
换成事件描述(如「CloudClip-Win · 收到手机剪贴同步」),鼠标悬停即可看到;
闪烁结束后恢复成常规状态文字。这样既不弹窗,也不至于完全没有痕迹。

实现上把两个动作拆成独立方法,别写成 FlashAndNotify 这种二合一的固定组合
——不然想改策略就得改调用点,还容易漏:

public static void Notify(string title, string body) { /* 只弹气泡 */ }
public static void Flash() { /* 只闪图标 */ }

最后用 grep -n ShowBalloonTip &lt;文件&gt; 清点全项目剩余通知点,确认没有漏改的调用。


工具使用坑:同一文件的多次编辑必须串行

在一条 message 里对同一个文件并行发多个 Edit 调用,会出现静默丢改动:
每个调用都返回「Successfully edited」,但实际只有其中一个落地(后写的覆盖前写的)。
本项目就踩过:一次并行改两处文案,其中一处始终没生效。

做法:同一文件的多次编辑串行发出;改完用 grep -n &lt;新文案&gt; 复核,
不要只信「成功」提示。批量改文案时更稳的是写个 Python 脚本一次性替换 + 打印逐项结果。


局域网回传失败的三段式诊断(别只丢一句 Timeout)

手机做接收端(App 内自建 HTTP 服务)时,电脑推不过去的原因至少有三种,
处理方式完全不同。只打一句 HttpClient.Timeout 用户没法判断动哪一头。

按顺序探两步,就能把结论收敛到唯一一条:

1) ping &lt;手机IP&gt;            → 判定「手机在不在这个网上」(网络层)
2) TCP connect &lt;IP&gt;:8080    → 判定「接收服务在不在」(应用层)
ping 端口 结论
通 通 只是 App 在处理或刚被唤醒,重试即可
通 不通 网络没问题,是接收端进程被系统清理了 → 让用户打开 App 留在前台、关电池优化
不通 — 设备不在网段了(IP 变了/换了网络)→ 重新上报 IP,或用菜单手动改

关键判据:timed out 和 connection refused 含义不同,别混为一谈。
- connection refused(秒回):能到主机,端口没人监听 —— TCP 栈回了 RST。
- timed out(等到超时):连 RST 都没人回。局域网里出现这个,通常意味着
目标端的监听 socket 根本不存在、且系统把 SYN 静默丢弃(Android 在应用被冻结时就是这样),
或者这个 IP 上压根没有主机。

实现要点
- 探测放在独立方法里返回结论字符串,别直接在里面打日志 —— 这样同一套诊断
能同时服务「运行期自动诊断」和「命令行体检」两条路
(--check-phone [ip]:探一次就退出,不启动服务、不占托盘)。
- 体检模式要在启动服务之前就 return,否则会占着单实例互斥量、把用户的正常启动挤掉。
- 探测前先打一行「正在诊断…」。用户很可能在这 3 秒里点了退出,
否则日志里只剩失败没有结论,白跑一趟。
- 跨类调用记得改 internal:private static 的时候另一个类访问会报
CS0122 不可访问,因为它具有一定的保护级别。
- 重试要放在火忘式任务里(_ = SendAsync(...)),别阻塞剪贴板监听循环 ——
4 秒超时 + 重试 + 诊断加起来十来秒,阻塞监听会漏掉这期间的所有剪贴板变化。


交付副本会漂移:改动后必须回头同步

把编译产物复制到「桌面交付文件夹」很容易忘同步,用户双击的恰好是那份旧副本,
于是会得到「你说修好了但我这里还是坏的」这种最耗时间的反馈。

本项目真实踩过:交付目录里的 bin\CloudClip-Win.exe 停留在 4 小时前的构建,
既没有退出卡死修复、也还没去掉通知。

做法
- 交付目录里只留一个入口:写个 启动XXX.bat,优先指向工作目录里那份
(D:/.../build/XXX.exe),找不到才退回交付目录内的 bin\;
再顺手把 bin\ 也刷新一遍,避免用户直接双击旧 exe。
- 每次改完代码,把 Program.cs + 构建产物 + README 一起同步,
并用哈希核对源码一致(hashlib.sha256 比对源与副本)。
- 交付目录里最好写明「以工作目录的构建为准」,否则下次一定还会踩。


在用户机器上验证时,别反复拉起常驻进程

一个真实踩坑:调试托盘程序的过程中,为了让「回传 → 诊断」这条链路跑起来,
我每隔一两分钟就被迫重新启动一次服务;用户那边只看到自己点了退出,过一会儿它又冒出来,
直接问「为什么它自动启动了?是不是有自启动」——差一点被当成程序 bug 来查。

这不是小事:常驻进程(托盘程序、后台服务)在用户眼里就是「开机就有的东西」,
它反复复活会立刻触发「这软件有毛病/删不掉/有自启动」的判断,
而真正的原因只是你的验证动作。

做法
- 优先用不落地的验证方式:把探测逻辑做成独立函数(--check-phone 这类命令行开关)、
用假对端(fake_phone.py 起个本地服务冒充手机)、纯逻辑单元测试。
这些跑完就退,不留痕迹。
- 必须真启动时,先说清楚再动手:「我现在启动一个实例来验证,验完会关掉」,
并且验完真的关掉(taskkill /PID),不要为了"用户待会儿可能要用"就把它留着。
- 用户说「它自己启动了」时,先把自启动的可能性全部查一遍再解释,
查询清单(本项目实测都为空,据此才能下结论):
- 计划任务:不只看任务名,还要扫 Get-ScheduledTask 所有 Action 的
Execute/Arguments/WorkingDirectory 里有没有这个 exe;
- HKCU/HKLM\Software\Microsoft\Windows\CurrentVersion\Run;
- 启动文件夹 [Environment]::GetFolderPath(&#x27;Startup&#x27;);
- Get-Service(是否注册成了服务);
- 程序自身的 Process.Start / Restart / schtasks / CreateService 代码。
- 然后用日志的时间线自证:服务已启动 / 托盘菜单:退出 两类日志行一拉出来,
「用户退出 → 下一次启动」的间隔一目了然,是不是你的动作一清二楚。
有数据就直说,比含糊的"可能是系统引起的"有用得多。


托盘程序的日志:要 WinExe + 内置窗口,别留控制台

托盘常驻程序用 dotnet run / dotnet build 出来的默认是 Console 子系统,
运行时任务栏就多一个黑框。用户几乎一定会提这一点("任务栏多一个界面很难看"),
而且他还要能看日志。两头都满足的做法:

1. 用 WinExe 彻底不创建控制台(别用"创建后再隐藏")

&lt;OutputType&gt;WinExe&lt;/OutputType&gt;   &lt;!-- Exe → WinExe,GUI 子系统 --&gt;

ShowWindow(GetConsoleWindow(), SW_HIDE) 这种"隐藏"的方案有两个毛病:
启动瞬间仍会闪一下黑框,任务栏也会短暂出现一下。WinExe 是根本不创建,
干净。验证方法(不用肉眼):

pe = struct.unpack(&#x27;&lt;I&#x27;, open(exe,&#x27;rb&#x27;).read(4096)[0x3C:0x40])[0]
sub = struct.unpack(&#x27;&lt;H&#x27;, open(exe,&#x27;rb&#x27;).read(4096)[pe+0x5C:pe+0x5E])[0]
# 2 = GUI(无控制台)  3 = Console(有黑框)

2. 命令行模式补 AttachConsole

WinExe 没有自己的控制台,--check-xxx 这类命令行开关的输出会凭空消失。
在 Main 最开头挂到父进程的控制台即可:

[DllImport(&quot;kernel32.dll&quot;, SetLastError = true)]
private static extern bool AttachConsole(uint dwProcessId);   // ATTACH_PARENT_PROCESS = 0xFFFFFFFF
try { AttachConsole(0xFFFFFFFF); } catch { }

双击启动时父进程(explorer)没有控制台,这一步安静失败,不会冒窗口。

3. 内置日志窗口(双击托盘图标弹出)

  • 独立一个 XxxLogWindow.cs,不要硬塞进已经很长的 Program.cs。
  • ShowInTaskbar = false:它自己也不占任务栏,只能从托盘图标进 —— 正合用户诉求。
  • 点 × 只 Hide(),不退出程序(OnFormClosing 里 if (e.CloseReason == UserClosing) { e.Cancel = true; Hide(); });
    真要退出仍走托盘菜单。
  • OnResize:WindowState == Minimized → Hide()。ShowInTaskbar=false 的窗口
    一旦最小化就基本找不回来了。
  • 首次打开用 File.ReadLines() 流式回读日志尾部的 N 行(日志可能几 MB,
    File.ReadAllLines 会把内存吃满),之后由日志函数跨线程 BeginInvoke 实时追加。
  • 行数上限 + 裁剪,长时间挂机不吃内存。
  • 布局用 TableLayoutPanel 三行(状态 / 正文 / 按钮条),别用多个 Dock=Top/Bottom/Fill
    叠着放
    ——Dock 的生效顺序按 z-order 反着来,很容易排错。

4. 验证 UI 不需要截屏

加一个隐藏开关(如 --selftest-log)让程序启动 N 秒后自己弹一次窗口,
再用 EnumWindows 断言,足够了:

断言:PE 子系统 == 2
      属于该进程的可见窗口里没有 ConsoleWindowClass
      存在标题含&quot;Xxx 运行日志&quot;的可见窗口,尺寸合理
      弹窗后进程仍存活;日志里有对应痕迹

不要用截屏去"看"窗口。既可能抓到用户的桌面内容(隐私),
在用户开全屏程序/游戏时还可能干扰他。窗口存在性 + 尺寸 + 进程存活
已经是足够强的证据。

5. 附带教训:会出现在屏幕上的验证,先打招呼

--selftest-log 这类开关会真的在用户屏幕上弹窗。
本项目实测:用户当时正在全屏玩 Dota 2,我弹的窗口直接盖在他游戏上。
→ 优先用不落地的方式验证;必须弹窗时,先说一句"我要弹个窗口验证,1 秒就好"。

6. 顺手记下的编译坑

  • csproj 的 XML 注释里不能出现 --(例如写 --check-phone)→ MSB4025 报"注释不能含 --"。
  • 用 cat &gt;&gt; Program.cs &lt;&lt;&#x27;EOF&#x27; 往 C# 文件里追代码,在这台机器上会因引号被 mangle
    而报 unexpected EOF while looking for matching &#x27;。改用编辑工具或新建独立文件。
  • 原来 private 的常量/字段被新类引用时会报 CS0103 / CS0112 → 改成 internal。
  • 同一文件连续两次 Edit 容易留下重复定义(CS0111)。
    凡是"改完一处又加了一处"的情况,务必 grep -n &lt;方法名&gt; 复核定义次数。

「手机 → 电脑:投递到光标处」的落地设计(实测过,v2 追加)

把手机上的文字打到电脑当前光标处,不要给帧格式加字段,而是在正文最前面加一个控制字符当旗标(本项目用 U+0001):

// 电脑端 src/Protocol.cs
public const char FlagPasteAtCaret = &#x27;\u0001&#x27;;
public static bool HasPasteFlag(string raw) =&gt; raw.Length &gt; 0 &amp;&amp; raw[0] == FlagPasteAtCaret;
public static string StripFlag(string raw) =&gt; HasPasteFlag(raw) ? raw[1..] : raw;

为什么:给帧加字段必须两端同时升级,旧版会把长度读错、整条链路错位;
加在正文开头则向后兼容 —— 旧版接收端只会把它当成一个看不见的字符写进剪贴板,
不会断连、不会串包。代价是"投递过去的文字前面多一个看不见的字符",可接受、且可说明。

配套的三条必须做对:

  1. 只有"用户明确投递"才打旗标。后台自动同步剪贴板的内容绝不能打 ——
    否则用户在手机上每复制一次东西,电脑正在用的窗口就会莫名多出一段文字。
    实现上就是一个布尔参数一路传下去(sendToPc(text, pasteAtCaret))。
  2. 手动投递要绕过"6 秒内重复就丢"的去重。用户完全可能连着发两次同样的文字
    (贴到两个窗口里),被去重丢掉的话现象是"第二遍没反应",最难查。
    本项目的做法:if (!wantPaste &amp;&amp; dedup.IsDuplicate(text)) return;,
    但仍然要 dedup.Remember(text)(去重表还要继续兜回环)。
  3. 注入前看一眼前台窗口是不是自己。用户点开日志面板时文字会糊在自己界面上:
    GetWindowThreadProcessId(GetForegroundWindow()) == Environment.ProcessId → 直接不动手,并写日志。

C# 侧最省事的正确写法

  • 主力路径 = 先写剪贴板、再发 Ctrl+V,await Task.Delay(150) 放在中间
    (剪贴板是全局资源,立刻 Ctrl+V 有概率粘到上一份内容)。中文/emoji 都不走 IME。
  • 兜底路径 = 逐字 KEYEVENTF_UNICODE(给不吃 Ctrl+V 的程序),换行要拆成真的 VK_RETURN
    —— Unicode 注入里的 \n 多数程序不认。
  • INPUT x64 必须 40 字节:union 里同时放 KEYBDINPUT/MOUSEINPUT/HARDWAREINPUT,
    用 Marshal.SizeOf&lt;INPUT&gt;() 在自检里断言(只放键盘结构 → 32 字节 → 全部静默失败)。
  • 失败要能说人话:SendInput 返回条数不足时取 Marshal.GetLastWin32Error(),
    5 = UIPI 拦下(目标程序是管理员权限)—— 这是用户最需要知道的唯一一句解释。
  • 日志里记下"这次打给了哪个窗口"(进程名 + 标题)。注入不可撤销,事后能对账最重要。

自检怎么写(别拿用户窗口试)

起一个自己的受控窗口当靶子(独立子进程,--paste-target &lt;dir&gt;),
父进程做三重校验才允许注入:窗口存在(FindWindowW)+ 它在最前
(GetForegroundWindow()==hwnd)+ 就绪标志文件是最近 5 秒写的。
再加一条容易被忽略的:光"窗口在最前"不够,键盘焦点可能还在顶层窗口上,
必须 GetGUIThreadInfo(GetCurrentThreadId()).hwndFocus == 编辑框句柄。

靶子落盘编辑框内容 → 父进程读回来断言,实测能同时验证
「Ctrl+V + 回车」和「逐字注入中文 + emoji + 换行」两条路径(本项目 17 项断言全过)。
顺手做两件体面事:跑完把用户的剪贴板还原(先备份原文),以及靶子设 25 秒自动关闭兜底。


「勾了回车却没换行」:粘贴之后补回车,三个会让它白按的坑

症状:手机端勾着「发送后按回车」,电脑端有时候换行、有时候不换。
第一步永远是对账,不是猜:看接收端日志。如果日志写着「已发送 Ctrl+V + 回车」,
那标志位没丢、文字也贴上去了 ⇒ 问题在注入这一下没落地,别再去查协议 / 载荷 / 解密。

会让回车白按的三种情况(要一起堵):

# 坑 为什么
① Ctrl 还粘着 目标程序收到的是 Ctrl+回车。记事本里这组合键什么也不做 —— 症状正是"文字贴上了、就是没换行"。Ctrl+V 是一个 batch(Ctrl↓ V↓ V↑ Ctrl↑),紧跟着又注入别的键,修饰键状态错位就把回车变成了组合键
② 粘贴和回车零间隔 SendCtrlV() 后一行都不隔就 SendEnter()。目标程序还在处理粘贴时收到的那次回车可能被吞(文本越长越明显)
③ 回车没带扫描码 wScan = 0。个别程序 / 输入法要靠扫描码把按键翻成字符(ToUnicode),只给虚拟键有被直接忽略的风险
④ 顺手把 Alt 也抬了一下 单独抬 Alt = 打开菜单栏;紧跟着的回车会被菜单吃掉 ⇒ 还是"没换行",外加"菜单自己弹开"(见下一节)。这是修 ① 时最容易自己引入的新 bug

修法(四件事一起做):

// 1) 抬起修饰键 —— 但**只抬真的按着的**,而且**永远不碰 Alt**
public static int ReleaseModifiers()
{
    var vks = PlanModifierRelease(IsDown);      // 纯函数、可穷举断言
    if (vks.Length == 0) return 0;              // 没按着就一条事件都不发(正常情况恒为此分支)
    SendInput(...);                             // 按着的是左手还是右手就抬哪一个
}

// 2) 回车带上扫描码
var scan = (ushort)MapVirtualKey(VK_RETURN, MAPVK_VK_TO_VSC);   // 一般 0x1C
Key(VK_RETURN, false, scan); Key(VK_RETURN, true, scan);

// 3) 粘贴与回车之间留间隔(越长越慢,150~400ms)
Thread.Sleep(gap);

日志里必须留下能自证的两个字段,下次出事不用再推理:

  • 回车前修饰键 Ctrl=up Shift=up Alt=up —— 出现 Ctrl=down 就坐实情况 ①;
  • 回车后前台窗口 —— 和粘贴时那个不一样,就是"粘贴打在 A、回车打在 B"(单独打一行 ⚠)。

别乱抬 Alt:单独一条 Alt↑ 就等于"按了一下 Alt"(打开菜单栏)

症状:手机发文字到电脑的记事本,记事本的「文件」菜单莫名其妙自己展开了
(下拉开着、第一项高亮)。看起来跟注入毫无关系,其实是注入干的。

机制链(Win32 的既定行为,不是 bug):

① Alt↑(没有配对的 Alt↓)        → 目标程序认为&quot;用户轻点了一下 Alt&quot; → 菜单栏被激活
                                    (记事本里&quot;文件&quot;高亮)
② 紧跟着注入的 Enter              → 落在激活的菜单栏上 = &quot;打开当前项&quot;
                                    → 「文件」下拉展开 = 用户看到的画面
                                    同时这次回车**没有进文档** ⇒ 表现为&quot;没换行&quot;

这就是"无条件把 Ctrl/Shift/Alt 都抬一遍"的代价:alt 那条根本没按着,
纯属凭空造了一次 Alt 轻点。

决定性证据怎么拿(别靠推理)

拿真实程序当靶子、用截图差当判据(菜单栏高亮 ≈ 0.7% 像素,下拉展开 ≈ 17%,
差两个数量级,一眼分得清):

注入内容 Alt 事件 窗口像素变化 结果
单独抬一条 Alt 1 条 0.76% 菜单栏被激活
单独抬 Alt + 紧接着回车 1 条 16.72% 「文件」下拉展开(= 用户截图)
只抬真按着的(新写法) 0 条 0.00% 一动没动
真·Alt 轻点(对照组) 2 条 0.76% 菜单栏被激活

对照组那一行是关键:前两行和对照组给出的数字完全一致(1323/174300),
才敢说"注入的 Alt 抬起 = 用户真的按了一下 Alt"。少了对照组只能算"看起来像"。

⚠️ 自建 WinForms 窗口当靶子测不出这件事:它的菜单栏要求"先看到 Alt 按下"才进菜单模式,
对"单独抬 Alt"没反应。要复现就得用真记事本那种 XAML 菜单栏
(它的老毛病是"Alt+Tab 切过去菜单自己展开"——因为 keydown 落在了别的窗口上,
和"我们只注入了一条没有配对的 Alt↑"是同一回事)。靶子选错会把真 bug 判成没问题。

通用码 vs 左右专用码(会同时坑掉断言和状态判断)

注入时给的是通用码,进系统后看到的是左右专用码:

逻辑键 注入用(通用) 钩子/异步状态里看到
Ctrl 0x11 0xA2(左) / 0xA3(右)
Shift 0x10 0xA0 / 0xA1
Alt 0x12 0xA4 / 0xA5

后果有两个,都要防:

  • 断言变空断言:按 ↑ vk= 17 / vk= 18 去认"Ctrl 抬起了 / 碰没碰 Alt",
    永远匹配不上,于是永远打印 ✓。判断前先在故意不合格的对照组上验证这条断言会红。
  • 状态判断失准:GetAsyncKeyState(VK_CONTROL) 有可能读不到(只有专用位被置起来时)。
    要"某个修饰键按着吗"就把通用 + 左 + 右三个码一起或起来;要抬就抬具体那个码。

⚠️ 环境:Agent / 沙箱拉起的进程,注入到底会不会被拦(2026-09-21 修正)

先纠正一条曾经写死的结论:早期记录说"Agent 拉起的进程注入是静默被拦的"。
后来在同一台机器、同一个 Agent 上下文里实测,键盘注入是能落地的:

  • WH_KEYBOARD_LL 钩子收到了完整的事件序列(↓ vk=162 / ↑ vk=162 / ↓ vk=13 …);
  • 真记事本被注入后菜单栏真的被激活了(截图差 1323 像素,和真按 Alt 一模一样)。

所以别再把"没反应"直接归因于沙箱。判据要一条一条量:

# 1) 键盘:装 WH_KEYBOARD_LL 或直接跑 --inject-emit,看有没有事件
#    有事件 = 注入进了系统;没事件才谈&quot;被拦&quot;
# 2) 鼠标相对移动:本机实测 SendInput 返回 1 但 GetCursorPos 不变
#    (键盘能进、相对鼠标移动不进,原因未明 —— 别拿它当&quot;整个注入被拦&quot;的证据)
# 3) 焦点:SetForegroundWindow 返回 FALSE 不代表不能注入,
#    挂输入队列(AttachThreadInput)+ SwitchToThisWindow 通常还能抢到

先跑发射端自检(装钩子看事件序列,30 秒),确认"注入这条链路通不通",
再决定要不要麻烦用户双击 .bat。自检输出里要写清当时的环境限制,
别让下一次的自己以为"是代码坏了"。

另外,判断"能不能注入"之前先看输入桌面:锁屏 / UAC 界面下注入进不了任何窗口
(OpenInputDesktop + GetUserObjectInformationW(UOI_NAME),正常应为 Default)。

注入自检的四种形态(按需要的证据强度选)

形态 做什么 需要前台窗口?
纯逻辑断言 把"该注入哪些键"抽成纯函数(如 PlanModifierRelease(isDown: Func&lt;ushort,bool&gt;)),穷举所有按键局面断言"绝不出现 Alt / 不虚发 / 按着就一定抬"。不注入、不开窗 ❌ 沙箱里也能跑,适合进 CI
发射端自检 装 WH_KEYBOARD_LL,把各写法的事件序列(vk / scanCode / time)打出来,断言「回车带扫描码、Ctrl 先抬起、间隔够、Alt 出现 0 次」 ❌ 不需要
端到端自检 开一个带 RichTextBox(RichEdit)的窗口当靶子,跑 N 轮 Ctrl+V+回车,读回控件文本判断换行有没有落下去 ✅ 需要(抢不到就拒绝跑并说明原因,绝不盲注入)
真实程序自检 拿用户实际在用的那个程序(记事本)当靶子,注入前后截图差判断副作用(菜单有没有弹开) ✅ 需要

四个都留着:纯逻辑最便宜、发射端最快、端到端回答"换行到底打进去没有"、
真实程序才回答"会不会在用户的程序里搞出别的事"。真实程序探针不要粘贴、
不要碰剪贴板、不要改用户的文档
(只按 Alt / Esc 这类无副作用键)。

自检台自己的两条纪律

  1. 对照组必须存在且必须"会红":新写法和旧写法各跑一遍,
    旧写法那组要能稳定复现出问题(如"1 条 Alt 事件")。只有新写法组通过、
    旧写法组也通过 ⇒ 说明自检根本测不到东西。
  2. 故意不合格的对照样本不计入结论:参考组天生带失败(如旧写法回车没扫描码),
    把它记进"有 N 项不符合预期"会让结论永远显示不通过,以后就没人看结论了。
    标记成 reference,只打印、不计分。

常见问题

5 问
答案由本站依据条目字段整理
Windows 文本按键注入怎么安装?

在支持 Skills 的客户端里执行:skillhub install user_825f001d/win-sendinput-injection。安装方式为未标注。装完刷新技能列表或重开客户端。

Windows 文本按键注入支持哪些客户端?

资料没有标注宿主客户端,但只要客户端支持 Skills(Claude、Codex、Cursor 等),一般都能加载。

Windows 文本按键注入权限高吗,安全吗?

权限级别为未标注权限。安装前先看它会读取或写入哪些内容。

Windows 文本按键注入收费吗?

本站不代收费用,也没有标注具体价格。是否收费取决于技能本身和所用的模型或客户端,正式使用前按作者说明确认。

Windows 文本按键注入用什么许可证?

许可证标注为 未说明(许可证不明确)。商用或二次分发前请到仓库确认条款原文。

热门专题

按场景挑技能
全部专题 →

技能内容以作者仓库为准;分类、权限级别与许可证状态的中文说明为中文整理,最近核验 未标注。