fix: keep active page on config save, add Cyrillic preview support, fix grid sizing
- daemon.go: save/restore active page on config reload instead of always activating default_page (fixes page switch on save) - render.go: replace basicfont.Face7x13 with parseDisplayFace() for unicode/Cyrillic support in edit key preview - web.go: add SSE endpoint (GET /api/events) for real-time page_changed events and POST /api/activate-page API - app.js: persist active page via localStorage, listen for SSE page_changed events - styles.css, index.html: fix grid sizing (width 100%, minmax(0, 1fr), aspect-ratio on key buttons, max-width 525px)
This commit is contained in:
parent
19e3040965
commit
cb52e639f0
28
daemon.go
28
daemon.go
@ -74,6 +74,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
|
|
||||||
web.SetDecks(decks)
|
web.SetDecks(decks)
|
||||||
web.SetPageManager(primaryPM)
|
web.SetPageManager(primaryPM)
|
||||||
|
web.SetExtraPageManagers(pageMgrs[1:])
|
||||||
|
|
||||||
for _, pm := range pageMgrs {
|
for _, pm := range pageMgrs {
|
||||||
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
|
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
|
||||||
@ -104,14 +105,19 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
if a == nil {
|
if a == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
oldPage := primaryPM.ActivePageName()
|
||||||
if err := ExecuteAction(a, primaryDeck, primaryPM); err != nil {
|
if err := ExecuteAction(a, primaryDeck, primaryPM); err != nil {
|
||||||
slog.Error("execute action", "error", err)
|
slog.Error("execute action", "error", err)
|
||||||
}
|
}
|
||||||
|
newPage := primaryPM.ActivePageName()
|
||||||
|
if newPage != oldPage {
|
||||||
if a.Type == "page" {
|
if a.Type == "page" {
|
||||||
asm.NotifyManualPage(a.Page)
|
asm.NotifyManualPage(a.Page)
|
||||||
for _, pm := range pageMgrs[1:] {
|
|
||||||
pm.ActivatePage(a.Page)
|
|
||||||
}
|
}
|
||||||
|
for _, pm := range pageMgrs[1:] {
|
||||||
|
pm.ActivatePage(newPage)
|
||||||
|
}
|
||||||
|
web.BroadcastPageChange(newPage)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -191,6 +197,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
}
|
}
|
||||||
pm.startPeriodicKeys()
|
pm.startPeriodicKeys()
|
||||||
}
|
}
|
||||||
|
web.BroadcastPageChange(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-configTicker.C:
|
case <-configTicker.C:
|
||||||
@ -214,7 +221,9 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
for _, d := range decks {
|
for _, d := range decks {
|
||||||
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
||||||
}
|
}
|
||||||
|
var activePages []string
|
||||||
for _, pm := range pageMgrs {
|
for _, pm := range pageMgrs {
|
||||||
|
activePages = append(activePages, pm.ActivePageName())
|
||||||
pm.stopPeriodicKeys()
|
pm.stopPeriodicKeys()
|
||||||
pm.defaultFont = cfg.Font
|
pm.defaultFont = cfg.Font
|
||||||
pm.LoadPages(cfg.Pages)
|
pm.LoadPages(cfg.Pages)
|
||||||
@ -225,9 +234,18 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
detector = NewWindowDetector()
|
detector = NewWindowDetector()
|
||||||
windowCh, _ = detector.Start(ctx)
|
windowCh, _ = detector.Start(ctx)
|
||||||
}
|
}
|
||||||
for _, pm := range pageMgrs {
|
for i, pm := range pageMgrs {
|
||||||
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
|
page := cfg.DefaultPage
|
||||||
slog.Warn("reload: activate default page", "error", err)
|
if i < len(activePages) && activePages[i] != "" {
|
||||||
|
for _, p := range cfg.Pages {
|
||||||
|
if p.Name == activePages[i] {
|
||||||
|
page = activePages[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := pm.ActivatePage(page); err != nil {
|
||||||
|
slog.Warn("reload: activate page", "error", err)
|
||||||
}
|
}
|
||||||
pm.startPeriodicKeys()
|
pm.startPeriodicKeys()
|
||||||
}
|
}
|
||||||
|
|||||||
30
render.go
30
render.go
@ -42,7 +42,11 @@ func RenderKeyToImage(k *config.KeyConfig, keySize int) image.Image {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if k.Label != "" {
|
if k.Label != "" {
|
||||||
return composeImageWithLabel(img, k.Label, keySize)
|
fontSize := 10.0
|
||||||
|
if k.FontSize != nil {
|
||||||
|
fontSize = *k.FontSize
|
||||||
|
}
|
||||||
|
return composeImageWithLabel(img, k.Label, keySize, fontSize)
|
||||||
}
|
}
|
||||||
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
||||||
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||||
@ -62,7 +66,11 @@ func RenderKeyToImage(k *config.KeyConfig, keySize int) image.Image {
|
|||||||
return blankImage(keySize, bg)
|
return blankImage(keySize, bg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return renderTextImage(k.Label, keySize)
|
fontSize := 12.0
|
||||||
|
if k.FontSize != nil {
|
||||||
|
fontSize = *k.FontSize
|
||||||
|
}
|
||||||
|
return renderTextImage(k.Label, keySize, fontSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func blankImage(size int, c color.Color) image.Image {
|
func blankImage(size int, c color.Color) image.Image {
|
||||||
@ -71,7 +79,7 @@ func blankImage(size int, c color.Color) image.Image {
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
func composeImageWithLabel(src image.Image, text string, keySize int) image.Image {
|
func composeImageWithLabel(src image.Image, text string, keySize int, fontSize float64) image.Image {
|
||||||
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
||||||
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||||
g.Draw(rgba, src)
|
g.Draw(rgba, src)
|
||||||
@ -84,7 +92,12 @@ func composeImageWithLabel(src image.Image, text string, keySize int) image.Imag
|
|||||||
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
||||||
|
|
||||||
if text != "" {
|
if text != "" {
|
||||||
face := basicfont.Face7x13
|
face, err := parseDisplayFace(fontSize)
|
||||||
|
if err != nil {
|
||||||
|
face = basicfont.Face7x13
|
||||||
|
} else {
|
||||||
|
defer face.Close()
|
||||||
|
}
|
||||||
textW := font.MeasureString(face, text).Ceil()
|
textW := font.MeasureString(face, text).Ceil()
|
||||||
posX := (keySize - textW) / 2
|
posX := (keySize - textW) / 2
|
||||||
if posX < 2 {
|
if posX < 2 {
|
||||||
@ -107,11 +120,16 @@ func composeImageWithLabel(src image.Image, text string, keySize int) image.Imag
|
|||||||
return rgba
|
return rgba
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTextImage(text string, keySize int) image.Image {
|
func renderTextImage(text string, keySize int, fontSize float64) image.Image {
|
||||||
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 255}).(*image.RGBA)
|
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 255}).(*image.RGBA)
|
||||||
|
|
||||||
if text != "" {
|
if text != "" {
|
||||||
face := basicfont.Face7x13
|
face, err := parseDisplayFace(fontSize)
|
||||||
|
if err != nil {
|
||||||
|
face = basicfont.Face7x13
|
||||||
|
} else {
|
||||||
|
defer face.Close()
|
||||||
|
}
|
||||||
textW := font.MeasureString(face, text).Ceil()
|
textW := font.MeasureString(face, text).Ceil()
|
||||||
posX := (keySize - textW) / 2
|
posX := (keySize - textW) / 2
|
||||||
if posX < 2 {
|
if posX < 2 {
|
||||||
|
|||||||
@ -51,6 +51,28 @@ document.addEventListener('alpine:init', () => {
|
|||||||
await this.loadConfig()
|
await this.loadConfig()
|
||||||
await this.loadDecks()
|
await this.loadDecks()
|
||||||
setInterval(() => this.pollDisplayOutputs(), 3000)
|
setInterval(() => this.pollDisplayOutputs(), 3000)
|
||||||
|
this.connectSSE()
|
||||||
|
},
|
||||||
|
|
||||||
|
connectSSE() {
|
||||||
|
if (this._sse) this._sse.close()
|
||||||
|
const es = new EventSource('/api/events')
|
||||||
|
this._sse = es
|
||||||
|
es.addEventListener('page_changed', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
if (data.page && this.pages.find(p => p.name === data.page)) {
|
||||||
|
this.activePage = data.page
|
||||||
|
localStorage.setItem('sd_active_page', data.page)
|
||||||
|
this.displayOutputs = {}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close()
|
||||||
|
this._sse = null
|
||||||
|
setTimeout(() => this.connectSSE(), 3000)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadDecks() {
|
async loadDecks() {
|
||||||
@ -79,8 +101,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.config = await res.json()
|
this.config = await res.json()
|
||||||
this.pages = this.config.pages
|
this.pages = this.config.pages
|
||||||
if (this.pages.length > 0) {
|
if (this.pages.length > 0) {
|
||||||
|
const saved = localStorage.getItem('sd_active_page')
|
||||||
|
if (saved && this.pages.find(p => p.name === saved)) {
|
||||||
|
this.activePage = saved
|
||||||
|
} else {
|
||||||
this.activePage = this.config.default_page || this.pages[0].name
|
this.activePage = this.config.default_page || this.pages[0].name
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.syncSettings()
|
this.syncSettings()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.showToast('Failed to load config', 'error')
|
this.showToast('Failed to load config', 'error')
|
||||||
@ -460,7 +487,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// ── Page management ──
|
// ── Page management ──
|
||||||
switchPage(name) {
|
switchPage(name) {
|
||||||
this.activePage = name
|
this.activePage = name
|
||||||
|
localStorage.setItem('sd_active_page', name)
|
||||||
this.displayOutputs = {}
|
this.displayOutputs = {}
|
||||||
|
fetch('/api/activate-page', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ page: name })
|
||||||
|
}).catch(() => {})
|
||||||
},
|
},
|
||||||
|
|
||||||
async addPage() {
|
async addPage() {
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<!-- ═══ Header ═══ -->
|
<!-- ═══ Header ═══ -->
|
||||||
<header>
|
<header>
|
||||||
<button class="icon-btn" @click="subView = 'switcher'" title="Switch page">
|
<button class="icon-btn" @click="view = 'grid'; subView = 'switcher'" title="Switch page">
|
||||||
<i class="fas fa-layer-group"></i>
|
<i class="fas fa-layer-group"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -53,7 +53,7 @@
|
|||||||
x-text="d.model + ' (' + d.keys_x + '\u00d7' + d.keys_y + ')'"></button>
|
x-text="d.model + ' (' + d.keys_x + '\u00d7' + d.keys_y + ')'"></button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="deck-grid" :style="'grid-template-columns: repeat(' + activeDeck.keys_x + ', 1fr)'">
|
<div class="deck-grid" :style="'grid-template-columns: repeat(' + activeDeck.keys_x + ', minmax(0, 1fr))'">
|
||||||
<template x-for="key in gridKeys" :key="key.index">
|
<template x-for="key in gridKeys" :key="key.index">
|
||||||
<button class="key-btn"
|
<button class="key-btn"
|
||||||
:style="key.displayBg ? { backgroundColor: key.displayBg } : {}"
|
:style="key.displayBg ? { backgroundColor: key.displayBg } : {}"
|
||||||
@ -68,7 +68,7 @@
|
|||||||
<span x-show="key.configured && !key.background && !key.icon && key.label"
|
<span x-show="key.configured && !key.background && !key.icon && key.label"
|
||||||
style="font-size:12px;color:#fff;opacity:0.75;text-align:center;line-height:1.2;padding:4px;"
|
style="font-size:12px;color:#fff;opacity:0.75;text-align:center;line-height:1.2;padding:4px;"
|
||||||
x-text="key.label"></span>
|
x-text="key.label"></span>
|
||||||
<span x-show="!key.configured" style="color:var(--text-muted);font-size:24px;opacity:0.5;">+</span>
|
<span x-show="!key.configured" style="color:var(--text-muted);font-size:24px;opacity:0.5;line-height:1;">+</span>
|
||||||
<div class="action-icons" x-show="key.hasAction">
|
<div class="action-icons" x-show="key.hasAction">
|
||||||
<template x-for="(t, ti) in key.actionTypes.slice(0,3)" :key="ti">
|
<template x-for="(t, ti) in key.actionTypes.slice(0,3)" :key="ti">
|
||||||
<i :class="'fas fa-' + actionIconMap[t] + ' action-icon type-' + t"
|
<i :class="'fas fa-' + actionIconMap[t] + ' action-icon type-' + t"
|
||||||
|
|||||||
@ -112,6 +112,9 @@ main {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 525px;
|
||||||
|
contain: layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-selector {
|
.deck-selector {
|
||||||
@ -155,6 +158,8 @@ main {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-btn:hover {
|
.key-btn:hover {
|
||||||
@ -169,11 +174,10 @@ main {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-btn.empty {
|
.key-btn.empty {
|
||||||
opacity: 0.3;
|
border-color: var(--border-light);
|
||||||
}
|
}
|
||||||
.key-btn.empty:hover {
|
.key-btn.empty:hover {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
104
web.go
104
web.go
@ -37,12 +37,20 @@ type WebServer struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
configPath string
|
configPath string
|
||||||
pm *PageManager
|
pm *PageManager
|
||||||
|
extraPMs []*PageManager
|
||||||
decks []*Deck
|
decks []*Deck
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
sseClients map[chan string]struct{}
|
||||||
|
sseMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebServer(cfg *config.Config, configPath string) *WebServer {
|
func NewWebServer(cfg *config.Config, configPath string) *WebServer {
|
||||||
return &WebServer{cfg: cfg, configPath: configPath}
|
return &WebServer{
|
||||||
|
cfg: cfg,
|
||||||
|
configPath: configPath,
|
||||||
|
sseClients: make(map[chan string]struct{}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WebServer) UpdateConfig(cfg *config.Config) {
|
func (s *WebServer) UpdateConfig(cfg *config.Config) {
|
||||||
@ -63,6 +71,23 @@ func (s *WebServer) SetDecks(decks []*Deck) {
|
|||||||
s.decks = decks
|
s.decks = decks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *WebServer) SetExtraPageManagers(pms []*PageManager) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.extraPMs = pms
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebServer) BroadcastPageChange(page string) {
|
||||||
|
s.sseMu.RLock()
|
||||||
|
defer s.sseMu.RUnlock()
|
||||||
|
for ch := range s.sseClients {
|
||||||
|
select {
|
||||||
|
case ch <- page:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *WebServer) Serve(ctx context.Context, addr string) error {
|
func (s *WebServer) Serve(ctx context.Context, addr string) error {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
@ -86,6 +111,9 @@ func (s *WebServer) Serve(ctx context.Context, addr string) error {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /api/status", s.handleGetStatus)
|
mux.HandleFunc("GET /api/status", s.handleGetStatus)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/events", s.handleSSE)
|
||||||
|
mux.HandleFunc("POST /api/activate-page", s.handleActivatePage)
|
||||||
|
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
|
||||||
|
|
||||||
mux.HandleFunc("/", s.handleSPA)
|
mux.HandleFunc("/", s.handleSPA)
|
||||||
@ -127,6 +155,80 @@ func (s *WebServer) handleSPA(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(data)
|
w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *WebServer) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
ch := make(chan string, 8)
|
||||||
|
|
||||||
|
s.sseMu.Lock()
|
||||||
|
s.sseClients[ch] = struct{}{}
|
||||||
|
s.sseMu.Unlock()
|
||||||
|
|
||||||
|
notify := r.Context().Done()
|
||||||
|
go func() {
|
||||||
|
<-notify
|
||||||
|
s.sseMu.Lock()
|
||||||
|
delete(s.sseClients, ch)
|
||||||
|
s.sseMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-notify:
|
||||||
|
return
|
||||||
|
case page := <-ch:
|
||||||
|
data, _ := json.Marshal(map[string]string{"page": page})
|
||||||
|
fmt.Fprintf(w, "event: page_changed\ndata: %s\n\n", data)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Page string `json:"page"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Page == "" {
|
||||||
|
http.Error(w, "page is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
allPMs := append([]*PageManager{s.pm}, s.extraPMs...)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, pm := range allPMs {
|
||||||
|
if pm == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := pm.ActivatePage(req.Page); err != nil {
|
||||||
|
slog.Warn("activate page", "page", req.Page, "error", err)
|
||||||
|
} else {
|
||||||
|
pm.stopPeriodicKeys()
|
||||||
|
pm.startPeriodicKeys()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.BroadcastPageChange(req.Page)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "page": req.Page})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *WebServer) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
func (s *WebServer) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user