feat: auto-discover Lingma transport endpoints
This commit is contained in:
@@ -4,12 +4,14 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -35,6 +37,12 @@ type DialOptions struct {
|
|||||||
WebSocketURL string
|
WebSocketURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sharedClientInfo struct {
|
||||||
|
WebSocketPort int `json:"websocketPort"`
|
||||||
|
PID int `json:"pid"`
|
||||||
|
IPCServerPath string `json:"ipcServerPath"`
|
||||||
|
}
|
||||||
|
|
||||||
type framedTransport interface {
|
type framedTransport interface {
|
||||||
ReadFrame() ([]byte, error)
|
ReadFrame() ([]byte, error)
|
||||||
WriteFrame([]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 != "" {
|
if pipe := strings.TrimSpace(os.Getenv("LINGMA_IPC_PIPE")); pipe != "" {
|
||||||
return normalizePipePath(pipe), nil
|
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)
|
entries, err := os.ReadDir(PipeDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,10 +142,32 @@ func ResolveWebSocketURL(explicit string) (string, error) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
value = strings.TrimSpace(os.Getenv("LINGMA_PROXY_WS_URL"))
|
value = strings.TrimSpace(os.Getenv("LINGMA_PROXY_WS_URL"))
|
||||||
}
|
}
|
||||||
if value == "" {
|
if value != "" {
|
||||||
return "", errors.New("no Lingma websocket URL configured")
|
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)
|
parsed, err := url.Parse(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse Lingma websocket URL %q: %w", value, err)
|
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
|
return parsed.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasConfiguredWebSocketURL(explicit string) bool {
|
func resolveSharedClientInfo() (sharedClientInfo, error) {
|
||||||
return strings.TrimSpace(explicit) != "" || strings.TrimSpace(os.Getenv("LINGMA_PROXY_WS_URL")) != ""
|
return resolveSharedClientInfoFromPaths(defaultSharedClientInfoPaths())
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePipePath(pipe string) string {
|
func defaultSharedClientInfoPaths() []string {
|
||||||
if strings.HasPrefix(pipe, PipeDir) {
|
bases := make([]string, 0, 2)
|
||||||
return pipe
|
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) {
|
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