238 lines
5.6 KiB
Go
238 lines
5.6 KiB
Go
// Command hhkb-web serves a browser-based HHKB keymap editor, styled after the
|
|
// QMK Configurator. The Go backend reuses internal/hhkb for all device I/O; the
|
|
// browser is just the renderer.
|
|
package main
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"time"
|
|
|
|
"hkkb/internal/hhkb"
|
|
)
|
|
|
|
//go:embed web
|
|
var webFS embed.FS
|
|
|
|
// devMu serializes access: only one request may touch the keyboard at a time.
|
|
var devMu sync.Mutex
|
|
|
|
func withDevice(fn func(*hhkb.Device) error) error {
|
|
devMu.Lock()
|
|
defer devMu.Unlock()
|
|
d, err := hhkb.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer d.Close()
|
|
return fn(d)
|
|
}
|
|
|
|
type keyDTO struct {
|
|
Num int `json:"num"`
|
|
Row int `json:"row"`
|
|
X float64 `json:"x"`
|
|
W float64 `json:"w"`
|
|
Def int `json:"def"`
|
|
DefName string `json:"defname"`
|
|
}
|
|
|
|
type modelDTO struct {
|
|
Name string `json:"name"`
|
|
Variant string `json:"variant"`
|
|
Hybrid bool `json:"hybrid"`
|
|
Keys []keyDTO `json:"keys"`
|
|
}
|
|
|
|
type codeDTO struct {
|
|
Code int `json:"code"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type groupDTO struct {
|
|
Name string `json:"name"`
|
|
Keys []codeDTO `json:"keys"`
|
|
}
|
|
|
|
type deviceDTO struct {
|
|
Connected bool `json:"connected"`
|
|
Model string `json:"model"`
|
|
Type string `json:"type"`
|
|
Serial string `json:"serial"`
|
|
Mode string `json:"mode"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func modelsDTO() []modelDTO {
|
|
out := make([]modelDTO, 0, len(hhkb.Models))
|
|
for _, m := range hhkb.Models {
|
|
md := modelDTO{Name: m.Name, Variant: m.Variant.String(), Hybrid: m.Hybrid}
|
|
for _, k := range m.Keys {
|
|
w := k.W
|
|
if w == 0 {
|
|
w = 1
|
|
}
|
|
md.Keys = append(md.Keys, keyDTO{
|
|
Num: k.Num, Row: k.Row, X: k.X, W: w,
|
|
Def: int(k.Def), DefName: hhkb.KeyName(k.Def),
|
|
})
|
|
}
|
|
out = append(out, md)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func paletteDTO() []groupDTO {
|
|
out := make([]groupDTO, 0, len(hhkb.Palette))
|
|
for _, g := range hhkb.Palette {
|
|
gd := groupDTO{Name: g.Name}
|
|
for _, c := range g.Codes {
|
|
gd.Keys = append(gd.Keys, codeDTO{Code: int(c), Name: hhkb.KeyName(c)})
|
|
}
|
|
out = append(out, gd)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func deviceStatus() deviceDTO {
|
|
var out deviceDTO
|
|
err := withDevice(func(d *hhkb.Device) error {
|
|
out.Connected = true
|
|
if m, ok, e := d.DetectModel(); e == nil && ok {
|
|
out.Model = m.Name
|
|
}
|
|
if i, e := d.Info(); e == nil {
|
|
out.Type, out.Serial = i.TypeNumber, i.Serial
|
|
}
|
|
if md, e := d.Mode(); e == nil {
|
|
out.Mode = md.String()
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
out.Connected = false
|
|
out.Error = friendlyErr(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func friendlyErr(err error) string {
|
|
if errors.Is(err, os.ErrPermission) {
|
|
return "No access to /dev/hidraw. Run once as root:\n" +
|
|
`echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="04fe", MODE="0660", GROUP="input"' | sudo tee /etc/udev/rules.d/70-hhkb.rules` +
|
|
"\nsudo udevadm control --reload-rules && sudo udevadm trigger"
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func handleInit(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"models": modelsDTO(),
|
|
"palette": paletteDTO(),
|
|
"device": deviceStatus(),
|
|
})
|
|
}
|
|
|
|
// handleDevice reports just the current device status, for lightweight polling.
|
|
func handleDevice(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, deviceStatus())
|
|
}
|
|
|
|
func handleKeymap(w http.ResponseWriter, r *http.Request) {
|
|
fn := r.URL.Query().Get("layer") == "fn"
|
|
keys := []map[string]any{}
|
|
err := withDevice(func(d *hhkb.Device) error {
|
|
lay, err := d.ReadLayer(fn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for num := 1; num < hhkb.LayerLen; num++ {
|
|
code := lay[num]
|
|
keys = append(keys, map[string]any{"num": num, "code": int(code), "name": hhkb.KeyName(code)})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, map[string]any{"error": friendlyErr(err)})
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"keys": keys})
|
|
}
|
|
|
|
func handleRemap(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Num int `json:"num"`
|
|
Code int `json:"code"`
|
|
Fn bool `json:"fn"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
err := withDevice(func(d *hhkb.Device) error {
|
|
return d.Remap(req.Num, byte(req.Code), req.Fn)
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, map[string]any{"error": friendlyErr(err)})
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
func handleReset(w http.ResponseWriter, r *http.Request) {
|
|
err := withDevice(func(d *hhkb.Device) error { return d.Reset() })
|
|
if err != nil {
|
|
writeJSON(w, map[string]any{"error": friendlyErr(err)})
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
func main() {
|
|
port := flag.Int("port", 8080, "port to listen on (localhost)")
|
|
open := flag.Bool("open", true, "open the editor in a browser")
|
|
flag.Parse()
|
|
|
|
static, err := fs.Sub(webFS, "web")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/", http.FileServer(http.FS(static)))
|
|
mux.HandleFunc("/api/init", handleInit)
|
|
mux.HandleFunc("/api/device", handleDevice)
|
|
mux.HandleFunc("/api/keymap", handleKeymap)
|
|
mux.HandleFunc("/api/remap", handleRemap)
|
|
mux.HandleFunc("/api/reset", handleReset)
|
|
|
|
addr := fmt.Sprintf("127.0.0.1:%d", *port)
|
|
url := "http://" + addr
|
|
log.Printf("hkkb editor on %s", url)
|
|
if *open {
|
|
go openBrowser(url)
|
|
}
|
|
log.Fatal(http.ListenAndServe(addr, mux))
|
|
}
|
|
|
|
func openBrowser(url string) {
|
|
time.Sleep(300 * time.Millisecond)
|
|
if err := exec.Command("xdg-open", url).Start(); err != nil {
|
|
fmt.Println("open", url, "in your browser")
|
|
}
|
|
}
|