feat: auto-discover Lingma transport endpoints
This commit is contained in:
@@ -4,12 +4,14 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -35,6 +37,12 @@ type DialOptions struct {
|
||||
WebSocketURL string
|
||||
}
|
||||
|
||||
type sharedClientInfo struct {
|
||||
WebSocketPort int `json:"websocketPort"`
|
||||
PID int `json:"pid"`
|
||||
IPCServerPath string `json:"ipcServerPath"`
|
||||
}
|
||||
|
||||
type framedTransport interface {
|
||||
ReadFrame() ([]byte, error)
|
||||
WriteFrame([]byte) error
|
||||
@@ -104,6 +112,11 @@ func ResolvePipePath(explicit string) (string, error) {
|
||||
if pipe := strings.TrimSpace(os.Getenv("LINGMA_IPC_PIPE")); pipe != "" {
|
||||
return normalizePipePath(pipe), nil
|
||||
}
|
||||
if info, err := resolveSharedClientInfo(); err == nil {
|
||||
if pipe := strings.TrimSpace(info.IPCServerPath); pipe != "" {
|
||||
return normalizePipePath(pipe), nil
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(PipeDir)
|
||||
if err != nil {
|
||||
@@ -129,10 +142,32 @@ func ResolveWebSocketURL(explicit string) (string, error) {
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(os.Getenv("LINGMA_PROXY_WS_URL"))
|
||||
}
|
||||
if value == "" {
|
||||
return "", errors.New("no Lingma websocket URL configured")
|
||||
if value != "" {
|
||||
return normalizeWebSocketURL(value)
|
||||
}
|
||||
|
||||
info, err := resolveSharedClientInfo()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("discover Lingma websocket URL: %w", err)
|
||||
}
|
||||
if info.WebSocketPort <= 0 {
|
||||
return "", errors.New("Lingma shared client info does not include a websocketPort")
|
||||
}
|
||||
return normalizeWebSocketURL(fmt.Sprintf("ws://127.0.0.1:%d/", info.WebSocketPort))
|
||||
}
|
||||
|
||||
func hasConfiguredWebSocketURL(explicit string) bool {
|
||||
return strings.TrimSpace(explicit) != "" || strings.TrimSpace(os.Getenv("LINGMA_PROXY_WS_URL")) != ""
|
||||
}
|
||||
|
||||
func normalizePipePath(pipe string) string {
|
||||
if strings.HasPrefix(pipe, PipeDir) {
|
||||
return pipe
|
||||
}
|
||||
return PipeDir + pipe
|
||||
}
|
||||
|
||||
func normalizeWebSocketURL(value string) (string, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse Lingma websocket URL %q: %w", value, err)
|
||||
@@ -149,15 +184,116 @@ func ResolveWebSocketURL(explicit string) (string, error) {
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func hasConfiguredWebSocketURL(explicit string) bool {
|
||||
return strings.TrimSpace(explicit) != "" || strings.TrimSpace(os.Getenv("LINGMA_PROXY_WS_URL")) != ""
|
||||
func resolveSharedClientInfo() (sharedClientInfo, error) {
|
||||
return resolveSharedClientInfoFromPaths(defaultSharedClientInfoPaths())
|
||||
}
|
||||
|
||||
func normalizePipePath(pipe string) string {
|
||||
if strings.HasPrefix(pipe, PipeDir) {
|
||||
return pipe
|
||||
func defaultSharedClientInfoPaths() []string {
|
||||
bases := make([]string, 0, 2)
|
||||
if appData := strings.TrimSpace(os.Getenv("APPDATA")); appData != "" {
|
||||
bases = append(bases, appData)
|
||||
}
|
||||
return PipeDir + pipe
|
||||
if userConfigDir, err := os.UserConfigDir(); err == nil && strings.TrimSpace(userConfigDir) != "" {
|
||||
bases = append(bases, userConfigDir)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
paths := make([]string, 0, len(bases)*2)
|
||||
for _, base := range bases {
|
||||
cacheDir := filepath.Join(base, "Lingma", "SharedClientCache")
|
||||
for _, name := range []string{".info.json", ".info"} {
|
||||
path := filepath.Join(cacheDir, name)
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func resolveSharedClientInfoFromPaths(paths []string) (sharedClientInfo, error) {
|
||||
var parseErrors []string
|
||||
foundFile := false
|
||||
|
||||
for _, path := range paths {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
parseErrors = append(parseErrors, fmt.Sprintf("%s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
foundFile = true
|
||||
|
||||
info, err := parseSharedClientInfo(body)
|
||||
if err != nil {
|
||||
parseErrors = append(parseErrors, fmt.Sprintf("%s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
if info.WebSocketPort <= 0 && strings.TrimSpace(info.IPCServerPath) == "" {
|
||||
parseErrors = append(parseErrors, fmt.Sprintf("%s: no websocketPort or ipcServerPath present", path))
|
||||
continue
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
if !foundFile {
|
||||
return sharedClientInfo{}, errors.New("no Lingma shared client cache info file was found")
|
||||
}
|
||||
if len(parseErrors) == 0 {
|
||||
return sharedClientInfo{}, errors.New("Lingma shared client cache info was empty")
|
||||
}
|
||||
return sharedClientInfo{}, errors.New(strings.Join(parseErrors, "; "))
|
||||
}
|
||||
|
||||
func parseSharedClientInfo(body []byte) (sharedClientInfo, error) {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
return sharedClientInfo{}, errors.New("file is empty")
|
||||
}
|
||||
if trimmed[0] == '{' {
|
||||
var info sharedClientInfo
|
||||
if err := json.Unmarshal(trimmed, &info); err != nil {
|
||||
return sharedClientInfo{}, fmt.Errorf("parse JSON shared client info: %w", err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
return parseLegacySharedClientInfo(string(trimmed))
|
||||
}
|
||||
|
||||
func parseLegacySharedClientInfo(body string) (sharedClientInfo, error) {
|
||||
lines := make([]string, 0, 3)
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return sharedClientInfo{}, errors.New("legacy shared client info is empty")
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(lines[0])
|
||||
if err != nil {
|
||||
return sharedClientInfo{}, fmt.Errorf("parse legacy websocket port %q: %w", lines[0], err)
|
||||
}
|
||||
info := sharedClientInfo{WebSocketPort: port}
|
||||
|
||||
if len(lines) >= 2 {
|
||||
pid, err := strconv.Atoi(lines[1])
|
||||
if err != nil {
|
||||
return sharedClientInfo{}, fmt.Errorf("parse legacy pid %q: %w", lines[1], err)
|
||||
}
|
||||
info.PID = pid
|
||||
}
|
||||
if len(lines) >= 3 {
|
||||
info.IPCServerPath = lines[2]
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func connectTransport(ctx context.Context, opts DialOptions) (framedTransport, error) {
|
||||
|
||||
63
internal/lingmaipc/transport_test.go
Normal file
63
internal/lingmaipc/transport_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package lingmaipc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveSharedClientInfoFromJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".info.json")
|
||||
content := `{"websocketPort":36510,"pid":14060,"ipcServerPath":"\\\\.\\pipe\\lingma-bf0f32","isDev":false}`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write shared info json: %v", err)
|
||||
}
|
||||
|
||||
info, err := resolveSharedClientInfoFromPaths([]string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve shared info json: %v", err)
|
||||
}
|
||||
if info.WebSocketPort != 36510 {
|
||||
t.Fatalf("unexpected websocket port: %d", info.WebSocketPort)
|
||||
}
|
||||
if info.PID != 14060 {
|
||||
t.Fatalf("unexpected pid: %d", info.PID)
|
||||
}
|
||||
if info.IPCServerPath != `\\.\pipe\lingma-bf0f32` {
|
||||
t.Fatalf("unexpected pipe path: %q", info.IPCServerPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSharedClientInfoFromLegacyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".info")
|
||||
content := "36510\n14060\n\\\\.\\pipe\\lingma-bf0f32\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write shared info legacy: %v", err)
|
||||
}
|
||||
|
||||
info, err := resolveSharedClientInfoFromPaths([]string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve shared info legacy: %v", err)
|
||||
}
|
||||
if info.WebSocketPort != 36510 {
|
||||
t.Fatalf("unexpected websocket port: %d", info.WebSocketPort)
|
||||
}
|
||||
if info.PID != 14060 {
|
||||
t.Fatalf("unexpected pid: %d", info.PID)
|
||||
}
|
||||
if info.IPCServerPath != `\\.\pipe\lingma-bf0f32` {
|
||||
t.Fatalf("unexpected pipe path: %q", info.IPCServerPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWebSocketURLAddsRootPath(t *testing.T) {
|
||||
got, err := normalizeWebSocketURL("ws://127.0.0.1:36510")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize websocket url: %v", err)
|
||||
}
|
||||
if got != "ws://127.0.0.1:36510/" {
|
||||
t.Fatalf("unexpected normalized websocket url: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user