From 1854a3ab9a5ef7a6b3dc0d62e6eea3057a2fcccc Mon Sep 17 00:00:00 2001 From: leokun Date: Mon, 3 Aug 2026 00:19:18 +0800 Subject: [PATCH] feat(cursor): add independent Cursor protocol debugging proxy and build tasks - Introduced a new task for running the Cursor protocol debugging proxy. - Added a build task for the Cursor protocol debugging proxy. - Updated documentation to include usage instructions for the debugging tool. --- .agents/skills/coding-guidance/SKILL.md | 23 + Taskfile.yml | 10 + cmd/cursor-proxy-debugger/main.go | 52 ++ cursor-proxy-debugger/README.en.md | 58 ++ cursor-proxy-debugger/README.md | 58 ++ cursor-proxy-debugger/capture.go | 88 +++ cursor-proxy-debugger/decode.go | 236 ++++++ cursor-proxy-debugger/proxy.go | 421 +++++++++++ cursor-proxy-debugger/store.go | 162 +++++ cursor-proxy-debugger/types.go | 110 +++ cursor-proxy-debugger/web.go | 112 +++ cursor-proxy-debugger/web/app.js | 452 ++++++++++++ cursor-proxy-debugger/web/i18n.js | 178 +++++ cursor-proxy-debugger/web/index.html | 125 ++++ cursor-proxy-debugger/web/styles.css | 921 ++++++++++++++++++++++++ 15 files changed, 3006 insertions(+) create mode 100644 cmd/cursor-proxy-debugger/main.go create mode 100644 cursor-proxy-debugger/README.en.md create mode 100644 cursor-proxy-debugger/README.md create mode 100644 cursor-proxy-debugger/capture.go create mode 100644 cursor-proxy-debugger/decode.go create mode 100644 cursor-proxy-debugger/proxy.go create mode 100644 cursor-proxy-debugger/store.go create mode 100644 cursor-proxy-debugger/types.go create mode 100644 cursor-proxy-debugger/web.go create mode 100644 cursor-proxy-debugger/web/app.js create mode 100644 cursor-proxy-debugger/web/i18n.js create mode 100644 cursor-proxy-debugger/web/index.html create mode 100644 cursor-proxy-debugger/web/styles.css diff --git a/.agents/skills/coding-guidance/SKILL.md b/.agents/skills/coding-guidance/SKILL.md index b259745..9bbc9b1 100644 --- a/.agents/skills/coding-guidance/SKILL.md +++ b/.agents/skills/coding-guidance/SKILL.md @@ -23,6 +23,29 @@ description: 本地模式实现指南 客户端是:/Users/leokun/Library/Application\ Support/Cursor 客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js +## 可选抓包调试工具 + +仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行: + +```bash +go run ./cmd/cursor-proxy-debugger +``` + +默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看: + +- `agent.v1.AgentService/RunSSE` +- `aiserver.v1.BidiService/BidiAppend` +- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据 +- 同一 `request_id` 对应的上下行消息 + +开发者启动工具后,需要自行完成以下配置: + +1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`。 +2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。 +3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。 + +这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。 + ## Cursor 客户端格式化快照 - 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。 diff --git a/Taskfile.yml b/Taskfile.yml index f00b123..8c75f45 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -178,6 +178,16 @@ tasks: cmds: - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} + proxy-debugger: + summary: 启动独立 Cursor 协议调试代理 + cmds: + - go run ./cmd/cursor-proxy-debugger + + proxy-debugger:build: + summary: 构建独立 Cursor 协议调试代理 + cmds: + - go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger + ads:install: summary: 安装广告页依赖 dir: '{{.TASKFILE_DIR}}/ads-page' diff --git a/cmd/cursor-proxy-debugger/main.go b/cmd/cursor-proxy-debugger/main.go new file mode 100644 index 0000000..f0f1375 --- /dev/null +++ b/cmd/cursor-proxy-debugger/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + proxydebugger "cursor/cursor-proxy-debugger" + + "github.com/pkg/browser" +) + +func main() { + config := proxydebugger.Config{} + openBrowser := true + flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址") + flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址") + flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机") + flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数") + flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器") + flag.Parse() + + server, err := proxydebugger.New(config) + if err != nil { + log.Fatal(err) + } + if err := server.Start(); err != nil { + log.Fatal(err) + } + + fmt.Printf("Cursor 协议调试代理已启动\n") + fmt.Printf("代理地址: http://%s\n", server.ProxyAddr()) + fmt.Printf("调试界面: %s\n", server.UIURL()) + if openBrowser { + _ = browser.OpenURL(server.UIURL()) + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + <-signals + + shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Close(shutdownContext); err != nil { + log.Printf("关闭调试代理失败:%v", err) + } +} diff --git a/cursor-proxy-debugger/README.en.md b/cursor-proxy-debugger/README.en.md new file mode 100644 index 0000000..377b450 --- /dev/null +++ b/cursor-proxy-debugger/README.en.md @@ -0,0 +1,58 @@ +# Cursor Protocol Debugger + +[中文](README.md) | [English](README.en.md) + +This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend` and `RunSSE` traffic. It does not modify Cursor, the system proxy, or the installed client. + +## Start + +Run the following command from the repository root: + +```bash +go run ./cmd/cursor-proxy-debugger +``` + +Default addresses: + +- HTTP/HTTPS proxy: `127.0.0.1:9090` +- Debugging UI: `http://127.0.0.1:9091` +- MITM target: `api2.cursor.sh` + +The debugging UI opens automatically after startup. + +## Configure Cursor + +The tool does not modify Cursor automatically. After starting it, configure Cursor manually: + +1. Open Cursor's proxy settings and set the proxy to the address printed by the tool. The default is `http://127.0.0.1:9090`. +2. Open Cursor's Network settings and enable HTTP/1.1. +3. Download the proxy CA certificate from `http://127.0.0.1:9091/api/ca.crt` and make sure Cursor trusts it. + +Restore the original Cursor proxy and Network settings after debugging to avoid affecting normal network requests. + +## Build + +```bash +go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger +``` + +## Options + +```text +-proxy-addr Proxy listen address; default: 127.0.0.1:9090 +-ui-addr Debugging UI listen address; default: 127.0.0.1:9091 +-target-host Host to decrypt and capture; default: api2.cursor.sh +-max-exchanges Maximum number of exchanges retained in memory; default: 200 +-open Open the browser after startup; default: true +``` + +## Data Handling + +- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged. +- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression. +- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`. +- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`. +- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection. +- Captured traffic is stored only in process memory and is discarded when the process exits. +- Sensitive HTTP headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default. +- Raw bodies are retained up to 2 MiB per side by default; forwarded traffic is never truncated. diff --git a/cursor-proxy-debugger/README.md b/cursor-proxy-debugger/README.md new file mode 100644 index 0000000..3c39f29 --- /dev/null +++ b/cursor-proxy-debugger/README.md @@ -0,0 +1,58 @@ +# Cursor 协议调试器 + +[中文](README.md) | [English](README.en.md) + +这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend` 和 `RunSSE` 通信。它不会修改 Cursor、系统代理或已安装客户端。 + +## 启动 + +在仓库根目录运行: + +```bash +go run ./cmd/cursor-proxy-debugger +``` + +默认监听: + +- HTTP/HTTPS 代理:`127.0.0.1:9090` +- 调试界面:`http://127.0.0.1:9091` +- MITM 目标:`api2.cursor.sh` + +启动后会自动打开调试界面。 + +## 配置 Cursor + +工具不会自动修改 Cursor。启动后需要手动完成以下配置: + +1. 打开 Cursor 的代理设置,将代理地址修改为工具启动时显示的地址,默认是 `http://127.0.0.1:9090`。 +2. 打开 Cursor 的 Network 设置,启用 HTTP/1.1。 +3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。 + +调试结束后,请恢复原来的 Cursor 代理和 Network 设置,以免影响正常网络请求。 + +## 构建 + +```bash +go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger +``` + +## 参数 + +```text +-proxy-addr 代理监听地址,默认 127.0.0.1:9090 +-ui-addr 调试界面监听地址,默认 127.0.0.1:9091 +-target-host 需要解密的目标主机,默认 api2.cursor.sh +-max-exchanges 内存中保留的最大请求数,默认 200 +-open 启动后是否打开浏览器,默认 true +``` + +## 数据处理 + +- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。 +- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。 +- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。 +- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。 +- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。 +- 抓包只保留在当前进程内存中;关闭进程后消失。 +- `Authorization`、`Cookie`、`Set-Cookie` 等 HTTP 头在界面中默认隐藏。 +- 单侧原始正文默认最多保留 2 MiB;代理转发的数据不会被截断。 diff --git a/cursor-proxy-debugger/capture.go b/cursor-proxy-debugger/capture.go new file mode 100644 index 0000000..9fefee2 --- /dev/null +++ b/cursor-proxy-debugger/capture.go @@ -0,0 +1,88 @@ +package proxydebugger + +import ( + "bytes" + "encoding/hex" + "io" + "sync" +) + +type captureReadCloser struct { + source io.ReadCloser + mu sync.Mutex + buffer bytes.Buffer + limit int + size int64 + truncated bool + done bool + onChunk func([]byte) + onDone func(captured []byte, size int64, truncated bool, readErr error) +} + +func newCaptureReadCloser( + source io.ReadCloser, + limit int, + onChunk func([]byte), + onDone func(captured []byte, size int64, truncated bool, readErr error), +) *captureReadCloser { + return &captureReadCloser{ + source: source, + limit: limit, + onChunk: onChunk, + onDone: onDone, + } +} + +func (reader *captureReadCloser) Read(payload []byte) (int, error) { + read, err := reader.source.Read(payload) + if read > 0 { + chunk := payload[:read] + reader.mu.Lock() + reader.size += int64(read) + remaining := reader.limit - reader.buffer.Len() + if remaining > 0 { + captured := read + if captured > remaining { + captured = remaining + } + _, _ = reader.buffer.Write(chunk[:captured]) + } + if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) { + reader.truncated = true + } + reader.mu.Unlock() + if reader.onChunk != nil { + reader.onChunk(append([]byte(nil), chunk...)) + } + } + if err != nil { + reader.finish(err) + } + return read, err +} + +func (reader *captureReadCloser) Close() error { + err := reader.source.Close() + reader.finish(err) + return err +} + +func (reader *captureReadCloser) finish(readErr error) { + reader.mu.Lock() + if reader.done { + reader.mu.Unlock() + return + } + reader.done = true + captured := append([]byte(nil), reader.buffer.Bytes()...) + size := reader.size + truncated := reader.truncated + reader.mu.Unlock() + if reader.onDone != nil { + reader.onDone(captured, size, truncated, readErr) + } +} + +func rawHex(payload []byte) string { + return hex.EncodeToString(payload) +} diff --git a/cursor-proxy-debugger/decode.go b/cursor-proxy-debugger/decode.go new file mode 100644 index 0000000..fa8b136 --- /dev/null +++ b/cursor-proxy-debugger/decode.go @@ -0,0 +1,236 @@ +package proxydebugger + +import ( + "bytes" + "compress/gzip" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "strings" + + "cursor/gen/agentv1" + "cursor/gen/aiserverv1" + agentprotocol "cursor/internal/backend/agent/protocol" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +const maxConnectFrameBytes = 64 << 20 + +type connectFrameDecoder struct { + buffer []byte + messageType string + codec string + maxFrames int + frameCount int + onFrame func(FrameView) +} + +func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder { + return &connectFrameDecoder{ + messageType: messageType, + codec: strings.TrimSpace(codec), + maxFrames: maxFrames, + onFrame: onFrame, + } +} + +func (decoder *connectFrameDecoder) Write(payload []byte) { + if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames { + return + } + decoder.buffer = append(decoder.buffer, payload...) + for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames { + flags := decoder.buffer[0] + length := int(binary.BigEndian.Uint32(decoder.buffer[1:5])) + if length < 0 || length > maxConnectFrameBytes { + decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"}) + decoder.buffer = nil + return + } + if len(decoder.buffer) < 5+length { + return + } + framePayload := append([]byte(nil), decoder.buffer[5:5+length]...) + decoder.buffer = decoder.buffer[5+length:] + decoder.emit(decoder.decode(flags, framePayload)) + } +} + +func (decoder *connectFrameDecoder) Close() { + if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames { + decoder.emit(FrameView{ + Length: len(decoder.buffer), + RawHex: clippedHex(decoder.buffer, 4096), + Error: "流结束时仍有不完整的 Connect 帧", + }) + } + decoder.buffer = nil +} + +func (decoder *connectFrameDecoder) emit(frame FrameView) { + frame.Index = decoder.frameCount + decoder.frameCount++ + if decoder.onFrame != nil { + decoder.onFrame(frame) + } +} + +func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView { + frame := FrameView{ + Flags: flags, + Length: len(payload), + Compressed: flags&0x01 != 0, + EndStream: flags&0x02 != 0, + RawHex: clippedHex(payload, 4096), + } + decoded := payload + if frame.Compressed { + var err error + decoded, err = decompressPayload(payload, decoder.codec) + if err != nil { + frame.Error = err.Error() + return frame + } + } + if frame.EndStream { + frame.Kind = "end_stream" + frame.MessageType = "connect.error.v1.EndStreamResponse" + frame.JSON = prettyJSON(decoded) + return frame + } + + message := newMessage(decoder.messageType) + if message == nil { + frame.Error = "未知的 protobuf 消息类型" + return frame + } + if err := proto.Unmarshal(decoded, message); err != nil { + frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err) + return frame + } + frame.MessageType = decoder.messageType + frame.Kind = activeOneofName(message) + if requestID, ok := message.(*aiserverv1.BidiRequestId); ok { + frame.RequestID = strings.TrimSpace(requestID.GetRequestId()) + } + frame.JSON = marshalProtoJSON(message) + return frame +} + +func decompressPayload(payload []byte, codec string) ([]byte, error) { + if codec != "" && !strings.EqualFold(codec, "gzip") { + return nil, fmt.Errorf("暂不支持压缩算法 %q", codec) + } + reader, err := gzip.NewReader(bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("gzip 解压失败:%w", err) + } + defer reader.Close() + decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1)) + if err != nil { + return nil, fmt.Errorf("读取 gzip 内容失败:%w", err) + } + if len(decoded) > maxConnectFrameBytes { + return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes) + } + return decoded, nil +} + +func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) { + var message proto.Message + switch path { + case "/aiserver.v1.BidiService/BidiAppend": + request := &aiserverv1.BidiAppendRequest{} + if err := proto.Unmarshal(payload, request); err != nil { + return "", "", "", err + } + requestID := strings.TrimSpace(request.GetRequestId().GetRequestId()) + outer := marshalProtoJSON(request) + clientMessage, clientKind, decodeErr := agentprotocol.DecodeAgentClientMessage(request.GetData()) + if decodeErr != nil || clientMessage == nil { + return outer, "bidi_append", requestID, decodeErr + } + combined := struct { + BidiAppendRequest json.RawMessage `json:"bidi_append_request"` + AgentClientKind string `json:"agent_client_kind"` + AgentClient json.RawMessage `json:"agent_client_message"` + }{ + BidiAppendRequest: json.RawMessage(outer), + AgentClientKind: clientKind, + AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)), + } + formatted, marshalErr := json.MarshalIndent(combined, "", " ") + return string(formatted), clientKind, requestID, marshalErr + default: + message = nil + } + if message == nil { + return "", "", "", nil + } + return marshalProtoJSON(message), activeOneofName(message), "", nil +} + +func newMessage(messageType string) proto.Message { + switch messageType { + case "aiserver.v1.BidiRequestId": + return &aiserverv1.BidiRequestId{} + case "agent.v1.AgentServerMessage": + return &agentv1.AgentServerMessage{} + default: + return nil + } +} + +func marshalProtoJSON(message proto.Message) string { + if message == nil { + return "" + } + payload, err := (protojson.MarshalOptions{ + UseProtoNames: true, + EmitUnpopulated: false, + Indent: " ", + }).Marshal(message) + if err != nil { + return "" + } + return string(payload) +} + +func activeOneofName(message proto.Message) string { + if message == nil { + return "" + } + reflected := message.ProtoReflect() + oneofs := reflected.Descriptor().Oneofs() + for index := 0; index < oneofs.Len(); index++ { + oneof := oneofs.Get(index) + field := reflected.WhichOneof(oneof) + if field != nil { + return string(field.Name()) + } + } + return string(reflected.Descriptor().Name()) +} + +func prettyJSON(payload []byte) string { + var target any + if err := json.Unmarshal(payload, &target); err != nil { + return string(payload) + } + formatted, err := json.MarshalIndent(target, "", " ") + if err != nil { + return string(payload) + } + return string(formatted) +} + +func clippedHex(payload []byte, max int) string { + if len(payload) > max { + return hex.EncodeToString(payload[:max]) + "..." + } + return hex.EncodeToString(payload) +} diff --git a/cursor-proxy-debugger/proxy.go b/cursor-proxy-debugger/proxy.go new file mode 100644 index 0000000..11d15d7 --- /dev/null +++ b/cursor-proxy-debugger/proxy.go @@ -0,0 +1,421 @@ +package proxydebugger + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "cursor/internal/certs" + + "github.com/elazarl/goproxy" +) + +type exchangeContext struct { + id string +} + +// Server runs the HTTPS debugging proxy and its local web UI. +type Server struct { + config Config + certManager *certs.Manager + store *exchangeStore + counter atomic.Uint64 + proxyServer *http.Server + uiServer *http.Server + proxyLn net.Listener + uiLn net.Listener + runMu sync.Mutex +} + +// New creates a standalone Cursor protocol debugger. +func New(config Config) (*Server, error) { + config = config.normalized() + if err := validateLoopbackAddress(config.UIAddr); err != nil { + return nil, err + } + manager, err := certs.NewEmbeddedManager() + if err != nil { + return nil, fmt.Errorf("加载 MITM CA 失败:%w", err) + } + server := &Server{ + config: config, + certManager: manager, + store: newExchangeStore(config.MaxExchanges), + } + proxyHandler, err := server.newProxyHandler() + if err != nil { + return nil, err + } + server.proxyServer = &http.Server{ + Handler: proxyHandler, + ErrorLog: log.New(io.Discard, "", 0), + } + server.uiServer = &http.Server{Handler: server.newUIHandler()} + return server, nil +} + +// Start starts both listeners without modifying Cursor or system proxy settings. +func (server *Server) Start() error { + server.runMu.Lock() + defer server.runMu.Unlock() + if server.proxyLn != nil || server.uiLn != nil { + return errors.New("调试代理已经启动") + } + proxyListener, err := net.Listen("tcp", server.config.ProxyAddr) + if err != nil { + return fmt.Errorf("启动代理监听失败:%w", err) + } + uiListener, err := net.Listen("tcp", server.config.UIAddr) + if err != nil { + _ = proxyListener.Close() + return fmt.Errorf("启动调试界面失败:%w", err) + } + server.proxyLn = proxyListener + server.uiLn = uiListener + go func() { _ = server.proxyServer.Serve(proxyListener) }() + go func() { _ = server.uiServer.Serve(uiListener) }() + return nil +} + +// Close stops both listeners. +func (server *Server) Close(ctx context.Context) error { + server.runMu.Lock() + proxyServer := server.proxyServer + uiServer := server.uiServer + server.proxyLn = nil + server.uiLn = nil + server.runMu.Unlock() + var errorsList []error + if proxyServer != nil { + if err := proxyServer.Shutdown(ctx); err != nil { + errorsList = append(errorsList, err) + } + } + if uiServer != nil { + if err := uiServer.Shutdown(ctx); err != nil { + errorsList = append(errorsList, err) + } + } + return errors.Join(errorsList...) +} + +func (server *Server) ProxyAddr() string { return server.config.ProxyAddr } +func (server *Server) UIAddr() string { return server.config.UIAddr } +func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) } + +func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) { + proxy := goproxy.NewProxyHttpServer() + proxy.Verbose = false + proxy.AllowHTTP2 = true + proxy.Logger = log.New(io.Discard, "", 0) + proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) { + id := exchangeID(context) + if id == "" { + return + } + server.store.update(id, func(exchange *Exchange) { + exchange.State = "error" + exchange.Error = connectionErr.Error() + exchange.DurationMS = elapsedMS(exchange.StartedAt) + }) + } + proxy.Tr = &http.Transport{ + Proxy: nil, + DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 200, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + + caCertificate, err := server.certManager.CATLSCertificate() + if err != nil { + return nil, fmt.Errorf("读取 MITM CA 失败:%w", err) + } + baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate) + mitmAction := &goproxy.ConnectAction{ + Action: goproxy.ConnectMitm, + TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) { + return baseTLSConfig(host, context) + }, + } + proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) { + if server.matchesTargetHost(host) { + return mitmAction, host + } + return goproxy.OkConnect, host + }) + + proxy.OnRequest().DoFunc(server.captureRequest) + proxy.OnResponse().DoFunc(server.captureResponse) + return proxy, nil +} + +func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) { + if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) { + return request, nil + } + id := strconv.FormatUint(server.counter.Add(1), 10) + path := request.URL.Path + requestCodec := requestContentCodec(path, request.Header) + exchange := &Exchange{ + ExchangeSummary: ExchangeSummary{ + ID: id, + StartedAt: time.Now(), + Method: request.Method, + URL: request.URL.String(), + Host: request.URL.Host, + Path: path, + State: "pending", + }, + Request: Payload{ + Headers: sortedHeaders(request.Header), + ContentType: request.Header.Get("Content-Type"), + ContentCodec: requestCodec, + Frames: make([]FrameView, 0), + }, + Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)}, + } + server.store.create(exchange) + context.UserData = exchangeContext{id: id} + + if request.Body == nil { + server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil) + return request, nil + } + var frameDecoder *connectFrameDecoder + if path == "/agent.v1.AgentService/RunSSE" { + frameDecoder = newConnectFrameDecoder( + "aiserver.v1.BidiRequestId", + requestCodec, + server.config.MaxFrames, + func(frame FrameView) { server.appendRequestFrame(id, frame) }, + ) + } + request.Body = newCaptureReadCloser( + request.Body, + server.config.MaxCaptureBytes, + func(chunk []byte) { + if frameDecoder != nil { + frameDecoder.Write(chunk) + } + }, + func(captured []byte, size int64, truncated bool, readErr error) { + if frameDecoder != nil { + frameDecoder.Close() + } + server.finishRequestBody(id, path, requestCodec, captured, size, truncated, readErr) + }, + ) + return request, nil +} + +func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response { + id := exchangeID(context) + if id == "" || response == nil { + return response + } + server.store.update(id, func(exchange *Exchange) { + exchange.Status = response.StatusCode + exchange.State = "streaming" + exchange.DurationMS = elapsedMS(exchange.StartedAt) + exchange.Response.Headers = sortedHeaders(response.Header) + exchange.Response.ContentType = response.Header.Get("Content-Type") + exchange.Response.ContentCodec = responseContentCodec(response.Header) + }) + if response.Body == nil { + server.finishResponseBody(id, nil, 0, false, nil) + return response + } + + path := "" + if response.Request != nil && response.Request.URL != nil { + path = response.Request.URL.Path + } + var frameDecoder *connectFrameDecoder + if path == "/agent.v1.AgentService/RunSSE" { + frameDecoder = newConnectFrameDecoder( + "agent.v1.AgentServerMessage", + response.Header.Get("Connect-Content-Encoding"), + server.config.MaxFrames, + func(frame FrameView) { server.appendResponseFrame(id, frame) }, + ) + } + response.Body = newCaptureReadCloser( + response.Body, + server.config.MaxCaptureBytes, + func(chunk []byte) { + if frameDecoder != nil { + frameDecoder.Write(chunk) + } + }, + func(captured []byte, size int64, truncated bool, readErr error) { + if frameDecoder != nil { + frameDecoder.Close() + } + server.finishResponseBody(id, captured, size, truncated, readErr) + }, + ) + return response +} + +func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) { + decodePayload := captured + var contentDecodeErr error + if path == "/aiserver.v1.BidiService/BidiAppend" && truncated { + contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码") + } else if path == "/aiserver.v1.BidiService/BidiAppend" && codec != "" && !strings.EqualFold(codec, "identity") { + decodePayload, contentDecodeErr = decompressPayload(captured, codec) + } + decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr + if decodeErr == nil { + decodedJSON, kind, requestID, decodeErr = decodeUnary(path, decodePayload) + } + server.store.update(id, func(exchange *Exchange) { + exchange.RequestBytes = size + exchange.Request.Size = size + exchange.Request.RawHex = rawHex(captured) + exchange.Request.RawTruncated = truncated + if decodedJSON != "" { + exchange.Request.DecodedJSON = decodedJSON + } + if kind != "" { + exchange.RequestKind = kind + } + if requestID != "" { + exchange.RequestID = requestID + } + if decodeErr != nil { + exchange.Request.DecodeError = decodeErr.Error() + } + if readErr != nil && !errors.Is(readErr, io.EOF) { + exchange.Error = readErr.Error() + } + }) +} + +func requestContentCodec(path string, headers http.Header) string { + if path == "/agent.v1.AgentService/RunSSE" { + return strings.TrimSpace(headers.Get("Connect-Content-Encoding")) + } + return strings.TrimSpace(headers.Get("Content-Encoding")) +} + +func responseContentCodec(headers http.Header) string { + if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" { + return codec + } + return strings.TrimSpace(headers.Get("Content-Encoding")) +} + +func (server *Server) finishResponseBody(id string, captured []byte, size int64, truncated bool, readErr error) { + server.store.update(id, func(exchange *Exchange) { + exchange.ResponseBytes = size + exchange.Response.Size = size + exchange.Response.RawHex = rawHex(captured) + exchange.Response.RawTruncated = truncated + exchange.DurationMS = elapsedMS(exchange.StartedAt) + exchange.State = "completed" + if readErr != nil && !errors.Is(readErr, io.EOF) { + exchange.State = "error" + exchange.Error = readErr.Error() + } + }) +} + +func (server *Server) appendRequestFrame(id string, frame FrameView) { + server.store.update(id, func(exchange *Exchange) { + if len(exchange.Request.Frames) < server.config.MaxFrames { + exchange.Request.Frames = append(exchange.Request.Frames, frame) + } + if frame.Kind != "" { + exchange.RequestKind = frame.Kind + } + if frame.RequestID != "" { + exchange.RequestID = frame.RequestID + } + }) +} + +func (server *Server) appendResponseFrame(id string, frame FrameView) { + server.store.update(id, func(exchange *Exchange) { + if len(exchange.Response.Frames) < server.config.MaxFrames { + exchange.Response.Frames = append(exchange.Response.Frames, frame) + } + exchange.FrameCount = len(exchange.Response.Frames) + if frame.Kind != "" && frame.Kind != "end_stream" { + exchange.ResponseKind = frame.Kind + } + if frame.Error != "" { + exchange.Response.DecodeError = frame.Error + } + }) +} + +func (server *Server) matchesHTTPRequest(request *http.Request) bool { + if request == nil { + return false + } + host := request.Host + if request.URL != nil && request.URL.Host != "" { + host = request.URL.Host + } + return server.matchesTargetHost(host) +} + +func (server *Server) matchesTargetHost(host string) bool { + host = strings.TrimSpace(strings.ToLower(host)) + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + host = parsedHost + } + target := strings.TrimSpace(strings.ToLower(server.config.TargetHost)) + return host == target +} + +func exchangeID(context *goproxy.ProxyCtx) string { + if context == nil { + return "" + } + value, ok := context.UserData.(exchangeContext) + if !ok { + return "" + } + return value.id +} + +func browserAddress(address string) string { + host, port, err := net.SplitHostPort(address) + if err != nil { + return address + } + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + return net.JoinHostPort(host, port) +} + +func validateLoopbackAddress(address string) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("调试界面监听地址无效:%w", err) + } + if strings.EqualFold(host, "localhost") { + return nil + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return errors.New("调试界面只能监听本机回环地址") + } + return nil +} diff --git a/cursor-proxy-debugger/store.go b/cursor-proxy-debugger/store.go new file mode 100644 index 0000000..cfebfaa --- /dev/null +++ b/cursor-proxy-debugger/store.go @@ -0,0 +1,162 @@ +package proxydebugger + +import ( + "sort" + "sync" + "time" +) + +type exchangeStore struct { + mu sync.RWMutex + max int + order []string + exchanges map[string]*Exchange + subscribers map[chan storeEvent]struct{} +} + +func newExchangeStore(max int) *exchangeStore { + return &exchangeStore{ + max: max, + exchanges: make(map[string]*Exchange), + subscribers: make(map[chan storeEvent]struct{}), + } +} + +func (store *exchangeStore) create(exchange *Exchange) { + store.mu.Lock() + store.exchanges[exchange.ID] = exchange + store.order = append([]string{exchange.ID}, store.order...) + for len(store.order) > store.max { + oldest := store.order[len(store.order)-1] + store.order = store.order[:len(store.order)-1] + delete(store.exchanges, oldest) + } + store.mu.Unlock() + store.publish(storeEvent{Type: "created", ID: exchange.ID}) +} + +func (store *exchangeStore) update(id string, apply func(*Exchange)) { + store.mu.Lock() + if exchange := store.exchanges[id]; exchange != nil { + apply(exchange) + } + store.mu.Unlock() + store.publish(storeEvent{Type: "updated", ID: id}) +} + +func (store *exchangeStore) summaries() []ExchangeSummary { + store.mu.RLock() + defer store.mu.RUnlock() + result := make([]ExchangeSummary, 0, len(store.order)) + for _, id := range store.order { + if exchange := store.exchanges[id]; exchange != nil { + result = append(result, exchange.ExchangeSummary) + } + } + return result +} + +func (store *exchangeStore) get(id string) (Exchange, bool) { + store.mu.RLock() + defer store.mu.RUnlock() + exchange := store.exchanges[id] + if exchange == nil { + return Exchange{}, false + } + return cloneExchange(*exchange), true +} + +func (store *exchangeStore) clear() { + store.mu.Lock() + store.order = nil + store.exchanges = make(map[string]*Exchange) + store.mu.Unlock() + store.publish(storeEvent{Type: "cleared"}) +} + +func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) { + updates := make(chan storeEvent, 32) + store.mu.Lock() + store.subscribers[updates] = struct{}{} + store.mu.Unlock() + return updates, func() { + store.mu.Lock() + if _, ok := store.subscribers[updates]; ok { + delete(store.subscribers, updates) + close(updates) + } + store.mu.Unlock() + } +} + +func (store *exchangeStore) publish(event storeEvent) { + store.mu.RLock() + defer store.mu.RUnlock() + for subscriber := range store.subscribers { + select { + case subscriber <- event: + default: + } + } +} + +func cloneExchange(exchange Exchange) Exchange { + exchange.Request = clonePayload(exchange.Request) + exchange.Response = clonePayload(exchange.Response) + return exchange +} + +func clonePayload(payload Payload) Payload { + payload.Headers = append([]Header(nil), payload.Headers...) + payload.Frames = append([]FrameView(nil), payload.Frames...) + return payload +} + +func elapsedMS(startedAt time.Time) int64 { + if startedAt.IsZero() { + return 0 + } + return time.Since(startedAt).Milliseconds() +} + +func sortedHeaders(headers map[string][]string) []Header { + result := make([]Header, 0, len(headers)) + for name, values := range headers { + value := "" + for index, item := range values { + if index > 0 { + value += ", " + } + value += item + } + if isSensitiveHeader(name) && value != "" { + value = "[已隐藏]" + } + result = append(result, Header{Name: name, Value: value}) + } + sort.Slice(result, func(left, right int) bool { + return result[left].Name < result[right].Name + }) + return result +} + +func isSensitiveHeader(name string) bool { + switch httpCanonicalLower(name) { + case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key": + return true + default: + return false + } +} + +func httpCanonicalLower(value string) string { + buffer := make([]byte, len(value)) + for index := range value { + character := value[index] + if character >= 'A' && character <= 'Z' { + character += 'a' - 'A' + } + buffer[index] = character + } + return string(buffer) +} diff --git a/cursor-proxy-debugger/types.go b/cursor-proxy-debugger/types.go new file mode 100644 index 0000000..a478fe2 --- /dev/null +++ b/cursor-proxy-debugger/types.go @@ -0,0 +1,110 @@ +package proxydebugger + +import "time" + +const ( + defaultProxyAddr = "127.0.0.1:9090" + defaultUIAddr = "127.0.0.1:9091" + defaultTargetHost = "api2.cursor.sh" + defaultMaxExchanges = 200 + defaultMaxCaptureBytes = 2 << 20 + defaultMaxFrames = 2000 +) + +// Config controls the standalone proxy debugger. +type Config struct { + ProxyAddr string + UIAddr string + TargetHost string + MaxExchanges int + MaxCaptureBytes int + MaxFrames int +} + +func (config Config) normalized() Config { + if config.ProxyAddr == "" { + config.ProxyAddr = defaultProxyAddr + } + if config.UIAddr == "" { + config.UIAddr = defaultUIAddr + } + if config.TargetHost == "" { + config.TargetHost = defaultTargetHost + } + if config.MaxExchanges <= 0 { + config.MaxExchanges = defaultMaxExchanges + } + if config.MaxCaptureBytes <= 0 { + config.MaxCaptureBytes = defaultMaxCaptureBytes + } + if config.MaxFrames <= 0 { + config.MaxFrames = defaultMaxFrames + } + return config +} + +// ExchangeSummary is the compact request-list representation. +type ExchangeSummary struct { + ID string `json:"id"` + StartedAt time.Time `json:"startedAt"` + Method string `json:"method"` + URL string `json:"url"` + Host string `json:"host"` + Path string `json:"path"` + Status int `json:"status"` + State string `json:"state"` + DurationMS int64 `json:"durationMs"` + RequestBytes int64 `json:"requestBytes"` + ResponseBytes int64 `json:"responseBytes"` + RequestID string `json:"requestId,omitempty"` + RequestKind string `json:"requestKind,omitempty"` + ResponseKind string `json:"responseKind,omitempty"` + FrameCount int `json:"frameCount"` + Error string `json:"error,omitempty"` +} + +// Exchange contains the request and response detail shown by the debugger. +type Exchange struct { + ExchangeSummary + Request Payload `json:"request"` + Response Payload `json:"response"` +} + +// Payload contains headers, captured raw bytes, and decoded protobuf frames. +type Payload struct { + Headers []Header `json:"headers"` + ContentType string `json:"contentType,omitempty"` + ContentCodec string `json:"contentCodec,omitempty"` + Size int64 `json:"size"` + RawHex string `json:"rawHex,omitempty"` + RawTruncated bool `json:"rawTruncated,omitempty"` + DecodedJSON string `json:"decodedJson,omitempty"` + DecodeError string `json:"decodeError,omitempty"` + Frames []FrameView `json:"frames,omitempty"` +} + +// Header is a stable, sorted HTTP header pair. +type Header struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// FrameView describes one Connect streaming envelope. +type FrameView struct { + Index int `json:"index"` + Flags uint8 `json:"flags"` + Length int `json:"length"` + Compressed bool `json:"compressed"` + EndStream bool `json:"endStream"` + Kind string `json:"kind,omitempty"` + MessageType string `json:"messageType,omitempty"` + RequestID string `json:"requestId,omitempty"` + JSON string `json:"json,omitempty"` + RawHex string `json:"rawHex,omitempty"` + Error string `json:"error,omitempty"` +} + +type storeEvent struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` +} diff --git a/cursor-proxy-debugger/web.go b/cursor-proxy-debugger/web.go new file mode 100644 index 0000000..255f6f6 --- /dev/null +++ b/cursor-proxy-debugger/web.go @@ -0,0 +1,112 @@ +package proxydebugger + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "strings" + "time" + + "cursor/internal/certs" +) + +//go:embed web/* +var webAssets embed.FS + +func (server *Server) newUIHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/status", server.handleStatus) + mux.HandleFunc("GET /api/exchanges", server.handleExchangeList) + mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail) + mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges) + mux.HandleFunc("GET /api/events", server.handleEvents) + mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate) + assets, _ := fs.Sub(webAssets, "web") + fileServer := http.FileServer(http.FS(assets)) + mux.Handle("/", fileServer) + return securityHeaders(mux) +} + +func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) { + writeJSON(writer, http.StatusOK, map[string]any{ + "proxyAddr": server.config.ProxyAddr, + "uiAddr": server.config.UIAddr, + "targetHost": server.config.TargetHost, + "running": true, + }) +} + +func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) { + writeJSON(writer, http.StatusOK, server.store.summaries()) +} + +func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) { + id := strings.TrimSpace(request.PathValue("id")) + exchange, ok := server.store.get(id) + if !ok { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"}) + return + } + writeJSON(writer, http.StatusOK, exchange) +} + +func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) { + server.store.clear() + writer.WriteHeader(http.StatusNoContent) +} + +func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) { + flusher, ok := writer.(http.Flusher) + if !ok { + http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "text/event-stream") + writer.Header().Set("Cache-Control", "no-cache") + writer.Header().Set("Connection", "keep-alive") + updates, unsubscribe := server.store.subscribe() + defer unsubscribe() + fmt.Fprint(writer, "event: ready\ndata: {}\n\n") + flusher.Flush() + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + for { + select { + case <-request.Context().Done(): + return + case event, open := <-updates: + if !open { + return + } + payload, _ := json.Marshal(event) + fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload) + flusher.Flush() + case <-heartbeat.C: + fmt.Fprint(writer, ": heartbeat\n\n") + flusher.Flush() + } + } +} + +func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/x-x509-ca-cert") + writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`) + _, _ = writer.Write(certs.EmbeddedCACertPEM()) +} + +func writeJSON(writer http.ResponseWriter, status int, payload any) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(payload) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("Referrer-Policy", "no-referrer") + writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'") + next.ServeHTTP(writer, request) + }) +} diff --git a/cursor-proxy-debugger/web/app.js b/cursor-proxy-debugger/web/app.js new file mode 100644 index 0000000..b854047 --- /dev/null +++ b/cursor-proxy-debugger/web/app.js @@ -0,0 +1,452 @@ +import { getLocale, setLocale, t, translateDocument } from "./i18n.js"; + +const state = { + status: null, + exchanges: [], + selectedId: null, + selected: null, + search: "", + requestId: "", + endpoint: "all", + sortOrder: "desc", + paused: false, + pendingRefresh: false, + connection: { connected: false, key: "status.connecting", values: {} }, + tabs: { + request: "body", + response: "frames", + }, +}; + +const elements = { + statusDot: document.querySelector("#status-dot"), + statusText: document.querySelector("#status-text"), + proxyAddress: document.querySelector("#proxy-address"), + targetHost: document.querySelector("#target-host"), + connectionLabel: document.querySelector("#connection-label"), + trafficSummary: document.querySelector("#traffic-summary"), + searchInput: document.querySelector("#search-input"), + requestIdInput: document.querySelector("#request-id-input"), + endpointFilter: document.querySelector("#endpoint-filter"), + sortOrder: document.querySelector("#sort-order"), + requestCount: document.querySelector("#request-count"), + requestList: document.querySelector("#request-list"), + emptyState: document.querySelector("#empty-state"), + selectionSummary: document.querySelector("#selection-summary"), + requestContent: document.querySelector("#request-content"), + responseContent: document.querySelector("#response-content"), + pauseButton: document.querySelector("#pause-button"), + clearButton: document.querySelector("#clear-button"), + localeSelect: document.querySelector("#locale-select"), + workspace: document.querySelector("#workspace"), + splitter: document.querySelector("#horizontal-splitter"), +}; + +async function fetchJSON(url, options) { + const response = await fetch(url, options); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + if (response.status === 204) return null; + return response.json(); +} + +async function loadStatus() { + state.status = await fetchJSON("/api/status"); + elements.statusDot.classList.toggle("online", Boolean(state.status.running)); + renderRuntimeStatus(); + elements.proxyAddress.textContent = `http://${state.status.proxyAddr}`; + elements.targetHost.textContent = state.status.targetHost; +} + +async function refreshList() { + state.exchanges = await fetchJSON("/api/exchanges"); + renderList(); + renderTrafficSummary(); + if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) { + await refreshDetail(state.selectedId); + } else if (state.selectedId) { + state.selectedId = null; + state.selected = null; + renderDetail(); + } +} + +async function refreshDetail(id) { + if (!id) return; + try { + const detail = await fetchJSON(`/api/exchanges/${encodeURIComponent(id)}`); + if (state.selectedId !== id) return; + state.selected = detail; + renderDetail(); + } catch (error) { + if (state.selectedId === id) { + state.selected = null; + renderDetailError(error); + } + } +} + +function scheduleRefresh() { + if (state.paused) { + state.pendingRefresh = true; + return; + } + if (state.pendingRefresh) return; + state.pendingRefresh = true; + window.setTimeout(async () => { + state.pendingRefresh = false; + try { + await refreshList(); + } catch (error) { + setConnectionState(false, "connection.refreshFailed", { message: error.message }); + } + }, 90); +} + +function connectEvents() { + const events = new EventSource("/api/events"); + events.addEventListener("open", () => setConnectionState(true, "connection.live")); + events.addEventListener("update", scheduleRefresh); + events.addEventListener("error", () => setConnectionState(false, "connection.retrying")); +} + +function setConnectionState(connected, key, values = {}) { + state.connection = { connected, key, values }; + renderConnectionState(); +} + +function renderRuntimeStatus() { + if (!state.status) { + elements.statusText.textContent = t("status.connecting"); + return; + } + elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped"); +} + +function renderConnectionState() { + const { connected, key, values } = state.connection; + elements.connectionLabel.textContent = t(key, values); + elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running)); +} + +function filteredExchanges() { + const query = state.search.trim().toLowerCase(); + const requestId = state.requestId.trim().toLowerCase(); + const direction = state.sortOrder === "asc" ? 1 : -1; + return state.exchanges + .filter((item) => { + if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false; + if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false; + if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false; + if (!query) return true; + return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(query)); + }) + .sort((left, right) => { + const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime(); + if (startedAtDelta !== 0) return startedAtDelta * direction; + return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction; + }); +} + +function renderList() { + const exchanges = filteredExchanges(); + elements.requestCount.textContent = t("count.requests", { count: exchanges.length }); + elements.emptyState.classList.toggle("hidden", exchanges.length > 0); + elements.requestList.innerHTML = exchanges + .map((item) => { + const selected = item.id === state.selectedId ? " selected" : ""; + const statusClass = item.status >= 400 ? "error" : item.status ? "success" : ""; + const kind = item.responseKind || item.requestKind || "-"; + return ` + + ${escapeHTML(item.id)} + ${escapeHTML(item.url)} + ${escapeHTML(item.requestId || "-")} + ${escapeHTML(kind)} + ${escapeHTML(item.method)} + ${item.status || "-"} + ${formatBytes(item.responseBytes)} + ${formatDuration(item.durationMs)} + `; + }) + .join(""); +} + +function renderTrafficSummary() { + const totals = state.exchanges.reduce( + (result, item) => { + result.up += item.requestBytes || 0; + result.down += item.responseBytes || 0; + return result; + }, + { up: 0, down: 0 }, + ); + elements.trafficSummary.textContent = `↑ ${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`; +} + +function renderDetail() { + if (!state.selected) { + elements.selectionSummary.innerHTML = `POST${escapeHTML(t("selection.waiting"))}${escapeHTML(t("selection.prompt"))}`; + elements.requestContent.innerHTML = `
${escapeHTML(t("notices.noRequest"))}
`; + elements.responseContent.innerHTML = `
${escapeHTML(t("notices.noResponse"))}
`; + return; + } + const item = state.selected; + const statusClass = item.status >= 200 && item.status < 400 ? "success" : ""; + elements.selectionSummary.innerHTML = `${escapeHTML(item.method)}${escapeHTML(item.status || formatState(item.state))}${escapeHTML(item.url)}`; + elements.requestContent.innerHTML = renderPayload(item.request, state.tabs.request); + elements.responseContent.innerHTML = renderPayload(item.response, state.tabs.response); +} + +function renderDetailError(error) { + elements.requestContent.innerHTML = `
${escapeHTML(error.message)}
`; + elements.responseContent.innerHTML = `
${escapeHTML(error.message)}
`; +} + +function renderPayload(payload, tab) { + if (!payload) return `
${escapeHTML(t("notices.noContent"))}
`; + if (tab === "headers") return renderHeaders(payload.headers); + if (tab === "frames") return renderFrames(payload.frames); + if (tab === "raw") { + const body = payload.rawHex ? formatHex(payload.rawHex) : t("notices.noRaw"); + return `
${escapeHTML(body)}
${renderTruncated(payload.rawTruncated)}`; + } + if (payload.decodedJson) { + return `
${escapeHTML(payload.decodedJson)}
${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`; + } + if (payload.frames?.length) return renderFrames(payload.frames); + if (payload.decodeError) return `
${escapeHTML(payload.decodeError)}
`; + return `
${escapeHTML(t("notices.noBody"))}
`; +} + +function renderHeaders(headers = []) { + const items = Array.isArray(headers) ? headers : []; + if (!items.length) return `
${escapeHTML(t("notices.noHeaders"))}
`; + return `${items + .map((header) => ``) + .join("")}
${escapeHTML(header.name)}${escapeHTML(header.value)}
`; +} + +function renderFrames(frames = []) { + const items = Array.isArray(frames) ? frames : []; + if (!items.length) return `
${escapeHTML(t("notices.noFrames"))}
`; + return `
${items + .map((frame) => { + const kind = frame.kind || frame.messageType || t("notices.unknown"); + const flags = `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`; + const content = frame.json + ? `
${escapeHTML(frame.json)}
` + : `
${escapeHTML(formatHex(frame.rawHex || ""))}
`; + return `
+ + #${frame.index} + ${escapeHTML(kind)} + ${formatBytes(frame.length)} + ${flags}${frame.compressed ? " gzip" : ""} + + ${frame.error ? `
${escapeHTML(frame.error)}
` : content} +
`; + }) + .join("")}
`; +} + +function renderDecodeError(error) { + return error ? `
${escapeHTML(error)}
` : ""; +} + +function renderTruncated(truncated) { + return truncated ? `
${escapeHTML(t("notices.truncated"))}
` : ""; +} + +function formatState(value) { + const key = { + pending: "state.pending", + streaming: "state.streaming", + completed: "state.completed", + error: "state.error", + }[value]; + return key ? t(key) : value || "-"; +} + +function currentCopyText(side) { + const payload = state.selected?.[side]; + if (!payload) return ""; + const tab = state.tabs[side]; + if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n"); + if (tab === "raw") return payload.rawHex || ""; + if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n"); + return payload.decodedJson || ""; +} + +function formatHex(value) { + const hex = String(value || "").replace(/[^0-9a-f]/gi, ""); + const lines = []; + for (let index = 0; index < hex.length; index += 32) { + const chunk = hex.slice(index, index + 32); + const bytes = chunk.match(/.{1,2}/g) || []; + lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`); + } + return lines.join("\n"); +} + +function formatBytes(value) { + const bytes = Number(value || 0); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatDuration(value) { + const milliseconds = Number(value || 0); + if (milliseconds < 1000) return `${milliseconds} ms`; + return `${(milliseconds / 1000).toFixed(1)} s`; +} + +function escapeHTML(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +elements.requestList.addEventListener("click", async (event) => { + const row = event.target.closest("tr[data-id]"); + if (!row) return; + state.selectedId = row.dataset.id; + state.selected = null; + renderList(); + renderDetail(); + await refreshDetail(state.selectedId); +}); + +elements.searchInput.addEventListener("input", (event) => { + state.search = event.target.value; + renderList(); +}); + +elements.requestIdInput.addEventListener("input", (event) => { + state.requestId = event.target.value; + renderList(); +}); + +elements.endpointFilter.addEventListener("click", (event) => { + const button = event.target.closest("button[data-value]"); + if (!button) return; + state.endpoint = button.dataset.value; + for (const item of elements.endpointFilter.querySelectorAll("button")) { + item.classList.toggle("active", item === button); + } + renderList(); +}); + +elements.sortOrder.addEventListener("click", (event) => { + const button = event.target.closest("button[data-value]"); + if (!button) return; + state.sortOrder = button.dataset.value; + for (const item of elements.sortOrder.querySelectorAll("button")) { + item.classList.toggle("active", item === button); + } + renderList(); +}); + +document.querySelectorAll(".payload-panel").forEach((panel) => { + panel.querySelector(".tabs").addEventListener("click", (event) => { + const button = event.target.closest("button[data-tab]"); + if (!button) return; + const side = panel.dataset.side; + state.tabs[side] = button.dataset.tab; + panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button)); + renderDetail(); + }); +}); + +document.querySelectorAll("[data-copy-side]").forEach((button) => { + button.addEventListener("click", async () => { + const text = currentCopyText(button.dataset.copySide); + if (!text) return; + await navigator.clipboard.writeText(text); + button.textContent = t("actions.copied"); + window.setTimeout(() => { + button.textContent = t("actions.copy"); + }, 900); + }); +}); + +function renderPauseState() { + elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ"; + const actionKey = state.paused ? "actions.resume" : "actions.pause"; + elements.pauseButton.title = t(actionKey); + elements.pauseButton.setAttribute("aria-label", t(actionKey)); +} + +elements.pauseButton.addEventListener("click", async () => { + state.paused = !state.paused; + elements.pauseButton.classList.toggle("active", state.paused); + renderPauseState(); + setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live"); + if (!state.paused && state.pendingRefresh) { + state.pendingRefresh = false; + await refreshList(); + } +}); + +function applyLocale() { + translateDocument(); + elements.localeSelect.value = getLocale(); + renderRuntimeStatus(); + renderConnectionState(); + renderPauseState(); + renderList(); + renderTrafficSummary(); + renderDetail(); +} + +elements.localeSelect.addEventListener("change", (event) => { + setLocale(event.target.value); + applyLocale(); +}); + +elements.clearButton.addEventListener("click", async () => { + await fetchJSON("/api/exchanges", { method: "DELETE" }); + state.selectedId = null; + state.selected = null; + await refreshList(); + renderDetail(); +}); + +let draggingSplitter = false; +elements.splitter.addEventListener("pointerdown", (event) => { + draggingSplitter = true; + elements.splitter.classList.add("dragging"); + elements.splitter.setPointerCapture(event.pointerId); +}); + +elements.splitter.addEventListener("pointermove", (event) => { + if (!draggingSplitter) return; + const bounds = elements.workspace.getBoundingClientRect(); + const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top)); + elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`; +}); + +elements.splitter.addEventListener("pointerup", () => { + draggingSplitter = false; + elements.splitter.classList.remove("dragging"); +}); + +async function bootstrap() { + applyLocale(); + renderDetail(); + try { + await Promise.all([loadStatus(), refreshList()]); + connectEvents(); + } catch (error) { + setConnectionState(false, "connection.connectFailed", { message: error.message }); + } +} + +void bootstrap(); diff --git a/cursor-proxy-debugger/web/i18n.js b/cursor-proxy-debugger/web/i18n.js new file mode 100644 index 0000000..ebfd5fd --- /dev/null +++ b/cursor-proxy-debugger/web/i18n.js @@ -0,0 +1,178 @@ +const SOURCE_LOCALE = "zh-CN"; +const DEFAULT_LOCALE = "en-US"; +const STORAGE_KEY = "cursor-proxy-debugger:locale:v1"; +const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE]; + +const messages = { + "zh-CN": { + "app.title": "Cursor 协议调试器", + "status.connecting": "正在连接", + "status.running": "代理运行中", + "status.stopped": "代理已停止", + "actions.downloadCA": "下载代理 CA 证书", + "actions.caCertificate": "CA 证书", + "actions.pause": "暂停界面更新", + "actions.resume": "继续界面更新", + "actions.clear": "清空", + "actions.copy": "复制", + "actions.copied": "已复制", + "language.label": "界面语言", + "filters.region": "请求过滤器", + "filters.urlPlaceholder": "过滤 URL、请求类型或状态", + "filters.requestIdPlaceholder": "按 Request ID 过滤", + "filters.endpoint": "接口过滤", + "filters.all": "全部", + "filters.sort": "排序方向", + "filters.ascending": "正序", + "filters.descending": "倒序", + "count.requests": "{count} 条", + "table.url": "网址", + "table.message": "消息", + "table.method": "方法", + "table.status": "状态", + "table.response": "响应", + "table.duration": "耗时", + "empty.waitingForCursor": "等待来自 Cursor 的请求", + "splitter.resize": "调整详情区域高度", + "selection.waiting": "等待选择", + "selection.prompt": "选择一条请求查看详情", + "panel.request": "请求", + "panel.response": "响应", + "panel.requestDetails": "请求详情", + "panel.responseDetails": "响应详情", + "tabs.headers": "标头", + "tabs.body": "正文", + "tabs.frames": "帧", + "tabs.raw": "原始", + "notices.noRequest": "暂无请求内容", + "notices.noResponse": "暂无响应内容", + "notices.noContent": "暂无内容", + "notices.noRaw": "暂无原始数据", + "notices.noBody": "暂无可显示的正文", + "notices.noHeaders": "暂无标头", + "notices.noFrames": "尚未收到完整帧", + "notices.unknown": "未识别", + "notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断", + "connection.live": "实时连接中", + "connection.retrying": "实时连接正在重试", + "connection.refreshFailed": "刷新失败:{message}", + "connection.paused": "界面更新已暂停", + "connection.connectFailed": "连接失败:{message}", + "state.pending": "等待中", + "state.streaming": "传输中", + "state.completed": "已完成", + "state.error": "错误", + }, + "en-US": { + "app.title": "Cursor Protocol Debugger", + "status.connecting": "Connecting", + "status.running": "Proxy running", + "status.stopped": "Proxy stopped", + "actions.downloadCA": "Download proxy CA certificate", + "actions.caCertificate": "CA Certificate", + "actions.pause": "Pause UI updates", + "actions.resume": "Resume UI updates", + "actions.clear": "Clear", + "actions.copy": "Copy", + "actions.copied": "Copied", + "language.label": "Interface language", + "filters.region": "Request filters", + "filters.urlPlaceholder": "Filter by URL, message type, or status", + "filters.requestIdPlaceholder": "Filter by Request ID", + "filters.endpoint": "Endpoint filter", + "filters.all": "All", + "filters.sort": "Sort order", + "filters.ascending": "Oldest first", + "filters.descending": "Newest first", + "count.requests": "{count} requests", + "table.url": "URL", + "table.message": "Message", + "table.method": "Method", + "table.status": "Status", + "table.response": "Response", + "table.duration": "Duration", + "empty.waitingForCursor": "Waiting for requests from Cursor", + "splitter.resize": "Resize details area", + "selection.waiting": "No selection", + "selection.prompt": "Select a request to inspect its details", + "panel.request": "Request", + "panel.response": "Response", + "panel.requestDetails": "Request details", + "panel.responseDetails": "Response details", + "tabs.headers": "Headers", + "tabs.body": "Body", + "tabs.frames": "Frames", + "tabs.raw": "Raw", + "notices.noRequest": "No request content", + "notices.noResponse": "No response content", + "notices.noContent": "No content", + "notices.noRaw": "No raw data", + "notices.noBody": "No body available", + "notices.noHeaders": "No headers", + "notices.noFrames": "No complete frames received yet", + "notices.unknown": "Unknown", + "notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated", + "connection.live": "Live connection", + "connection.retrying": "Reconnecting live updates", + "connection.refreshFailed": "Refresh failed: {message}", + "connection.paused": "UI updates paused", + "connection.connectFailed": "Connection failed: {message}", + "state.pending": "Pending", + "state.streaming": "Streaming", + "state.completed": "Completed", + "state.error": "Error", + }, +}; + +function matchLocale(locale) { + const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase(); + if (!normalized) return ""; + const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized); + if (exact) return exact; + return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : ""; +} + +function resolveInitialLocale() { + const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY)); + if (stored) return stored; + for (const candidate of navigator.languages || [navigator.language]) { + const matched = matchLocale(candidate); + if (matched) return matched; + } + return DEFAULT_LOCALE; +} + +let currentLocale = resolveInitialLocale(); + +export function getLocale() { + return currentLocale; +} + +export function t(key, values = {}) { + const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key; + return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? "")); +} + +export function translateDocument(root = document) { + document.documentElement.lang = currentLocale; + document.title = t("app.title"); + for (const element of root.querySelectorAll("[data-i18n]")) { + element.textContent = t(element.dataset.i18n); + } + for (const [attribute, dataAttribute] of [ + ["aria-label", "i18nAriaLabel"], + ["placeholder", "i18nPlaceholder"], + ["title", "i18nTitle"], + ]) { + for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) { + element.setAttribute(attribute, t(element.dataset[dataAttribute])); + } + } +} + +export function setLocale(locale) { + currentLocale = matchLocale(locale) || DEFAULT_LOCALE; + window.localStorage.setItem(STORAGE_KEY, currentLocale); + translateDocument(); + return currentLocale; +} diff --git a/cursor-proxy-debugger/web/index.html b/cursor-proxy-debugger/web/index.html new file mode 100644 index 0000000..34eddb4 --- /dev/null +++ b/cursor-proxy-debugger/web/index.html @@ -0,0 +1,125 @@ + + + + + + + Cursor 协议调试器 + + + +
+
+
+ + Cursor 协议调试器 +
+
+ + 正在连接 + +
+
+ + CA 证书 + + +
+
+ +
+ + +
+ + + +
+
+ + +
+ 0 条 +
+ +
+
+ + + + + + + + + + + + + + + +
#网址Request ID消息方法状态响应耗时
+
等待来自 Cursor 的请求
+
+ + + +
+
+ POST + 等待选择 + 选择一条请求查看详情 +
+
+
+
+ 请求 + + +
+
+
+
+
+ 响应 + + +
+
+
+
+
+
+ +
+ 实时连接中 + ↑ 0 B ↓ 0 B + +
+
+ + + diff --git a/cursor-proxy-debugger/web/styles.css b/cursor-proxy-debugger/web/styles.css new file mode 100644 index 0000000..0f1082b --- /dev/null +++ b/cursor-proxy-debugger/web/styles.css @@ -0,0 +1,921 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #171818; + color: #dedfdd; + font-synthesis: none; + --surface-0: #171818; + --surface-1: #1d1f1f; + --surface-2: #242626; + --surface-3: #2c2f2f; + --border: #343737; + --border-strong: #454949; + --muted: #8d9390; + --text: #dedfdd; + --accent: #4ea58b; + --accent-soft: #25473d; + --cyan: #55a8ba; + --orange: #c88762; + --danger: #c56d65; + --selection: #245b73; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + background: var(--surface-0); +} + +button, +input, +select, +a { + font: inherit; + letter-spacing: 0; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +a:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: -1px; +} + +.app-shell { + display: grid; + grid-template-rows: 48px 46px minmax(0, 1fr) 26px; + min-width: 760px; + background: var(--surface-0); +} + +.topbar, +.filterbar, +.statusbar { + display: flex; + align-items: center; + border-color: var(--border); + background: var(--surface-1); +} + +.topbar { + justify-content: space-between; + gap: 18px; + padding: 0 14px; + border-bottom: 1px solid var(--border); +} + +.brand, +.runtime-status, +.toolbar-actions { + display: flex; + align-items: center; + min-width: 0; +} + +.brand { + gap: 9px; + white-space: nowrap; +} + +.brand strong { + font-size: 14px; + font-weight: 650; +} + +.brand-mark { + width: 12px; + height: 12px; + border: 2px solid var(--accent); + border-radius: 50%; + box-shadow: inset 0 0 0 2px var(--surface-1); + background: var(--accent); +} + +.runtime-status { + justify-content: center; + gap: 7px; + min-width: 240px; + color: #bec3c0; + font-size: 12px; +} + +.runtime-status code { + overflow: hidden; + max-width: 260px; + color: var(--muted); + font-family: var(--mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #7d8380; +} + +.status-dot.online { + background: #42bd79; + box-shadow: 0 0 0 3px rgb(66 189 121 / 14%); +} + +.toolbar-actions { + justify-content: flex-end; + gap: 7px; +} + +.locale-picker { + display: flex; +} + +.locale-picker select { + width: 58px; + height: 29px; + border: 1px solid var(--border-strong); + border-radius: 5px; + padding: 0 6px; + background: var(--surface-2); + color: var(--text); + cursor: pointer; + font-size: 12px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.button, +.icon-button, +.copy-button { + height: 29px; + border: 1px solid var(--border-strong); + border-radius: 5px; + background: var(--surface-2); + color: var(--text); + cursor: pointer; + text-decoration: none; +} + +.button { + display: inline-flex; + align-items: center; + padding: 0 10px; + font-size: 12px; +} + +.button:hover, +.icon-button:hover, +.copy-button:hover { + background: var(--surface-3); +} + +.button.danger:hover { + border-color: #744640; + color: #f2b0aa; +} + +.icon-button { + width: 31px; + padding: 0; + font-family: var(--mono); + font-weight: 700; +} + +.icon-button.active { + border-color: var(--orange); + color: #f1bb98; +} + +.filterbar { + gap: 10px; + padding: 7px 14px; + border-bottom: 1px solid var(--border); +} + +.search-box { + display: flex; + align-items: center; + flex: 1; + min-width: 260px; + max-width: 640px; + height: 31px; + border: 1px solid var(--border); + border-radius: 5px; + background: #191b1b; + color: var(--muted); +} + +.search-box > span { + padding-left: 9px; + font-size: 17px; +} + +.search-box input { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + padding: 0 9px; + outline: 0; + background: transparent; + color: var(--text); + font-size: 12px; +} + +.search-box input::placeholder { + color: #6f7572; +} + +.url-filter { + min-width: 280px; + max-width: 420px; +} + +.request-id-filter { + flex: 0 1 320px; + min-width: 220px; + max-width: 340px; +} + +.segmented-control { + display: flex; + height: 31px; + border: 1px solid var(--border); + border-radius: 5px; + overflow: hidden; +} + +.segmented-control button { + min-width: 62px; + border: 0; + border-right: 1px solid var(--border); + padding: 0 10px; + background: #1b1d1d; + color: var(--muted); + cursor: pointer; + font-size: 12px; +} + +.segmented-control button:last-child { + border-right: 0; +} + +.segmented-control button.active { + background: var(--accent-soft); + color: #bce8d9; +} + +.sort-control button { + min-width: 52px; +} + +.request-count { + margin-left: auto; + color: var(--muted); + font-family: var(--mono); + font-size: 11px; + white-space: nowrap; +} + +.workspace { + display: grid; + grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%); + min-height: 0; + overflow: hidden; +} + +.request-list-pane { + position: relative; + min-height: 0; + overflow: auto; + background: #181a1a; +} + +.request-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; +} + +.request-table thead { + position: sticky; + top: 0; + z-index: 2; + background: #202222; +} + +.request-table th, +.request-table td { + height: 30px; + border-right: 1px solid #2c2f2f; + border-bottom: 1px solid #292c2c; + padding: 0 9px; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.request-table th { + color: #9da29f; + font-weight: 550; +} + +.request-table tbody tr { + cursor: default; +} + +.request-table tbody tr:hover { + background: #222525; +} + +.request-table tbody tr.selected { + background: var(--selection); + color: #f3f8f8; +} + +.request-table code { + font-family: var(--mono); +} + +.status-column { + width: 30px; +} + +.index-column { + width: 54px; +} + +.request-id-column { + width: 250px; +} + +.kind-column { + width: 180px; +} + +.method-column { + width: 72px; +} + +.code-column { + width: 66px; +} + +.size-column { + width: 86px; +} + +.time-column { + width: 74px; +} + +.row-state { + display: block; + width: 8px; + height: 8px; + margin: auto; + border-radius: 50%; + background: #7c8380; +} + +.row-state.streaming { + background: #45ba77; +} + +.row-state.completed { + background: var(--cyan); +} + +.row-state.error { + background: var(--danger); +} + +.method-text { + color: #61b9df; + font-family: var(--mono); + font-weight: 650; +} + +.status-text.success { + color: #68c991; +} + +.status-text.error { + color: #e18b83; +} + +.kind-text { + color: #d3a17f; + font-family: var(--mono); +} + +.request-id-text { + color: #8bc2cc; +} + +.empty-state { + position: absolute; + inset: 34px 0 0; + display: grid; + place-items: center; + color: #686e6b; + font-size: 13px; +} + +.empty-state.hidden { + display: none; +} + +.horizontal-splitter { + cursor: row-resize; + background: #343737; +} + +.horizontal-splitter:hover, +.horizontal-splitter.dragging { + background: var(--cyan); +} + +.detail-pane { + display: grid; + grid-template-rows: 38px minmax(0, 1fr); + min-height: 0; + background: var(--surface-0); +} + +.selection-summary { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 0 14px; + border-bottom: 1px solid var(--border); + background: #1b1d1d; +} + +.selection-summary code { + overflow: hidden; + color: #aeb4b1; + font-family: var(--mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.method-badge, +.status-badge, +.frame-badge { + display: inline-flex; + align-items: center; + height: 22px; + border: 1px solid var(--border-strong); + border-radius: 4px; + padding: 0 7px; + font-family: var(--mono); + font-size: 11px; + white-space: nowrap; +} + +.method-badge { + border-color: #34667a; + color: #74c8e8; +} + +.status-badge.success { + border-color: #3f7157; + color: #83d5a5; +} + +.detail-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + min-height: 0; +} + +.payload-panel { + display: grid; + grid-template-rows: 36px minmax(0, 1fr); + min-width: 0; + min-height: 0; + border-right: 1px solid var(--border); +} + +.payload-panel:last-child { + border-right: 0; +} + +.panel-header { + display: flex; + align-items: center; + min-width: 0; + border-bottom: 1px solid var(--border); + background: #202222; +} + +.panel-header > strong { + padding: 0 10px; + color: #c9cdca; + font-size: 12px; +} + +.tabs { + display: flex; + align-self: stretch; +} + +.tabs button { + position: relative; + min-width: 46px; + border: 0; + padding: 0 9px; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 12px; +} + +.tabs button:hover { + color: #d7dad8; +} + +.tabs button.active { + color: #71c7e2; +} + +.tabs button.active::after { + position: absolute; + right: 8px; + bottom: 0; + left: 8px; + height: 2px; + background: var(--cyan); + content: ""; +} + +.copy-button { + width: 48px; + height: 24px; + margin-right: 7px; + margin-left: auto; + font-size: 11px; +} + +.panel-content { + min-height: 0; + overflow: auto; + background: #181a1a; +} + +.code-view, +.hex-view { + min-width: 100%; + min-height: 100%; + margin: 0; + padding: 12px 14px 30px; + color: #ccd1ce; + font: 11px/1.55 var(--mono); + tab-size: 2; + white-space: pre; +} + +.hex-view { + color: #b6c2bd; +} + +.headers-table { + width: 100%; + border-collapse: collapse; + font: 11px/1.4 var(--mono); +} + +.headers-table th, +.headers-table td { + border-bottom: 1px solid #292c2c; + padding: 7px 10px; + text-align: left; + vertical-align: top; +} + +.headers-table th { + width: 38%; + color: #62b3cd; + font-weight: 500; + overflow-wrap: anywhere; +} + +.headers-table td { + color: #c5c9c6; + overflow-wrap: anywhere; +} + +.frame-list { + min-width: 480px; +} + +.frame-item { + border-bottom: 1px solid #292c2c; +} + +.frame-item summary { + display: grid; + grid-template-columns: 58px minmax(150px, 1fr) 90px 82px; + align-items: center; + height: 32px; + padding: 0 10px; + color: #c5cac7; + cursor: pointer; + font: 11px var(--mono); + list-style: none; +} + +.frame-item summary::-webkit-details-marker { + display: none; +} + +.frame-item summary:hover { + background: #222525; +} + +.frame-item[open] summary { + background: #242727; +} + +.frame-index { + color: #747b77; +} + +.frame-kind { + overflow: hidden; + color: #dfaa85; + text-overflow: ellipsis; + white-space: nowrap; +} + +.frame-size, +.frame-flags { + color: #7faeb7; + text-align: right; +} + +.frame-error { + margin: 10px 14px; + color: #ec968e; + font: 11px/1.5 var(--mono); +} + +.notice { + padding: 14px; + color: #7d8581; + font: 12px/1.6 var(--mono); +} + +.notice.error { + color: #df8b83; +} + +.truncated-notice { + position: sticky; + bottom: 0; + padding: 5px 10px; + border-top: 1px solid #674f3f; + background: #3d3028; + color: #e5b28e; + font-size: 11px; +} + +.statusbar { + justify-content: flex-end; + gap: 16px; + padding: 0 10px; + border-top: 1px solid var(--border); + color: #848b87; + font: 10px var(--mono); +} + +.statusbar span:first-child { + margin-right: auto; +} + +@media (max-width: 920px) { + .app-shell { + grid-template-rows: 48px 84px minmax(0, 1fr) 26px; + min-width: 0; + } + + .filterbar { + align-content: center; + flex-wrap: wrap; + gap: 6px; + } + + .url-filter, + .request-id-filter { + flex: 1 1 300px; + max-width: none; + } + + .runtime-status code, + .kind-column, + .request-table td:nth-child(5) { + display: none; + } + + .detail-columns { + grid-template-columns: 1fr; + grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr); + overflow: auto; + } + + .payload-panel { + min-height: 260px; + border-right: 0; + border-bottom: 1px solid var(--border); + } +} + +@media (max-width: 640px) { + .app-shell { + grid-template-rows: 82px 122px minmax(0, 1fr) 26px; + } + + .topbar { + position: relative; + align-content: center; + flex-wrap: wrap; + gap: 4px 10px; + padding: 8px 10px; + } + + .brand { + flex: 1; + overflow: hidden; + } + + .brand strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + } + + .runtime-status { + order: 3; + justify-content: flex-start; + width: 100%; + min-width: 0; + } + + .runtime-status code { + display: block; + max-width: none; + } + + .toolbar-actions { + gap: 4px; + } + + .toolbar-actions .button { + padding: 0 7px; + } + + .filterbar { + align-content: center; + flex-wrap: wrap; + gap: 6px; + padding: 7px 10px; + } + + .url-filter, + .request-id-filter { + flex: 0 0 100%; + width: 100%; + min-width: 0; + max-width: none; + } + + .request-count { + order: 5; + margin-left: auto; + } + + #endpoint-filter { + order: 3; + flex: 1; + } + + .sort-control { + order: 4; + flex: 0 0 104px; + } + + .segmented-control button { + flex: 1; + min-width: 0; + } + + .workspace { + grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%); + } + + .request-table th, + .request-table td { + padding: 0 6px; + } + + .request-table .index-column, + .request-table th:nth-child(2), + .request-table td:nth-child(2), + .size-column, + .request-table th:nth-child(8), + .request-table td:nth-child(8), + .time-column, + .request-table th:nth-child(9), + .request-table td:nth-child(9) { + display: none; + } + + .request-id-column { + width: 130px; + } + + .method-column { + width: 58px; + } + + .code-column { + width: 50px; + } + + .selection-summary { + padding: 0 8px; + } + + .detail-columns { + grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr); + } + + .panel-header > strong { + width: 72px; + padding: 0 7px; + font-size: 11px; + } + + .tabs { + overflow-x: auto; + } + + .tabs button { + min-width: 42px; + padding: 0 6px; + } + + .copy-button { + width: 42px; + margin-right: 4px; + } + + .frame-list { + min-width: 0; + } + + .frame-item summary { + grid-template-columns: 42px minmax(100px, 1fr) 62px 72px; + padding: 0 7px; + } + + .statusbar { + gap: 8px; + } + + #target-host { + display: none; + } +}