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