init
commit
27ff3f8a46
|
@ -0,0 +1,5 @@
|
|||
keyboard
|
||||
keyboard.exe
|
||||
|
||||
.claude
|
||||
CLAUDE.md
|
|
@ -0,0 +1,12 @@
|
|||
This simple Go program uses robotgo to simulate a numpad when using a 60% keyboard or a smaller keyboard.
|
||||
There are cases when you need to use a number pad.
|
||||
|
||||
For example, some programs or games have shortcuts that require the numpad numbers.
|
||||
|
||||
Now, you can use your phone or another device with a browser to simulate key input to your host.
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------
|
||||
|
||||
Disclaimer: This tool hasn't been tested with any game with anti-cheat software. Use it at your own risk.
|
||||
|
||||
LICENSE: Apache 2.0
|
|
@ -0,0 +1,20 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
module keyboard
|
||||
|
||||
go 1.24.6
|
||||
|
||||
require (
|
||||
github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e // indirect
|
||||
github.com/ebitengine/purego v0.8.3 // indirect
|
||||
github.com/gen2brain/shm v0.1.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-vgo/robotgo v0.110.8 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jezek/xgb v1.1.1 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||
github.com/otiai10/gosseract v2.2.1+incompatible // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/robotn/xgb v0.10.0 // indirect
|
||||
github.com/robotn/xgbutil v0.10.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.4 // indirect
|
||||
github.com/tailscale/win v0.0.0-20250213223159-5992cb43ca35 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/vcaesar/gops v0.41.0 // indirect
|
||||
github.com/vcaesar/imgo v0.41.0 // indirect
|
||||
github.com/vcaesar/keycode v0.10.1 // indirect
|
||||
github.com/vcaesar/screenshot v0.11.1 // indirect
|
||||
github.com/vcaesar/tt v0.20.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
||||
golang.org/x/image v0.27.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
|
@ -0,0 +1,61 @@
|
|||
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ=
|
||||
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e h1:L+XrFvD0vBIBm+Wf9sFN6aU395t7JROoai0qXZraA4U=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
|
||||
github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
|
||||
github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/gen2brain/shm v0.1.1 h1:1cTVA5qcsUFixnDHl14TmRoxgfWEEZlTezpUj1vm5uQ=
|
||||
github.com/gen2brain/shm v0.1.1/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-vgo/robotgo v0.110.8 h1:tWoUyqlZgDJ61bQju3WGSb/NIIfNV4TkYL3GFeWcHio=
|
||||
github.com/go-vgo/robotgo v0.110.8/go.mod h1:45w33PzprtFncpw4cAt9SzMtSY9XnVfotu+RrCVN8JE=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
|
||||
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/otiai10/gosseract v2.2.1+incompatible h1:Ry5ltVdpdp4LAa2bMjsSJH34XHVOV7XMi41HtzL8X2I=
|
||||
github.com/otiai10/gosseract v2.2.1+incompatible/go.mod h1:XrzWItCzCpFRZ35n3YtVTgq5bLAhFIkascoRo8G32QE=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/robotn/xgb v0.0.0-20190912153532-2cb92d044934/go.mod h1:SxQhJskUJ4rleVU44YvnrdvxQr0tKy5SRSigBrCgyyQ=
|
||||
github.com/robotn/xgb v0.10.0 h1:O3kFbIwtwZ3pgLbp1h5slCQ4OpY8BdwugJLrUe6GPIM=
|
||||
github.com/robotn/xgb v0.10.0/go.mod h1:SxQhJskUJ4rleVU44YvnrdvxQr0tKy5SRSigBrCgyyQ=
|
||||
github.com/robotn/xgbutil v0.10.0 h1:gvf7mGQqCWQ68aHRtCxgdewRk+/KAJui6l3MJQQRCKw=
|
||||
github.com/robotn/xgbutil v0.10.0/go.mod h1:svkDXUDQjUiWzLrA0OZgHc4lbOts3C+uRfP6/yjwYnU=
|
||||
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
|
||||
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||
github.com/tailscale/win v0.0.0-20250213223159-5992cb43ca35 h1:wAZbkTZkqDzWsqxPh2qkBd3KvFU7tcxV0BP0Rnhkxog=
|
||||
github.com/tailscale/win v0.0.0-20250213223159-5992cb43ca35/go.mod h1:aMd4yDHLjbOuYP6fMxj1d9ACDQlSWwYztcpybGHCQc8=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/vcaesar/gops v0.41.0 h1:FG748Jyw3FOuZnbzSgB+CQSx2e5LbLCPWV2JU1brFdc=
|
||||
github.com/vcaesar/gops v0.41.0/go.mod h1:/3048L7Rj7QjQKTSB+kKc7hDm63YhTWy5QJ10TCP37A=
|
||||
github.com/vcaesar/imgo v0.41.0 h1:kNLYGrThXhB9Dd6IwFmfPnxq9P6yat2g7dpPjr7OWO8=
|
||||
github.com/vcaesar/imgo v0.41.0/go.mod h1:/LGOge8etlzaVu/7l+UfhJxR6QqaoX5yeuzGIMfWb4I=
|
||||
github.com/vcaesar/keycode v0.10.1 h1:0DesGmMAPWpYTCYddOFiCMKCDKgNnwiQa2QXindVUHw=
|
||||
github.com/vcaesar/keycode v0.10.1/go.mod h1:JNlY7xbKsh+LAGfY2j4M3znVrGEm5W1R8s/Uv6BJcfQ=
|
||||
github.com/vcaesar/screenshot v0.11.1 h1:GgPuN89XC4Yh38dLx4quPlSo3YiWWhwIria/j3LtrqU=
|
||||
github.com/vcaesar/screenshot v0.11.1/go.mod h1:gJNwHBiP1v1v7i8TQ4yV1XJtcyn2I/OJL7OziVQkwjs=
|
||||
github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4=
|
||||
github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
@ -0,0 +1,55 @@
|
|||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/go-vgo/robotgo"
|
||||
)
|
||||
|
||||
type KeySimulator interface {
|
||||
PressKey(key string) error
|
||||
}
|
||||
|
||||
type keySimulator struct{}
|
||||
|
||||
func NewKeySimulator() KeySimulator {
|
||||
return &keySimulator{}
|
||||
}
|
||||
|
||||
func (k *keySimulator) PressKey(key string) error {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return k.pressKeyLinux(key)
|
||||
case "windows":
|
||||
return k.pressKeyWindows(key)
|
||||
default:
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func (k *keySimulator) pressKeyLinux(key string) error {
|
||||
return k.pressKeyWithRobotGo(key)
|
||||
}
|
||||
|
||||
func (k *keySimulator) pressKeyWindows(key string) error {
|
||||
return k.pressKeyWithRobotGo(key)
|
||||
}
|
||||
|
||||
func (k *keySimulator) pressKeyWithRobotGo(key string) error {
|
||||
keyMap := map[string]string{
|
||||
"0": "kp_0", "1": "kp_1", "2": "kp_2", "3": "kp_3", "4": "kp_4",
|
||||
"5": "kp_5", "6": "kp_6", "7": "kp_7", "8": "kp_8", "9": "kp_9",
|
||||
"*": "kp_multiply", "+": "kp_add", "-": "kp_subtract", ".": "kp_decimal", "/": "kp_divide",
|
||||
"enter": "kp_enter", "backspace": "backspace", "escape": "escape",
|
||||
}
|
||||
|
||||
mappedKey, exists := keyMap[strings.ToLower(key)]
|
||||
if !exists {
|
||||
mappedKey = key
|
||||
}
|
||||
|
||||
robotgo.KeyTap(mappedKey)
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"keyboard/config"
|
||||
"keyboard/input"
|
||||
"keyboard/server"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
staticFS, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create static filesystem: %v", err)
|
||||
}
|
||||
|
||||
httpServer := server.NewHTTPServer(cfg, staticFS)
|
||||
wsServer := server.NewWebSocketServer()
|
||||
|
||||
keySimulator := input.NewKeySimulator()
|
||||
wsServer.SetInputSimulator(keySimulator)
|
||||
|
||||
go func() {
|
||||
log.Printf("Starting server on :%s", cfg.Port)
|
||||
if err := httpServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
wsServer.SetupRoutes(httpServer.Router())
|
||||
go wsServer.Run()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("Server shutdown error: %v", err)
|
||||
}
|
||||
|
||||
wsServer.Shutdown()
|
||||
log.Println("Server stopped")
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"keyboard/config"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type HTTPServer struct {
|
||||
server *http.Server
|
||||
router *mux.Router
|
||||
staticFS fs.FS
|
||||
}
|
||||
|
||||
func NewHTTPServer(cfg *config.Config, staticFS fs.FS) *HTTPServer {
|
||||
router := mux.NewRouter()
|
||||
|
||||
return &HTTPServer{
|
||||
server: &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
},
|
||||
router: router,
|
||||
staticFS: staticFS,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) Router() *mux.Router {
|
||||
return s.router
|
||||
}
|
||||
|
||||
func (s *HTTPServer) Start() error {
|
||||
s.setupStaticFiles()
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *HTTPServer) Shutdown(ctx context.Context) error {
|
||||
return s.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) setupStaticFiles() {
|
||||
s.router.PathPrefix("/").Handler(http.FileServer(http.FS(s.staticFS)))
|
||||
}
|
|
@ -0,0 +1,223 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"keyboard/input"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
HandshakeTimeout: 30 * time.Second,
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
EnableCompression: true,
|
||||
}
|
||||
|
||||
type KeyMessage struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
type WebSocketServer struct {
|
||||
connections map[*Connection]bool
|
||||
register chan *Connection
|
||||
unregister chan *Connection
|
||||
broadcast chan []byte
|
||||
mutex sync.Mutex
|
||||
input input.KeySimulator
|
||||
}
|
||||
|
||||
func NewWebSocketServer() *WebSocketServer {
|
||||
return &WebSocketServer{
|
||||
connections: make(map[*Connection]bool),
|
||||
register: make(chan *Connection),
|
||||
unregister: make(chan *Connection),
|
||||
broadcast: make(chan []byte),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebSocketServer) SetupRoutes(router *mux.Router) {
|
||||
router.HandleFunc("/ws", s.handleWebSocket)
|
||||
}
|
||||
|
||||
func (s *WebSocketServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
conn.SetReadLimit(512)
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
connection := &Connection{
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
s.register <- connection
|
||||
|
||||
go s.writePump(connection)
|
||||
go s.readPump(connection)
|
||||
}
|
||||
|
||||
func (s *WebSocketServer) readPump(connection *Connection) {
|
||||
defer func() {
|
||||
s.unregister <- connection
|
||||
connection.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
connection.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
_, message, err := connection.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
log.Printf("WebSocket read error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
log.Printf("Received message: %s", string(message))
|
||||
|
||||
var keyMsg KeyMessage
|
||||
if err := json.Unmarshal(message, &keyMsg); err != nil {
|
||||
log.Printf("JSON parse error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.input != nil && keyMsg.Type == "key" {
|
||||
log.Printf("Key pressed: %s", keyMsg.Key)
|
||||
if err := s.input.PressKey(keyMsg.Key); err != nil {
|
||||
log.Printf("Key press error: %v", err)
|
||||
errorMsg, _ := json.Marshal(map[string]string{
|
||||
"error": "Failed to press key: " + keyMsg.Key,
|
||||
})
|
||||
connection.send <- errorMsg
|
||||
} else {
|
||||
log.Printf("Key successfully pressed: %s", keyMsg.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebSocketServer) writePump(connection *Connection) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
connection.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-connection.send:
|
||||
if !ok {
|
||||
connection.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
if err := connection.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
log.Printf("WebSocket write error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
connection.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := connection.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebSocketServer) Run() {
|
||||
for {
|
||||
select {
|
||||
case connection := <-s.register:
|
||||
s.mutex.Lock()
|
||||
if len(s.connections) >= 1 {
|
||||
existingConn := s.getExistingConnection()
|
||||
if existingConn != nil {
|
||||
closeMsg, _ := json.Marshal(map[string]string{
|
||||
"status": "disconnected",
|
||||
"reason": "Another device connected",
|
||||
})
|
||||
select {
|
||||
case existingConn.send <- closeMsg:
|
||||
// Wait a bit for the message to be sent
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
default:
|
||||
// Channel full, connection closing
|
||||
}
|
||||
existingConn.conn.Close()
|
||||
}
|
||||
}
|
||||
s.connections[connection] = true
|
||||
welcomeMsg, _ := json.Marshal(map[string]string{
|
||||
"status": "connected",
|
||||
})
|
||||
connection.send <- welcomeMsg
|
||||
s.mutex.Unlock()
|
||||
log.Printf("New WebSocket connection. Total connections: %d", len(s.connections))
|
||||
|
||||
case connection := <-s.unregister:
|
||||
s.mutex.Lock()
|
||||
if _, ok := s.connections[connection]; ok {
|
||||
delete(s.connections, connection)
|
||||
close(connection.send)
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
log.Printf("WebSocket disconnected. Total connections: %d", len(s.connections))
|
||||
|
||||
case message := <-s.broadcast:
|
||||
s.mutex.Lock()
|
||||
for connection := range s.connections {
|
||||
select {
|
||||
case connection.send <- message:
|
||||
default:
|
||||
close(connection.send)
|
||||
delete(s.connections, connection)
|
||||
}
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebSocketServer) getExistingConnection() *Connection {
|
||||
for conn := range s.connections {
|
||||
return conn
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebSocketServer) SetInputSimulator(input input.KeySimulator) {
|
||||
s.input = input
|
||||
}
|
||||
|
||||
func (s *WebSocketServer) Shutdown() {
|
||||
s.mutex.Lock()
|
||||
for connection := range s.connections {
|
||||
close(connection.send)
|
||||
connection.conn.Close()
|
||||
delete(s.connections, connection)
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Remote Numpad Keyboard</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="status-bar">
|
||||
<span id="connection-status">Disconnected</span>
|
||||
<span id="device-info">Device: None</span>
|
||||
</div>
|
||||
|
||||
<div class="numpad">
|
||||
<div class="numpad-row">
|
||||
<button class="key" data-key="7">7</button>
|
||||
<button class="key" data-key="8">8</button>
|
||||
<button class="key" data-key="9">9</button>
|
||||
<button class="key operator" data-key="/">÷</button>
|
||||
</div>
|
||||
<div class="numpad-row">
|
||||
<button class="key" data-key="4">4</button>
|
||||
<button class="key" data-key="5">5</button>
|
||||
<button class="key" data-key="6">6</button>
|
||||
<button class="key operator" data-key="*">×</button>
|
||||
</div>
|
||||
<div class="numpad-row">
|
||||
<button class="key" data-key="1">1</button>
|
||||
<button class="key" data-key="2">2</button>
|
||||
<button class="key" data-key="3">3</button>
|
||||
<button class="key operator" data-key="-">−</button>
|
||||
</div>
|
||||
<div class="numpad-row">
|
||||
<button class="key" data-key="0">0</button>
|
||||
<button class="key" data-key=".">.</button>
|
||||
<button class="key special" data-key="enter">Enter</button>
|
||||
<button class="key operator" data-key="+">+</button>
|
||||
</div>
|
||||
<div class="numpad-row">
|
||||
<button class="key special" data-key="backspace">Backspace</button>
|
||||
<button class="key special" data-key="escape">Escape</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-panel">
|
||||
<h3>Instructions:</h3>
|
||||
<ul>
|
||||
<li>Click or tap the buttons to send key presses to the host computer</li>
|
||||
<li>Only one device can connect at a time</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,291 @@
|
|||
class NumpadKeyboard {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.connected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 2000;
|
||||
this.wasReplaced = false;
|
||||
this.connecting = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.connectWebSocket();
|
||||
this.updateDeviceId();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Setup numpad button clicks
|
||||
const keys = document.querySelectorAll('.key');
|
||||
keys.forEach(key => {
|
||||
key.addEventListener('click', (e) => this.handleKeyPress(e));
|
||||
|
||||
// Add touch support for mobile
|
||||
key.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
key.classList.add('pressed');
|
||||
});
|
||||
|
||||
key.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
key.classList.remove('pressed');
|
||||
this.handleKeyPress(e);
|
||||
});
|
||||
|
||||
// Add visual feedback for mouse
|
||||
key.addEventListener('mousedown', (e) => {
|
||||
key.classList.add('pressed');
|
||||
});
|
||||
|
||||
key.addEventListener('mouseup', (e) => {
|
||||
key.classList.remove('pressed');
|
||||
});
|
||||
|
||||
key.addEventListener('mouseleave', (e) => {
|
||||
key.classList.remove('pressed');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle window focus/blur for connection management
|
||||
window.addEventListener('focus', () => {
|
||||
if (!this.connected && this.reconnectAttempts === 0) {
|
||||
this.connectWebSocket();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleKeyPress(event) {
|
||||
const key = event.target.getAttribute('data-key');
|
||||
if (key && this.connected) {
|
||||
this.sendKeyMessage(key);
|
||||
this.addVisualFeedback(event.target);
|
||||
} else if (!this.connected) {
|
||||
this.showNotification('Not connected to server', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
addVisualFeedback(element) {
|
||||
element.style.transform = 'scale(0.95)';
|
||||
element.style.background = '#007bff';
|
||||
element.style.color = 'white';
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.transform = '';
|
||||
element.style.background = '';
|
||||
element.style.color = '';
|
||||
}, 150);
|
||||
}
|
||||
|
||||
connectWebSocket() {
|
||||
if (this.connecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connecting = true;
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.connected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.connecting = false;
|
||||
this.updateConnectionStatus('Connected', 'connected');
|
||||
this.showNotification('Connected to server', 'success');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleWebSocketMessage(event);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.connected = false;
|
||||
this.updateConnectionStatus('Disconnected');
|
||||
// Only attempt reconnect if not due to being replaced by another device
|
||||
if (this.wasReplaced) {
|
||||
this.wasReplaced = false;
|
||||
return;
|
||||
}
|
||||
this.attemptReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.connecting = false;
|
||||
this.showNotification('Connection error', 'error');
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
this.connecting = false;
|
||||
this.showNotification('Failed to establish connection', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
handleWebSocketMessage(event) {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.status === 'connected') {
|
||||
this.updateConnectionStatus('Connected', 'connected');
|
||||
this.showNotification('Successfully connected', 'success');
|
||||
} else if (message.status === 'disconnected') {
|
||||
this.updateConnectionStatus('Disconnected');
|
||||
if (message.reason === 'Another device connected') {
|
||||
this.wasReplaced = true;
|
||||
}
|
||||
this.showNotification(message.reason || 'Connection closed', 'warning');
|
||||
} else if (message.error) {
|
||||
this.showNotification(message.error, 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
sendKeyMessage(key) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
key: key,
|
||||
type: 'key',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
console.log(`Sent key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
attemptReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
this.updateConnectionStatus(`Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.connectWebSocket();
|
||||
}, this.reconnectDelay);
|
||||
} else {
|
||||
this.updateConnectionStatus('Connection failed');
|
||||
this.showNotification('Failed to reconnect. Please refresh the page.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionStatus(status, className = '') {
|
||||
const statusElement = document.getElementById('connection-status');
|
||||
statusElement.textContent = status;
|
||||
statusElement.className = className;
|
||||
}
|
||||
|
||||
updateDeviceId() {
|
||||
const deviceInfo = document.getElementById('device-info');
|
||||
const deviceId = this.getDeviceId();
|
||||
deviceInfo.textContent = `Device: ${deviceId}`;
|
||||
}
|
||||
|
||||
getDeviceId() {
|
||||
const userAgent = navigator.userAgent;
|
||||
let deviceType = 'Unknown';
|
||||
|
||||
if (userAgent.match(/Mobile|Android|iPhone|iPad|iPod/i)) {
|
||||
deviceType = 'Mobile';
|
||||
} else if (userAgent.match(/Tablet|iPad/i)) {
|
||||
deviceType = 'Tablet';
|
||||
} else {
|
||||
deviceType = 'Desktop';
|
||||
}
|
||||
|
||||
const browser = this.getBrowserName();
|
||||
return `${deviceType} (${browser})`;
|
||||
}
|
||||
|
||||
getBrowserName() {
|
||||
const userAgent = navigator.userAgent;
|
||||
|
||||
if (userAgent.match(/Chrome/i)) {
|
||||
return 'Chrome';
|
||||
} else if (userAgent.match(/Firefox/i)) {
|
||||
return 'Firefox';
|
||||
} else if (userAgent.match(/Safari/i)) {
|
||||
return 'Safari';
|
||||
} else if (userAgent.match(/Edge/i)) {
|
||||
return 'Edge';
|
||||
} else {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification ${type}`;
|
||||
notification.textContent = message;
|
||||
|
||||
// Style the notification
|
||||
Object.assign(notification.style, {
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontWeight: '500',
|
||||
zIndex: '1000',
|
||||
opacity: '0',
|
||||
transform: 'translateX(100%)',
|
||||
transition: 'all 0.3s ease'
|
||||
});
|
||||
|
||||
// Set background color based on type
|
||||
switch (type) {
|
||||
case 'success':
|
||||
notification.style.background = '#28a745';
|
||||
break;
|
||||
case 'error':
|
||||
notification.style.background = '#dc3545';
|
||||
break;
|
||||
case 'warning':
|
||||
notification.style.background = '#ffc107';
|
||||
notification.style.color = '#212529';
|
||||
break;
|
||||
default:
|
||||
notification.style.background = '#007bff';
|
||||
}
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Animate out and remove
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(notification);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the numpad keyboard when the page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new NumpadKeyboard();
|
||||
});
|
||||
|
||||
// Handle page visibility changes
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
console.log('Page hidden - maintaining connection');
|
||||
} else {
|
||||
console.log('Page visible - checking connection');
|
||||
}
|
||||
});
|
|
@ -0,0 +1,186 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #000;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 0 0 2px #000;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 15px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
#connection-status {
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#connection-status.connected {
|
||||
color: #000;
|
||||
font-weight: 800;
|
||||
background: #e0e0e0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#device-info {
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.numpad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.numpad-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.key {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid #000;
|
||||
border-radius: 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.key:hover {
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.key:active {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.key.pressed {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.key.operator {
|
||||
background: #f0f0f0;
|
||||
color: #000;
|
||||
border: 3px solid #333;
|
||||
}
|
||||
|
||||
.key.operator:hover {
|
||||
background: #e0e0e0;
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.key.special {
|
||||
background: #d0d0d0;
|
||||
color: #000;
|
||||
border: 3px solid #555;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.key.special:hover {
|
||||
background: #b0b0b0;
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
background: #f8f8f8;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border: 2px solid #666;
|
||||
}
|
||||
|
||||
.info-panel h3 {
|
||||
color: #000;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-panel ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-panel li {
|
||||
color: #000;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-panel li:before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.key {
|
||||
min-width: 50px;
|
||||
height: 50px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.key.special {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.key {
|
||||
min-width: 70px;
|
||||
height: 70px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.key.special {
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue