main
maye 2025-08-29 15:20:57 +08:00
commit 27ff3f8a46
12 changed files with 1055 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
keyboard
keyboard.exe
.claude
CLAUDE.md

12
README.txt Normal file
View File

@ -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

20
config/config.go Normal file
View File

@ -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,
}
}

33
go.mod Normal file
View File

@ -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
)

61
go.sum Normal file
View File

@ -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=

55
input/simulator.go Normal file
View File

@ -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
}

60
main.go Normal file
View File

@ -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")
}

51
server/http.go Normal file
View File

@ -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)))
}

223
server/websocket.go Normal file
View File

@ -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()
}

58
static/index.html Normal file
View File

@ -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="/">&divide;</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="*">&times;</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="-">&minus;</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>

291
static/script.js Normal file
View File

@ -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');
}
});

186
static/style.css Normal file
View File

@ -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;
}
}