From 96fb6dea5c686c56b503d74cf1b5386bda648525 Mon Sep 17 00:00:00 2001 From: onedotmint <3420725328@qq.com> Date: Sat, 8 Aug 2026 13:46:09 +0800 Subject: [PATCH 1/2] feat: add WSL distribution detection --- app.go | 5 ++ frontend/wailsjs/go/main/App.d.ts | 3 + frontend/wailsjs/go/main/App.js | 4 ++ frontend/wailsjs/go/models.ts | 19 ++++++ internal/wsl/detection.go | 54 +++++++++++++++ internal/wsl/detection_stub.go | 7 ++ internal/wsl/detection_test.go | 109 ++++++++++++++++++++++++++++++ internal/wsl/detection_windows.go | 33 +++++++++ 8 files changed, 234 insertions(+) create mode 100644 internal/wsl/detection.go create mode 100644 internal/wsl/detection_stub.go create mode 100644 internal/wsl/detection_test.go create mode 100644 internal/wsl/detection_windows.go diff --git a/app.go b/app.go index b6c2bb0..29c4cd5 100644 --- a/app.go +++ b/app.go @@ -20,6 +20,7 @@ import ( "piswitch/internal/provider" "piswitch/internal/system" "piswitch/internal/updater" + "piswitch/internal/wsl" ) const appVersion = "0.0.0.14" @@ -242,6 +243,10 @@ func (a *App) GetAppState() (config.AppState, error) { }, nil } +func (a *App) GetWSLDetection() (wsl.Detection, error) { + return wsl.Detect() +} + func (a *App) ListProviders() ([]provider.Config, error) { cfg, err := a.coordinator.Load() if err != nil { diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 9cb684b..ade6ec9 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -4,6 +4,7 @@ import {system} from '../models'; import {updater} from '../models'; import {provider} from '../models'; import {config} from '../models'; +import {wsl} from '../models'; import {pi} from '../models'; export function CheckEnvVar(arg1:string):Promise; @@ -20,6 +21,8 @@ export function FetchModels(arg1:string):Promise>; export function GetAppState():Promise; +export function GetWSLDetection():Promise; + export function ImportModels(arg1:string,arg2:Array):Promise; export function InstallUpdate():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 2ce0748..c872e1c 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -30,6 +30,10 @@ export function GetAppState() { return window['go']['main']['App']['GetAppState'](); } +export function GetWSLDetection() { + return window['go']['main']['App']['GetWSLDetection'](); +} + export function ImportModels(arg1, arg2) { return window['go']['main']['App']['ImportModels'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 527bd01..0ee5ee1 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -237,3 +237,22 @@ export namespace updater { } +export namespace wsl { + + export class Detection { + detected: boolean; + distros: string[]; + + static createFrom(source: any = {}) { + return new Detection(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.detected = source["detected"]; + this.distros = source["distros"]; + } + } + +} + diff --git a/internal/wsl/detection.go b/internal/wsl/detection.go new file mode 100644 index 0000000..c124b9b --- /dev/null +++ b/internal/wsl/detection.go @@ -0,0 +1,54 @@ +package wsl + +import ( + "errors" + "strings" + "unicode/utf16" +) + +type Detection struct { + Detected bool `json:"detected"` + Distros []string `json:"distros"` +} + +func parseDistroList(data []byte) ([]string, error) { + if len(data) >= 2 && data[0] == 0xff && data[1] == 0xfe { + data = data[2:] + } + if len(data)%2 != 0 { + return nil, errors.New("WSL 发行版列表不是有效的 UTF-16LE 输出") + } + + units := make([]uint16, len(data)/2) + for i := range units { + units[i] = uint16(data[i*2]) | uint16(data[i*2+1])<<8 + } + if err := validateUTF16(units); err != nil { + return nil, err + } + + distros := make([]string, 0) + for _, line := range strings.Split(string(utf16.Decode(units)), "\n") { + distro := strings.TrimSpace(strings.Trim(line, "\x00")) + if distro != "" { + distros = append(distros, distro) + } + } + return distros, nil +} + +func validateUTF16(units []uint16) error { + for index, unit := range units { + switch { + case unit >= 0xd800 && unit <= 0xdbff: + if index+1 >= len(units) || units[index+1] < 0xdc00 || units[index+1] > 0xdfff { + return errors.New("WSL 发行版列表包含无效的 UTF-16 代理项") + } + case unit >= 0xdc00 && unit <= 0xdfff: + if index == 0 || units[index-1] < 0xd800 || units[index-1] > 0xdbff { + return errors.New("WSL 发行版列表包含无效的 UTF-16 代理项") + } + } + } + return nil +} diff --git a/internal/wsl/detection_stub.go b/internal/wsl/detection_stub.go new file mode 100644 index 0000000..e08e15f --- /dev/null +++ b/internal/wsl/detection_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package wsl + +func Detect() (Detection, error) { + return Detection{Distros: []string{}}, nil +} diff --git a/internal/wsl/detection_test.go b/internal/wsl/detection_test.go new file mode 100644 index 0000000..cb3ba06 --- /dev/null +++ b/internal/wsl/detection_test.go @@ -0,0 +1,109 @@ +package wsl + +import ( + "encoding/json" + "reflect" + "runtime" + "testing" + "unicode/utf16" +) + +func TestParseDistroList(t *testing.T) { + tests := []struct { + name string + data []byte + want []string + }{ + { + name: "one distro", + data: utf16LE("Ubuntu\r\n", false), + want: []string{"Ubuntu"}, + }, + { + name: "multiple distros", + data: utf16LE("Ubuntu\r\nDebian\r\n", false), + want: []string{"Ubuntu", "Debian"}, + }, + { + name: "BOM whitespace and spaces in name", + data: utf16LE("\r\n Ubuntu \r\nUbuntu 24.04 LTS\r\n", true), + want: []string{"Ubuntu", "Ubuntu 24.04 LTS"}, + }, + { + name: "supplementary plane Unicode", + data: utf16LE("Ubuntu \U0001F680\r\n", false), + want: []string{"Ubuntu \U0001F680"}, + }, + { + name: "terminal NUL", + data: utf16LE("Debian\r\n\x00", false), + want: []string{"Debian"}, + }, + { + name: "empty output", + data: []byte{}, + want: []string{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := parseDistroList(test.data) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("parseDistroList() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestParseDistroList_InvalidUTF16(t *testing.T) { + tests := [][]byte{ + {0x55}, + {0x00, 0xd8}, + {0x00, 0xdc}, + } + + for _, data := range tests { + if _, err := parseDistroList(data); err == nil { + t.Errorf("parseDistroList(% x) error = nil, want error", data) + } + } +} + +func TestDetectionJSONEmptyDistros(t *testing.T) { + data, err := json.Marshal(Detection{Distros: []string{}}) + if err != nil { + t.Fatal(err) + } + if string(data) != `{"detected":false,"distros":[]}` { + t.Errorf("json.Marshal(Detection{}) = %s, want empty distros array", data) + } +} + +func TestDetect_NonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the Windows implementation requires a WSL installation") + } + + detection, err := Detect() + if err != nil { + t.Fatal(err) + } + if detection.Detected || !reflect.DeepEqual(detection.Distros, []string{}) { + t.Errorf("Detect() = %#v, want unavailable empty result", detection) + } +} + +func utf16LE(text string, bom bool) []byte { + data := make([]byte, 0, len(text)*2+2) + if bom { + data = append(data, 0xff, 0xfe) + } + for _, unit := range utf16.Encode([]rune(text)) { + data = append(data, byte(unit), byte(unit>>8)) + } + return data +} diff --git a/internal/wsl/detection_windows.go b/internal/wsl/detection_windows.go new file mode 100644 index 0000000..b89f6a7 --- /dev/null +++ b/internal/wsl/detection_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package wsl + +import ( + "errors" + "fmt" + "os/exec" + "syscall" +) + +const createNoWindow uint32 = 0x08000000 + +func Detect() (Detection, error) { + unavailable := Detection{Distros: []string{}} + + cmd := exec.Command("wsl.exe", "--list", "--quiet") + // Prevent a console window for this noninteractive WSL query. + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow} + output, err := cmd.Output() + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return unavailable, nil + } + return unavailable, fmt.Errorf("查询 WSL 发行版失败: %w", err) + } + + distros, err := parseDistroList(output) + if err != nil { + return unavailable, fmt.Errorf("无法解析 WSL 发行版列表: %w", err) + } + return Detection{Detected: true, Distros: distros}, nil +} From aa69cda02f3cdb1b9053b9decdba9ce2192446b6 Mon Sep 17 00:00:00 2001 From: onedotmint <3420725328@qq.com> Date: Sun, 9 Aug 2026 11:30:21 +0800 Subject: [PATCH 2/2] feat: detect Pi availability in WSL distro --- app.go | 4 + frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 26 +++ internal/wsl/pi_detection.go | 141 ++++++++++++ internal/wsl/pi_detection_stub.go | 9 + internal/wsl/pi_detection_test.go | 265 ++++++++++++++++++++++ internal/wsl/pi_detection_windows.go | 23 ++ internal/wsl/pi_detection_windows_test.go | 36 +++ 9 files changed, 510 insertions(+) create mode 100644 internal/wsl/pi_detection.go create mode 100644 internal/wsl/pi_detection_stub.go create mode 100644 internal/wsl/pi_detection_test.go create mode 100644 internal/wsl/pi_detection_windows.go create mode 100644 internal/wsl/pi_detection_windows_test.go diff --git a/app.go b/app.go index 29c4cd5..4a7df60 100644 --- a/app.go +++ b/app.go @@ -247,6 +247,10 @@ func (a *App) GetWSLDetection() (wsl.Detection, error) { return wsl.Detect() } +func (a *App) GetWSLPiDetection(distro string) (wsl.PiDetection, error) { + return wsl.DetectPi(distro) +} + func (a *App) ListProviders() ([]provider.Config, error) { cfg, err := a.coordinator.Load() if err != nil { diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index ade6ec9..ca981e3 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -23,6 +23,8 @@ export function GetAppState():Promise; export function GetWSLDetection():Promise; +export function GetWSLPiDetection(arg1:string):Promise; + export function ImportModels(arg1:string,arg2:Array):Promise; export function InstallUpdate():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index c872e1c..6e1501a 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -34,6 +34,10 @@ export function GetWSLDetection() { return window['go']['main']['App']['GetWSLDetection'](); } +export function GetWSLPiDetection(arg1) { + return window['go']['main']['App']['GetWSLPiDetection'](arg1); +} + export function ImportModels(arg1, arg2) { return window['go']['main']['App']['ImportModels'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 0ee5ee1..7f3121a 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -253,6 +253,32 @@ export namespace wsl { this.distros = source["distros"]; } } + export class PiDetection { + distro: string; + home: string; + piAvailable: boolean; + piPath: string; + piHome: string; + piHomeExists: boolean; + settingsExists: boolean; + modelsExists: boolean; + + static createFrom(source: any = {}) { + return new PiDetection(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.distro = source["distro"]; + this.home = source["home"]; + this.piAvailable = source["piAvailable"]; + this.piPath = source["piPath"]; + this.piHome = source["piHome"]; + this.piHomeExists = source["piHomeExists"]; + this.settingsExists = source["settingsExists"]; + this.modelsExists = source["modelsExists"]; + } + } } diff --git a/internal/wsl/pi_detection.go b/internal/wsl/pi_detection.go new file mode 100644 index 0000000..24f4db3 --- /dev/null +++ b/internal/wsl/pi_detection.go @@ -0,0 +1,141 @@ +package wsl + +import ( + "bytes" + "fmt" + "path" + "strings" + "unicode/utf8" +) + +const piDetectionFieldCount = 6 + +const piDetectionScript = `home=${HOME-} +pi_found=0 +if command -v pi >/dev/null 2>&1; then +pi_found=1 +fi +pi_home="$home/.pi" +pi_home_exists=0 +settings_exists=0 +models_exists=0 +[ -d "$pi_home" ] && pi_home_exists=1 +[ -f "$pi_home/agent/settings.json" ] && settings_exists=1 +[ -f "$pi_home/agent/models.json" ] && models_exists=1 +printf '%s\000' "$home" +if [ "$pi_found" = 1 ]; then +command -v pi | head -c -1 || exit 65 +fi +printf '\000%s\000%s\000%s\000%s\000' "$pi_home" "$pi_home_exists" "$settings_exists" "$models_exists" +` + +type PiDetection struct { + Distro string `json:"distro"` + Home string `json:"home"` + PiAvailable bool `json:"piAvailable"` + PiPath string `json:"piPath"` + PiHome string `json:"piHome"` + PiHomeExists bool `json:"piHomeExists"` + SettingsExists bool `json:"settingsExists"` + ModelsExists bool `json:"modelsExists"` +} + +type piProbe func(string) ([]byte, error) + +func detectPi(distro string, detect func() (Detection, error), probe piProbe) (PiDetection, error) { + if strings.TrimSpace(distro) == "" { + return PiDetection{}, fmt.Errorf("WSL distribution name is required") + } + + detection, err := detect() + if err != nil { + return PiDetection{}, fmt.Errorf("detect WSL distributions: %w", err) + } + if !detection.Detected { + return PiDetection{}, fmt.Errorf("WSL is unavailable") + } + if !containsDistro(detection.Distros, distro) { + return PiDetection{}, fmt.Errorf("unknown WSL distribution %q", distro) + } + + data, err := probe(distro) + if err != nil { + return PiDetection{}, fmt.Errorf("probe Pi in WSL distribution %q: %w", distro, err) + } + detectionResult, err := parsePiDetection(data) + if err != nil { + return PiDetection{}, fmt.Errorf("parse WSL Pi detection result: %w", err) + } + detectionResult.Distro = distro + return detectionResult, nil +} + +func containsDistro(distros []string, target string) bool { + for _, distro := range distros { + if distro == target { + return true + } + } + return false +} + +func parsePiDetection(data []byte) (PiDetection, error) { + if !utf8.Valid(data) { + return PiDetection{}, fmt.Errorf("probe output is not valid UTF-8") + } + if len(data) == 0 || data[len(data)-1] != 0 { + return PiDetection{}, fmt.Errorf("probe output must end with NUL") + } + + fields := bytes.Split(data[:len(data)-1], []byte{0}) + if len(fields) != piDetectionFieldCount { + return PiDetection{}, fmt.Errorf("probe output has %d fields, want %d", len(fields), piDetectionFieldCount) + } + + home := string(fields[0]) + piPath := string(fields[1]) + piHome := string(fields[2]) + if !path.IsAbs(home) { + return PiDetection{}, fmt.Errorf("home path is not absolute") + } + if piPath != "" && !path.IsAbs(piPath) { + return PiDetection{}, fmt.Errorf("Pi path is not absolute") + } + if !path.IsAbs(piHome) { + return PiDetection{}, fmt.Errorf("Pi home path is not absolute") + } + + piHomeExists, err := parseProbeFlag(fields[3]) + if err != nil { + return PiDetection{}, fmt.Errorf("parse Pi home state: %w", err) + } + settingsExists, err := parseProbeFlag(fields[4]) + if err != nil { + return PiDetection{}, fmt.Errorf("parse settings state: %w", err) + } + modelsExists, err := parseProbeFlag(fields[5]) + if err != nil { + return PiDetection{}, fmt.Errorf("parse models state: %w", err) + } + + return PiDetection{ + Home: home, + PiAvailable: piPath != "", + PiPath: piPath, + PiHome: piHome, + PiHomeExists: piHomeExists, + SettingsExists: settingsExists, + ModelsExists: modelsExists, + }, nil +} + +func parseProbeFlag(value []byte) (bool, error) { + switch string(value) { + case "0": + return false, nil + case "1": + return true, nil + default: + return false, fmt.Errorf("invalid flag %q", value) + } +} diff --git a/internal/wsl/pi_detection_stub.go b/internal/wsl/pi_detection_stub.go new file mode 100644 index 0000000..30d0be3 --- /dev/null +++ b/internal/wsl/pi_detection_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package wsl + +import "fmt" + +func DetectPi(string) (PiDetection, error) { + return PiDetection{}, fmt.Errorf("WSL Pi detection is supported only on Windows") +} diff --git a/internal/wsl/pi_detection_test.go b/internal/wsl/pi_detection_test.go new file mode 100644 index 0000000..508bbe8 --- /dev/null +++ b/internal/wsl/pi_detection_test.go @@ -0,0 +1,265 @@ +package wsl + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestParsePiDetection(t *testing.T) { + tests := []struct { + name string + fields []string + want PiDetection + }{ + { + name: "complete result", + fields: []string{"/home/dotmint", "/usr/local/bin/pi", "/home/dotmint/.pi", "1", "1", "1"}, + want: PiDetection{ + Home: "/home/dotmint", + PiAvailable: true, + PiPath: "/usr/local/bin/pi", + PiHome: "/home/dotmint/.pi", + PiHomeExists: true, + SettingsExists: true, + ModelsExists: true, + }, + }, + { + name: "Pi unavailable with configuration directory", + fields: []string{"/home/dotmint", "", "/home/dotmint/.pi", "1", "0", "1"}, + want: PiDetection{ + Home: "/home/dotmint", + PiHome: "/home/dotmint/.pi", + PiHomeExists: true, + ModelsExists: true, + }, + }, + { + name: "root home with absent Pi paths", + fields: []string{"/root", "", "/root/.pi", "0", "0", "0"}, + want: PiDetection{ + Home: "/root", + PiHome: "/root/.pi", + }, + }, + { + name: "paths containing whitespace and newline", + fields: []string{"/srv/users/first last\nuser", "/opt/pi tools\ncurrent/pi\n", "/srv/users/first last\nuser/.pi", "1", "1", "0"}, + want: PiDetection{ + Home: "/srv/users/first last\nuser", + PiAvailable: true, + PiPath: "/opt/pi tools\ncurrent/pi\n", + PiHome: "/srv/users/first last\nuser/.pi", + PiHomeExists: true, + SettingsExists: true, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := parsePiDetection(probeRecord(test.fields...)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("parsePiDetection() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestParsePiDetection_InvalidOutput(t *testing.T) { + tests := []struct { + name string + data []byte + }{ + { + name: "invalid UTF-8", + data: []byte{0xff, 0}, + }, + { + name: "missing terminal NUL", + data: []byte("/home/dotmint\x00\x00/home/dotmint/.pi\x000\x000\x000"), + }, + { + name: "wrong field count", + data: probeRecord("/home/dotmint", "", "/home/dotmint/.pi", "0", "0"), + }, + { + name: "invalid flag", + data: probeRecord("/home/dotmint", "", "/home/dotmint/.pi", "2", "0", "0"), + }, + { + name: "empty home", + data: probeRecord("", "", "/home/dotmint/.pi", "0", "0", "0"), + }, + { + name: "relative home", + data: probeRecord("home/dotmint", "", "/home/dotmint/.pi", "0", "0", "0"), + }, + { + name: "relative Pi path", + data: probeRecord("/home/dotmint", "pi", "/home/dotmint/.pi", "0", "0", "0"), + }, + { + name: "relative Pi home", + data: probeRecord("/home/dotmint", "", ".pi", "0", "0", "0"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := parsePiDetection(test.data); err == nil { + t.Fatal("parsePiDetection() error = nil, want error") + } + }) + } +} + +func TestDetectPi(t *testing.T) { + validProbe := func(string) ([]byte, error) { + return probeRecord("/home/dotmint", "/usr/local/bin/pi", "/home/dotmint/.pi", "1", "1", "1"), nil + } + validDetect := func() (Detection, error) { + return Detection{Detected: true, Distros: []string{"Ubuntu"}}, nil + } + + t.Run("complete result", func(t *testing.T) { + got, err := detectPi("Ubuntu", validDetect, validProbe) + if err != nil { + t.Fatal(err) + } + if got.Distro != "Ubuntu" || !got.PiAvailable || got.PiPath != "/usr/local/bin/pi" { + t.Errorf("detectPi() = %#v, want complete Ubuntu Pi result", got) + } + }) + + t.Run("empty distro skips detection", func(t *testing.T) { + called := false + _, err := detectPi(" ", func() (Detection, error) { + called = true + return Detection{}, nil + }, validProbe) + if err == nil || !strings.Contains(err.Error(), "required") { + t.Errorf("detectPi() error = %v, want required distro error", err) + } + if called { + t.Error("Detect() was called for an empty distro") + } + }) + + t.Run("detection error", func(t *testing.T) { + _, err := detectPi("Ubuntu", func() (Detection, error) { + return Detection{}, errors.New("list failed") + }, validProbe) + if err == nil || !strings.Contains(err.Error(), "detect WSL distributions") { + t.Errorf("detectPi() error = %v, want contextual detection error", err) + } + }) + + t.Run("WSL unavailable skips probe", func(t *testing.T) { + called := false + _, err := detectPi("Ubuntu", func() (Detection, error) { + return Detection{Distros: []string{}}, nil + }, func(string) ([]byte, error) { + called = true + return nil, nil + }) + if err == nil || !strings.Contains(err.Error(), "unavailable") { + t.Errorf("detectPi() error = %v, want unavailable WSL error", err) + } + if called { + t.Error("probe was called while WSL was unavailable") + } + }) + + t.Run("unknown distro skips probe", func(t *testing.T) { + called := false + _, err := detectPi("Debian", validDetect, func(string) ([]byte, error) { + called = true + return nil, nil + }) + if err == nil || !strings.Contains(err.Error(), `unknown WSL distribution "Debian"`) { + t.Errorf("detectPi() error = %v, want unknown distro error", err) + } + if called { + t.Error("probe was called for an unknown distro") + } + }) + + t.Run("probe error", func(t *testing.T) { + _, err := detectPi("Ubuntu", validDetect, func(string) ([]byte, error) { + return nil, errors.New("probe failed") + }) + if err == nil || !strings.Contains(err.Error(), `probe Pi in WSL distribution "Ubuntu"`) { + t.Errorf("detectPi() error = %v, want contextual probe error", err) + } + }) + + t.Run("malformed probe output", func(t *testing.T) { + _, err := detectPi("Ubuntu", validDetect, func(string) ([]byte, error) { + return []byte("not a probe record"), nil + }) + if err == nil || !strings.Contains(err.Error(), "parse WSL Pi detection result") { + t.Errorf("detectPi() error = %v, want contextual parse error", err) + } + }) +} + +func TestPiDetectionScriptProtocol(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the probe script is exercised with POSIX sh on non-Windows hosts") + } + + home := t.TempDir() + binDir := t.TempDir() + piPath := filepath.Join(binDir, "pi") + if err := os.WriteFile(piPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + piHome := filepath.Join(home, ".pi") + if err := os.MkdirAll(filepath.Join(piHome, "agent"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(piHome, "agent", "settings.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(piHome, "agent", "models.json"), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("sh", "-c", piDetectionScript) + cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+binDir+":"+os.Getenv("PATH")) + output, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + result, err := parsePiDetection(output) + if err != nil { + t.Fatal(err) + } + if !result.PiAvailable || result.PiPath != piPath || !result.PiHomeExists || !result.SettingsExists || result.ModelsExists { + t.Errorf("probe result = %#v, want available Pi, directory home, settings file, and models directory excluded", result) + } +} + +func TestDetectPi_NonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the Windows implementation requires a WSL installation") + } + + if _, err := DetectPi("Ubuntu"); err == nil { + t.Error("DetectPi() error = nil, want unsupported error") + } +} + +func probeRecord(fields ...string) []byte { + return []byte(strings.Join(fields, "\x00") + "\x00") +} diff --git a/internal/wsl/pi_detection_windows.go b/internal/wsl/pi_detection_windows.go new file mode 100644 index 0000000..0a52138 --- /dev/null +++ b/internal/wsl/pi_detection_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package wsl + +import ( + "os/exec" + "syscall" +) + +const piDetectionCreateNoWindow uint32 = 0x08000000 + +var piDetectionCommand = exec.Command + +func DetectPi(distro string) (PiDetection, error) { + return detectPi(distro, Detect, probePi) +} + +func probePi(distro string) ([]byte, error) { + cmd := piDetectionCommand("wsl.exe", "--distribution", distro, "--exec", "sh", "-c", piDetectionScript) + // Prevent a console window for this noninteractive WSL query. + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: piDetectionCreateNoWindow} + return cmd.Output() +} diff --git a/internal/wsl/pi_detection_windows_test.go b/internal/wsl/pi_detection_windows_test.go new file mode 100644 index 0000000..a72b9f0 --- /dev/null +++ b/internal/wsl/pi_detection_windows_test.go @@ -0,0 +1,36 @@ +//go:build windows + +package wsl + +import ( + "os/exec" + "reflect" + "testing" +) + +func TestProbePiCommand(t *testing.T) { + original := piDetectionCommand + defer func() { piDetectionCommand = original }() + + var gotName string + var gotArgs []string + var gotCommand *exec.Cmd + piDetectionCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + gotCommand = exec.Command("cmd", "/c", "exit", "0") + return gotCommand + } + + if _, err := probePi("Ubuntu"); err != nil { + t.Fatal(err) + } + + wantArgs := []string{"--distribution", "Ubuntu", "--exec", "sh", "-c", piDetectionScript} + if gotName != "wsl.exe" || !reflect.DeepEqual(gotArgs, wantArgs) { + t.Errorf("probePi() command = %q %#v, want %q %#v", gotName, gotArgs, "wsl.exe", wantArgs) + } + if gotCommand.SysProcAttr == nil || gotCommand.SysProcAttr.CreationFlags != piDetectionCreateNoWindow { + t.Errorf("probePi() CreationFlags = %#v, want %#v", gotCommand.SysProcAttr, piDetectionCreateNoWindow) + } +}