init
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
42
internal/tray/controller.go
Normal file
42
internal/tray/controller.go
Normal 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
|
||||
}
|
||||
57
internal/tray/controller_test.go
Normal file
57
internal/tray/controller_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
9
internal/tray/tray_other.go
Normal file
9
internal/tray/tray_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package tray
|
||||
|
||||
import "context"
|
||||
|
||||
func start(_ context.Context, _ *Controller) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
405
internal/tray/tray_windows.go
Normal file
405
internal/tray/tray_windows.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user