This commit is contained in:
2026-05-13 20:40:14 +08:00
parent bd13c842a8
commit c8a75ef2d5
17 changed files with 920 additions and 93 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
@@ -45,8 +46,8 @@ type apiUser struct {
func NewClient(baseURL string, auth Auth) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
auth: auth,
baseURL: strings.TrimRight(baseURL, "/"),
auth: auth,
httpClient: &http.Client{Timeout: 20 * time.Second},
}
}
@@ -80,7 +81,7 @@ func (c *Client) ListOrgSkills(ctx context.Context, org string) ([]domain.Remote
Name: repo.Name,
FullName: repo.FullName,
Description: repo.Description,
CloneURL: repo.CloneURL,
CloneURL: normalizeCloneURL(repo.CloneURL, c.baseURL),
SSHURL: repo.SSHURL,
DefaultBranch: repo.DefaultBranch,
UpdatedAt: repo.UpdatedAt,
@@ -175,3 +176,34 @@ func (c *Client) newRequest(ctx context.Context, method, endpoint string) (*http
req.Header.Set("Accept", "application/json")
return req, nil
}
func normalizeCloneURL(cloneURL, baseURL string) string {
clone, err := url.Parse(cloneURL)
if err != nil || clone.Scheme == "" || clone.Host == "" {
return cloneURL
}
if clone.Scheme != "http" && clone.Scheme != "https" {
return cloneURL
}
if !isLoopbackHost(clone.Hostname()) {
return cloneURL
}
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return cloneURL
}
if clone.Scheme == base.Scheme && strings.EqualFold(clone.Host, base.Host) {
return cloneURL
}
clone.Scheme = base.Scheme
clone.Host = base.Host
return clone.String()
}
func isLoopbackHost(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

View File

@@ -44,6 +44,31 @@ func TestListOrgSkillsFiltersReposWithoutSkillMD(t *testing.T) {
}
}
func TestListOrgSkillsNormalizesLoopbackCloneURLToConfiguredBaseURL(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/orgs/skills/repos", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]apiRepo{
{Name: "good", FullName: "skills/good", CloneURL: "http://localhost:3000/skills/good.git", DefaultBranch: "main"},
})
})
mux.HandleFunc("/api/v1/repos/skills/good/contents/SKILL.md", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(mux)
defer server.Close()
client := NewClient(server.URL, Auth{Type: AuthPassword, Username: "alice", Secret: "secret"})
repos, err := client.ListOrgSkills(context.Background(), "skills")
if err != nil {
t.Fatalf("ListOrgSkills returned error: %v", err)
}
want := server.URL + "/skills/good.git"
if repos[0].CloneURL != want {
t.Fatalf("CloneURL = %q, want %q", repos[0].CloneURL, want)
}
}
func TestListOrgSkillsUsesTokenAuth(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/orgs/skills/repos", func(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,42 @@
package tray
import (
"context"
"errors"
)
const (
wmLeftButtonUp = 0x0202
wmLeftButtonDoubleClick = 0x0203
)
type Config struct {
Tooltip string
IconPath string
OnOpen func()
}
type Controller struct {
config Config
}
func Start(ctx context.Context, config Config) (func(), error) {
if config.OnOpen == nil {
return nil, errors.New("tray open handler is required")
}
return start(ctx, newController(config))
}
func newController(config Config) *Controller {
return &Controller{config: config}
}
func (c *Controller) handleTrayEvent(event uintptr) bool {
if event&0xffff != wmLeftButtonDoubleClick {
return false
}
if c.config.OnOpen != nil {
c.config.OnOpen()
}
return true
}

View File

@@ -0,0 +1,57 @@
package tray
import "testing"
func TestControllerOpensWindowOnTrayDoubleClick(t *testing.T) {
opened := 0
controller := newController(Config{
OnOpen: func() {
opened++
},
})
handled := controller.handleTrayEvent(wmLeftButtonDoubleClick)
if !handled {
t.Fatal("expected tray double-click to be handled")
}
if opened != 1 {
t.Fatalf("expected open callback to run once, got %d", opened)
}
}
func TestControllerOpensWindowWhenTrayEventContainsIconID(t *testing.T) {
opened := 0
controller := newController(Config{
OnOpen: func() {
opened++
},
})
handled := controller.handleTrayEvent(wmLeftButtonDoubleClick | 1<<16)
if !handled {
t.Fatal("expected packed tray double-click event to be handled")
}
if opened != 1 {
t.Fatalf("expected open callback to run once, got %d", opened)
}
}
func TestControllerIgnoresTraySingleClick(t *testing.T) {
opened := 0
controller := newController(Config{
OnOpen: func() {
opened++
},
})
handled := controller.handleTrayEvent(wmLeftButtonUp)
if handled {
t.Fatal("expected single-click to be ignored")
}
if opened != 0 {
t.Fatalf("expected open callback not to run, got %d calls", opened)
}
}

View File

@@ -0,0 +1,9 @@
//go:build !windows
package tray
import "context"
func start(_ context.Context, _ *Controller) (func(), error) {
return func() {}, nil
}

View File

@@ -0,0 +1,405 @@
//go:build windows
package tray
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
wmClose = 0x0010
wmDestroy = 0x0002
wmApp = 0x8000
trayCallbackMessage = wmApp + 1
hwndMessage = ^uintptr(2)
nimAdd = 0x00000000
nimDelete = 0x00000002
nimSetVersion = 0x00000004
nifMessage = 0x00000001
nifIcon = 0x00000002
nifTip = 0x00000004
notifyIconVersion = 4
imageIcon = 1
lrLoadFromFile = 0x00000010
idiApplication = 32512
smCxSmallIcon = 49
smCySmallIcon = 50
)
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
user32 = windows.NewLazySystemDLL("user32.dll")
shell32 = windows.NewLazySystemDLL("shell32.dll")
procGetModuleHandle = kernel32.NewProc("GetModuleHandleW")
procRegisterClassEx = user32.NewProc("RegisterClassExW")
procCreateWindowEx = user32.NewProc("CreateWindowExW")
procDefWindowProc = user32.NewProc("DefWindowProcW")
procDestroyWindow = user32.NewProc("DestroyWindow")
procPostMessage = user32.NewProc("PostMessageW")
procGetMessage = user32.NewProc("GetMessageW")
procTranslateMsg = user32.NewProc("TranslateMessage")
procDispatchMsg = user32.NewProc("DispatchMessageW")
procPostQuitMessage = user32.NewProc("PostQuitMessage")
procLoadIcon = user32.NewProc("LoadIconW")
procLoadImage = user32.NewProc("LoadImageW")
procDestroyIcon = user32.NewProc("DestroyIcon")
procGetSystemMetric = user32.NewProc("GetSystemMetrics")
procShellNotifyIcon = shell32.NewProc("Shell_NotifyIconW")
procExtractIconEx = shell32.NewProc("ExtractIconExW")
)
type notifyIconData struct {
cbSize uint32
hwnd uintptr
uid uint32
flags uint32
callbackMessage uint32
icon uintptr
tip [128]uint16
state uint32
stateMask uint32
info [256]uint16
version uint32
infoTitle [64]uint16
infoFlags uint32
guidItem windows.GUID
balloonIcon uintptr
}
type wndClassEx struct {
cbSize uint32
style uint32
wndProc uintptr
clsExtra int32
wndExtra int32
instance uintptr
icon uintptr
cursor uintptr
background uintptr
menuName *uint16
className *uint16
iconSmall uintptr
}
type point struct {
x int32
y int32
}
type msg struct {
hwnd uintptr
message uint32
wparam uintptr
lparam uintptr
time uint32
pt point
}
type windowsTray struct {
controller *Controller
wndProc uintptr
hwnd atomic.Uintptr
icon uintptr
destroyIcon bool
ready chan error
done chan struct{}
stopOnce sync.Once
cleanupOnce sync.Once
}
func start(ctx context.Context, controller *Controller) (func(), error) {
tray := &windowsTray{
controller: controller,
ready: make(chan error, 1),
done: make(chan struct{}),
}
go tray.run()
if err := <-tray.ready; err != nil {
return nil, err
}
stop := func() {
tray.stop()
}
go func() {
<-ctx.Done()
stop()
}()
return stop, nil
}
func (t *windowsTray) run() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := t.init(); err != nil {
hwnd := t.hwnd.Load()
t.cleanup()
destroyWindow(hwnd)
t.ready <- err
close(t.done)
return
}
t.ready <- nil
t.messageLoop()
t.cleanup()
close(t.done)
}
func (t *windowsTray) init() error {
t.wndProc = windows.NewCallback(t.windowProc)
instance, _, _ := procGetModuleHandle.Call(0)
className, err := windows.UTF16PtrFromString(fmt.Sprintf("SGGAISkillManagerTrayWindow-%d", os.Getpid()))
if err != nil {
return err
}
class := wndClassEx{
cbSize: uint32(unsafe.Sizeof(wndClassEx{})),
wndProc: t.wndProc,
instance: instance,
className: className,
}
if ret, _, callErr := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&class))); ret == 0 {
return syscallError("RegisterClassExW", callErr)
}
hwnd, _, callErr := procCreateWindowEx.Call(
0,
uintptr(unsafe.Pointer(className)),
0,
0,
0,
0,
0,
0,
hwndMessage,
0,
instance,
0,
)
if hwnd == 0 {
return syscallError("CreateWindowExW", callErr)
}
t.hwnd.Store(hwnd)
icon, destroyIcon, err := loadTrayIcon(t.controller.config.IconPath)
if err != nil {
return err
}
t.icon = icon
t.destroyIcon = destroyIcon
nid := t.newNotifyIconData()
if !shellNotifyIcon(nimAdd, &nid) {
return fmt.Errorf("Shell_NotifyIconW add failed")
}
nid.version = notifyIconVersion
if !shellNotifyIcon(nimSetVersion, &nid) {
return fmt.Errorf("Shell_NotifyIconW set version failed")
}
return nil
}
func (t *windowsTray) messageLoop() {
var message msg
for {
ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0)
if int32(ret) <= 0 {
return
}
procTranslateMsg.Call(uintptr(unsafe.Pointer(&message)))
procDispatchMsg.Call(uintptr(unsafe.Pointer(&message)))
}
}
func (t *windowsTray) stop() {
t.stopOnce.Do(func() {
if hwnd := t.hwnd.Load(); hwnd != 0 {
procPostMessage.Call(hwnd, wmClose, 0, 0)
<-t.done
}
})
}
func (t *windowsTray) windowProc(hwnd uintptr, message uint32, wparam uintptr, lparam uintptr) uintptr {
switch message {
case trayCallbackMessage:
if t.controller.handleTrayEvent(lparam) {
return 0
}
case wmClose:
t.cleanup()
procDestroyWindow.Call(hwnd)
return 0
case wmDestroy:
procPostQuitMessage.Call(0)
return 0
}
ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(message), wparam, lparam)
return ret
}
func (t *windowsTray) cleanup() {
t.cleanupOnce.Do(func() {
if hwnd := t.hwnd.Load(); hwnd != 0 {
nid := t.newNotifyIconData()
shellNotifyIcon(nimDelete, &nid)
t.hwnd.Store(0)
}
if t.icon != 0 && t.destroyIcon {
procDestroyIcon.Call(t.icon)
}
})
}
func destroyWindow(hwnd uintptr) {
if hwnd != 0 {
procDestroyWindow.Call(hwnd)
}
}
func (t *windowsTray) newNotifyIconData() notifyIconData {
nid := notifyIconData{
cbSize: uint32(unsafe.Sizeof(notifyIconData{})),
hwnd: t.hwnd.Load(),
uid: 1,
flags: nifMessage | nifIcon | nifTip,
callbackMessage: trayCallbackMessage,
icon: t.icon,
}
copyUTF16(nid.tip[:], t.controller.config.Tooltip)
return nid
}
func loadTrayIcon(iconPath string) (uintptr, bool, error) {
if icon, ok := loadIconFromFile(iconPath); ok {
return icon, true, nil
}
if icon, ok := extractExecutableIcon(); ok {
return icon, true, nil
}
if icon, _, _ := procLoadIcon.Call(0, idiApplication); icon != 0 {
return icon, false, nil
}
return 0, false, fmt.Errorf("load tray icon failed")
}
func loadIconFromFile(iconPath string) (uintptr, bool) {
if iconPath == "" {
return 0, false
}
absolutePath, err := filepath.Abs(iconPath)
if err != nil {
return 0, false
}
if _, err := os.Stat(absolutePath); err != nil {
return 0, false
}
path, err := windows.UTF16PtrFromString(absolutePath)
if err != nil {
return 0, false
}
width := systemMetric(smCxSmallIcon, 16)
height := systemMetric(smCySmallIcon, 16)
icon, _, _ := procLoadImage.Call(
0,
uintptr(unsafe.Pointer(path)),
imageIcon,
uintptr(width),
uintptr(height),
lrLoadFromFile,
)
return icon, icon != 0
}
func extractExecutableIcon() (uintptr, bool) {
exePath, err := os.Executable()
if err != nil {
return 0, false
}
exe, err := windows.UTF16PtrFromString(exePath)
if err != nil {
return 0, false
}
var largeIcon uintptr
var smallIcon uintptr
count, _, _ := procExtractIconEx.Call(
uintptr(unsafe.Pointer(exe)),
0,
uintptr(unsafe.Pointer(&largeIcon)),
uintptr(unsafe.Pointer(&smallIcon)),
1,
)
if count == 0 {
return 0, false
}
if smallIcon != 0 {
if largeIcon != 0 {
procDestroyIcon.Call(largeIcon)
}
return smallIcon, true
}
return largeIcon, largeIcon != 0
}
func systemMetric(index int32, fallback int) int {
value, _, _ := procGetSystemMetric.Call(uintptr(index))
if value == 0 {
return fallback
}
return int(value)
}
func shellNotifyIcon(command uintptr, data *notifyIconData) bool {
ret, _, _ := procShellNotifyIcon.Call(command, uintptr(unsafe.Pointer(data)))
return ret != 0
}
func copyUTF16(destination []uint16, value string) {
encoded, err := windows.UTF16FromString(value)
if err != nil {
return
}
if len(encoded) > len(destination) {
encoded = encoded[:len(destination)]
encoded[len(encoded)-1] = 0
}
copy(destination, encoded)
}
func syscallError(operation string, err error) error {
if errno, ok := err.(syscall.Errno); ok && errno == 0 {
return fmt.Errorf("%s failed", operation)
}
return fmt.Errorf("%s failed: %w", operation, err)
}