Release 3.8.2.7: Windows taskbar/tray sea-wave icon when connected.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

Match macOS Dock behavior using the same idle/connected glyph assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-08-01 16:45:55 +03:00
co-authored by Cursor
parent 5d7c96f7fc
commit fed1ace33e
20 changed files with 395 additions and 24 deletions
+6
View File
@@ -12,6 +12,9 @@ import (
"github.com/ebitengine/purego/objc"
)
// BindWindow is a no-op on macOS (Dock uses NSApplication icon image).
func BindWindow(h unsafe.Pointer) { _ = h }
//go:embed icon_idle.png
var iconIdlePNG []byte
@@ -103,4 +106,7 @@ func SetConnected(connected bool) {
}
// AppKit must run on the main thread (glaze owns it).
app.Send(selPerf, selSet, img, false)
if OnIconChange != nil {
OnIconChange(connected)
}
}
+7 -2
View File
@@ -1,6 +1,11 @@
//go:build !darwin
//go:build !darwin && !windows
package dockicon
// SetConnected is a no-op outside macOS (Dock icons are a Darwin feature).
import "unsafe"
// SetConnected is a no-op on platforms without a Dock/taskbar icon swap.
func SetConnected(connected bool) {}
// BindWindow is a no-op outside Windows.
func BindWindow(h unsafe.Pointer) { _ = h }
+295
View File
@@ -0,0 +1,295 @@
//go:build windows
package dockicon
import (
"bytes"
_ "embed"
"image"
"image/png"
"sync"
"unsafe"
"golang.org/x/image/draw"
"golang.org/x/sys/windows"
)
//go:embed icon_idle.png
var iconIdlePNG []byte
//go:embed icon_connected.png
var iconConnectedPNG []byte
var (
user32 = windows.NewLazySystemDLL("user32.dll")
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
procSendMessage = user32.NewProc("SendMessageW")
procCreateIconFromResource = user32.NewProc("CreateIconFromResourceEx")
procCreateIconIndirect = user32.NewProc("CreateIconIndirect")
procDestroyIcon = user32.NewProc("DestroyIcon")
procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
procCreateBitmap = gdi32.NewProc("CreateBitmap")
procDeleteObject = gdi32.NewProc("DeleteObject")
mu sync.Mutex
hwnd uintptr
last *bool
idleSmall windows.Handle
idleBig windows.Handle
connSmall windows.Handle
connBig windows.Handle
iconsReady bool
)
const (
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
biRGB = 0
dibRGBColors = 0
)
type iconInfo struct {
FIcon int32
XHotspot uint32
YHotspot uint32
HbmMask windows.Handle
HbmColor windows.Handle
}
type bitmapInfoHeader struct {
BiSize uint32
BiWidth int32
BiHeight int32
BiPlanes uint16
BiBitCount uint16
BiCompression uint32
BiSizeImage uint32
BiXPelsPerMeter int32
BiYPelsPerMeter int32
BiClrUsed uint32
BiClrImportant uint32
}
type bitmapInfo struct {
Header bitmapInfoHeader
Colors [1]uint32
}
// BindWindow attaches the main HWND so SetConnected can update the taskbar icon.
func BindWindow(h unsafe.Pointer) {
mu.Lock()
defer mu.Unlock()
hwnd = uintptr(h)
if err := ensureIconsLocked(); err != nil {
return
}
connected := last != nil && *last
applyLocked(connected)
}
// SetConnected swaps the taskbar/window icon: sea-wave N when connected, original when idle.
func SetConnected(connected bool) {
mu.Lock()
if last != nil && *last == connected {
mu.Unlock()
return
}
v := connected
last = &v
if hwnd == 0 {
mu.Unlock()
if OnIconChange != nil {
OnIconChange(connected)
}
return
}
if err := ensureIconsLocked(); err != nil {
mu.Unlock()
return
}
applyLocked(connected)
mu.Unlock()
if OnIconChange != nil {
OnIconChange(connected)
}
}
// StatusIcon returns a shared HICON for the current connection state (do not DestroyIcon).
func StatusIcon(connected bool) windows.Handle {
mu.Lock()
defer mu.Unlock()
if err := ensureIconsLocked(); err != nil {
return 0
}
if connected {
return connBig
}
return idleBig
}
func applyLocked(connected bool) {
if hwnd == 0 {
return
}
small, big := idleSmall, idleBig
if connected {
small, big = connSmall, connBig
}
if small != 0 {
procSendMessage.Call(hwnd, wmSetIcon, iconSmall, uintptr(small))
}
if big != 0 {
procSendMessage.Call(hwnd, wmSetIcon, iconBig, uintptr(big))
}
}
func ensureIconsLocked() error {
if iconsReady {
return nil
}
var err error
idleSmall, err = hiconFromPNG(iconIdlePNG, 16)
if err != nil {
return err
}
idleBig, err = hiconFromPNG(iconIdlePNG, 32)
if err != nil {
return err
}
connSmall, err = hiconFromPNG(iconConnectedPNG, 16)
if err != nil {
return err
}
connBig, err = hiconFromPNG(iconConnectedPNG, 32)
if err != nil {
return err
}
iconsReady = true
return nil
}
func hiconFromPNG(src []byte, size int) (windows.Handle, error) {
resized, err := resizePNG(src, size)
if err != nil {
return 0, err
}
if h := createIconFromPNGResource(resized, size); h != 0 {
return h, nil
}
return createIconFromImage(resized, size)
}
func resizePNG(src []byte, size int) ([]byte, error) {
img, err := png.Decode(bytes.NewReader(src))
if err != nil {
return nil, err
}
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
var buf bytes.Buffer
if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func createIconFromPNGResource(pngBytes []byte, size int) windows.Handle {
if len(pngBytes) == 0 {
return 0
}
h, _, _ := procCreateIconFromResource.Call(
uintptr(unsafe.Pointer(&pngBytes[0])),
uintptr(len(pngBytes)),
1,
0x00030000,
uintptr(size),
uintptr(size),
0,
)
return windows.Handle(h)
}
func createIconFromImage(pngBytes []byte, size int) (windows.Handle, error) {
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
return 0, err
}
rgba, ok := img.(*image.RGBA)
if !ok {
tmp := image.NewRGBA(img.Bounds())
draw.Draw(tmp, tmp.Bounds(), img, img.Bounds().Min, draw.Src)
rgba = tmp
}
hbmColor, err := dibFromRGBA(rgba)
if err != nil {
return 0, err
}
// Monochrome mask: all zeros => fully opaque where color has alpha handled by 32-bit DIB.
hbmMask, _, err2 := procCreateBitmap.Call(uintptr(size), uintptr(size), 1, 1, 0)
if hbmMask == 0 {
procDeleteObject.Call(uintptr(hbmColor))
if err2 != nil {
return 0, err2
}
return 0, windows.ERROR_INVALID_HANDLE
}
ii := iconInfo{
FIcon: 1,
XHotspot: 0,
YHotspot: 0,
HbmMask: windows.Handle(hbmMask),
HbmColor: hbmColor,
}
h, _, err3 := procCreateIconIndirect.Call(uintptr(unsafe.Pointer(&ii)))
procDeleteObject.Call(uintptr(hbmMask))
procDeleteObject.Call(uintptr(hbmColor))
if h == 0 {
if err3 != nil {
return 0, err3
}
return 0, windows.ERROR_INVALID_HANDLE
}
return windows.Handle(h), nil
}
func dibFromRGBA(src *image.RGBA) (windows.Handle, error) {
w := src.Bounds().Dx()
h := src.Bounds().Dy()
bi := bitmapInfo{
Header: bitmapInfoHeader{
BiSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
BiWidth: int32(w),
BiHeight: -int32(h), // top-down
BiPlanes: 1,
BiBitCount: 32,
BiCompression: biRGB,
},
}
var bits unsafe.Pointer
hbm, _, err := procCreateDIBSection.Call(0, uintptr(unsafe.Pointer(&bi)), dibRGBColors, uintptr(unsafe.Pointer(&bits)), 0, 0)
if hbm == 0 || bits == nil {
if err != nil {
return 0, err
}
return 0, windows.ERROR_INVALID_HANDLE
}
// Windows wants BGRA.
dst := unsafe.Slice((*byte)(bits), w*h*4)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := src.PixOffset(x, y)
o := (y*w + x) * 4
dst[o+0] = src.Pix[i+2] // B
dst[o+1] = src.Pix[i+1] // G
dst[o+2] = src.Pix[i+0] // R
dst[o+3] = src.Pix[i+3] // A
}
}
return windows.Handle(hbm), nil
}
+5
View File
@@ -0,0 +1,5 @@
package dockicon
// OnIconChange is invoked after SetConnected updates the platform icon.
// Used on Windows to keep the tray glyph in sync; unused on other platforms.
var OnIconChange func(connected bool)
+3
View File
@@ -8,3 +8,6 @@ func start(appName string, h Hooks) bool {
_ = h
return false
}
// SetConnectedIcon is a no-op when tray is unsupported.
func SetConnectedIcon(connected bool) { _ = connected }
+53 -2
View File
@@ -4,8 +4,11 @@ package trayhost
import (
"runtime"
"sync"
"unsafe"
"vpnclient/internal/dockicon"
"golang.org/x/sys/windows"
)
@@ -90,6 +93,12 @@ type msg struct {
Pt point
}
var (
trayMu sync.Mutex
trayNID *notifyIconData
trayLastUp *bool
)
func start(appName string, h Hooks) bool {
if h.Quit == nil {
return false
@@ -102,6 +111,35 @@ func start(appName string, h Hooks) bool {
return true
}
// SetConnectedIcon updates the tray glyph (sea-wave N when connected).
func SetConnectedIcon(connected bool) {
trayMu.Lock()
nid := trayNID
if trayLastUp != nil && *trayLastUp == connected {
trayMu.Unlock()
return
}
v := connected
trayLastUp = &v
trayMu.Unlock()
if nid == nil {
return
}
icon := dockicon.StatusIcon(connected)
if icon == 0 {
return
}
trayMu.Lock()
if trayNID == nil {
trayMu.Unlock()
return
}
trayNID.HIcon = icon
trayNID.UFlags = nifMessage | nifIcon | nifTip
procShellNotify.Call(nimModify, uintptr(unsafe.Pointer(trayNID)))
trayMu.Unlock()
}
func runTray(appName string, h Hooks) {
className, _ := windows.UTF16PtrFromString("NavisTrayClass")
title, _ := windows.UTF16PtrFromString(appName)
@@ -155,17 +193,27 @@ func runTray(appName string, h Hooks) {
return
}
icon, _, _ := procLoadIcon.Call(0, uintptr(idiApplication))
icon := dockicon.StatusIcon(false)
if icon == 0 {
h, _, _ := procLoadIcon.Call(0, uintptr(idiApplication))
icon = windows.Handle(h)
}
var nid notifyIconData
nid.CbSize = uint32(unsafe.Sizeof(nid))
nid.Hwnd = windows.Handle(hwnd)
nid.UID = 1
nid.UFlags = nifMessage | nifIcon | nifTip
nid.UCallbackMessage = wmTray
nid.HIcon = windows.Handle(icon)
nid.HIcon = icon
tip, _ := windows.UTF16FromString(appName)
copy(nid.SzTip[:], tip)
procShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&nid)))
trayMu.Lock()
trayNID = &nid
trayMu.Unlock()
if h.IsUp != nil {
SetConnectedIcon(h.IsUp())
}
var m msg
for {
@@ -176,6 +224,9 @@ func runTray(appName string, h Hooks) {
procTranslate.Call(uintptr(unsafe.Pointer(&m)))
procDispatch.Call(uintptr(unsafe.Pointer(&m)))
}
trayMu.Lock()
trayNID = nil
trayMu.Unlock()
procShellNotify.Call(nimDelete, uintptr(unsafe.Pointer(&nid)))
}
+1 -1
View File
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 6
const BuildNumber = 7
// DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"