Release 3.8.2.7: Windows taskbar/tray sea-wave icon when connected.
Match macOS Dock behavior using the same idle/connected glyph assets. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user