桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)

- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化)
- 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转
- 设置→外观 主题顺序调整为 深色/浅色/跟随系统
- release 二进制重编并验证(浏览器实测关于页链接可开文档页)
- 附带工作区中未提交的扁平化重构现状(另一会话进行中)
- homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
logan committed 2026-10-16 03:03:36 +08:00
1 parent 6452026fd6
commit 2bb4ef7a95
299 files changed
+6059 -3322

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
package proxy
import (
"crypto/rand"
"encoding/hex"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
// WSTunnel 让浏览器借道 Gin 连接带自定义 Header 的 WebSocket:
//
// POST /v1/proxy/ws/connect {url, headers, protocols} → {sessionId}
// GET /v1/proxy/ws/attach?sessionId=… (浏览器 WebSocket 双向桥)
// POST /v1/proxy/ws/close {sessionId}
type wsTunnelSpec struct {
URL string `json:"url" binding:"required"`
Headers map[string]string `json:"headers,omitempty"`
Protocols []string `json:"protocols,omitempty"`
}
type wsTunnelSession struct {
id string
target *websocket.Conn
attach chan *websocket.Conn
createdAt time.Time
}
type WSTunnel struct {
mu sync.Mutex
sessions map[string]*wsTunnelSession
upgrader websocket.Upgrader
}
func NewWSTunnel() *WSTunnel {
t := &WSTunnel{
sessions: map[string]*wsTunnelSession{},
upgrader: websocket.Upgrader{
ReadBufferSize: 32 * 1024,
WriteBufferSize: 32 * 1024,
CheckOrigin: func(r *http.Request) bool {
// 仅本地 UI 使用;Origin 校验交给 CORS 中间件
return true
},
},
}
go t.gc()
return t
}
func (t *WSTunnel) gc() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
t.mu.Lock()
for id, s := range t.sessions {
// 未 attach 或空闲超过 10 分钟的会话回收
if time.Since(s.createdAt) > 10*time.Minute {
_ = s.target.Close()
delete(t.sessions, id)
}
}
t.mu.Unlock()
}
}
// ValidateWSURL 校验 ws/wss 目标(http/https 自动换算)
func ValidateWSURL(raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
switch u.Scheme {
case "ws", "http":
u.Scheme = "ws"
case "wss", "https":
u.Scheme = "wss"
default:
return nil, errOnlyWS
}
if u.Hostname() == "" {
return nil, errEmptyHost
}
if _, blocked := blockedHosts[strings.ToLower(u.Hostname())]; blocked {
return nil, errBlockedHost
}
return u, nil
}
var (
errOnlyWS = &wsTunnelError{"only ws/wss schemes are allowed"}
errEmptyHost = &wsTunnelError{"url host is required"}
errBlockedHost = &wsTunnelError{"blocked metadata host"}
)
type wsTunnelError struct{ msg string }
func (e *wsTunnelError) Error() string { return e.msg }
func (t *WSTunnel) Connect(c *gin.Context) {
var spec wsTunnelSpec
if err := c.ShouldBindJSON(&spec); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid spec", "detail": err.Error()})
return
}
u, err := ValidateWSURL(spec.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second}
dialHeader := http.Header{}
for key, value := range spec.Headers {
if !isHopByHop(key) {
dialHeader.Set(key, value)
}
}
target, _, err := dialer.Dial(u.String(), dialHeader)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "dial failed", "detail": err.Error()})
return
}
id := randomSessionID()
session := &wsTunnelSession{id: id, target: target, attach: make(chan *websocket.Conn, 1), createdAt: time.Now()}
t.mu.Lock()
t.sessions[id] = session
t.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"sessionId": id})
// 30 秒内没有浏览器 attach 就回收
go func() {
select {
case client := <-session.attach:
t.bridge(client, session)
case <-time.After(30 * time.Second):
t.mu.Lock()
if s, ok := t.sessions[id]; ok {
_ = s.target.Close()
delete(t.sessions, id)
}
t.mu.Unlock()
}
}()
}
func (t *WSTunnel) Attach(c *gin.Context) {
id := c.Query("sessionId")
t.mu.Lock()
session, ok := t.sessions[id]
t.mu.Unlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
client, err := t.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
select {
case session.attach <- client:
// bridge goroutine 已在 Connect 中启动
default:
_ = client.Close()
}
}
func (t *WSTunnel) Close(c *gin.Context) {
var body struct {
SessionID string `json:"sessionId"`
}
_ = c.ShouldBindJSON(&body)
t.mu.Lock()
session, ok := t.sessions[body.SessionID]
if ok {
delete(t.sessions, body.SessionID)
}
t.mu.Unlock()
if ok {
_ = session.target.Close()
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// bridge 双向转发:浏览器 → 目标;目标 → 浏览器
func (t *WSTunnel) bridge(client *websocket.Conn, session *wsTunnelSession) {
defer func() {
_ = client.Close()
_ = session.target.Close()
t.mu.Lock()
delete(t.sessions, session.id)
t.mu.Unlock()
}()
done := make(chan struct{})
// 浏览器 → 目标
go func() {
defer close(done)
for {
msgType, data, err := client.ReadMessage()
if err != nil {
return
}
if err := session.target.WriteMessage(msgType, data); err != nil {
return
}
}
}()
// 目标 → 浏览器(bridge 协议:控制帧 + 数据帧)
_ = client.WriteMessage(websocket.TextMessage, []byte(`{"type":"open"}`))
for {
msgType, data, err := session.target.ReadMessage()
if err != nil {
_ = client.WriteMessage(websocket.TextMessage, []byte(`{"type":"closed","reason":"target closed"}`))
return
}
if err := client.WriteMessage(msgType, data); err != nil {
return
}
}
}
func randomSessionID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return time.Now().Format("20060102150405.000000000")
}
return hex.EncodeToString(b)
}