refactor: 优化代码质量,遵循 KISS 原则
- 移除自签证书回退逻辑,简化为仅使用 AnyIP 证书 - 删除 internal/tls/generate.go(不再需要) - 重构 main.go:提取初始化逻辑,main() 从 156 行降至 13 行 - 重构 internal/ws/handler.go:提取消息处理,handleConn() 从 131 行降至 25 行 - 重构 internal/config/load.go:使用 map 驱动消除重复代码 - 优化前端 startRecording():使用标准 AbortController API - 优化前端 showToast():预定义 DOM 元素,代码减少 50% 代码行数减少 90 行,复杂度显著降低,所有构建通过
This commit is contained in:
@@ -41,14 +41,15 @@ func Load(configPath string) (Config, error) {
|
||||
|
||||
// applyEnv overrides config fields with environment variables.
|
||||
func applyEnv(cfg *Config) {
|
||||
if v := os.Getenv("DOUBAO_APP_ID"); v != "" {
|
||||
cfg.Doubao.AppID = v
|
||||
envStringMap := map[string]*string{
|
||||
"DOUBAO_APP_ID": &cfg.Doubao.AppID,
|
||||
"DOUBAO_ACCESS_TOKEN": &cfg.Doubao.AccessToken,
|
||||
"DOUBAO_RESOURCE_ID": &cfg.Doubao.ResourceID,
|
||||
}
|
||||
if v := os.Getenv("DOUBAO_ACCESS_TOKEN"); v != "" {
|
||||
cfg.Doubao.AccessToken = v
|
||||
}
|
||||
if v := os.Getenv("DOUBAO_RESOURCE_ID"); v != "" {
|
||||
cfg.Doubao.ResourceID = v
|
||||
for key, target := range envStringMap {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
*target = v
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("PORT"); v != "" {
|
||||
if port, err := strconv.Atoi(v); err == nil {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package tls
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generateSelfSigned creates a self-signed certificate for the given IP,
|
||||
// saves it to disk, and returns the tls.Certificate.
|
||||
func generateSelfSigned(lanIP, certFile, keyFile string) (tls.Certificate, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("generate serial: %w", err)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"VoicePaste"},
|
||||
CommonName: "VoicePaste Local",
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP(lanIP)},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("create certificate: %w", err)
|
||||
}
|
||||
|
||||
// Save cert PEM
|
||||
certOut, err := os.Create(certFile)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("create cert file: %w", err)
|
||||
}
|
||||
defer certOut.Close()
|
||||
pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
// Save key PEM
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("create key file: %w", err)
|
||||
}
|
||||
defer keyOut.Close()
|
||||
pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
return tls.LoadX509KeyPair(certFile, keyFile)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -26,11 +25,10 @@ func certDir() string {
|
||||
return dir
|
||||
}
|
||||
|
||||
// Result holds the TLS config and metadata about which cert source was used.
|
||||
// Result holds the TLS config and the AnyIP hostname.
|
||||
type Result struct {
|
||||
Config *tls.Config
|
||||
AnyIP bool // true if AnyIP cert is active
|
||||
Host string // hostname to use in URLs (AnyIP domain or raw IP)
|
||||
Host string // AnyIP hostname (e.g. voicepaste-192-168-1-5.anyip.dev)
|
||||
}
|
||||
|
||||
// AnyIPHost returns the AnyIP hostname for a given LAN IP.
|
||||
@@ -40,82 +38,61 @@ func AnyIPHost(lanIP string) string {
|
||||
return fmt.Sprintf("voicepaste-%s.anyip.dev", dashed)
|
||||
}
|
||||
|
||||
// GetTLSConfig returns a TLS config for the given LAN IP.
|
||||
// Priority: cached AnyIP → download AnyIP → cached self-signed → generate self-signed.
|
||||
// GetTLSConfig returns a TLS config using AnyIP wildcard certificate.
|
||||
// It tries cached cert first, then downloads fresh if needed.
|
||||
func GetTLSConfig(lanIP string) (*Result, error) {
|
||||
dir := certDir()
|
||||
anyipDir := filepath.Join(dir, "anyip")
|
||||
os.MkdirAll(anyipDir, 0700)
|
||||
anyipCert := filepath.Join(anyipDir, "fullchain.pem")
|
||||
anyipKey := filepath.Join(anyipDir, "privkey.pem")
|
||||
certFile := filepath.Join(anyipDir, "fullchain.pem")
|
||||
keyFile := filepath.Join(anyipDir, "privkey.pem")
|
||||
host := AnyIPHost(lanIP)
|
||||
|
||||
// 1. Try cached AnyIP cert
|
||||
if cert, err := tls.LoadX509KeyPair(anyipCert, anyipKey); err == nil {
|
||||
if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
|
||||
if time.Now().Before(leaf.NotAfter.Add(-24 * time.Hour)) { // 1 day buffer
|
||||
slog.Info("using cached AnyIP certificate", "expires", leaf.NotAfter.Format("2006-01-02"))
|
||||
return &Result{
|
||||
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
|
||||
AnyIP: true,
|
||||
Host: AnyIPHost(lanIP),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
// Try cached cert first
|
||||
if cert, err := loadAndValidateCert(certFile, keyFile); err == nil {
|
||||
slog.Info("using cached AnyIP certificate")
|
||||
return &Result{
|
||||
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
|
||||
Host: host,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 2. Try downloading AnyIP cert
|
||||
if err := downloadAnyIPCert(anyipCert, anyipKey); err == nil {
|
||||
if cert, err := tls.LoadX509KeyPair(anyipCert, anyipKey); err == nil {
|
||||
slog.Info("downloaded fresh AnyIP certificate")
|
||||
return &Result{
|
||||
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
|
||||
AnyIP: true,
|
||||
Host: AnyIPHost(lanIP),
|
||||
}, nil
|
||||
}
|
||||
} else {
|
||||
slog.Warn("AnyIP cert download failed, falling back to self-signed", "err", err)
|
||||
// Download fresh cert
|
||||
slog.Info("downloading AnyIP certificate")
|
||||
if err := downloadAnyIPCert(certFile, keyFile); err != nil {
|
||||
return nil, fmt.Errorf("failed to download AnyIP certificate: %w", err)
|
||||
}
|
||||
|
||||
// 3. Try cached self-signed
|
||||
ssCert := filepath.Join(dir, "cert.pem")
|
||||
ssKey := filepath.Join(dir, "key.pem")
|
||||
if cert, err := tls.LoadX509KeyPair(ssCert, ssKey); err == nil {
|
||||
if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
|
||||
if time.Now().Before(leaf.NotAfter) && certCoversIP(leaf, lanIP) {
|
||||
slog.Info("using cached self-signed certificate", "expires", leaf.NotAfter.Format("2006-01-02"))
|
||||
return &Result{
|
||||
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
|
||||
Host: lanIP,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Generate self-signed
|
||||
slog.Info("generating self-signed TLS certificate", "ip", lanIP)
|
||||
cert, err := generateSelfSigned(lanIP, ssCert, ssKey)
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate TLS cert: %w", err)
|
||||
return nil, fmt.Errorf("failed to load downloaded certificate: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("downloaded fresh AnyIP certificate")
|
||||
return &Result{
|
||||
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
|
||||
Host: lanIP,
|
||||
Host: host,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// certCoversIP checks if the certificate covers the given IP.
|
||||
func certCoversIP(cert *x509.Certificate, ip string) bool {
|
||||
target := net.ParseIP(ip)
|
||||
if target == nil {
|
||||
return false
|
||||
// loadAndValidateCert loads a certificate and validates it's not expired.
|
||||
func loadAndValidateCert(certFile, keyFile string) (tls.Certificate, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
for _, certIP := range cert.IPAddresses {
|
||||
if certIP.Equal(target) {
|
||||
return true
|
||||
}
|
||||
|
||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return false
|
||||
|
||||
// Check if cert expires within 24 hours
|
||||
if time.Now().After(leaf.NotAfter.Add(-24 * time.Hour)) {
|
||||
return tls.Certificate{}, fmt.Errorf("certificate expired or expiring soon")
|
||||
}
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// downloadAnyIPCert downloads the AnyIP wildcard cert and key.
|
||||
|
||||
@@ -9,6 +9,17 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// session holds the state for a single WebSocket connection.
|
||||
type session struct {
|
||||
conn *websocket.Conn
|
||||
log *slog.Logger
|
||||
resultCh chan ServerMsg
|
||||
previewMu sync.Mutex
|
||||
previewText string
|
||||
sendAudio func([]byte)
|
||||
cleanup func()
|
||||
active bool
|
||||
}
|
||||
// PasteFunc is called when the server should paste text into the focused app.
|
||||
type PasteFunc func(text string) error
|
||||
|
||||
@@ -53,134 +64,125 @@ func (h *Handler) Register(app *fiber.App) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleConn(c *websocket.Conn) {
|
||||
log := slog.With("remote", c.RemoteAddr().String())
|
||||
log.Info("ws connected")
|
||||
defer log.Info("ws disconnected")
|
||||
|
||||
// Result channel for ASR → phone
|
||||
resultCh := make(chan ServerMsg, 32)
|
||||
defer close(resultCh)
|
||||
|
||||
// Writer goroutine: single writer to avoid concurrent writes
|
||||
// bigmodel_async with enable_nonstream: server returns full text each time (not incremental)
|
||||
// We replace preview text on each update instead of accumulating.
|
||||
var wg sync.WaitGroup
|
||||
var previewMu sync.Mutex
|
||||
var previewText string
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for msg := range resultCh {
|
||||
// Replace preview text with latest result (full mode)
|
||||
if msg.Type == MsgPartial || msg.Type == MsgFinal {
|
||||
previewMu.Lock()
|
||||
previewText = msg.Text
|
||||
preview := ServerMsg{Type: msg.Type, Text: previewText}
|
||||
previewMu.Unlock()
|
||||
if err := c.WriteMessage(websocket.TextMessage, preview.Bytes()); err != nil {
|
||||
log.Warn("ws write error", "err", err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Forward other messages (error, pasted) as-is
|
||||
if err := c.WriteMessage(websocket.TextMessage, msg.Bytes()); err != nil {
|
||||
log.Warn("ws write error", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// ASR session state
|
||||
var (
|
||||
sendAudio func([]byte)
|
||||
cleanup func()
|
||||
active bool
|
||||
)
|
||||
defer func() {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
for {
|
||||
mt, data, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
switch mt {
|
||||
case websocket.BinaryMessage:
|
||||
// Audio frame
|
||||
if active && sendAudio != nil {
|
||||
sendAudio(data)
|
||||
}
|
||||
|
||||
case websocket.TextMessage:
|
||||
var msg ClientMsg
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
log.Warn("invalid json", "err", err)
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case MsgStart:
|
||||
if active {
|
||||
continue
|
||||
}
|
||||
// Reset preview text for new session
|
||||
previewMu.Lock()
|
||||
previewText = ""
|
||||
previewMu.Unlock()
|
||||
sa, cl, err := h.asrFactory(resultCh)
|
||||
if err != nil {
|
||||
log.Error("asr start failed", "err", err)
|
||||
resultCh <- ServerMsg{Type: MsgError, Message: "ASR start failed"}
|
||||
continue
|
||||
}
|
||||
sendAudio = sa
|
||||
cleanup = cl
|
||||
active = true
|
||||
log.Info("recording started")
|
||||
|
||||
case MsgStop:
|
||||
if !active {
|
||||
continue
|
||||
}
|
||||
// Finish ASR session — waits for final result from readLoop
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
cleanup = nil
|
||||
}
|
||||
sendAudio = nil
|
||||
active = false
|
||||
// Paste the final preview text
|
||||
previewMu.Lock()
|
||||
finalText := previewText
|
||||
previewText = ""
|
||||
previewMu.Unlock()
|
||||
if finalText != "" && h.pasteFunc != nil {
|
||||
if err := h.pasteFunc(finalText); err != nil {
|
||||
log.Error("auto-paste failed", "err", err)
|
||||
} else {
|
||||
resultCh <- ServerMsg{Type: MsgPasted}
|
||||
}
|
||||
}
|
||||
log.Info("recording stopped")
|
||||
|
||||
case MsgPaste:
|
||||
if msg.Text == "" {
|
||||
continue
|
||||
}
|
||||
if h.pasteFunc != nil {
|
||||
if err := h.pasteFunc(msg.Text); err != nil {
|
||||
log.Error("paste failed", "err", err)
|
||||
resultCh <- ServerMsg{Type: MsgError, Message: "paste failed"}
|
||||
} else {
|
||||
resultCh <- ServerMsg{Type: MsgPasted}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sess := &session{
|
||||
conn: c,
|
||||
log: slog.With("remote", c.RemoteAddr().String()),
|
||||
resultCh: make(chan ServerMsg, 32),
|
||||
}
|
||||
sess.log.Info("ws connected")
|
||||
defer sess.log.Info("ws disconnected")
|
||||
defer close(sess.resultCh)
|
||||
defer sess.cleanupASR()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go sess.writerLoop(&wg)
|
||||
defer wg.Wait()
|
||||
for {
|
||||
mt, data, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if mt == websocket.BinaryMessage {
|
||||
sess.handleAudioFrame(data)
|
||||
} else if mt == websocket.TextMessage {
|
||||
h.handleTextMessage(sess, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *session) writerLoop(wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
for msg := range s.resultCh {
|
||||
if msg.Type == MsgPartial || msg.Type == MsgFinal {
|
||||
s.previewMu.Lock()
|
||||
s.previewText = msg.Text
|
||||
preview := ServerMsg{Type: msg.Type, Text: s.previewText}
|
||||
s.previewMu.Unlock()
|
||||
if err := s.conn.WriteMessage(websocket.TextMessage, preview.Bytes()); err != nil {
|
||||
s.log.Warn("ws write error", "err", err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.conn.WriteMessage(websocket.TextMessage, msg.Bytes()); err != nil {
|
||||
s.log.Warn("ws write error", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *session) handleAudioFrame(data []byte) {
|
||||
if s.active && s.sendAudio != nil {
|
||||
s.sendAudio(data)
|
||||
}
|
||||
}
|
||||
func (h *Handler) handleTextMessage(s *session, data []byte) {
|
||||
var msg ClientMsg
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
s.log.Warn("invalid json", "err", err)
|
||||
return
|
||||
}
|
||||
switch msg.Type {
|
||||
case MsgStart:
|
||||
h.handleStart(s)
|
||||
case MsgStop:
|
||||
h.handleStop(s)
|
||||
case MsgPaste:
|
||||
h.handlePaste(s, msg.Text)
|
||||
}
|
||||
}
|
||||
func (h *Handler) handleStart(s *session) {
|
||||
if s.active {
|
||||
return
|
||||
}
|
||||
s.previewMu.Lock()
|
||||
s.previewText = ""
|
||||
s.previewMu.Unlock()
|
||||
sa, cl, err := h.asrFactory(s.resultCh)
|
||||
if err != nil {
|
||||
s.log.Error("asr start failed", "err", err)
|
||||
s.resultCh <- ServerMsg{Type: MsgError, Message: "ASR start failed"}
|
||||
return
|
||||
}
|
||||
s.sendAudio = sa
|
||||
s.cleanup = cl
|
||||
s.active = true
|
||||
s.log.Info("recording started")
|
||||
}
|
||||
func (h *Handler) handleStop(s *session) {
|
||||
if !s.active {
|
||||
return
|
||||
}
|
||||
s.cleanupASR()
|
||||
s.sendAudio = nil
|
||||
s.active = false
|
||||
s.previewMu.Lock()
|
||||
finalText := s.previewText
|
||||
s.previewText = ""
|
||||
s.previewMu.Unlock()
|
||||
if finalText != "" && h.pasteFunc != nil {
|
||||
if err := h.pasteFunc(finalText); err != nil {
|
||||
s.log.Error("auto-paste failed", "err", err)
|
||||
} else {
|
||||
s.resultCh <- ServerMsg{Type: MsgPasted}
|
||||
}
|
||||
}
|
||||
s.log.Info("recording stopped")
|
||||
}
|
||||
func (h *Handler) handlePaste(s *session, text string) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
if h.pasteFunc != nil {
|
||||
if err := h.pasteFunc(text); err != nil {
|
||||
s.log.Error("paste failed", "err", err)
|
||||
s.resultCh <- ServerMsg{Type: MsgError, Message: "paste failed"}
|
||||
} else {
|
||||
s.resultCh <- ServerMsg{Type: MsgPasted}
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *session) cleanupASR() {
|
||||
if s.cleanup != nil {
|
||||
s.cleanup()
|
||||
s.cleanup = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user