commit 27ff3f8a46a0f1a7e2b61e1b8d11e6c58842710c Author: maye Date: Fri Aug 29 15:20:57 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d83a4d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +keyboard +keyboard.exe + +.claude +CLAUDE.md diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..84c51fd --- /dev/null +++ b/README.txt @@ -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 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..45b7b5a --- /dev/null +++ b/config/config.go @@ -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, + } +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2501680 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ac13863 --- /dev/null +++ b/go.sum @@ -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= diff --git a/input/simulator.go b/input/simulator.go new file mode 100644 index 0000000..3d1d813 --- /dev/null +++ b/input/simulator.go @@ -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 +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..428a551 --- /dev/null +++ b/main.go @@ -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") +} diff --git a/server/http.go b/server/http.go new file mode 100644 index 0000000..2268c45 --- /dev/null +++ b/server/http.go @@ -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))) +} diff --git a/server/websocket.go b/server/websocket.go new file mode 100644 index 0000000..c8fc130 --- /dev/null +++ b/server/websocket.go @@ -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() +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..8e772aa --- /dev/null +++ b/static/index.html @@ -0,0 +1,58 @@ + + + + + + Remote Numpad Keyboard + + + +
+
+ Disconnected + Device: None +
+ +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + +
+
+ +
+

Instructions:

+
    +
  • Click or tap the buttons to send key presses to the host computer
  • +
  • Only one device can connect at a time
  • +
+
+
+ + + + diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..f9a52e2 --- /dev/null +++ b/static/script.js @@ -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'); + } +}); \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..7cde8f9 --- /dev/null +++ b/static/style.css @@ -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; + } +} \ No newline at end of file