Compare commits

..

4 Commits

Author SHA1 Message Date
879ae24ca6 feat: Janela sobre o relogio 2026-04-27 10:12:06 -03:00
1de5a60843 feat: mudar o foco da janela quando for pro client 2026-04-27 09:40:10 -03:00
5d016d7087 WIP: mini-window approach for scroll capture
Tentativa de capturar scroll via mini janela sempre-no-topo.
Inclui: MoveWindow/GetMonitorWorkArea via Win32, park consistente
em wx+150/wy+50, ShowCursor loop no goroutine pós-resize.
Revertível: git checkout HEAD~1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:51:00 -03:00
6bdf5fcdbd Fix scroll and mouse stability using UI capture and center parking 2026-04-24 12:45:30 -03:00
12 changed files with 1246 additions and 129 deletions

View File

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(go build:*)",
"Bash(git:*)"
]
}
}

View File

@ -1,4 +1,8 @@
# CLAUDE.md — KVMote (Go/Wails) # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# KVMote (Go/Wails)
Reescrita em Go do KVMote (original em C#/WinForms em `C:\vscode\KVMote`). Reescrita em Go do KVMote (original em C#/WinForms em `C:\vscode\KVMote`).
@ -30,16 +34,21 @@ Hardware suportado: ESP32-S3 (BLE NUS) e Arduino Leonardo + HC-06 (Serial SPP).
``` ```
main.go — entry point Wails, bind App main.go — entry point Wails, bind App
app.go — App struct, métodos expostos ao frontend app.go — App struct, métodos expostos ao frontend; implementa WindowManager
internal/ internal/
transport/ transport/
transport.go — interface Transport (Detect/Connect/Send/SendLossy...) transport.go — interface Transport (Detect/Connect/Send/SendLossy...)
ble_windows.go — BLE NUS via tinygo/bluetooth ble_windows.go — BLE NUS via tinygo/bluetooth
kvm/ kvm/
engine.go — lógica KVM: mouse, teclado, clipboard, modo cliente engine.go — lógica KVM: mouse, teclado, clipboard, modo cliente
WindowManager interface (SetMiniMode/RestoreNormalMode)
LogDebug → kvmote_debug.log (arquivo na raiz)
input/ input/
input.go — interface InputHandler + tipos (Point, MouseEvent, KeyboardEvent) input.go — interface InputHandler + tipos (Point, MouseEvent, KeyboardEvent)
input_windows.go — hooks Win32, SetCursorPos, ShowCursor, GetSystemMetrics input_windows.go — hooks Win32, SetCursorPos, ShowCursor, GetSystemMetrics
overlay_windows.go — janela Win32 nativa independente do Wails (neon "KVMote")
aparece no canto durante modo cliente; usa sua própria goroutine
com LockOSThread + message pump próprio
frontend/ frontend/
dist/index.html — UI dist/index.html — UI
wails.json — config Wails wails.json — config Wails
@ -120,9 +129,17 @@ Saída: `build/bin/kvmote.exe`
--- ---
## Mini-mode / Overlay
Ao entrar em modo cliente, `engine` chama `WindowManager.SetMiniMode()``App` emite evento `window-mode:mini` ao frontend e exibe `OverlayWindow` nativo Win32. `OverlayWindow` tem goroutine própria com `LockOSThread` e message pump independente do Wails — não interfere com WebView2, Raw Input, nem coordenadas Wails.
---
## ⚠️ Cuidados ## ⚠️ Cuidados
- **Scroll touchpad:** `scrollActive` e `scrollTimer` não devem ser resetados em enter/exitClientMode - **Scroll touchpad:** `scrollActive` e `scrollTimer` não devem ser resetados em enter/exitClientMode
- **runtime.LockOSThread:** nunca remover da goroutine de hooks - **runtime.LockOSThread:** nunca remover da goroutine de hooks nem da goroutine do overlay
- **isWarping flag:** previne loop infinito SetCursorPos → WM_MOUSEMOVE → SetCursorPos - **isWarping flag:** previne loop infinito SetCursorPos → WM_MOUSEMOVE → SetCursorPos
- **Mutex ordering:** engine.mu protege todo estado KVM; transport.mu protege conexão BLE - **Mutex ordering:** engine.mu protege todo estado KVM; transport.mu protege conexão BLE
- **overlayOnce:** `RegisterClassExW` só pode ser chamado uma vez por processo; overlay usa `sync.Once`
- **LogDebug:** escreve em `kvmote_debug.log` na raiz do projeto (ignorado pelo git)

307
S3/KVMote_ESP32S3.ino Normal file
View File

@ -0,0 +1,307 @@
/*
KVMote ESP32-S3 N16R8
BLE NUS (Nordic UART Service) USB HID nativo
Substitui: Arduino Leonardo + HC-06 + LED RGB externo
LED: WS2812B embutido na placa (GPIO 48 na maioria das DevKit-C1).
Se a sua placa usar outro pino, altere LED_PIN abaixo.
Protocolo binário: idêntico ao KVMote.ino (Leonardo).
Mouse move 'M' dx(int8) dy(int8) 3 bytes
Mouse wheel 'W' delta(int8) 2 bytes
Tecla write 'K' char 2 bytes
Clique 'C' 'L'|'R' 2 bytes
Tecla press 'P' keycode 2 bytes
Tecla release 'U' keycode 2 bytes
ReleaseAll 'A' 1 byte
Mouse press 'D' 'L'|'R' 2 bytes
Mouse release 'E' 'L'|'R' 2 bytes
LED cliente 'O' (magenta)
LED host ok 'H' (azul)
LED sem host 'G' (verde)
Ping/Pong '~' responde [PONG] 1 byte
Dependências (instale pela Library Manager do Arduino IDE):
- Adafruit NeoPixel (by Adafruit)
incluídas no core ESP32:
- USB / USBHIDKeyboard / USBHIDMouse
- BLEDevice / BLEServer / BLE2902
Board: "ESP32S3 Dev Module"
USB Mode Hardware CDC and JTAG mantém JTAG para upload via porta COM
USB CDC On Boot Disabled CRÍTICO: libera USB nativo para HID
Upload Mode Internal USB (ou USB-OTG CDC)
PSRAM OPI PSRAM (para N16R8)
Flash Size 16MB
Conexões:
Porta USB (nativa OTG) PC cliente (aparece como teclado+mouse HID)
Porta COM (CH343) PC de desenvolvimento (upload de firmware)
BLE Host PC (sem fio, KVMote.exe)
*/
#include "USB.h"
#include "USBHIDKeyboard.h"
#include "USBHIDMouse.h"
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Adafruit_NeoPixel.h>
// ── NUS UUIDs ─────────────────────────────────────────────────────────────────
#define NUS_SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
#define NUS_RX_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" // PC escreve aqui
#define NUS_TX_UUID "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" // ESP32 notifica (PONG)
// ── LED WS2812B embutido ──────────────────────────────────────────────────────
#define LED_PIN 48 // GPIO 48 — ESP32-S3-DevKitC-1; altere se necessário
#define LED_COUNT 1
#define LED_BRIGHTNESS 80 // 0255 (80 ≈ 30%, evita ofuscar)
// ── Objetos USB HID ───────────────────────────────────────────────────────────
USBHIDKeyboard Keyboard;
USBHIDMouse Mouse;
// ── NeoPixel ──────────────────────────────────────────────────────────────────
Adafruit_NeoPixel pixel(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
void ledCor(uint8_t r, uint8_t g, uint8_t b) {
pixel.setPixelColor(0, pixel.Color(r, g, b));
pixel.show();
}
// Cor base atual — restaurada após paste batch
uint8_t basR = 0, basG = 255, basB = 0; // verde = aguardando conexão
// ── BLE ───────────────────────────────────────────────────────────────────────
BLEServer* pServer = nullptr;
BLECharacteristic* pTxChar = nullptr;
bool bleConn = false;
// ── Fila BLE → HID (desacopla callback BLE do USB TinyUSB) ───────────────────
// O callback BLE roda numa task FreeRTOS separada. Chamar Mouse.move() /
// Keyboard.press() de lá bloqueia a task BLE esperando o USB. A fila resolve:
// callback só enfileira bytes, loop() drena e chama o HID.
static QueueHandle_t rxQueue;
// ── Máquina de estados (idêntica ao Leonardo) ─────────────────────────────────
enum Estado : uint8_t {
AGUARDA_CMD,
AGUARDA_MOUSE_DX,
AGUARDA_MOUSE_DY,
AGUARDA_MOUSE_WHEEL,
AGUARDA_TECLA,
AGUARDA_CLIQUE,
AGUARDA_PRESS_KEY,
AGUARDA_RELEASE_KEY,
AGUARDA_MOUSE_PRESS,
AGUARDA_MOUSE_RELEASE,
AGUARDA_BATCH_LEN1, // recebendo byte alto do tamanho do chunk
AGUARDA_BATCH_LEN2, // recebendo byte baixo do tamanho do chunk
AGUARDA_BATCH_DATA // recebendo bytes de texto → Keyboard.write()
};
Estado estado = AGUARDA_CMD;
int8_t pendingDX = 0;
uint16_t batchRemaining = 0;
uint32_t batchLedMs = 0;
bool batchLedOn = false;
// ── Processa um byte do protocolo ─────────────────────────────────────────────
// Chamada diretamente do callback BLE (task separada do FreeRTOS).
// As funções HID do ESP32 são thread-safe.
void processaByte(uint8_t b) {
switch (estado) {
case AGUARDA_CMD:
if (b == 'M') estado = AGUARDA_MOUSE_DX;
else if (b == 'W') estado = AGUARDA_MOUSE_WHEEL;
else if (b == 'K') estado = AGUARDA_TECLA;
else if (b == 'C') estado = AGUARDA_CLIQUE;
else if (b == 'P') estado = AGUARDA_PRESS_KEY;
else if (b == 'U') estado = AGUARDA_RELEASE_KEY;
else if (b == 'D') estado = AGUARDA_MOUSE_PRESS;
else if (b == 'E') estado = AGUARDA_MOUSE_RELEASE;
else if (b == 'A') { Keyboard.releaseAll(); }
else if (b == 'O') { basR = 255; basG = 0; basB = 255; ledCor(basR, basG, basB); }
else if (b == 'H') { basR = 0; basG = 0; basB = 255; ledCor(basR, basG, basB); }
else if (b == 'G') { basR = 0; basG = 255; basB = 0; ledCor(basR, basG, basB); }
else if (b == 'T') { estado = AGUARDA_BATCH_LEN1; }
else if (b == '~') {
if (pTxChar && bleConn) {
pTxChar->setValue((uint8_t*)"[PONG]", 6);
pTxChar->notify();
}
}
break;
case AGUARDA_MOUSE_DX:
pendingDX = (int8_t)b;
estado = AGUARDA_MOUSE_DY;
break;
case AGUARDA_MOUSE_DY:
Mouse.move(pendingDX, (int8_t)b, 0);
estado = AGUARDA_CMD;
break;
case AGUARDA_MOUSE_WHEEL:
Mouse.move(0, 0, (int8_t)b);
estado = AGUARDA_CMD;
break;
case AGUARDA_TECLA:
Keyboard.write(b); // keycodes >= 0x80 seguem a mesma convenção do Arduino HID
estado = AGUARDA_CMD;
break;
case AGUARDA_CLIQUE:
if (b == 'L') Mouse.click(MOUSE_LEFT);
if (b == 'R') Mouse.click(MOUSE_RIGHT);
estado = AGUARDA_CMD;
break;
case AGUARDA_PRESS_KEY:
Keyboard.press(b);
estado = AGUARDA_CMD;
break;
case AGUARDA_RELEASE_KEY:
Keyboard.release(b);
estado = AGUARDA_CMD;
break;
case AGUARDA_MOUSE_PRESS:
if (b == 'L') Mouse.press(MOUSE_LEFT);
if (b == 'R') Mouse.press(MOUSE_RIGHT);
estado = AGUARDA_CMD;
break;
case AGUARDA_MOUSE_RELEASE:
if (b == 'L') Mouse.release(MOUSE_LEFT);
if (b == 'R') Mouse.release(MOUSE_RIGHT);
estado = AGUARDA_CMD;
break;
// ── Paste batch: T + uint16_be(N) + N bytes → Keyboard.write por byte ────
// Sem buffer: processa byte a byte enquanto chegam. LED pisca vermelho.
case AGUARDA_BATCH_LEN1:
batchRemaining = (uint16_t)b << 8;
estado = AGUARDA_BATCH_LEN2;
break;
case AGUARDA_BATCH_LEN2:
batchRemaining |= b;
if (batchRemaining == 0) { estado = AGUARDA_CMD; break; }
batchLedMs = millis();
batchLedOn = true;
ledCor(220, 0, 0); // vermelho: início do chunk
estado = AGUARDA_BATCH_DATA;
break;
case AGUARDA_BATCH_DATA: {
uint32_t now = millis();
if (now - batchLedMs >= 150) {
batchLedMs = now;
batchLedOn = !batchLedOn;
ledCor(batchLedOn ? 220 : 0, 0, 0);
}
Keyboard.write(b);
delay(5); // inter-char delay: evita drop do Shift no USB HID em rajada
if (--batchRemaining == 0) {
ledCor(basR, basG, basB); // restaura cor anterior
estado = AGUARDA_CMD;
}
break;
}
}
}
// ── Callback: chegada de dados pelo BLE (PC → ESP32) ─────────────────────────
// Apenas enfileira — não chama HID aqui para não bloquear a task BLE.
class RxCallback : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic* pChar) override {
String val = pChar->getValue();
for (int i = 0; i < val.length(); i++) {
uint8_t b = (uint8_t)val[i];
xQueueSend(rxQueue, &b, 0); // não bloqueia se a fila estiver cheia
}
}
};
// ── Callbacks de conexão / desconexão BLE ─────────────────────────────────────
class ServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer*) override {
bleConn = true;
// LED permanece verde até o host enviar 'H'
}
void onDisconnect(BLEServer*) override {
bleConn = false;
estado = AGUARDA_CMD;
batchRemaining = 0;
Keyboard.releaseAll();
basR = 0; basG = 255; basB = 0;
ledCor(basR, basG, basB); // verde — aguardando host
BLEDevice::startAdvertising(); // permite reconexão imediata
}
};
// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
// LED
pixel.begin();
pixel.setBrightness(LED_BRIGHTNESS);
ledCor(0, 255, 0); // verde — aguardando conexão
// USB HID (TinyUSB via USB OTG)
USB.productName("KVMote");
USB.manufacturerName("KVMote");
USB.begin();
Keyboard.begin();
Mouse.begin();
// BLE — NUS
BLEDevice::init("KVMote");
pServer = BLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
BLEService* pService = pServer->createService(NUS_SERVICE_UUID);
// RX: aceita Write e Write Without Response — sem criptografia (evita AccessDenied no Windows)
BLECharacteristic* pRxChar = pService->createCharacteristic(
NUS_RX_UUID,
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR
);
pRxChar->setAccessPermissions(ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE);
pRxChar->setCallbacks(new RxCallback());
// TX: apenas Notify (para [PONG]) — sem criptografia
pTxChar = pService->createCharacteristic(
NUS_TX_UUID,
BLECharacteristic::PROPERTY_NOTIFY
);
pTxChar->setAccessPermissions(ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE);
BLE2902* cccd = new BLE2902();
cccd->setAccessPermissions(ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE);
pTxChar->addDescriptor(cccd);
pService->start();
rxQueue = xQueueCreate(2048, sizeof(uint8_t));
BLEAdvertising* pAdv = BLEDevice::getAdvertising();
pAdv->addServiceUUID(NUS_SERVICE_UUID);
pAdv->setScanResponse(true);
pAdv->setMinPreferred(0x06); // melhora compatibilidade com iOS/Windows
BLEDevice::startAdvertising();
}
// ── Loop ──────────────────────────────────────────────────────────────────────
void loop() {
// Drena a fila e processa no contexto do loop (seguro para TinyUSB HID)
uint8_t b;
while (xQueueReceive(rxQueue, &b, 0) == pdTRUE)
processaByte(b);
delay(1);
}

34
app.go
View File

@ -8,12 +8,16 @@ import (
"kvmote/internal/input" "kvmote/internal/input"
"kvmote/internal/kvm" "kvmote/internal/kvm"
"kvmote/internal/transport" "kvmote/internal/transport"
"github.com/wailsapp/wails/v2/pkg/runtime"
) )
// App struct // App struct
type App struct { type App struct {
ctx context.Context ctx context.Context
engine *kvm.Engine engine *kvm.Engine
overlay *input.OverlayWindow
isMini bool
} }
// NewApp creates a new App application struct // NewApp creates a new App application struct
@ -21,9 +25,33 @@ func NewApp() *App {
t := transport.NewBleTransport() t := transport.NewBleTransport()
h := input.NewInputHandler() h := input.NewInputHandler()
e := kvm.NewEngine(t, h) e := kvm.NewEngine(t, h)
return &App{ app := &App{
engine: e, engine: e,
overlay: input.NewOverlayWindow(),
} }
e.SetWindowManager(app)
return app
}
func (a *App) SetMiniMode() {
if a.isMini {
return
}
a.isMini = true
runtime.EventsEmit(a.ctx, "window-mode", "mini")
// Overlay nativo Win32 no canto — não toca na janela principal
mx, my, mw, mh := a.engine.GetMonitorWorkArea()
a.overlay.Show(mx, my, mw, mh)
}
func (a *App) RestoreNormalMode() {
if !a.isMini {
return
}
a.isMini = false
runtime.EventsEmit(a.ctx, "window-mode", "normal")
a.overlay.Hide()
} }
// startup is called when the app starts. The context is saved // startup is called when the app starts. The context is saved
@ -76,3 +104,7 @@ func (a *App) ChangeLayout(layout int) {
func (a *App) SetPosition(pos int) { func (a *App) SetPosition(pos int) {
a.engine.SetPosition(pos) a.engine.SetPosition(pos)
} }
func (a *App) HandleScroll(delta int) {
a.engine.HandleManualScroll(delta)
}

View File

@ -115,9 +115,27 @@
font-weight: bold; font-weight: bold;
} }
.btn-cad { margin-top: 10px; background: #2a2a2a; color: #ccc; } .btn-cad { margin-top: 10px; background: #2a2a2a; color: #ccc; }
/* Estilo Neon para Modo Mini */
.neon-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background-color: black;
cursor: default !important; /* Força cursor visível */
}
.neon-text {
font-size: 1.6rem;
font-weight: bold;
color: #39FF14;
text-shadow: 0 0 5px #39FF14, 0 0 10px #39FF14;
letter-spacing: 1px;
}
</style> </style>
</head> </head>
<body x-data="kvmApp()"> <body x-data="kvmApp()" style="cursor: default !important;">
<template x-if="mode === 'normal'">
<div class="app-container"> <div class="app-container">
<div class="section-title">Posição do PC Cliente:</div> <div class="section-title">Posição do PC Cliente:</div>
@ -179,11 +197,23 @@
</div> </div>
</div> </div>
</div> </div>
</template>
<div class="status-bar"> <template x-if="mode === 'mini'">
<div class="neon-container" id="mini-bg">
<div class="neon-text" x-text="'KVMote [' + scrollCount + ']'"></div>
</div>
</template>
<div class="status-bar" x-show="mode === 'normal'">
<span x-text="statusText"></span> <span x-text="statusText"></span>
</div> </div>
<!-- Área rolável fake para forçar captura de scroll -->
<div id="scroll-sink" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; overflow-y: scroll; z-index: -1; pointer-events: none;">
<div style="height: 5000px; width: 1px;"></div>
</div>
<script> <script>
function kvmApp() { function kvmApp() {
return { return {
@ -192,6 +222,31 @@
statusText: 'Desconectado', statusText: 'Desconectado',
pos: 'right', pos: 'right',
layout: 1, layout: 1,
mode: 'normal',
scrollCount: 0,
init() {
if (window.runtime) {
window.runtime.EventsOn("window-mode", (m) => {
this.mode = m;
});
}
// Listener de Scroll interno ao kvmApp para acessar scrollCount
window.addEventListener('wheel', (event) => {
if (this.mode === 'mini') {
this.scrollCount++;
// Feedback visual rápido
const bg = document.getElementById('mini-bg');
if (bg) {
bg.style.backgroundColor = '#1a3300';
setTimeout(() => bg.style.backgroundColor = 'black', 50);
}
}
if (window.go && window.go.main && window.go.main.App) {
window.go.main.App.HandleScroll(Math.round(event.deltaY));
}
}, { passive: false });
},
toggleConnect() { toggleConnect() {
if (this.connected) { if (this.connected) {
window.go.main.App.Disconnect().then(res => { window.go.main.App.Disconnect().then(res => {
@ -221,6 +276,14 @@
} }
} }
} }
// Captura o scroll globalmente na janela do KVMote
window.addEventListener('wheel', (event) => {
console.log("Wheel event:", event.deltaY);
if (window.go && window.go.main && window.go.main.App) {
window.go.main.App.HandleScroll(Math.round(event.deltaY));
}
}, { passive: false });
</script> </script>
</body> </body>
</html> </html>

View File

@ -7,6 +7,12 @@ export function Connect():Promise<string>;
export function Disconnect():Promise<string>; export function Disconnect():Promise<string>;
export function HandleScroll(arg1:number):Promise<void>;
export function RestoreNormalMode():Promise<void>;
export function SendCtrlAltDel():Promise<void>; export function SendCtrlAltDel():Promise<void>;
export function SetMiniMode():Promise<void>;
export function SetPosition(arg1:number):Promise<void>; export function SetPosition(arg1:number):Promise<void>;

View File

@ -14,10 +14,22 @@ export function Disconnect() {
return window['go']['main']['App']['Disconnect'](); return window['go']['main']['App']['Disconnect']();
} }
export function HandleScroll(arg1) {
return window['go']['main']['App']['HandleScroll'](arg1);
}
export function RestoreNormalMode() {
return window['go']['main']['App']['RestoreNormalMode']();
}
export function SendCtrlAltDel() { export function SendCtrlAltDel() {
return window['go']['main']['App']['SendCtrlAltDel'](); return window['go']['main']['App']['SendCtrlAltDel']();
} }
export function SetMiniMode() {
return window['go']['main']['App']['SetMiniMode']();
}
export function SetPosition(arg1) { export function SetPosition(arg1) {
return window['go']['main']['App']['SetPosition'](arg1); return window['go']['main']['App']['SetPosition'](arg1);
} }

View File

@ -25,4 +25,19 @@ type InputHandler interface {
SetCursorPos(x, y int32) bool SetCursorPos(x, y int32) bool
ShowCursor(show bool) ShowCursor(show bool)
GetScreenResolution() (int32, int32) GetScreenResolution() (int32, int32)
RequestFocus()
SetCursorClip(clip bool)
GetWindowPos() (int32, int32)
GetWindowSize() (int32, int32)
GetMonitorWorkArea() (x, y, w, h int32)
MoveWindow(x, y, w, h int32, topmost bool)
GetDpiScale() float64
GetForegroundWindow() uintptr
SetForegroundTo(hwnd uintptr)
GetAppHwnd() uintptr
IsMinimized(hwnd uintptr) bool
RestoreWindow(hwnd uintptr)
MinimizeWindow(hwnd uintptr)
GetRestoredRect() (x, y, w, h int32)
RegisterRawScrollSink(callback func(delta int16)) error
} }

View File

@ -1,4 +1,5 @@
//go:build windows //go:build windows
package input package input
import ( import (
@ -24,6 +25,85 @@ var (
procGetSystemMetrics = user32.NewProc("GetSystemMetrics") procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware") procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware")
procPostThreadMessageW = user32.NewProc("PostThreadMessageW") procPostThreadMessageW = user32.NewProc("PostThreadMessageW")
procGetActiveWindow = user32.NewProc("GetActiveWindow")
procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
procGetWindowRect = user32.NewProc("GetWindowRect")
procClipCursor = user32.NewProc("ClipCursor")
procSetWindowPos = user32.NewProc("SetWindowPos")
procMonitorFromWindow = user32.NewProc("MonitorFromWindow")
procGetMonitorInfoW = user32.NewProc("GetMonitorInfoW")
procCreateWindowExW = user32.NewProc("CreateWindowExW")
procRegisterRawInputDevices = user32.NewProc("RegisterRawInputDevices")
procGetRawInputData = user32.NewProc("GetRawInputData")
procGetDpiForWindow = user32.NewProc("GetDpiForWindow")
procGetForegroundWindow = user32.NewProc("GetForegroundWindow")
procIsIconic = user32.NewProc("IsIconic")
procShowWindowAsync = user32.NewProc("ShowWindowAsync")
procGetWindowPlacement = user32.NewProc("GetWindowPlacement")
procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
procAttachThreadInput = user32.NewProc("AttachThreadInput")
procBringWindowToTop = user32.NewProc("BringWindowToTop")
)
type RECT struct {
Left, Top, Right, Bottom int32
}
type WINDOWPLACEMENT struct {
Length uint32
Flags uint32
ShowCmd uint32
PtMinPosition Point
PtMaxPosition Point
RcNormalPosition RECT
RcDevice RECT
}
type MONITORINFO struct {
CbSize uint32
RcMonitor RECT
RcWork RECT
DwFlags uint32
}
// Raw Input structs (layout deve bater exatamente com winuser.h)
type RAWINPUTHEADER struct {
DwType uint32
DwSize uint32
HDevice uintptr
WParam uintptr
}
type RAWMOUSE struct {
UsFlags uint16
_ [2]byte // padding para alinhar union de 4 bytes
UsButtonFlags uint16
UsButtonData uint16
UlRawButtons uint32
LLastX int32
LLastY int32
UlExtraInfo uint32
}
type RAWINPUT struct {
Header RAWINPUTHEADER
Mouse RAWMOUSE
}
type RAWINPUTDEVICE struct {
UsUsagePage uint16
UsUsage uint16
DwFlags uint32
HwndTarget uintptr
}
const (
WM_INPUT = 0x00FF
RID_INPUT = 0x10000003
RIM_TYPEMOUSE = 0
RI_MOUSE_WHEEL = 0x0400
RIDEV_INPUTSINK = 0x00000100
HWND_MESSAGE = ^uintptr(2) // -3
) )
const ( const (
@ -40,7 +120,7 @@ func init() {
type MSLLHOOKSTRUCT struct { type MSLLHOOKSTRUCT struct {
Pt Point Pt Point
MouseData uint32 // Mantemos uint32 mas vamos converter no callback MouseData uint32
Flags uint32 Flags uint32
Time uint32 Time uint32
DwExtraInfo uintptr DwExtraInfo uintptr
@ -58,6 +138,7 @@ type windowsInputHandler struct {
mouseHook uintptr mouseHook uintptr
keyHook uintptr keyHook uintptr
tid uint32 tid uint32
hwnd uintptr
} }
func NewInputHandler() InputHandler { func NewInputHandler() InputHandler {
@ -65,6 +146,10 @@ func NewInputHandler() InputHandler {
} }
func (h *windowsInputHandler) Install(ctx context.Context, onMouse func(MouseEvent) bool, onKey func(KeyboardEvent) bool) error { func (h *windowsInputHandler) Install(ctx context.Context, onMouse func(MouseEvent) bool, onKey func(KeyboardEvent) bool) error {
// GetForegroundWindow é system-wide (GetActiveWindow é thread-local e pode retornar 0)
hwnd, _, _ := procGetForegroundWindow.Call()
h.hwnd = hwnd
ready := make(chan error, 1) ready := make(chan error, 1)
go func() { go func() {
@ -81,7 +166,7 @@ func (h *windowsInputHandler) Install(ctx context.Context, onMouse func(MouseEve
Data: info.MouseData, Data: info.MouseData,
} }
// Se a engine tratar o evento, não passamos para o próximo hook // Se a engine tratar o evento (retornar true), bloqueamos o Windows
if onMouse(ev) { if onMouse(ev) {
return 1 return 1
} }
@ -160,7 +245,6 @@ func (h *windowsInputHandler) ShowCursor(show bool) {
if show { if show {
s = 1 s = 1
} }
// Vamos usar uma abordagem mais direta se necessário, mas por enquanto:
procShowCursor.Call(uintptr(s)) procShowCursor.Call(uintptr(s))
} }
@ -169,3 +253,218 @@ func (h *windowsInputHandler) GetScreenResolution() (int32, int32) {
h_res, _, _ := procGetSystemMetrics.Call(SM_CYSCREEN) h_res, _, _ := procGetSystemMetrics.Call(SM_CYSCREEN)
return int32(w), int32(h_res) return int32(w), int32(h_res)
} }
func (h *windowsInputHandler) RequestFocus() {
if h.hwnd == 0 {
return
}
// AttachThreadInput trick: único jeito confiável de roubar foco de outra janela no Windows.
// SetForegroundWindow sozinho falha se o processo chamante não é o foreground.
fgHwnd, _, _ := procGetForegroundWindow.Call()
fgTid, _, _ := procGetWindowThreadProcessId.Call(fgHwnd, 0)
myTid := uintptr(windows.GetCurrentThreadId())
if fgTid != 0 && fgTid != myTid {
procAttachThreadInput.Call(myTid, fgTid, 1) // attach
}
procBringWindowToTop.Call(h.hwnd)
procSetForegroundWindow.Call(h.hwnd)
if fgTid != 0 && fgTid != myTid {
procAttachThreadInput.Call(myTid, fgTid, 0) // detach
}
}
func (h *windowsInputHandler) SetCursorClip(clip bool) {
// Removido temporariamente para testes
if !clip {
procClipCursor.Call(0)
}
}
func (h *windowsInputHandler) GetWindowPos() (int32, int32) {
if h.hwnd != 0 {
var rect RECT
procGetWindowRect.Call(h.hwnd, uintptr(unsafe.Pointer(&rect)))
return rect.Left, rect.Top
}
return 0, 0
}
func (h *windowsInputHandler) GetWindowSize() (int32, int32) {
if h.hwnd != 0 {
var rect RECT
procGetWindowRect.Call(h.hwnd, uintptr(unsafe.Pointer(&rect)))
return rect.Right - rect.Left, rect.Bottom - rect.Top
}
return 0, 0
}
func (h *windowsInputHandler) GetMonitorWorkArea() (x, y, w, ht int32) {
if h.hwnd == 0 {
return 0, 0, 1920, 1080
}
monitor, _, _ := procMonitorFromWindow.Call(h.hwnd, 2) // MONITOR_DEFAULTTONEAREST
if monitor == 0 {
return 0, 0, 1920, 1080
}
var mi MONITORINFO
mi.CbSize = uint32(unsafe.Sizeof(mi))
procGetMonitorInfoW.Call(monitor, uintptr(unsafe.Pointer(&mi)))
return mi.RcWork.Left, mi.RcWork.Top,
mi.RcWork.Right - mi.RcWork.Left,
mi.RcWork.Bottom - mi.RcWork.Top
}
func (h *windowsInputHandler) MoveWindow(x, y, w, ht int32, topmost bool) {
if h.hwnd == 0 {
return
}
// HWND_TOPMOST = -1, HWND_NOTOPMOST = -2
zorder := ^uintptr(1) // HWND_NOTOPMOST
if topmost {
zorder = ^uintptr(0) // HWND_TOPMOST
}
const SWP_NOACTIVATE = 0x0010
procSetWindowPos.Call(
h.hwnd, zorder,
uintptr(x), uintptr(y), uintptr(w), uintptr(ht),
SWP_NOACTIVATE,
)
}
func (h *windowsInputHandler) GetDpiScale() float64 {
if h.hwnd == 0 {
return 1.0
}
dpi, _, _ := procGetDpiForWindow.Call(h.hwnd)
if dpi == 0 {
return 1.0
}
return float64(dpi) / 96.0
}
func (h *windowsInputHandler) GetForegroundWindow() uintptr {
hwnd, _, _ := procGetForegroundWindow.Call()
return hwnd
}
func (h *windowsInputHandler) SetForegroundTo(hwnd uintptr) {
if hwnd == 0 {
return
}
fgHwnd, _, _ := procGetForegroundWindow.Call()
fgTid, _, _ := procGetWindowThreadProcessId.Call(fgHwnd, 0)
myTid := uintptr(windows.GetCurrentThreadId())
if fgTid != 0 && fgTid != myTid {
procAttachThreadInput.Call(myTid, fgTid, 1)
}
procBringWindowToTop.Call(hwnd)
procSetForegroundWindow.Call(hwnd)
if fgTid != 0 && fgTid != myTid {
procAttachThreadInput.Call(myTid, fgTid, 0)
}
}
func (h *windowsInputHandler) GetAppHwnd() uintptr {
return h.hwnd
}
// GetRestoredRect retorna posição e tamanho do KVMote no estado restaurado,
// mesmo que a janela esteja minimizada (GetWindowRect falha nesse caso).
func (h *windowsInputHandler) GetRestoredRect() (x, y, w, ht int32) {
if h.hwnd == 0 {
return 0, 0, 800, 600
}
var wp WINDOWPLACEMENT
wp.Length = uint32(unsafe.Sizeof(wp))
procGetWindowPlacement.Call(h.hwnd, uintptr(unsafe.Pointer(&wp)))
r := wp.RcNormalPosition
return r.Left, r.Top, r.Right - r.Left, r.Bottom - r.Top
}
func (h *windowsInputHandler) IsMinimized(hwnd uintptr) bool {
ret, _, _ := procIsIconic.Call(hwnd)
return ret != 0
}
func (h *windowsInputHandler) RestoreWindow(hwnd uintptr) {
procShowWindowAsync.Call(hwnd, 9) // SW_RESTORE
}
func (h *windowsInputHandler) MinimizeWindow(hwnd uintptr) {
procShowWindowAsync.Call(hwnd, 6) // SW_MINIMIZE
}
func (h *windowsInputHandler) RegisterRawScrollSink(callback func(delta int16)) error {
ready := make(chan error, 1)
go func() {
runtime.LockOSThread()
// Janela message-only como alvo do Raw Input (sem UI, sem z-order)
className, _ := windows.UTF16PtrFromString("STATIC")
hwndMsg, _, _ := procCreateWindowExW.Call(
0,
uintptr(unsafe.Pointer(className)),
0, 0,
0, 0, 0, 0,
HWND_MESSAGE, // janela oculta sem pai na tela
0, 0, 0,
)
if hwndMsg == 0 {
ready <- fmt.Errorf("CreateWindowExW failed")
return
}
// Registra mouse raw com RIDEV_INPUTSINK: recebe eventos mesmo sem foco/cursor
rid := RAWINPUTDEVICE{
UsUsagePage: 0x01, // HID_USAGE_PAGE_GENERIC
UsUsage: 0x02, // HID_USAGE_GENERIC_MOUSE
DwFlags: RIDEV_INPUTSINK,
HwndTarget: hwndMsg,
}
ret, _, _ := procRegisterRawInputDevices.Call(
uintptr(unsafe.Pointer(&rid)),
1,
uintptr(unsafe.Sizeof(rid)),
)
if ret == 0 {
ready <- fmt.Errorf("RegisterRawInputDevices failed")
return
}
ready <- nil
// Loop de mensagens dedicado para WM_INPUT
var msg struct {
Hwnd uintptr
Message uint32
WParam uintptr
LParam uintptr
Time uint32
Pt Point
}
for {
r, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&msg)), hwndMsg, 0, 0)
if r == 0 || msg.Message == WM_QUIT {
break
}
if msg.Message == WM_INPUT {
var raw RAWINPUT
size := uint32(unsafe.Sizeof(raw))
procGetRawInputData.Call(
msg.LParam, // HRAWINPUT
RID_INPUT,
uintptr(unsafe.Pointer(&raw)),
uintptr(unsafe.Pointer(&size)),
uintptr(unsafe.Sizeof(RAWINPUTHEADER{})),
)
if raw.Header.DwType == RIM_TYPEMOUSE &&
raw.Mouse.UsButtonFlags&RI_MOUSE_WHEEL != 0 {
callback(int16(raw.Mouse.UsButtonData))
}
}
}
}()
return <-ready
}

View File

@ -0,0 +1,203 @@
//go:build windows
package input
import (
"runtime"
"sync"
"unsafe"
"golang.org/x/sys/windows"
)
var (
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush")
procGdiDeleteObject = gdi32.NewProc("DeleteObject")
procGetStockObject = gdi32.NewProc("GetStockObject")
procGdiSelectObject = gdi32.NewProc("SelectObject")
procSetBkMode = gdi32.NewProc("SetBkMode")
procSetTextColor = gdi32.NewProc("SetTextColor")
procBeginPaint = user32.NewProc("BeginPaint")
procEndPaint = user32.NewProc("EndPaint")
procGetClientRect = user32.NewProc("GetClientRect")
procFillRect = user32.NewProc("FillRect")
procDrawTextW = user32.NewProc("DrawTextW")
procRegisterClassExW = user32.NewProc("RegisterClassExW")
procDefWindowProcW = user32.NewProc("DefWindowProcW")
procUpdateWindow = user32.NewProc("UpdateWindow")
procShowWindowFn = user32.NewProc("ShowWindow")
procTranslateMessage = user32.NewProc("TranslateMessage")
procDispatchMessageW = user32.NewProc("DispatchMessageW")
)
// WNDCLASSEXW layout: 80 bytes on 64-bit
type WNDCLASSEXW 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
}
// PAINTSTRUCT layout: 72 bytes on 64-bit (includes trailing padding)
type PAINTSTRUCT struct {
Hdc uintptr
FErase int32
RcPaint RECT
FRestore int32
FIncUpdate int32
RgbReserved [32]byte
}
var (
overlayOnce sync.Once
overlayCallback uintptr // prevent GC of callback
)
// OverlayWindow é uma janela Win32 nativa independente do Wails.
// Exibe indicador neon "KVMote" no canto da tela durante modo cliente.
// Não interfere com WebView2, Raw Input, nem coordenadas do Wails.
type OverlayWindow struct {
hwnd uintptr
ready chan struct{}
}
func NewOverlayWindow() *OverlayWindow {
ov := &OverlayWindow{ready: make(chan struct{})}
go ov.run()
<-ov.ready
return ov
}
func (ov *OverlayWindow) run() {
runtime.LockOSThread()
className, _ := windows.UTF16PtrFromString("KVMoteOverlay")
overlayOnce.Do(func() {
cb := windows.NewCallback(func(hwnd, msg, wParam, lParam uintptr) uintptr {
const (
wmPaint = 0x000F
wmEraseBkgnd = 0x0014
dtCenter = 0x00000001
dtVCenter = 0x00000004
dtSingleLine = 0x00000020
transparent = 1
neonMagenta = 0x00FF00FF // #FF00FF em COLORREF (0x00BBGGRR)
defaultFont = 17 // DEFAULT_GUI_FONT
)
switch msg {
case wmEraseBkgnd:
return 1
case wmPaint:
var ps PAINTSTRUCT
hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
var rc RECT
procGetClientRect.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
brush, _, _ := procCreateSolidBrush.Call(0) // preto
procFillRect.Call(hdc, uintptr(unsafe.Pointer(&rc)), brush)
procGdiDeleteObject.Call(brush)
font, _, _ := procGetStockObject.Call(defaultFont)
old, _, _ := procGdiSelectObject.Call(hdc, font)
procSetBkMode.Call(hdc, transparent)
procSetTextColor.Call(hdc, neonMagenta)
text, _ := windows.UTF16PtrFromString("KVMote")
procDrawTextW.Call(hdc, uintptr(unsafe.Pointer(text)), ^uintptr(0),
uintptr(unsafe.Pointer(&rc)), dtCenter|dtVCenter|dtSingleLine)
procGdiSelectObject.Call(hdc, old)
procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
return 0
}
r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam)
return r
})
overlayCallback = cb
hMod, _, _ := procGetModuleHandleW.Call(0)
wc := WNDCLASSEXW{
LpfnWndProc: cb,
HInstance: hMod,
LpszClassName: className,
}
wc.CbSize = uint32(unsafe.Sizeof(wc))
procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc)))
})
const (
wsPopup = 0x80000000
wsExTopmost = 0x00000008
wsExNoActivate = 0x08000000
wsExToolWindow = 0x00000080
)
hMod, _, _ := procGetModuleHandleW.Call(0)
hwnd, _, _ := procCreateWindowExW.Call(
wsExTopmost|wsExNoActivate|wsExToolWindow,
uintptr(unsafe.Pointer(className)),
0, wsPopup,
0, 0, 200, 60,
0, 0, hMod, 0,
)
ov.hwnd = hwnd
close(ov.ready)
if hwnd == 0 {
return
}
var msg struct {
Hwnd uintptr
Message uint32
WParam uintptr
LParam uintptr
Time uint32
Pt Point
}
for {
ret, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&msg)), hwnd, 0, 0)
if ret == 0 {
break
}
procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&msg)))
}
}
// Show exibe o overlay no canto superior esquerdo do monitor especificado.
// mx, my, mw, mh: work area do monitor em pixels físicos.
func (ov *OverlayWindow) Show(mx, my, mw, mh int32) {
if ov.hwnd == 0 {
return
}
_ = mw
_ = mh
const overlayW, overlayH = int32(150), int32(40)
const swpNoActivate = 0x0010
// Canto superior esquerdo do monitor — sem depender de taskbar
x := mx + 10
y := my + 10
procSetWindowPos.Call(ov.hwnd, ^uintptr(0), // HWND_TOPMOST
uintptr(x), uintptr(y), uintptr(overlayW), uintptr(overlayH), swpNoActivate)
procShowWindowFn.Call(ov.hwnd, 5) // SW_SHOW
procUpdateWindow.Call(ov.hwnd)
}
// Hide esconde o overlay.
func (ov *OverlayWindow) Hide() {
if ov.hwnd == 0 {
return
}
procShowWindowFn.Call(ov.hwnd, 0) // SW_HIDE
}

View File

@ -40,10 +40,17 @@ const (
LayoutUsIntl LayoutUsIntl
) )
type WindowManager interface {
SetMiniMode()
RestoreNormalMode()
}
type Engine struct { type Engine struct {
mu sync.Mutex mu sync.Mutex
rawScrollOnce sync.Once
transport transport.Transport transport transport.Transport
inputHandler input.InputHandler inputHandler input.InputHandler
winManager WindowManager
clientMode bool clientMode bool
clientPos ClientPos clientPos ClientPos
@ -66,6 +73,13 @@ type Engine struct {
wheelAccum int32 wheelAccum int32
mouseThrottle time.Time mouseThrottle time.Time
prevForegroundHwnd uintptr // janela que estava em foco antes de entrar em client mode
prevWasActive bool // true se salvamos uma janela diferente do KVMote
kvmoteOrigX, kvmoteOrigY int32 // posição original da janela KVMote
kvmoteOrigW, kvmoteOrigH int32 // tamanho original da janela KVMote
kvmoteWasMinimized bool // se estava minimizado ao entrar em client mode
} }
func NewEngine(t transport.Transport, h input.InputHandler) *Engine { func NewEngine(t transport.Transport, h input.InputHandler) *Engine {
@ -76,35 +90,81 @@ func NewEngine(t transport.Transport, h input.InputHandler) *Engine {
} }
} }
func (e *Engine) SetWindowManager(wm WindowManager) {
e.winManager = wm
}
func (e *Engine) Transport() transport.Transport { func (e *Engine) Transport() transport.Transport {
return e.transport return e.transport
} }
func (e *Engine) GetScreenResolution() (int32, int32) {
return e.inputHandler.GetScreenResolution()
}
func (e *Engine) GetWindowSize() (int32, int32) {
return e.inputHandler.GetWindowSize()
}
func (e *Engine) GetWindowPos() (int32, int32) {
return e.inputHandler.GetWindowPos()
}
func (e *Engine) GetMonitorWorkArea() (x, y, w, h int32) {
return e.inputHandler.GetMonitorWorkArea()
}
func (e *Engine) MoveWindow(x, y, w, h int32, topmost bool) {
e.inputHandler.MoveWindow(x, y, w, h, topmost)
}
func (e *Engine) GetDpiScale() float64 {
return e.inputHandler.GetDpiScale()
}
func (e *Engine) Start(ctx context.Context) error { func (e *Engine) Start(ctx context.Context) error {
w, h := e.inputHandler.GetScreenResolution() w, h := e.inputHandler.GetScreenResolution()
LogDebug(fmt.Sprintf("Engine Iniciada. Tela: %dx%d. Pos: %v", w, h, e.clientPos)) LogDebug(fmt.Sprintf("Engine Iniciada. Tela: %dx%d. Pos: %v", w, h, e.clientPos))
// Registra apenas uma vez — Connect() pode chamar Start() múltiplas vezes
e.rawScrollOnce.Do(func() {
if err := e.inputHandler.RegisterRawScrollSink(e.onRawScroll); err != nil {
LogDebug(fmt.Sprintf("Raw scroll sink falhou: %v", err))
} else {
LogDebug("Raw scroll sink registrado OK")
}
})
return e.inputHandler.Install(ctx, e.onMouse, e.onKey) return e.inputHandler.Install(ctx, e.onMouse, e.onKey)
} }
func (e *Engine) processarScroll(data uint32) { func (e *Engine) onRawScroll(delta int16) {
e.mu.Lock()
defer e.mu.Unlock()
if !e.clientMode || !e.transport.IsConnected() {
return
}
e.scrollActive = true e.scrollActive = true
e.scrollTimer = time.Now() e.scrollTimer = time.Now()
delta := int32(int16(data >> 16)) // Mesmo acumulador e divisor do HandleManualScroll
e.wheelAccum += delta // delta vem em unidades WHEEL_DELTA (±120 por notch)
e.wheelAccum += int32(delta)
const Divisor = 40 // Bem sensível para touchpad const Divisor = 30
toSend := e.wheelAccum / Divisor toSend := e.wheelAccum / Divisor
if toSend != 0 { if toSend != 0 {
e.wheelAccum -= toSend * Divisor e.wheelAccum -= toSend * Divisor
err := e.transport.Send([]byte{'W', byte(int8(clamp(int(toSend), -127, 127)))}) val := int8(clamp(int(toSend), -127, 127))
if err == nil { go func(v int8) {
LogDebug(fmt.Sprintf("SCROLL: delta=%d enviado=%d", delta, toSend)) e.transport.Send([]byte{'W', byte(v)})
} LogDebug(fmt.Sprintf("RAW SCROLL: delta=%d → W=%d", delta, v))
}(val)
} }
} }
func (e *Engine) onMouse(ev input.MouseEvent) bool { func (e *Engine) onMouse(ev input.MouseEvent) bool {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@ -113,6 +173,11 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool {
return false return false
} }
// LOG PARA DIAGNÓSTICO: Registrar qualquer mensagem que não seja movimento simples (0x0200)
if ev.Message != 0x0200 {
go LogDebug(fmt.Sprintf("MSG MOUSE: 0x%X | ClientMode: %v", ev.Message, e.clientMode))
}
if !e.clientMode { if !e.clientMode {
if ev.Message == 0x0200 && e.isAtExitEdge(ev.Point) { if ev.Message == 0x0200 && e.isAtExitEdge(ev.Point) {
e.enterClientMode(ev.Point) e.enterClientMode(ev.Point)
@ -123,29 +188,24 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool {
// ─── MODO CLIENTE ATIVO ─── // ─── MODO CLIENTE ATIVO ───
// Log de qualquer evento que não seja movimento simples (para descobrir o ID do touchpad)
if ev.Message != 0x0200 {
LogDebug(fmt.Sprintf("Evento Mouse: 0x%X | Data: %d", ev.Message, ev.Data))
}
switch ev.Message { switch ev.Message {
case 0x020A, 0x020E: // Roda Vertical ou Horizontal case 0x020A, 0x020E: // Roda Vertical ou Horizontal
e.processarScroll(ev.Data) // Não bloquear: evento passa para o webview → JS wheel → HandleManualScroll
return true // (WH_MOUSE_LL não recebe scroll de touchpad precision; mini janela captura via JS)
e.scrollActive = true
e.scrollTimer = time.Now()
return false
case 0x0200: // Move case 0x0200: // Move
if e.isWarping { e.isWarping = false; return true } if e.isWarping { e.isWarping = false; return true }
if e.scrollActive { if e.scrollActive {
if time.Since(e.scrollTimer) > 300*time.Millisecond { if time.Since(e.scrollTimer) > 250*time.Millisecond {
e.scrollActive = false e.scrollActive = false
e.virtualX, e.virtualY = 0, 0 e.virtualX, e.virtualY = 0, 0
} }
e.lastRawPos = ev.Point } else {
return true dx, dy := ev.Point.X-e.lastRawPos.X, ev.Point.Y-e.lastRawPos.Y
}
dx, dy := ev.Point.X - e.lastRawPos.X, ev.Point.Y - e.lastRawPos.Y
e.virtualX += dx e.virtualX += dx
e.virtualY += dy e.virtualY += dy
e.pendingDX += dx e.pendingDX += dx
@ -157,20 +217,20 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool {
return true return true
} }
e.virtualX, e.virtualY = 0, 0 e.virtualX, e.virtualY = 0, 0
return true } else if time.Since(e.mouseThrottle) >= 40*time.Millisecond {
}
w, h := e.inputHandler.GetScreenResolution()
e.isWarping = true
e.inputHandler.SetCursorPos(w/2, h/2)
e.lastRawPos = input.Point{X: w / 2, Y: h / 2}
if time.Since(e.mouseThrottle) >= 40*time.Millisecond {
e.mouseThrottle = time.Now() e.mouseThrottle = time.Now()
sdx, sdy := int8(clamp(int(e.pendingDX), -127, 127)), int8(clamp(int(e.pendingDY), -127, 127)) sdx, sdy := int8(clamp(int(e.pendingDX), -127, 127)), int8(clamp(int(e.pendingDY), -127, 127))
e.pendingDX, e.pendingDY = 0, 0 e.pendingDX, e.pendingDY = 0, 0
e.transport.SendLossy([]byte{'M', byte(sdx), byte(sdy)}) e.transport.SendLossy([]byte{'M', byte(sdx), byte(sdy)})
} }
}
// Re-parka cursor no canto inferior direito (perto do relógio/bateria)
w, h := e.inputHandler.GetScreenResolution()
cx, cy := w-150, h-160
e.isWarping = true
e.inputHandler.SetCursorPos(cx, cy)
e.lastRawPos = input.Point{X: cx, Y: cy}
return true return true
case 0x0201: e.transport.Send([]byte{'D', 'L'}); return true case 0x0201: e.transport.Send([]byte{'D', 'L'}); return true
@ -214,11 +274,42 @@ func (e *Engine) enterClientMode(p input.Point) {
e.wheelAccum = 0 e.wheelAccum = 0
e.mouseThrottle = time.Now() e.mouseThrottle = time.Now()
// Salva estado original do KVMote (posição/tamanho/minimizado) e move pra direita
kvHwnd := e.inputHandler.GetAppHwnd()
e.kvmoteWasMinimized = e.inputHandler.IsMinimized(kvHwnd)
e.kvmoteOrigX, e.kvmoteOrigY, e.kvmoteOrigW, e.kvmoteOrigH = e.inputHandler.GetRestoredRect()
if e.kvmoteWasMinimized {
e.inputHandler.RestoreWindow(kvHwnd)
}
sw, sh := e.inputHandler.GetScreenResolution()
e.inputHandler.MoveWindow(sw-e.kvmoteOrigW, sh-350, e.kvmoteOrigW, 280, false)
LogDebug(fmt.Sprintf("KVMote movido para direita: x=%d y=%d w=%d h=300", sw-e.kvmoteOrigW, sh-350, e.kvmoteOrigW))
// Salva janela em foco e traz KVMote pra frente, se necessário
fgHwnd := e.inputHandler.GetForegroundWindow()
if fgHwnd != kvHwnd {
e.prevForegroundHwnd = fgHwnd
e.prevWasActive = true
e.inputHandler.RequestFocus()
LogDebug(fmt.Sprintf("Janela anterior salva: 0x%X, KVMote trazido ao foco", fgHwnd))
} else {
e.prevWasActive = false
e.prevForegroundHwnd = 0
}
// Mantém processo ativo + mostra overlay
if e.winManager != nil {
e.winManager.SetMiniMode()
}
// Parka cursor no canto inferior direito (perto do relógio/bateria)
w, h := e.inputHandler.GetScreenResolution() w, h := e.inputHandler.GetScreenResolution()
cx, cy := w-150, h-160
e.isWarping = true e.isWarping = true
e.inputHandler.SetCursorPos(w/2, h/2) e.inputHandler.SetCursorPos(cx, cy)
e.lastRawPos = input.Point{X: w / 2, Y: h / 2} e.lastRawPos = input.Point{X: cx, Y: cy}
e.inputHandler.ShowCursor(false)
e.transport.Send([]byte{'A'})
e.transport.Send([]byte{'O'}) e.transport.Send([]byte{'O'})
} }
@ -226,10 +317,34 @@ func (e *Engine) exitClientMode() {
LogDebug("Saindo Modo Cliente.") LogDebug("Saindo Modo Cliente.")
e.clientMode = false e.clientMode = false
e.lastModeChange = time.Now() e.lastModeChange = time.Now()
if e.winManager != nil {
e.winManager.RestoreNormalMode()
}
e.inputHandler.ShowCursor(true) e.inputHandler.ShowCursor(true)
// Restaura posição/tamanho original do KVMote
if e.kvmoteOrigW > 0 {
e.inputHandler.MoveWindow(e.kvmoteOrigX, e.kvmoteOrigY, e.kvmoteOrigW, e.kvmoteOrigH, false)
LogDebug(fmt.Sprintf("KVMote restaurado: x=%d y=%d w=%d h=%d", e.kvmoteOrigX, e.kvmoteOrigY, e.kvmoteOrigW, e.kvmoteOrigH))
}
if e.kvmoteWasMinimized {
e.inputHandler.MinimizeWindow(e.inputHandler.GetAppHwnd())
e.kvmoteWasMinimized = false
}
// Restaura janela anterior ao foco
if e.prevWasActive && e.prevForegroundHwnd != 0 {
e.inputHandler.SetForegroundTo(e.prevForegroundHwnd)
LogDebug(fmt.Sprintf("Janela anterior restaurada: 0x%X", e.prevForegroundHwnd))
e.prevForegroundHwnd = 0
e.prevWasActive = false
}
w, h := e.inputHandler.GetScreenResolution() w, h := e.inputHandler.GetScreenResolution()
var ret input.Point var ret input.Point
const Offset = 120 const Offset = 120
// Retornamos o cursor exatamente para a borda onde ele entrou
switch e.clientPos { switch e.clientPos {
case PosRight: ret = input.Point{X: w - Offset, Y: e.edgeEntry.Y} case PosRight: ret = input.Point{X: w - Offset, Y: e.edgeEntry.Y}
case PosLeft: ret = input.Point{X: Offset, Y: e.edgeEntry.Y} case PosLeft: ret = input.Point{X: Offset, Y: e.edgeEntry.Y}
@ -242,6 +357,35 @@ func (e *Engine) exitClientMode() {
e.transport.Send([]byte{'A'}) e.transport.Send([]byte{'A'})
} }
func (e *Engine) HandleManualScroll(delta int) {
e.mu.Lock()
defer e.mu.Unlock()
if !e.clientMode || !e.transport.IsConnected() {
return
}
e.scrollActive = true
e.scrollTimer = time.Now()
// Acumulamos o delta da UI para não perder movimentos pequenos
e.wheelAccum += int32(-delta) // Invertemos o delta da UI para bater com o padrão HID
// Divisor menor = Mais sensível
const Divisor = 15
toSend := e.wheelAccum / Divisor
if toSend != 0 {
e.wheelAccum -= toSend * Divisor
val := int8(clamp(int(toSend), -127, 127))
go func(v int8) {
e.transport.Send([]byte{'W', byte(v)})
LogDebug(fmt.Sprintf("UI SCROLL -> WHEEL %d (accum remain=%d)", v, e.wheelAccum))
}(val)
}
}
func (e *Engine) onKey(ev input.KeyboardEvent) bool { func (e *Engine) onKey(ev input.KeyboardEvent) bool {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()

11
main.go
View File

@ -6,6 +6,7 @@ import (
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/windows"
) )
//go:embed all:frontend/dist //go:embed all:frontend/dist
@ -20,6 +21,11 @@ func main() {
Title: "KVMote", Title: "KVMote",
Width: 400, Width: 400,
Height: 550, Height: 550,
DisableResize: false,
Fullscreen: false,
Frameless: false,
MinWidth: 200,
MinHeight: 50,
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
}, },
@ -28,6 +34,11 @@ func main() {
Bind: []interface{}{ Bind: []interface{}{
app, app,
}, },
Windows: &windows.Options{
DisableWindowIcon: false,
WebviewIsTransparent: false,
WindowIsTranslucent: false,
},
}) })
if err != nil { if err != nil {