internal/nativewin/window_windows.go
Rama main · réplica de lectura del repositorio original.
//go:build windows
package nativewin
import (
"syscall"
"unsafe"
webview2 "github.com/jchv/go-webview2"
)
// Win32 bits used to reshape and drive the window. Declared here rather than
// pulled from a helper package because this is the only file that needs them,
// and the list is short enough that a dependency would cost more than it saves.
const (
gwlStyle = -16
gwlpWndProc = -4
gclpHbrBackground = -10
wsCaption = 0x00C00000
wsSysMenu = 0x00080000
wsThickFrame = 0x00040000
wsMinimizeBox = 0x00020000
wsMaximizeBox = 0x00010000
wsClipChildren = 0x02000000
wsVisible = 0x10000000
smCyFrame = 33
smCxPaddedBorder = 92
swpFrameChanged = 0x0020
swpNoMove = 0x0002
swpNoSize = 0x0001
swpNoZOrder = 0x0004
swpNoActivate = 0x0010
wmNcLButtonDown = 0x00A1
htCaption = 2
wmSize = 0x0005
wmNcCalcSize = 0x0083
wmClose = 0x0010
// Windows 11 draws a 1px border around every window in the system accent
// or a light grey. Around a chrome-less dark window that border is the
// stray light line at the edge; DWMWA_COLOR_NONE removes it.
dwmwaBorderColor = 34
dwmwaCaptionColor = 35
dwmwaColorNone = 0xFFFFFFFE
swHide = 0
swMinimize = 6
swMaximize = 3
swRestore = 9
swShow = 5
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
gdi32 = syscall.NewLazyDLL("gdi32.dll")
dwmapi = syscall.NewLazyDLL("dwmapi.dll")
dwmAttr = dwmapi.NewProc("DwmSetWindowAttribute")
createSolidBrush = gdi32.NewProc("CreateSolidBrush")
deleteObject = gdi32.NewProc("DeleteObject")
messageBoxW = user32.NewProc("MessageBoxW")
getWindowLongPtrW = user32.NewProc("GetWindowLongPtrW")
setWindowLongPtrW = user32.NewProc("SetWindowLongPtrW")
callWindowProcW = user32.NewProc("CallWindowProcW")
setWindowPos = user32.NewProc("SetWindowPos")
showWindowProc = user32.NewProc("ShowWindow")
setForegroundWin = user32.NewProc("SetForegroundWindow")
releaseCapture = user32.NewProc("ReleaseCapture")
sendMessageW = user32.NewProc("SendMessageW")
postMessageW = user32.NewProc("PostMessageW")
isZoomed = user32.NewProc("IsZoomed")
isWindowVisible = user32.NewProc("IsWindowVisible")
)
// Options tunes what Attach sets up. The zero value is the wizard's behaviour:
// a frameless window whose close button destroys it.
type Options struct {
// HideOnClose turns the close button (and the page's own close verb) into
// "hide", leaving the webview warm so reopening from a tray icon is
// instant instead of a cold WebView2 start. The caller is then responsible
// for ever showing it again -- a hidden window with no tray icon is a
// process the user cannot reach.
HideOnClose bool
// OnClose replaces the default close action, for every way of closing the
// window and not just the page's button. It takes precedence over
// HideOnClose. nil destroys the window, which is what the wizard wants.
OnClose func()
}
// state is package scope because a process has exactly one of these windows:
// the wizard is a program that shows one window and exits, and the app window
// is the single main window of the server process. Two would need this keyed
// by hwnd, and a second window is not a thing either program wants.
var (
prevWndProc uintptr
opts Options
)
// Attach gives w the chrome-less look the page draws its own title bar into,
// binds the title-bar verbs the page needs, and returns the window handle.
//
// It must be called on the thread that created w and that will pump its
// message loop; every binding it registers marshals back onto that thread.
// Bindings are registered here rather than by the caller because go-webview2
// turns each one into a document-creation init script, so they have to exist
// before Navigate -- and callers that forget get a page whose title bar
// silently does nothing.
func Attach(w webview2.WebView, o Options) uintptr {
hwnd := uintptr(w.Window())
if hwnd == 0 {
return 0
}
opts = o
ApplyIcon(hwnd)
frameless(hwnd)
hideStatusBar(w)
// Dispatch rather than a direct call: a binding runs on the UI thread from
// inside a WebView2 event handler, and both dragging and closing enter a
// modal loop of their own. Posting them keeps the handler shallow.
_ = w.Bind("qmDrag", func() { w.Dispatch(func() { Drag(hwnd) }) })
_ = w.Bind("qmMinimize", func() { w.Dispatch(func() { showWindow(hwnd, swMinimize) }) })
_ = w.Bind("qmMaximize", func() { w.Dispatch(func() { ToggleMaximize(hwnd) }) })
// Posted as WM_CLOSE rather than acted on here, so the page's X and the
// system's own close verbs (Alt+F4, the taskbar's Close, PostClose) all
// run the ONE policy in wndProc. Deciding it here as well is how the page
// button came to terminate the webview even under HideOnClose, destroying
// the window the tray's "Open" then had nothing left to show.
_ = w.Bind("qmClose", func() { PostClose(hwnd) })
// Returns "" when the user cancels, which the page treats as "keep what is
// in the box" -- a cancelled picker must not blank a path already typed.
_ = w.Bind("qmPickFolder", func(title, start string) (string, error) {
return PickFolder(hwnd, title, start)
})
// Not dispatched: OpenExternal only spawns a process, and going through the
// UI thread would put a browser launch behind whatever is rendering.
_ = w.Bind("qmOpenExternal", func(u string) { OpenExternal(u) })
// The page tells us what colour its title bar came out, because only it
// knows the theme -- see SetCaptionColor.
_ = w.Bind("qmCaptionColor", func(r, g, b int) { SetCaptionColor(hwnd, r, g, b) })
return hwnd
}
// frameless turns the library's WS_OVERLAPPEDWINDOW into a window the page can
// draw its own header into.
//
// **The style keeps WS_CAPTION.** Nothing draws a caption -- WM_NCCALCSIZE below
// takes the top inset to 0, so the client area covers the pixels it would have
// occupied and there is no non-client space left for it to paint into. The bit
// is kept because it is what Windows animates: a window without it is not a
// caption window, and DWM skips the maximise/restore transition for it
// entirely, which is why this window used to SNAP to full screen while every
// other app on the desktop grew into it. Same reason Chromium and Electron
// build their frameless windows this way rather than out of WS_POPUP.
//
// WS_THICKFRAME survives on the same principle: it is the non-client area, so
// it costs no pixels the page can see, and it is what keeps edge resizing and
// Aero snap. WS_SYSMENU/WS_MINIMIZEBOX/WS_MAXIMIZEBOX are kept for the
// taskbar's right-click menu, Alt+Space and Win+Down, even though nothing
// draws them.
func frameless(hwnd uintptr) {
if hwnd == 0 {
return
}
style := uintptr(wsCaption | wsSysMenu | wsThickFrame | wsMinimizeBox |
wsMaximizeBox | wsClipChildren | wsVisible)
_, _, _ = setWindowLongPtrW.Call(hwnd, winIndex(gwlStyle), style)
subclass(hwnd)
// SWP_FRAMECHANGED is what makes Windows recompute the non-client area;
// without it the caption stays on screen until something else resizes us.
// It is sent after the subclass is installed so one recompute covers both
// the style change and the WM_NCCALCSIZE override.
_, _, _ = setWindowPos.Call(hwnd, 0, 0, 0, 0, 0,
swpFrameChanged|swpNoMove|swpNoSize|swpNoZOrder|swpNoActivate)
// Dropping the caption grows the CLIENT area by its height while the window
// itself keeps its size, so no WM_SIZE is generated and the embedded browser
// stays laid out for the smaller rect it was created with. The window class
// registers no background brush, so the strip of client area the browser no
// longer covers is left holding whatever was last painted there -- the pale
// bar along the edge. The library's WM_SIZE handler refits the browser from
// GetClientRect and ignores the message parameters, so sending it a bare one
// is the whole fix.
_, _, _ = sendMessageW.Call(hwnd, wmSize, 0, 0)
// Best effort: DWMWA_BORDER_COLOR is Windows 11 22000+, and on anything
// older the call fails and leaves the border as it was.
color := uint32(dwmwaColorNone)
_, _, _ = dwmAttr.Call(hwnd, dwmwaBorderColor,
uintptr(unsafe.Pointer(&color)), unsafe.Sizeof(color))
}
// SetCaptionColor dyes the window frame, which on this window is visible in
// exactly two pixels.
//
// Windows 11 rounds the window's corners, and DWM draws the frame under that
// rounded mask. With the top inset taken to 0 the client area covers the whole
// top edge -- except where the corner arc meets it, where a 1-2px stub of frame
// survives the mask and reads as a small white dot in each top corner. Measured
// by screenshotting the composited corners: DWMWCP_DONOTROUND removes both dots
// (at the price of square corners), giving the top edge 1px of non-client back
// turns each dot into a full white line across the top, and DWMWA_BORDER_COLOR
// does not touch them -- it colours the outline, which is already COLOR_NONE.
// Painting the frame the same colour as the title bar is what makes them
// disappear while keeping the platform's rounded corners.
//
// It has to come from the page: the frame colour has to match whatever the
// title bar actually rendered, and the theme lives in the browser's storage,
// not here. Until the page calls, the dots are the system default -- two pixels
// for the fraction of a second before the first paint.
func SetCaptionColor(hwnd uintptr, r, g, b int) {
if hwnd == 0 {
return
}
clamp := func(v int) uint32 {
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return uint32(v)
}
// COLORREF is 0x00BBGGRR, not RGB.
color := clamp(r) | clamp(g)<<8 | clamp(b)<<16
_, _, _ = dwmAttr.Call(hwnd, dwmwaCaptionColor,
uintptr(unsafe.Pointer(&color)), unsafe.Sizeof(color))
setBackgroundBrush(hwnd, color)
}
// ourBrush is the class background brush this package created, and the only one
// it may destroy: SetClassLongPtr hands back whatever was there before, which
// on the first call is go-webview2's own registration and can be a SYSTEM brush
// (COLOR_WINDOW+1). Deleting one of those corrupts it process-wide.
var ourBrush uintptr
// setBackgroundBrush paints the client area the title bar's own colour whenever
// something uncovers it.
//
// The window class registers no useful background, so any client pixel nothing
// has drawn yet keeps whatever was last composited there. It shows during a
// maximise: DWM grows the window a frame ahead of WebView2's next paint, and
// the strip of new client area on the right and bottom flashes with stale
// content for a frame or two. An erase to the chrome colour turns that into a
// flash of the app's own background, which reads as part of the animation
// instead of as garbage.
//
// The colour is the page's, for the same reason SetCaptionColor's is: it has to
// follow the theme, and the theme lives in the browser's storage.
func setBackgroundBrush(hwnd uintptr, color uint32) {
brush, _, _ := createSolidBrush.Call(uintptr(color))
if brush == 0 {
return
}
_, _, _ = setClassLongPtrW.Call(hwnd, winIndex(gclpHbrBackground), brush)
if ourBrush != 0 {
_, _, _ = deleteObject.Call(ourBrush)
}
ourBrush = brush
}
// winRect is the Win32 RECT. Win32 rects are half-open and in screen
// coordinates here, which is what WM_NCCALCSIZE hands us.
type winRect struct{ Left, Top, Right, Bottom int32 }
// ncCalcSizeParams is NCCALCSIZE_PARAMS. Only rgrc[0] -- the proposed client
// rect -- is read or written; the other two and lppos are along for the ride
// because the struct is passed by pointer and has to match in size and layout.
type ncCalcSizeParams struct {
rgrc [3]winRect
lppos uintptr
}
// subclass displaces the window procedure so we can answer two messages the
// library does not.
//
// WM_NCCALCSIZE is what actually removes the title bar, since the style no
// longer does (see frameless). The default handler insets the client rect by
// the caption AND by the frame's top edge, which DWM draws at
// SM_CYSIZEFRAME+SM_CXPADDEDBORDER (4+4 on a 100% display) while the other
// three edges get 1px -- measured, with the caption bit cleared: top=7 left=1
// right=1 bottom=1. Both are pale strips above the page that DWMWA_BORDER_COLOR
// cannot touch (that attribute colours the 1px outline, not the frame it
// surrounds). Letting the default handler inset all four edges and then putting
// the top back where it started reclaims the lot in one move, whatever the
// caption's height on this display.
//
// The cost is the top edge as a resize handle. It cannot be given back with
// WM_NCHITTEST either: with no non-client area up there the hit test never
// reaches this window at all -- the WebView2 child owns those pixels and eats
// the mouse. Three edges, all four corners, Aero snap and maximise still work.
//
// WM_CLOSE is the close-to-tray hook; see Options.HideOnClose.
func subclass(hwnd uintptr) {
if prevWndProc != 0 {
return
}
prev, _, _ := getWindowLongPtrW.Call(hwnd, winIndex(gwlpWndProc))
if prev == 0 {
return
}
prevWndProc = prev
_, _, _ = setWindowLongPtrW.Call(hwnd, winIndex(gwlpWndProc),
syscall.NewCallback(wndProc))
}
func wndProc(hwnd, msg, wparam, lparam uintptr) uintptr {
switch msg {
case wmClose:
// Every way of closing this window arrives here -- the page's own X
// posts WM_CLOSE rather than deciding for itself -- so this switch is
// the whole close policy. Returning without chaining is what
// suppresses the destroy: the window stays alive with its page
// loaded, so the next Show is instant.
switch {
case opts.OnClose != nil:
opts.OnClose()
return 0
case opts.HideOnClose:
Hide(hwnd)
return 0
}
case wmNcCalcSize:
// wparam == 0 means the simple form, where lparam is a bare RECT and
// there is nothing to preserve.
if wparam != 0 && lparam != 0 {
p := (*ncCalcSizeParams)(unsafe.Pointer(lparam))
top := p.rgrc[0].Top
ret, _, _ := callWindowProcW.Call(prevWndProc, hwnd, msg, wparam, lparam)
// Restored: the client takes the whole window, caption included.
//
// Maximised: it cannot. A zoomed window's rect deliberately
// overhangs the work area by the frame thickness on every edge, so
// a client that started at the window's own top would begin
// off-screen and the first rows of the page would be cut off by the
// monitor. Giving back exactly the frame -- and not the caption on
// top of it, which is what the default handler also inset -- lands
// the client on the screen edge with the title bar's first pixel
// visible.
if zoomed, _, _ := isZoomed.Call(hwnd); zoomed != 0 {
top += int32(frameThickness(hwnd))
}
p.rgrc[0].Top = top
return ret
}
case wmDpiChanged:
// Only ever delivered because the process is per-monitor aware: the
// window has crossed onto a display with a different scale, and Windows
// is handing us the rect that keeps it the same apparent size. Taking
// it is the whole of the work; WebView2 re-renders the page at the new
// scale on its own.
applyDpiChange(hwnd, lparam)
return 0
}
ret, _, _ := callWindowProcW.Call(prevWndProc, hwnd, msg, wparam, lparam)
return ret
}
// frameThickness is how far a maximised window overhangs the work area on each
// edge: the sizing border plus the padded border Windows adds around it.
//
// Read every time rather than cached, and read for THIS window's display: the
// process is per-monitor DPI aware (dpi_windows.go), so both the metrics and
// the rects in WM_NCCALCSIZE are in physical pixels, and a window dragged from
// a 150% laptop panel to a 100% monitor needs the smaller frame from the moment
// it lands. systemMetric is what keeps the two in step -- GetSystemMetrics
// alone would keep answering for the primary display.
func frameThickness(hwnd uintptr) int {
return systemMetric(hwnd, smCyFrame) + systemMetric(hwnd, smCxPaddedBorder)
}
// Drag hands the mouse to the window manager mid-click, which is how a custom
// title bar moves a window: ReleaseCapture drops the webview's grab, and
// WM_NCLBUTTONDOWN/HTCAPTION tells Windows to treat what follows as a drag of
// the caption it is no longer drawing.
func Drag(hwnd uintptr) {
_, _, _ = releaseCapture.Call()
_, _, _ = sendMessageW.Call(hwnd, wmNcLButtonDown, htCaption, 0)
}
// ToggleMaximize is the double-click-the-title-bar verb.
func ToggleMaximize(hwnd uintptr) {
zoomed, _, _ := isZoomed.Call(hwnd)
if zoomed != 0 {
showWindow(hwnd, swRestore)
return
}
showWindow(hwnd, swMaximize)
}
// Show makes a hidden window visible and puts it in front, which is what a
// tray "Open" has to do: SW_SHOW alone can leave it behind whatever the user
// was looking at.
func Show(hwnd uintptr) {
showWindow(hwnd, swShow)
_, _, _ = setForegroundWin.Call(hwnd)
}
// Hide removes the window from the screen and the taskbar without destroying
// it. Safe to call from any thread.
func Hide(hwnd uintptr) { showWindow(hwnd, swHide) }
// Visible reports whether the window is on screen.
func Visible(hwnd uintptr) bool {
v, _, _ := isWindowVisible.Call(hwnd)
return v != 0
}
// PostClose asks the window to close from another thread, taking whichever
// path Options.HideOnClose selected. Posted rather than sent so the caller
// does not block on the window thread.
func PostClose(hwnd uintptr) { _, _, _ = postMessageW.Call(hwnd, wmClose, 0, 0) }
func showWindow(hwnd, cmd uintptr) { _, _, _ = showWindowProc.Call(hwnd, cmd) }
// winIndex widens a negative Win32 index (GWL_*, GCLP_*) to the uintptr a
// syscall argument wants. It has to pass through a variable: converting a
// negative CONSTANT to uintptr is a compile-time overflow, while converting a
// negative int32 VALUE is the two's-complement widening these APIs expect.
func winIndex(i int32) uintptr { return uintptr(i) }
// MessageBox puts a message on screen. A -H=windowsgui binary has no console,
// so this is the only channel that reaches a user who double-clicked it.
func MessageBox(title, msg string) {
const mbIconError = 0x00000010
t, err := syscall.UTF16PtrFromString(title)
if err != nil {
return
}
body, err := syscall.UTF16PtrFromString(msg)
if err != nil {
return
}
_, _, _ = messageBoxW.Call(0,
uintptr(unsafe.Pointer(body)), uintptr(unsafe.Pointer(t)), mbIconError)
}