feat: Phase 1 WinAuth Go 移植完整实现
将原 C#/.NET WinAuth 移植为 Go + Gio GUI,覆盖 Phase 1 全部功能。 核心模块: - internal/authenticator: TOTP (Google/Microsoft/Okta) + HOTP + BattleNet + Steam,含 enroll/sync/code 生成、Steam 交易确认轮询 - internal/config: YAML 配置 + 老版 WinAuth XML 导入(DPAPI + Password + Blowfish/PBKDF2 解密链) - internal/crypto: 现代加密 (WAGO1) + DPAPI 跨平台封装 + 老版 Blowfish ECB - internal/win32: 单实例 Mutex 锁 + 全局热键管理器 (RegisterHotKey + PeekMessage 泵) + SendInput Unicode 注入 + 剪贴板文本/CF_DIB 图像读写 + AttachThreadInput 焦点切换 - internal/hotkey: "Ctrl+Alt+G" 风格快捷键字符串解析/格式化 - internal/qr: gozxing 二维码解码 + otpauth:// URI 解析 - internal/i18n: en/zh-CN/de 三语 TOML UI 模块 (Gio): - 主窗口:圆环倒计时进度条、复制按钮 + Toast 反馈、空列表占位、行分隔线 - 添加流程:vendor 菜单 + 各 vendor 独立对话框 + 二维码扫描入口(文件 / 剪贴板) - 设置:密码加密、老版 XML 导入、每条目热键配置 - Steam:注册向导(含 captcha/email/SMS 多步)+ 交易确认窗 构建:Windows 主目标,非 Windows 平台所有 Win32 功能走 build-tag 桩实现。
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package win32
|
||||
|
||||
import "image"
|
||||
|
||||
// GetClipboardImage is a no-op on non-Windows builds.
|
||||
func GetClipboardImage() (image.Image, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build windows
|
||||
|
||||
package win32
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// GetClipboardImage attempts to read an image from the Windows clipboard.
|
||||
// It supports CF_DIB and CF_DIBV5 (the two formats produced by Snipping
|
||||
// Tool / Win+Shift+S and most other screenshot tools). Returns nil, nil
|
||||
// if the clipboard does not currently hold an image format we can read.
|
||||
func GetClipboardImage() (image.Image, error) {
|
||||
if err := openClipboardRetry(0); err != nil {
|
||||
return nil, fmt.Errorf("win32: OpenClipboard: %w", err)
|
||||
}
|
||||
defer procCloseClipboard.Call()
|
||||
|
||||
// Try CF_DIBV5 (17) first, then CF_DIB (8).
|
||||
if img, err := readDIBFromClipboard(17); err != nil {
|
||||
return nil, err
|
||||
} else if img != nil {
|
||||
return img, nil
|
||||
}
|
||||
if img, err := readDIBFromClipboard(8); err != nil {
|
||||
return nil, err
|
||||
} else if img != nil {
|
||||
return img, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func readDIBFromClipboard(format uint32) (image.Image, error) {
|
||||
r, _, _ := procGetClipboardData.Call(uintptr(format))
|
||||
if r == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
src, _, _ := procGlobalLock.Call(r)
|
||||
if src == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
defer procGlobalUnlock.Call(r)
|
||||
|
||||
size, _, _ := procGlobalSize.Call(r)
|
||||
if size == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
data := unsafe.Slice((*byte)(unsafe.Pointer(src)), size)
|
||||
img := decodeDIB(data)
|
||||
if img == nil {
|
||||
return nil, fmt.Errorf("win32: unsupported DIB format")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// decodeDIB decodes a BITMAPINFOHEADER-based DIB blob (no BMP file
|
||||
// header) into an *image.RGBA. Only uncompressed 24-bit and 32-bit
|
||||
// pixel formats are supported, which covers what the Windows snipping
|
||||
// stack produces.
|
||||
func decodeDIB(dib []byte) image.Image {
|
||||
if len(dib) < 40 {
|
||||
return nil
|
||||
}
|
||||
headerSize := int(readU32(dib, 0))
|
||||
width := int(int32(readU32(dib, 4)))
|
||||
height := int(int32(readU32(dib, 8)))
|
||||
bitCount := int(readU16(dib, 14))
|
||||
compression := int(readU32(dib, 16))
|
||||
|
||||
if width <= 0 || height == 0 {
|
||||
return nil
|
||||
}
|
||||
if compression != 0 || (bitCount != 24 && bitCount != 32) {
|
||||
return nil
|
||||
}
|
||||
absH := height
|
||||
topDown := false
|
||||
if absH < 0 {
|
||||
absH = -absH
|
||||
topDown = true
|
||||
}
|
||||
|
||||
rowBytes := ((bitCount*width + 31) / 32) * 4
|
||||
pixelOffset := headerSize
|
||||
// BITMAPV4/V5 headers may include color masks immediately after, but
|
||||
// since we only accept BI_RGB, headerSize already points past them.
|
||||
|
||||
if len(dib) < pixelOffset+rowBytes*absH {
|
||||
return nil
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, absH))
|
||||
for y := 0; y < absH; y++ {
|
||||
var srcY int
|
||||
if topDown {
|
||||
srcY = y
|
||||
} else {
|
||||
srcY = absH - 1 - y
|
||||
}
|
||||
row := dib[pixelOffset+srcY*rowBytes:]
|
||||
for x := 0; x < width; x++ {
|
||||
off := x * (bitCount / 8)
|
||||
b := row[off]
|
||||
g := row[off+1]
|
||||
r := row[off+2]
|
||||
var a byte = 0xff
|
||||
if bitCount == 32 {
|
||||
// CF_DIB normally writes 0 in the alpha slot; treat 0
|
||||
// as opaque to avoid an invisible bitmap.
|
||||
ax := row[off+3]
|
||||
if ax != 0 {
|
||||
a = ax
|
||||
}
|
||||
}
|
||||
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func readU16(b []byte, off int) uint16 {
|
||||
return uint16(b[off]) | uint16(b[off+1])<<8
|
||||
}
|
||||
|
||||
func readU32(b []byte, off int) uint32 {
|
||||
return uint32(b[off]) | uint32(b[off+1])<<8 | uint32(b[off+2])<<16 | uint32(b[off+3])<<24
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Package win32 wraps the small set of Win32 APIs winauth-go needs for
|
||||
// platform-specific UX: single-instance locking, global hotkeys, key
|
||||
// injection, clipboard access, and foreground-window juggling.
|
||||
//
|
||||
// All exported functions are no-ops or return ErrUnsupported on
|
||||
// non-Windows platforms. Callers should treat failure as informational
|
||||
// (warn + continue) rather than fatal — none of this functionality is
|
||||
// load-bearing for the core authenticator code.
|
||||
package win32
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrUnsupported is returned by every entry point when the build is not
|
||||
// Windows. Callers should test with errors.Is.
|
||||
var ErrUnsupported = errors.New("win32: feature only available on Windows")
|
||||
@@ -0,0 +1,26 @@
|
||||
package win32
|
||||
|
||||
// Hotkey describes a single global key combination. Mods is the bitwise
|
||||
// OR of ModCtrl/ModAlt/ModShift/ModWin; VK is a virtual-key code
|
||||
// (0x41='A', ...).
|
||||
type Hotkey struct {
|
||||
Mods uint32
|
||||
VK uint32
|
||||
}
|
||||
|
||||
// Modifier bits passed to RegisterHotKey.
|
||||
const (
|
||||
ModAlt uint32 = 0x1
|
||||
ModCtrl uint32 = 0x2
|
||||
ModShift uint32 = 0x4
|
||||
ModWin uint32 = 0x8
|
||||
// ModNoRepeat suppresses auto-repeat events when the key is held.
|
||||
// Supported on Windows 7+.
|
||||
ModNoRepeat uint32 = 0x4000
|
||||
)
|
||||
|
||||
// HotkeyEvent fires when a registered hotkey is pressed. ID matches the
|
||||
// id returned by HotkeyManager.Register.
|
||||
type HotkeyEvent struct {
|
||||
ID int32
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
|
||||
package win32
|
||||
|
||||
// HotkeyManager is the cross-platform stub. Register always errors;
|
||||
// Events returns a never-firing channel.
|
||||
type HotkeyManager struct {
|
||||
events chan HotkeyEvent
|
||||
}
|
||||
|
||||
func NewHotkeyManager() *HotkeyManager {
|
||||
return &HotkeyManager{events: make(chan HotkeyEvent)}
|
||||
}
|
||||
|
||||
func (m *HotkeyManager) Events() <-chan HotkeyEvent { return m.events }
|
||||
|
||||
func (m *HotkeyManager) Register(h Hotkey) (int32, error) { return 0, ErrUnsupported }
|
||||
func (m *HotkeyManager) Unregister(id int32) error { return ErrUnsupported }
|
||||
func (m *HotkeyManager) Stop() { close(m.events) }
|
||||
@@ -0,0 +1,203 @@
|
||||
//go:build windows
|
||||
|
||||
package win32
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// HotkeyManager owns a dedicated OS thread that runs a GetMessageW
|
||||
// pump. RegisterHotKey can only be called from the thread that will
|
||||
// receive the messages, so all register/unregister/dispatch operations
|
||||
// are serialized onto that goroutine via the cmd channel.
|
||||
type HotkeyManager struct {
|
||||
cmd chan hkCmd
|
||||
events chan HotkeyEvent
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int32
|
||||
stopped bool
|
||||
}
|
||||
|
||||
type hkCmd struct {
|
||||
kind hkCmdKind
|
||||
id int32
|
||||
hotkey Hotkey
|
||||
reply chan error
|
||||
}
|
||||
|
||||
type hkCmdKind int
|
||||
|
||||
const (
|
||||
hkCmdRegister hkCmdKind = iota
|
||||
hkCmdUnregister
|
||||
hkCmdStop
|
||||
)
|
||||
|
||||
// NewHotkeyManager starts the dedicated thread and returns a manager
|
||||
// ready to accept Register calls. Events() yields presses; the channel
|
||||
// is closed on Stop().
|
||||
func NewHotkeyManager() *HotkeyManager {
|
||||
m := &HotkeyManager{
|
||||
cmd: make(chan hkCmd),
|
||||
events: make(chan HotkeyEvent, 16),
|
||||
nextID: 1,
|
||||
}
|
||||
started := make(chan struct{})
|
||||
go m.run(started)
|
||||
<-started
|
||||
return m
|
||||
}
|
||||
|
||||
// Events returns the read-only event channel.
|
||||
func (m *HotkeyManager) Events() <-chan HotkeyEvent { return m.events }
|
||||
|
||||
// Register adds a global hotkey and returns its assigned id. Re-registering
|
||||
// a combination that is already taken by another application returns an
|
||||
// error; the caller should surface it to the user.
|
||||
func (m *HotkeyManager) Register(h Hotkey) (int32, error) {
|
||||
m.mu.Lock()
|
||||
if m.stopped {
|
||||
m.mu.Unlock()
|
||||
return 0, fmt.Errorf("win32: hotkey manager stopped")
|
||||
}
|
||||
id := m.nextID
|
||||
m.nextID++
|
||||
m.mu.Unlock()
|
||||
|
||||
reply := make(chan error, 1)
|
||||
m.cmd <- hkCmd{kind: hkCmdRegister, id: id, hotkey: h, reply: reply}
|
||||
if err := <-reply; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Unregister removes a previously registered hotkey.
|
||||
func (m *HotkeyManager) Unregister(id int32) error {
|
||||
reply := make(chan error, 1)
|
||||
m.cmd <- hkCmd{kind: hkCmdUnregister, id: id, reply: reply}
|
||||
return <-reply
|
||||
}
|
||||
|
||||
// Stop tears down the message pump and closes Events(). Subsequent
|
||||
// Register calls fail.
|
||||
func (m *HotkeyManager) Stop() {
|
||||
m.mu.Lock()
|
||||
if m.stopped {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.stopped = true
|
||||
m.mu.Unlock()
|
||||
reply := make(chan error, 1)
|
||||
m.cmd <- hkCmd{kind: hkCmdStop, reply: reply}
|
||||
<-reply
|
||||
}
|
||||
|
||||
// run is the dedicated-thread loop. It owns the message queue that
|
||||
// RegisterHotKey targets.
|
||||
func (m *HotkeyManager) run(started chan struct{}) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
// Force the message queue to exist before anybody tries to post to
|
||||
// us. PeekMessage with PM_NOREMOVE is the canonical incantation.
|
||||
var msg msgStruct
|
||||
procPeekMessageW.Call(
|
||||
uintptr(unsafe.Pointer(&msg)),
|
||||
0, 0, 0, 0, // PM_NOREMOVE
|
||||
)
|
||||
close(started)
|
||||
|
||||
for {
|
||||
// Non-blocking message pump: drain hotkey messages first, then
|
||||
// service one cmd, then sleep briefly. A blocking GetMessage
|
||||
// would freeze the cmd intake.
|
||||
for {
|
||||
r, _, _ := procPeekMessageW.Call(
|
||||
uintptr(unsafe.Pointer(&msg)),
|
||||
0, 0, 0, 1, // PM_REMOVE
|
||||
)
|
||||
if r == 0 {
|
||||
break
|
||||
}
|
||||
if msg.message == wmHotKey {
|
||||
select {
|
||||
case m.events <- HotkeyEvent{ID: int32(msg.wParam)}:
|
||||
default:
|
||||
// Listener slow — drop to avoid stalling the pump.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case c := <-m.cmd:
|
||||
switch c.kind {
|
||||
case hkCmdRegister:
|
||||
err := registerHotKey(0, c.id, c.hotkey.Mods, c.hotkey.VK)
|
||||
c.reply <- err
|
||||
case hkCmdUnregister:
|
||||
err := unregisterHotKey(0, c.id)
|
||||
c.reply <- err
|
||||
case hkCmdStop:
|
||||
close(m.events)
|
||||
c.reply <- nil
|
||||
return
|
||||
}
|
||||
default:
|
||||
// brief sleep so we don't busy-loop. 30ms is well under any
|
||||
// human-perceptible hotkey latency.
|
||||
windows.SleepEx(30, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// raw syscalls
|
||||
|
||||
var (
|
||||
procRegisterHotKey = user32.NewProc("RegisterHotKey")
|
||||
procUnregisterHotKey = user32.NewProc("UnregisterHotKey")
|
||||
procPeekMessageW = user32.NewProc("PeekMessageW")
|
||||
)
|
||||
|
||||
const wmHotKey uint32 = 0x0312
|
||||
|
||||
type msgStruct struct {
|
||||
hwnd uintptr
|
||||
message uint32
|
||||
wParam uintptr
|
||||
lParam uintptr
|
||||
time uint32
|
||||
pt struct{ x, y int32 }
|
||||
}
|
||||
|
||||
func registerHotKey(hwnd uintptr, id int32, mods, vk uint32) error {
|
||||
r, _, e := procRegisterHotKey.Call(
|
||||
hwnd, uintptr(id), uintptr(mods), uintptr(vk),
|
||||
)
|
||||
if r == 0 {
|
||||
if errno, ok := e.(syscall.Errno); ok && errno != 0 {
|
||||
return fmt.Errorf("win32: RegisterHotKey: %w", errno)
|
||||
}
|
||||
return fmt.Errorf("win32: RegisterHotKey: unknown failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unregisterHotKey(hwnd uintptr, id int32) error {
|
||||
r, _, e := procUnregisterHotKey.Call(hwnd, uintptr(id))
|
||||
if r == 0 {
|
||||
if errno, ok := e.(syscall.Errno); ok && errno != 0 {
|
||||
return fmt.Errorf("win32: UnregisterHotKey: %w", errno)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build !windows
|
||||
|
||||
package win32
|
||||
|
||||
// SetClipboardText errors on non-Windows; callers should warn and fall
|
||||
// back to displaying the code in the UI.
|
||||
func SetClipboardText(s string) error { return ErrUnsupported }
|
||||
|
||||
// GetForegroundWindow returns 0 on non-Windows so Auto-type sites can
|
||||
// detect the absence and skip.
|
||||
func GetForegroundWindow() uintptr { return 0 }
|
||||
|
||||
// FocusWindow is a no-op on non-Windows.
|
||||
func FocusWindow(hwnd uintptr) error { return ErrUnsupported }
|
||||
|
||||
// TypeUnicode is a no-op on non-Windows.
|
||||
func TypeUnicode(s string) error { return ErrUnsupported }
|
||||
|
||||
// PressKey is a no-op on non-Windows.
|
||||
func PressKey(vk uint16) error { return ErrUnsupported }
|
||||
|
||||
// VK_RETURN is exposed for cross-platform compile.
|
||||
const VK_RETURN uint16 = 0x0D
|
||||
@@ -0,0 +1,214 @@
|
||||
//go:build windows
|
||||
|
||||
package win32
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// SetClipboardText copies s onto the Windows clipboard as CF_UNICODETEXT.
|
||||
// OpenClipboard may transiently fail if another process holds the
|
||||
// clipboard; we retry a few times before giving up.
|
||||
func SetClipboardText(s string) error {
|
||||
const cfUnicodeText = 13
|
||||
utf := utf16.Encode([]rune(s + "\x00"))
|
||||
size := len(utf) * 2
|
||||
|
||||
hMem, _, e := procGlobalAlloc.Call(0x0042 /*GMEM_MOVEABLE|GMEM_ZEROINIT*/, uintptr(size))
|
||||
if hMem == 0 {
|
||||
return fmt.Errorf("win32: GlobalAlloc: %w", e)
|
||||
}
|
||||
dst, _, _ := procGlobalLock.Call(hMem)
|
||||
if dst == 0 {
|
||||
procGlobalFree.Call(hMem)
|
||||
return fmt.Errorf("win32: GlobalLock failed")
|
||||
}
|
||||
dstSlice := unsafe.Slice((*uint16)(unsafe.Pointer(dst)), len(utf))
|
||||
copy(dstSlice, utf)
|
||||
procGlobalUnlock.Call(hMem)
|
||||
|
||||
if err := openClipboardRetry(0); err != nil {
|
||||
procGlobalFree.Call(hMem)
|
||||
return err
|
||||
}
|
||||
procEmptyClipboard.Call()
|
||||
r, _, ce := procSetClipboardData.Call(cfUnicodeText, hMem)
|
||||
if r == 0 {
|
||||
procCloseClipboard.Call()
|
||||
procGlobalFree.Call(hMem)
|
||||
return fmt.Errorf("win32: SetClipboardData: %w", ce)
|
||||
}
|
||||
// Ownership of hMem transfers to the system on success — do not free.
|
||||
procCloseClipboard.Call()
|
||||
return nil
|
||||
}
|
||||
|
||||
func openClipboardRetry(hwnd uintptr) error {
|
||||
var last error
|
||||
for i := 0; i < 8; i++ {
|
||||
r, _, e := procOpenClipboard.Call(hwnd)
|
||||
if r != 0 {
|
||||
return nil
|
||||
}
|
||||
last = e
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("win32: OpenClipboard: %w", last)
|
||||
}
|
||||
|
||||
// GetForegroundWindow returns the HWND that currently has keyboard
|
||||
// focus. Use this to remember the target window before the user clicks
|
||||
// into winauth-go (which itself becomes foreground and would otherwise
|
||||
// receive injected keystrokes).
|
||||
func GetForegroundWindow() uintptr {
|
||||
r, _, _ := procGetForegroundWindow.Call()
|
||||
return r
|
||||
}
|
||||
|
||||
// FocusWindow restores hwnd to the foreground. Hits the well-known
|
||||
// SetForegroundWindow restriction (only the foreground process may
|
||||
// hand focus to another); we work around it by attaching to the target
|
||||
// thread's input queue briefly, the trick documented in MSDN's
|
||||
// "AttachThreadInput" page.
|
||||
func FocusWindow(hwnd uintptr) error {
|
||||
if hwnd == 0 {
|
||||
return fmt.Errorf("win32: FocusWindow: nil hwnd")
|
||||
}
|
||||
curTID, _, _ := procGetCurrentThreadId.Call()
|
||||
targetTID, _, _ := procGetWindowThreadProcessId.Call(hwnd, 0)
|
||||
if targetTID == 0 {
|
||||
return fmt.Errorf("win32: GetWindowThreadProcessId failed")
|
||||
}
|
||||
if curTID != targetTID {
|
||||
procAttachThreadInput.Call(curTID, targetTID, 1)
|
||||
defer procAttachThreadInput.Call(curTID, targetTID, 0)
|
||||
}
|
||||
procSetForegroundWindowProc.Call(hwnd)
|
||||
procShowWindowProc.Call(hwnd, 9 /*SW_RESTORE*/)
|
||||
procBringWindowToTop.Call(hwnd)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TypeUnicode injects s as Unicode characters using SendInput KEYEVENTF_UNICODE.
|
||||
// Special characters in s pass through transparently; no translation
|
||||
// of newlines / tabs happens. If you need an Enter at the end, pass
|
||||
// "\n" and let the caller append it explicitly.
|
||||
func TypeUnicode(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
utf := utf16.Encode([]rune(s))
|
||||
// Each rune becomes 2 inputs (keydown + keyup).
|
||||
inputs := make([]inputUnion, 0, len(utf)*2)
|
||||
for _, u := range utf {
|
||||
inputs = append(inputs,
|
||||
makeUnicodeInput(u, false),
|
||||
makeUnicodeInput(u, true),
|
||||
)
|
||||
}
|
||||
r, _, e := procSendInput.Call(
|
||||
uintptr(len(inputs)),
|
||||
uintptr(unsafe.Pointer(&inputs[0])),
|
||||
unsafe.Sizeof(inputs[0]),
|
||||
)
|
||||
if int(r) != len(inputs) {
|
||||
if errno, ok := e.(syscall.Errno); ok && errno != 0 {
|
||||
return fmt.Errorf("win32: SendInput: sent %d/%d: %w", r, len(inputs), errno)
|
||||
}
|
||||
return fmt.Errorf("win32: SendInput: sent %d/%d", r, len(inputs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PressKey injects a single virtual-key down+up pair (e.g. VK_RETURN).
|
||||
func PressKey(vk uint16) error {
|
||||
inputs := [2]inputUnion{
|
||||
makeVKInput(vk, false),
|
||||
makeVKInput(vk, true),
|
||||
}
|
||||
r, _, e := procSendInput.Call(
|
||||
uintptr(len(inputs)),
|
||||
uintptr(unsafe.Pointer(&inputs[0])),
|
||||
unsafe.Sizeof(inputs[0]),
|
||||
)
|
||||
if int(r) != len(inputs) {
|
||||
if errno, ok := e.(syscall.Errno); ok && errno != 0 {
|
||||
return fmt.Errorf("win32: SendInput (vk): %w", errno)
|
||||
}
|
||||
return fmt.Errorf("win32: SendInput (vk): short send")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VK_RETURN is exposed for callers that want to press Enter after Auto-type.
|
||||
const VK_RETURN uint16 = 0x0D
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// raw syscalls + structs
|
||||
|
||||
var (
|
||||
procOpenClipboard = user32.NewProc("OpenClipboard")
|
||||
procCloseClipboard = user32.NewProc("CloseClipboard")
|
||||
procEmptyClipboard = user32.NewProc("EmptyClipboard")
|
||||
procSetClipboardData = user32.NewProc("SetClipboardData")
|
||||
procGetClipboardData = user32.NewProc("GetClipboardData")
|
||||
procGetForegroundWindow = user32.NewProc("GetForegroundWindow")
|
||||
procSetForegroundWindowProc = user32.NewProc("SetForegroundWindow")
|
||||
procShowWindowProc = user32.NewProc("ShowWindow")
|
||||
procBringWindowToTop = user32.NewProc("BringWindowToTop")
|
||||
procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
|
||||
procAttachThreadInput = user32.NewProc("AttachThreadInput")
|
||||
procSendInput = user32.NewProc("SendInput")
|
||||
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGlobalAlloc = kernel32.NewProc("GlobalAlloc")
|
||||
procGlobalFree = kernel32.NewProc("GlobalFree")
|
||||
procGlobalLock = kernel32.NewProc("GlobalLock")
|
||||
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
|
||||
procGlobalSize = kernel32.NewProc("GlobalSize")
|
||||
procGetCurrentThreadId = kernel32.NewProc("GetCurrentThreadId")
|
||||
)
|
||||
|
||||
// inputUnion is the Win32 INPUT structure, sized for KEYBDINPUT (the
|
||||
// largest variant on x64 is MOUSEINPUT but KEYBDINPUT padded to 40
|
||||
// works because INPUT_KEYBOARD never reads the extra trailing bytes).
|
||||
type inputUnion struct {
|
||||
typ uint32
|
||||
_pad uint32 // alignment on 64-bit
|
||||
wVk uint16
|
||||
wScan uint16
|
||||
dwFlags uint32
|
||||
time uint32
|
||||
dwExtra uintptr
|
||||
// 8 bytes of MOUSEINPUT-sized padding so the layout is large enough
|
||||
// for the union on amd64.
|
||||
_padTail [8]byte
|
||||
}
|
||||
|
||||
const (
|
||||
inputKeyboard uint32 = 1
|
||||
keyeventfKeyUp uint32 = 0x0002
|
||||
keyeventfUnicode uint32 = 0x0004
|
||||
)
|
||||
|
||||
func makeUnicodeInput(r uint16, up bool) inputUnion {
|
||||
flags := keyeventfUnicode
|
||||
if up {
|
||||
flags |= keyeventfKeyUp
|
||||
}
|
||||
return inputUnion{typ: inputKeyboard, wScan: r, dwFlags: flags}
|
||||
}
|
||||
|
||||
func makeVKInput(vk uint16, up bool) inputUnion {
|
||||
var flags uint32
|
||||
if up {
|
||||
flags = keyeventfKeyUp
|
||||
}
|
||||
return inputUnion{typ: inputKeyboard, wVk: vk, dwFlags: flags}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package win32
|
||||
|
||||
// AcquireInstanceLock always reports "first instance" on non-Windows
|
||||
// platforms — there is no convenient cross-platform equivalent and
|
||||
// running two copies is not catastrophic for the YAML store (last
|
||||
// writer wins).
|
||||
func AcquireInstanceLock(name string) (release func(), alreadyRunning bool, err error) {
|
||||
return func() {}, false, nil
|
||||
}
|
||||
|
||||
// ActivateOtherInstance is a no-op on non-Windows platforms.
|
||||
func ActivateOtherInstance(windowTitle string) error { return ErrUnsupported }
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build windows
|
||||
|
||||
package win32
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// AcquireInstanceLock takes a named mutex so a second launch of
|
||||
// winauth-go can detect the first one. Returns a Release closer to be
|
||||
// deferred at program exit. If another instance is already running it
|
||||
// returns alreadyRunning=true and a zero Release; the caller may then
|
||||
// call ActivateOtherInstance(windowTitle) to bring the existing window
|
||||
// forward and exit.
|
||||
func AcquireInstanceLock(name string) (release func(), alreadyRunning bool, err error) {
|
||||
wname, err := syscall.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("win32: utf16: %w", err)
|
||||
}
|
||||
h, err := windows.CreateMutex(nil, false, wname)
|
||||
if h == 0 {
|
||||
return nil, false, fmt.Errorf("win32: CreateMutex: %w", err)
|
||||
}
|
||||
// CreateMutex returns the existing handle when the name is taken; in
|
||||
// that case GetLastError reports ERROR_ALREADY_EXISTS. err is set
|
||||
// regardless of whether the handle is valid, so check the error
|
||||
// number rather than err != nil.
|
||||
if errno, ok := err.(syscall.Errno); ok && errno == windows.ERROR_ALREADY_EXISTS {
|
||||
windows.CloseHandle(h)
|
||||
return nil, true, nil
|
||||
}
|
||||
return func() { windows.CloseHandle(h) }, false, nil
|
||||
}
|
||||
|
||||
// ActivateOtherInstance walks the top-level windows looking for one
|
||||
// whose title matches windowTitle, then restores + foregrounds it. The
|
||||
// match is exact. Used after AcquireInstanceLock reports a duplicate
|
||||
// launch.
|
||||
func ActivateOtherInstance(windowTitle string) error {
|
||||
wtitle, err := syscall.UTF16PtrFromString(windowTitle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hwnd, _ := findWindow(nil, wtitle)
|
||||
if hwnd == 0 {
|
||||
// The mutex says somebody is running, but the window isn't up
|
||||
// yet — give it a brief moment then retry once.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
hwnd, _ = findWindow(nil, wtitle)
|
||||
}
|
||||
if hwnd == 0 {
|
||||
return fmt.Errorf("win32: could not locate existing window %q", windowTitle)
|
||||
}
|
||||
const (
|
||||
SW_RESTORE = 9
|
||||
)
|
||||
showWindow(hwnd, SW_RESTORE)
|
||||
setForegroundWindow(hwnd)
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// raw syscall stubs — the windows package does not expose these directly.
|
||||
|
||||
var (
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
procFindWindowW = user32.NewProc("FindWindowW")
|
||||
procShowWindow = user32.NewProc("ShowWindow")
|
||||
procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
|
||||
)
|
||||
|
||||
func findWindow(class, title *uint16) (windows.HWND, error) {
|
||||
r, _, e := procFindWindowW.Call(
|
||||
uintptr(unsafe.Pointer(class)),
|
||||
uintptr(unsafe.Pointer(title)),
|
||||
)
|
||||
if r == 0 {
|
||||
return 0, e
|
||||
}
|
||||
return windows.HWND(r), nil
|
||||
}
|
||||
|
||||
func showWindow(hwnd windows.HWND, cmdShow int32) bool {
|
||||
r, _, _ := procShowWindow.Call(uintptr(hwnd), uintptr(cmdShow))
|
||||
return r != 0
|
||||
}
|
||||
|
||||
func setForegroundWindow(hwnd windows.HWND) bool {
|
||||
r, _, _ := procSetForegroundWindow.Call(uintptr(hwnd))
|
||||
return r != 0
|
||||
}
|
||||
Reference in New Issue
Block a user