//go:build windows // ============================================================================ // 系统级鼠标提醒(Windows 原生实现) // // 需求:平时鼠标是正常样式;有消息来(通过 API 触发)时,鼠标图标闪烁, // 且光标下方浮出简短的半透明提示文字,可覆盖任意窗口(系统级)。 // // 实现原理: // 1. 全局低层鼠标钩子 WH_MOUSE_LL:捕获系统级鼠标移动,获取屏幕坐标。 // 2. 一个置顶、透明、点击穿透的覆盖层窗口(WS_EX_LAYERED|WS_EX_TRANSPARENT // |WS_EX_TOOLWINDOW|WS_EX_NOACTIVATE),用 GDI UpdateLayeredWindow 在鼠标 // 位置绘制闪烁光标环与下方半透明文字。 // 3. 定时器驱动闪烁:普通光标样式 <-> 提醒光标样式交替。 // 4. 提醒可自动消退(默认 8 秒),也可再调 API 停止。 // // 说明:本文件仅在 Windows 编译。其他平台见 mousealert_other.go 空实现。 // ============================================================================ package main import ( "sync" "syscall" "time" "unsafe" ) // Windows API 常量 const ( whMouseLL = 14 // WH_MOUSE_LL 全局低层鼠标钩子 wmTimer = 0x0113 wmDestroy = 0x0002 wmNcDestroy = 0x0082 wndExLayered = 0x00080000 // WS_EX_LAYERED 分层窗口(支持透明) wndExTrans = 0x00000020 // WS_EX_TRANSPARENT 点击穿透 wndExTool = 0x00000080 // WS_EX_TOOLWINDOW 不占任务栏 wndExTopMost = 0x00000008 // WS_EX_TOPMOST 置顶 wndExNoActivate = 0x08000000 // WS_EX_NOACTIVATE 不抢焦点 swpNoActivate = 0x0010 // SWP_NOACTIVATE swpShowWindow = 0x0040 // SWP_SHOWWINDOW biRgb = 0 // BI_RGB timerFlash = 1 // 闪烁定时器 ID overlayWidth = 220 overlayHeight = 90 textTopY = 26 // 文字起始 Y 坐标(覆盖层内),用于区分半透明文字区 alertDuration = 8 * time.Second flashIntervalMs = 250 // 闪烁周期 250ms ) // 光标提示样式:普通状态 vs 提醒状态(闪烁交替) const ( flashColorNormal = 0x00FFFFFF // 普通:白 flashColorAlert = 0x00FF5050 // 提醒:红 ) // hwndTopMost HWND_TOPMOST = (HWND)-1 const hwndTopMost uintptr = ^uintptr(0) // ------------------------------------------------------------------------ // Windows 结构体与函数指针声明(用 syscall 直接调用,避免引入额外依赖) // ------------------------------------------------------------------------ type point struct { X int32 Y int32 } // rect Windows RECT 结构(GetClientRect 输出) type rect struct { Left int32 Top int32 Right int32 Bottom int32 } type msg struct { HWND uintptr Message uint32 WParam uintptr LParam uintptr Time uint32 Pt point LPrivate uint32 } type msllhookstruct struct { Pt point MouseData uint32 Flags uint32 Time uint32 DWExtraInfo uintptr } type wndclassex struct { CbSize uint32 Style uint32 LpfnWndProc uintptr CbClsExtra int32 CbWndExtra int32 HInstance uintptr HIcon uintptr HCursor uintptr HbrBackground uintptr LpszMenuName *uint16 LpszClassName *uint16 HIconSm uintptr } type bitmapinfoheader struct { BiSize uint32 BiWidth int32 BiHeight int32 BiPlanes uint16 BiBitCount uint16 BiCompression uint32 BiSizeImage uint32 BiXPelsPerMeter int32 BiYPelsPerMeter int32 BiClrUsed uint32 BiClrImportant uint32 } type blendfunction struct { BlendOp byte BlendFlags byte SourceConstantAlpha byte AlphaFormat byte } // 延迟加载的 user32.dll / gdi32.dll / kernel32.dll 函数 var ( user32 = syscall.NewLazyDLL("user32.dll") gdi32 = syscall.NewLazyDLL("gdi32.dll") kernel32 = syscall.NewLazyDLL("kernel32.dll") procSetWindowsHookEx = user32.NewProc("SetWindowsHookExW") procUnhookWindowsHook = user32.NewProc("UnhookWindowsHookEx") procGetMessage = user32.NewProc("GetMessageW") procTranslateMessage = user32.NewProc("TranslateMessage") procDispatchMessage = user32.NewProc("DispatchMessageW") procRegisterClassEx = user32.NewProc("RegisterClassExW") procCreateWindowEx = user32.NewProc("CreateWindowExW") procDefWindowProc = user32.NewProc("DefWindowProcW") procDestroyWindow = user32.NewProc("DestroyWindow") procSetTimer = user32.NewProc("SetTimer") procKillTimer = user32.NewProc("KillTimer") procShowWindow = user32.NewProc("ShowWindow") procSetWindowPos = user32.NewProc("SetWindowPos") procUpdateLayeredWindow = user32.NewProc("UpdateLayeredWindow") procGetDC = user32.NewProc("GetDC") procReleaseDC = user32.NewProc("ReleaseDC") procGetCursorPos = user32.NewProc("GetCursorPos") procPostMessage = user32.NewProc("PostMessageW") procGetClientRect = user32.NewProc("GetClientRect") procGetWindowRect = user32.NewProc("GetWindowRect") procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC") procDeleteDC = gdi32.NewProc("DeleteDC") procCreateDIBSection = gdi32.NewProc("CreateDIBSection") procSelectObject = gdi32.NewProc("SelectObject") procDeleteObject = gdi32.NewProc("DeleteObject") procSetBkMode = gdi32.NewProc("SetBkMode") procSetTextColor = gdi32.NewProc("SetTextColor") procSetTextAlign = gdi32.NewProc("SetTextAlign") procTextOutW = gdi32.NewProc("TextOutW") procCreateFontW = gdi32.NewProc("CreateFontW") procSetPixel = gdi32.NewProc("SetPixel") procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") ) // ------------------------------------------------------------------------ // 鼠标提醒运行时状态 // ------------------------------------------------------------------------ // mouseAlertState 鼠标提醒的运行时状态(单实例) type mouseAlertState struct { mu sync.Mutex started bool // 工作线程是否已启动(持久化,仅启动一次) hook uintptr overlayWnd uintptr active bool text string flashOn bool lastX, lastY int32 stopTime time.Time hookProc uintptr wndProc uintptr classOK bool } var mouseAlert = &mouseAlertState{} // triggerMouseAlert 触发鼠标提醒:显示闪烁光标 + 光标下方透明文字。 // 文字为空时用默认提示。重复触发会刷新提醒计时并立即显示。 // // 定位策略:主动用 GetCursorPos 读取当前鼠标位置并记录为 lastX/lastY, // 再通过 PostMessage 唤醒窗口线程立即重绘,避免依赖全局钩子回调(钩子 // 回调因负担重可能被系统卸载,导致窗口停留在旧位置不跟随鼠标)。 func triggerMouseAlert(text string) { if text == "" { text = "有新消息提醒" } // 读取当前鼠标位置,供窗口重绘时定位(不依赖钩子回调) var pt point curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) hasCursor := curRet != 0 mouseAlert.mu.Lock() mouseAlert.text = text mouseAlert.active = true mouseAlert.flashOn = true mouseAlert.stopTime = time.Now().Add(alertDuration) if hasCursor { mouseAlert.lastX, mouseAlert.lastY = pt.X, pt.Y } started := mouseAlert.started mouseAlert.started = true overlay := mouseAlert.overlayWnd mouseAlert.mu.Unlock() // 首次触发:启动常驻工作线程(幂等),建窗后由线程主动定位并重绘 if !started { go mouseAlertLoop() return } // 已启动:唤醒窗口线程立即重绘(窗口线程负责 SetWindowPos/绘制, // 避免跨线程操作窗口;若窗口尚在初始化,线程建窗后也会主动绘制) if overlay != 0 { procShowWindow.Call(overlay, 5) // SW_SHOW procPostMessage.Call(overlay, wmTimer, timerFlash, 0) } } // stopMouseAlert 立即停止鼠标提醒(隐藏覆盖层;钩子保持常驻) func stopMouseAlert() { mouseAlert.mu.Lock() mouseAlert.active = false overlay := mouseAlert.overlayWnd mouseAlert.mu.Unlock() if overlay != 0 { procShowWindow.Call(overlay, 0) // SW_HIDE } } // mouseAlertLoop 常驻工作线程:注册窗口类、安装全局钩子、创建覆盖层窗口, // 进入消息循环。低层钩子必须有消息循环,故全程运行在独立线程。 func mouseAlertLoop() { hInst, _, _ := procGetModuleHandleW.Call(0) mouseAlert.mu.Lock() className, _ := syscall.UTF16PtrFromString("TengTuMouseAlertOverlay") wc := wndclassex{ CbSize: uint32(unsafe.Sizeof(wndclassex{})), LpfnWndProc: mouseAlert.wndProcAddr(), HInstance: hInst, LpszClassName: className, HCursor: 0, } atom, _, _ := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc))) if atom != 0 { mouseAlert.classOK = true } mouseAlert.mu.Unlock() // 安装全局低层鼠标钩子 hookProc := mouseAlert.hookProcAddr() hHook, _, _ := procSetWindowsHookEx.Call(whMouseLL, hookProc, 0, 0) if hHook != 0 { mouseAlert.mu.Lock() mouseAlert.hook = hHook mouseAlert.mu.Unlock() } // 创建覆盖层窗口(初始 220x90,位置随后由钩子/定时器设置) style := uintptr(wndExLayered | wndExTrans | wndExTool | wndExTopMost | wndExNoActivate) overlay, _, _ := procCreateWindowEx.Call( style, uintptr(unsafe.Pointer(className)), 0, 0, 0, overlayWidth, overlayHeight, 0, 0, 0, hInst, 0, ) mouseAlert.mu.Lock() mouseAlert.overlayWnd = overlay mouseAlert.mu.Unlock() // 启动闪烁定时器 if overlay != 0 { procSetTimer.Call(overlay, timerFlash, flashIntervalMs, 0) // 建窗后立即用当前鼠标位置定位并重绘一次,保证首次触发即可见, // 不依赖全局钩子回调(钩子回调在系统线程执行,仅做转发)。 var pt point curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) if curRet != 0 { mouseAlert.mu.Lock() if mouseAlert.active { mouseAlert.lastX, mouseAlert.lastY = pt.X, pt.Y text := mouseAlert.text flashOn := mouseAlert.flashOn mouseAlert.mu.Unlock() mouseAlert.renderOverlay(overlay, text, flashOn, pt.X, pt.Y) } else { mouseAlert.mu.Unlock() } } } // 消息循环(常驻,直到线程退出) var m msg for { ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) if ret == 0 { break // WM_QUIT } procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) procDispatchMessage.Call(uintptr(unsafe.Pointer(&m))) } // 清理 if overlay != 0 { procDestroyWindow.Call(overlay) } if hHook != 0 { procUnhookWindowsHook.Call(hHook) } mouseAlert.mu.Lock() mouseAlert.hook = 0 mouseAlert.overlayWnd = 0 mouseAlert.mu.Unlock() } // hookProcAddr 返回全局钩子回调地址(保持 Go 函数引用) func (s *mouseAlertState) hookProcAddr() uintptr { if s.hookProc != 0 { return s.hookProc } s.hookProc = syscall.NewCallback(s.hookCallback) return s.hookProc } // wndProcAddr 返回覆盖层窗口过程地址 func (s *mouseAlertState) wndProcAddr() uintptr { if s.wndProc != 0 { return s.wndProc } s.wndProc = syscall.NewCallback(s.wndProcCallback) return s.wndProc } // hookCallback 全局低层鼠标钩子回调:仅转发给下一钩子,不访问任何 Go 状态。 // // 重要:此回调在系统创建的钩子线程上执行,且 CGO 启用时 syscall.NewCallback // 的回调从该线程进入 Go runtime。此前的实现在此处访问 s.mu(sync.Mutex 锁)、 // 调用 time.Now()、procShowWindow 等 Go 状态操作,会导致进程在钩子回调触发时 // 静默退出(app.Run 返回,exit 0)。故此处只做 CallNextHookEx 转发,不做任何 // 其他工作;鼠标位置跟踪与到期判断全部移到窗口线程的定时器(wndProcCallback // 的 wmTimer 分支,用 GetCursorPos 主动读取),避免在钩子线程做 Go 操作。 func (s *mouseAlertState) hookCallback(nCode int, wParam uintptr, lParam uintptr) uintptr { ret, _, _ := procCallNextHookEx.Call(0, uintptr(nCode), wParam, lParam) return ret } // renderOverlay 定位覆盖层到鼠标位置并重绘闪烁光标 + 下方透明文字。 // 在窗口线程内调用(mouseAlertLoop 建窗后 / wndProcCallback 定时器), // 窗口/DC 操作均为同线程,避免跨线程操作窗口导致绘制失败。 func (s *mouseAlertState) renderOverlay(overlay uintptr, text string, flashOn bool, x, y int32) { if overlay == 0 { return } // 让覆盖层左上角位于鼠标位置偏左上,使光标环中心对准鼠标 left := x - 10 top := y - 4 procSetWindowPos.Call(overlay, hwndTopMost, uintptr(int32(left)), uintptr(int32(top)), overlayWidth, overlayHeight, swpNoActivate|swpShowWindow) s.drawLayered(overlay, text, flashOn) } // drawLayered 用 32 位 DIB + UpdateLayeredWindow 绘制闪烁光标环与半透明文字。 func (s *mouseAlertState) drawLayered(overlay uintptr, text string, flashOn bool) { // 用窗口实际总尺寸创建 DIB,并用 GetWindowRect 获取窗口屏幕位置, // 显式传给 UpdateLayeredWindow 的 pptDst/psize,确保 DIB 与窗口完全 // 匹配(此前用 GetClientRect 且 psize=nil,当 DIB 尺寸与窗口实际尺寸 // 不一致时 UpdateLayeredWindow 绘制失效,导致覆盖层显示为空白)。 var wr rect procGetWindowRect.Call(overlay, uintptr(unsafe.Pointer(&wr))) w := int(wr.Right - wr.Left) h := int(wr.Bottom - wr.Top) if w < 8 || h < 8 { // 兜底:窗口尺寸异常时用默认尺寸 w, h = overlayWidth, overlayHeight } hdcScreen, _, _ := procGetDC.Call(0) if hdcScreen == 0 { return } defer procReleaseDC.Call(0, hdcScreen) hdcMem, _, _ := procCreateCompatibleDC.Call(hdcScreen) if hdcMem == 0 { return } defer procDeleteDC.Call(hdcMem) // 32 位 DIB 位图(负高度:自顶向下) bi := bitmapinfoheader{} bi.BiSize = 40 // sizeof(BITMAPINFOHEADER) bi.BiWidth = int32(w) bi.BiHeight = int32(-h) bi.BiPlanes = 1 bi.BiBitCount = 32 bi.BiCompression = biRgb var bits unsafe.Pointer hBitmap, _, _ := procCreateDIBSection.Call(hdcScreen, uintptr(unsafe.Pointer(&bi)), 0, uintptr(unsafe.Pointer(&bits)), 0, 0) if hBitmap == 0 { return } defer procDeleteObject.Call(hBitmap) oldBmp, _, _ := procSelectObject.Call(hdcMem, hBitmap) // 清空为全透明 clearPixels(bits, w, h) // 光标环中心放在覆盖层 (10, 4) 附近,即鼠标位置 drawCursorRing(hdcMem, 10, 4, flashOn) // 光标下方半透明文字 if text != "" { drawAlertText(hdcMem, text, 10, textTopY) } // GDI 绘制到 DIB 不写 alpha 通道,此处按亮度补全 alpha, // 使图形/文字边缘抗锯齿平滑(文字区半透明) setAlphaFromLuma(bits, w, h) procSelectObject.Call(hdcMem, oldBmp) // UpdateLayeredWindow:显式传 pptDst(窗口屏幕左上角)与 psize(窗口尺寸), // 使 DIB 内容精确定位到窗口,避免 psize=nil 时因尺寸不匹配绘制失效。 var blend blendfunction blend.BlendOp = 0 // AC_SRC_OVER blend.AlphaFormat = 0x01 // AC_SRC_ALPHA blend.SourceConstantAlpha = 255 var dstPt point dstPt.X = wr.Left dstPt.Y = wr.Top var szPt point szPt.X = int32(w) szPt.Y = int32(h) procUpdateLayeredWindow.Call(overlay, hdcScreen, uintptr(unsafe.Pointer(&dstPt)), // pptDst = 窗口左上角屏幕坐标 uintptr(unsafe.Pointer(&szPt)), // psize = 窗口尺寸 hdcMem, 0, 0, uintptr(unsafe.Pointer(&blend)), 0x00000002 /*ULW_ALPHA*/) } // clearPixels 把 DIB 位图全部置为透明(ARGB=0) func clearPixels(bits unsafe.Pointer, w, h int) { if bits == nil { return } total := uintptr(w) * uintptr(h) * 4 for i := uintptr(0); i+8 <= total; i += 8 { *(*uint64)(unsafe.Add(bits, i)) = 0 } } // setAlphaFromLuma 按亮度补全 alpha:非透明像素的 alpha = max(r,g,b), // 文字区(y >= textTopY)乘以 3/4 实现半透明。 func setAlphaFromLuma(bits unsafe.Pointer, w, h int) { if bits == nil { return } for y := 0; y < h; y++ { for x := 0; x < w; x++ { p := (*uint32)(unsafe.Add(bits, uintptr(y*w+x)*4)) v := *p b := uint8(v) g := uint8(v >> 8) r := uint8(v >> 16) m := uint32(r) if uint32(g) > m { m = uint32(g) } if uint32(b) > m { m = uint32(b) } if m == 0 { *p = 0 // 保持透明 continue } if y >= textTopY { m = m * 3 / 4 // 文字半透明 } *p = v&0x00FFFFFF | (m << 24) // 写入 alpha } } } // drawCursorRing 在覆盖层坐标 (cx,cy) 画闪烁光标环(提醒样式) func drawCursorRing(hdc uintptr, cx, cy int32, flashOn bool) { color := flashColorNormal if flashOn { color = flashColorAlert } const r = 12 for angle := 0; angle < 360; angle += 4 { rad := float64(angle) * 3.14159265 / 180.0 px := cx + int32(float64(r)*cos(rad)) py := cy + int32(float64(r)*sin(rad)) procSetPixel.Call(hdc, uintptr(px), uintptr(py), uintptr(color)) } // 中心小点 procSetPixel.Call(hdc, uintptr(cx), uintptr(cy), uintptr(color)) } // drawAlertText 在 (x,y) 绘制白色提示文字(抗锯齿边缘由 setAlphaFromLuma 处理) func drawAlertText(hdc uintptr, text string, x, y int32) { // CreateFontW 参数顺序:nHeight,nWidth,nEscapement,nOrientation,nWeight, // bItalic,bUnderline,bStrikeOut,iCharSet,iOutPrecision,iClipPrecision, // iQuality,iPitchAndFamily,pszFaceName。nHeight=-16 表示 16 像素高。 var height int32 = -16 hFont, _, _ := procCreateFontW.Call( uintptr(height), 0, 0, 0, 400, // 高16、粗体 0, 0, 0, 134, // 非斜体、GB2312_CHARSET 0, 0, 4, 2, // 抗锯齿、DEFAULT_PITCH 0, // lpszFace=NULL 用系统默认字体 ) if hFont == 0 { return } defer procDeleteObject.Call(hFont) oldFont, _, _ := procSelectObject.Call(hdc, hFont) procSetBkMode.Call(hdc, 1) // TRANSPARENT procSetTextColor.Call(hdc, 0x00FFFFFF) // 白 procSetTextAlign.Call(hdc, 0) // TA_LEFT textPtr, _ := syscall.UTF16PtrFromString(text) procTextOutW.Call(hdc, uintptr(x), uintptr(y), uintptr(unsafe.Pointer(textPtr)), uintptr(len([]rune(text)))) procSelectObject.Call(hdc, oldFont) } // wndProcCallback 覆盖层窗口过程:处理定时器(闪烁)与销毁 func (s *mouseAlertState) wndProcCallback(hwnd uintptr, msg uint32, wParam uintptr, lParam uintptr) uintptr { switch msg { case wmTimer: if uintptr(wParam) == timerFlash { // 主动用 GetCursorPos 读取当前鼠标位置(窗口线程,安全)。 // 不再依赖全局钩子回调更新 lastX/lastY(钩子回调在系统线程 // 执行,任何 Go 状态操作都会导致进程退出,故仅做转发)。 var pt point curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) s.mu.Lock() if s.active { if curRet != 0 { s.lastX, s.lastY = pt.X, pt.Y } // 到期判断(原先在钩子回调,现移到窗口线程) if !s.stopTime.IsZero() && time.Now().After(s.stopTime) { s.active = false s.mu.Unlock() procShowWindow.Call(hwnd, 0) // SW_HIDE return 0 } s.flashOn = !s.flashOn text := s.text flashOn := s.flashOn x, y := s.lastX, s.lastY s.mu.Unlock() s.renderOverlay(hwnd, text, flashOn, x, y) return 0 } s.mu.Unlock() } return 0 case wmDestroy, wmNcDestroy: procKillTimer.Call(hwnd, timerFlash) return 0 } ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(msg), wParam, lParam) return ret } // procCallNextHookEx 钩子链传递 var procCallNextHookEx = user32.NewProc("CallNextHookEx") // cos / sin 简单实现(避免依赖 math 的浮点精度问题) func cos(rad float64) float64 { r := rad for r > 3.14159265 { r -= 6.28318530 } for r < -3.14159265 { r += 6.28318530 } r2 := r * r return 1 - r2/2 + r2*r2/24 - r2*r2*r2/720 } func sin(rad float64) float64 { r := rad for r > 3.14159265 { r -= 6.28318530 } for r < -3.14159265 { r += 6.28318530 } r2 := r * r return r - r2*r/6 + r2*r2*r/120 - r2*r2*r2*r/5040 }