Files
hkkb-layout-web/cmd/hhkb-web/main.go
2026-05-27 18:30:14 +09:00

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