Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Cubalyst は記録を [MCP](https://modelcontextprotocol.io/) のツールとし

- **持ち方に合わせた表記**:解法・再生・解析の記号を、自分の持ち方(下の色・前の色)に合わせて読み替えます
- **持ち替えの記録**:ジャイロのあるキューブ(GAN i4 などの Gen4)なら、ソルブ中の持ち替え(y・x・z)も記録し、解法をその時点の持ち方で表示します。設定でオンにし、キューブ側のジャイロは GAN のアプリの省電力の設定で切り替えます(ジャイロは電池を多く使います)
- **中継**:キューブをつないだブラウザのタイマー画面(スクランブル・タイマー・キューブの状態・直前の段階)を、ほかのブラウザ(スマホなど)の「中継」(`/live`)に映します。見るだけで、記録も操作もしません。状態はサーバーのメモリにだけ持ちます
- **クロス色**を設定でき、白以外のクロスでも段階を正しく分けます(設定は右上の ⚙ から開きます)
- 記録は手元の SQLite ファイルに保存し、外部のサービスには送りません

Expand Down
5 changes: 4 additions & 1 deletion cmd/cubalyst/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,14 @@ func run() error {
}
log.Printf("db: %s (schema version %d)", cfg.DBPath, v)

app := server.New(database, cfg.StaticDir)
srv := &http.Server{
Addr: cfg.Addr,
Handler: server.New(database, cfg.StaticDir).Handler(),
Handler: app.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
// 止めるとき、タイマー画面の中継(SSE)の接続を切る。切らないと Shutdown が終わるのを待ち続ける
srv.RegisterOnShutdown(app.Close)
errCh := make(chan error, 1)
go func() {
log.Printf("listening on http://%s", cfg.Addr)
Expand Down
88 changes: 88 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ tags:
- name: 入出力
- name: 集計
description: 期間の練習の集計。文面にするのは呼ぶ側(通知の本文を作る kawaraban など)
- name: 中継
description: キューブをつないだブラウザ(メイン)のタイマー画面を、ほかのブラウザ(`/live`)に映す。状態はサーバーのメモリにだけ持つ
- name: その他

paths:
Expand Down Expand Up @@ -467,6 +469,46 @@ paths:
schema: { $ref: "#/components/schemas/PracticeStats" }
"400": { $ref: "#/components/responses/BadRequest" }

/api/live/events:
post:
tags: [中継]
summary: メインのタイマー画面の出来事を送る
description: |
キューブをつないだブラウザ(メイン)が、タイマー画面の出来事を順に送る。見る側(`GET /api/live/stream`)にそのまま流す。
何もなくても15秒ごとに送り(`events` は空でよい)、40秒届かなければメインはいなくなったとみなす。
メインは1つの前提で、2つのブラウザから送ると混ざる。
operationId: publishLiveEvents
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [events]
properties:
events:
type: array
items: { $ref: "#/components/schemas/LiveEvent" }
responses:
"204": { description: 受け取った }
"400": { $ref: "#/components/responses/BadRequest" }

/api/live/stream:
get:
tags: [中継]
summary: メインのタイマー画面の状態を受け取り続ける(SSE)
description: |
Server-Sent Events。各出来事は `data:` の1行の JSON(LiveMessage)。
最初に今の状態(`snapshot`)を送り、そのあとメインから届いた出来事と、15秒ごとの `ping` を送る。
読むのが遅いと切るので、つなぎ直す(EventSource は自動でつなぎ直し、また `snapshot` から受け取る)。
operationId: liveStream
responses:
"200":
description: 出来事の流れ
content:
text/event-stream:
schema: { $ref: "#/components/schemas/LiveMessage" }

components:
parameters:
period:
Expand Down Expand Up @@ -517,6 +559,52 @@ components:
schema: { $ref: "#/components/schemas/Error" }

schemas:
LiveEvent:
type: object
description: |
メインから届く出来事。
- main:キューブの接続の状態(cube)
- move:1手の回転(move)と、回したあとの状態(facelets)
- facelets:状態の置き換え(実機に合わせ直したときなど)
- session:タイマーの状態(LiveSession)
- bye:メインが画面を閉じた
required: [type]
properties:
type: { type: string, enum: [main, move, facelets, session, bye] }
cube: { type: string, enum: [connected, connecting, disconnected] }
move: { type: string, example: "R'" }
facelets:
type: string
description: キューブの状態。URFDLB の54文字(Kociemba の並び)
example: UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB
session: { $ref: "#/components/schemas/LiveSession" }
LiveMessage:
type: object
description: |
見る側に送る出来事。LiveEvent の main・move・facelets・session をそのまま送るほか、
- snapshot:見始めたときの今の状態(online・cube・facelets・session・age_ms)
- ping:メインが生きているか(online)
- main:bye のときは online=false で送る
required: [type]
properties:
type: { type: string, enum: [snapshot, ping, main, move, facelets, session] }
online: { type: boolean, description: メインがいるか }
cube: { type: string, enum: [connected, connecting, disconnected] }
move: { type: string }
facelets: { type: string }
session: { $ref: "#/components/schemas/LiveSession" }
age_ms: { type: integer, description: session を受け取ってからの時間(snapshot のときだけ)。経過時間をずらすのに使う }
LiveSession:
type: object
description: |
タイマーの状態。中身は画面(web/src/live/protocol.ts)が決め、サーバーは確かめずにそのまま流す。
時刻は端末ごとに基準が違うので、`*_ago_ms`(送った時点で、計測やインスペクションが始まってから何 ms たったか)で送る。
additionalProperties: true
properties:
phase: { type: string, enum: [generating, scrambling, ready, running] }
scramble: { type: [string, "null"] }
started_ago_ms: { type: [integer, "null"] }
ready_ago_ms: { type: [integer, "null"] }
PracticeStats:
type: object
required: [period, count, full, stages, days, practice]
Expand Down
228 changes: 228 additions & 0 deletions internal/live/hub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// Package live は、キューブをつないだブラウザ(メイン)のタイマー画面の状態を、ほかのブラウザに中継する。
//
// メインは出来事(回転・状態・タイマー)を POST で送り、見る側は SSE で受け取る。
// 状態はメモリにだけ持ち、DB には書かない(サーバーを再起動すると消える)。
package live

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
)

// StaleAfter は、メインから何も届かないとき、メインがいなくなったとみなすまでの時間。
// メインは何もなくても15秒ごとに送ってくる。
const StaleAfter = 40 * time.Second

// subscriberBuffer は、見る側1つに溜めておける出来事の数。溢れたら切り、つなぎ直してもらう(最初の状態から送り直す)。
const subscriberBuffer = 256

// maxSession は、タイマーの状態(JSON)の大きさの上限。
const maxSession = 64 << 10

// Event はメインから届く出来事。
//
// - main:メインのキューブの接続の状態(cube)。何もなくても送り、生きていることを知らせる
// - move:1手の回転(move)と、回したあとの状態(facelets)
// - facelets:状態の置き換え(実機に合わせ直したときなど)
// - session:タイマーの状態(中身はアプリが決める JSON のオブジェクト)
// - bye:メインが画面を閉じた
type Event struct {
Type string `json:"type"`
Cube string `json:"cube,omitempty"`
Move string `json:"move,omitempty"`
Facelets string `json:"facelets,omitempty"`
Session json.RawMessage `json:"session,omitempty"`
}

// Message は見る側に送る出来事。snapshot は見始めたときの今の状態、ping は生きているかの知らせ。
type Message struct {
Type string `json:"type"`
Online *bool `json:"online,omitempty"`
Cube string `json:"cube,omitempty"`
Move string `json:"move,omitempty"`
Facelets string `json:"facelets,omitempty"`
Session json.RawMessage `json:"session,omitempty"`
// AgeMS は session を受け取ってからの時間(snapshot のときだけ)。タイマーの経過時間をずらすのに使う。
AgeMS int64 `json:"age_ms,omitempty"`
}

// Hub はメインの最新の状態と、見る側の一覧を持つ。
type Hub struct {
now func() time.Time

mu sync.Mutex
lastSeen time.Time
bye bool
cube string
facelets string
session json.RawMessage
sessionAt time.Time
subs map[chan []byte]struct{}
closed bool
}

// NewHub は Hub を作る。
func NewHub() *Hub {
return &Hub{now: time.Now, subs: map[chan []byte]struct{}{}}
}

// ErrInvalid は、メインから届いた出来事が正しくないときのエラー。
var ErrInvalid = errors.New("invalid live event")

func invalid(format string, a ...any) error {
return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, a...))
}

// Validate は出来事の中身を確かめる。
func (e Event) Validate() error {
switch e.Type {
case "main":
switch e.Cube {
case "connected", "connecting", "disconnected":
default:
return invalid("cube must be connected, connecting or disconnected")
}
case "move":
if e.Move == "" || len(e.Move) > 4 {
return invalid("move is required")
}
if err := validFacelets(e.Facelets); err != nil {
return err
}
case "facelets":
if err := validFacelets(e.Facelets); err != nil {
return err
}
case "session":
if len(e.Session) > maxSession {
return invalid("session is too large")
}
if t := bytes.TrimSpace(e.Session); len(t) == 0 || t[0] != '{' || !json.Valid(t) {
return invalid("session must be a JSON object")
}
case "bye":
default:
return invalid("unknown type %q", e.Type)
}
return nil
}

func validFacelets(f string) error {
if len(f) != 54 || strings.Trim(f, "URFDLB") != "" {
return invalid("facelets must be 54 letters of URFDLB")
}
return nil
}

// Publish はメインから届いた出来事を、状態に反映して見る側に送る。先に Validate で確かめておく。
func (h *Hub) Publish(events []Event) {
h.mu.Lock()
defer h.mu.Unlock()
now := h.now()
h.lastSeen = now
h.bye = false
for _, e := range events {
msg := Message{Type: e.Type}
switch e.Type {
case "main":
h.cube = e.Cube
msg.Online, msg.Cube = boolPtr(true), e.Cube
case "move", "facelets":
h.facelets = e.Facelets
msg.Move, msg.Facelets = e.Move, e.Facelets
case "session":
h.session, h.sessionAt = compact(e.Session), now
msg.Session = h.session
case "bye":
h.bye = true
msg = Message{Type: "main", Online: boolPtr(false), Cube: h.cube}
}
h.broadcast(msg)
}
}

// Subscribe は見る側を足し、最初に送る今の状態と、続く出来事を受け取るチャネルを返す。
// チャネルが閉じたら(溢れたとき・Close のとき)接続を終える。終えるときは unsubscribe を呼ぶ。
func (h *Hub) Subscribe() (snapshot []byte, ch <-chan []byte, unsubscribe func()) {
h.mu.Lock()
defer h.mu.Unlock()
c := make(chan []byte, subscriberBuffer)
if h.closed {
close(c)
return nil, c, func() {}
}
h.subs[c] = struct{}{}
now := h.now()
snap := Message{Type: "snapshot", Online: boolPtr(h.online(now)), Cube: h.cube, Facelets: h.facelets, Session: h.session}
if h.session != nil {
snap.AgeMS = max(1, now.Sub(h.sessionAt).Milliseconds())
}
return marshal(snap), c, func() {
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.subs[c]; ok {
delete(h.subs, c)
close(c)
}
}
}

// Ping は、メインが生きているかの知らせ(見る側に定期的に送る)。
func (h *Hub) Ping() []byte {
h.mu.Lock()
defer h.mu.Unlock()
return marshal(Message{Type: "ping", Online: boolPtr(h.online(h.now()))})
}

// Close は見る側をすべて切る(サーバーを止めるとき)。
func (h *Hub) Close() {
h.mu.Lock()
defer h.mu.Unlock()
h.closed = true
for c := range h.subs {
delete(h.subs, c)
close(c)
}
}

func (h *Hub) online(now time.Time) bool {
return !h.lastSeen.IsZero() && !h.bye && now.Sub(h.lastSeen) < StaleAfter
}

// broadcast は h.mu を持ったまま呼ぶ。
func (h *Hub) broadcast(msg Message) {
b := marshal(msg)
for c := range h.subs {
select {
case c <- b:
default:
// 読むのが遅い見る側は切る(つなぎ直すと、今の状態から送り直す)
delete(h.subs, c)
close(c)
}
}
}

func marshal(m Message) []byte {
b, err := json.Marshal(m)
if err != nil {
panic(err) // Message は必ず JSON にできる
}
return b
}

// compact は JSON を1行にする(SSE の data は1行で送る)。
func compact(raw json.RawMessage) json.RawMessage {
var buf bytes.Buffer
if err := json.Compact(&buf, raw); err != nil {
return raw
}
return buf.Bytes()
}

func boolPtr(b bool) *bool { return &b }
Loading
Loading