初始提交
Win11 动态壁纸引擎:WebView2 + systray + 和风天气 - WebGL 极光背景动画 - 实时天气(24h/7d预报) - 星座运势(托盘切换) - 暂停/继续控制 - 单实例互斥锁防双开 - vendor systray 修复 ClickedCh 静默丢弃
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Config (user-specific)
|
||||||
|
config/
|
||||||
|
|
||||||
|
# Vendor (go mod vendor)
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
u-desktop
|
||||||
5
docs/backup-opengl/go_opengl.mod.txt
Normal file
5
docs/backup-opengl/go_opengl.mod.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module u-desktop
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.45.0
|
||||||
530
docs/backup-opengl/main_opengl.go.txt
Normal file
530
docs/backup-opengl/main_opengl.go.txt
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
WM_DESTROY = 0x0002
|
||||||
|
WM_CLOSE = 0x0010
|
||||||
|
WM_QUIT = 0x0012
|
||||||
|
WM_MOUSEMOVE = 0x0200
|
||||||
|
WM_LBUTTONDOWN = 0x0201
|
||||||
|
|
||||||
|
WS_POPUP = 0x80000000
|
||||||
|
WS_VISIBLE = 0x10000000
|
||||||
|
WS_CHILD = 0x40000000
|
||||||
|
WS_CLIPCHILDREN = 0x02000000
|
||||||
|
WS_CLIPSIBLINGS = 0x04000000
|
||||||
|
|
||||||
|
GWL_STYLE = ^uintptr(0) - 15
|
||||||
|
|
||||||
|
PFD_DRAW_TO_WINDOW = 0x04
|
||||||
|
PFD_SUPPORT_OPENGL = 0x20
|
||||||
|
PFD_DOUBLEBUFFER = 0x01
|
||||||
|
|
||||||
|
GL_COLOR_BUFFER_BIT = 0x4060
|
||||||
|
GL_TRIANGLE_STRIP = 0x0005
|
||||||
|
GL_ARRAY_BUFFER = 0x8892
|
||||||
|
GL_STATIC_DRAW = 0x88E4
|
||||||
|
GL_VERTEX_SHADER = 0x8B31
|
||||||
|
GL_FRAGMENT_SHADER = 0x8B30
|
||||||
|
GL_COMPILE_STATUS = 0x8B81
|
||||||
|
GL_INFO_LOG_LENGTH = 0x8B84
|
||||||
|
GL_RGBA = 0x1908
|
||||||
|
GL_UNSIGNED_BYTE = 0x1401
|
||||||
|
GL_FLOAT = 0x1406
|
||||||
|
GL_VERSION = 0x1F02
|
||||||
|
|
||||||
|
DIB_RGB_COLORS = 0
|
||||||
|
SWP_NOSIZE = 0x0001
|
||||||
|
SWP_NOMOVE = 0x0002
|
||||||
|
SWP_NOZORDER = 0x0004
|
||||||
|
SWP_FRAMECHANGED = 0x0020
|
||||||
|
HWND_BOTTOM = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
type bitmapInfoHeader struct {
|
||||||
|
Size uint32
|
||||||
|
Width int32
|
||||||
|
Height int32
|
||||||
|
Planes uint16
|
||||||
|
BitCount uint16
|
||||||
|
Compression uint32
|
||||||
|
SizeImage uint32
|
||||||
|
XPelsPerMeter int32
|
||||||
|
YPelsPerMeter int32
|
||||||
|
ClrUsed uint32
|
||||||
|
ClrImportant uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||||
|
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
|
||||||
|
opengl32 = windows.NewLazySystemDLL("opengl32.dll")
|
||||||
|
procFindWindowW = user32.NewProc("FindWindowW")
|
||||||
|
procFindWindowExW = user32.NewProc("FindWindowExW")
|
||||||
|
procSendMsgTimeout = user32.NewProc("SendMessageTimeoutW")
|
||||||
|
procSetParent = user32.NewProc("SetParent")
|
||||||
|
procMoveWindow = user32.NewProc("MoveWindow")
|
||||||
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
|
procSetWindowLongPtrW = user32.NewProc("SetWindowLongPtrW")
|
||||||
|
procSetWindowPos = user32.NewProc("SetWindowPos")
|
||||||
|
procCreateWindowExW = user32.NewProc("CreateWindowExW")
|
||||||
|
procDefWindowProcW = user32.NewProc("DefWindowProcW")
|
||||||
|
procRegisterClassExW = user32.NewProc("RegisterClassExW")
|
||||||
|
procEnumDisplayMonitors = user32.NewProc("EnumDisplayMonitors")
|
||||||
|
procEnumWindows = user32.NewProc("EnumWindows")
|
||||||
|
procGetClassNameW = user32.NewProc("GetClassNameW")
|
||||||
|
procGetWindowRect = user32.NewProc("GetWindowRect")
|
||||||
|
procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware")
|
||||||
|
procPeekMessageW = user32.NewProc("PeekMessageW")
|
||||||
|
procTranslateMessage = user32.NewProc("TranslateMessage")
|
||||||
|
procDispatchMessageW = user32.NewProc("DispatchMessageW")
|
||||||
|
procPostQuitMessage = user32.NewProc("PostQuitMessage")
|
||||||
|
procGetDC = user32.NewProc("GetDC")
|
||||||
|
procReleaseDC = user32.NewProc("ReleaseDC")
|
||||||
|
procSetDIBitsToDevice = gdi32.NewProc("SetDIBitsToDevice")
|
||||||
|
procChoosePixelFormat = gdi32.NewProc("ChoosePixelFormat")
|
||||||
|
procSetPixelFormat = gdi32.NewProc("SetPixelFormat")
|
||||||
|
procSwapBuffers = gdi32.NewProc("SwapBuffers")
|
||||||
|
glFinish = opengl32.NewProc("glFinish")
|
||||||
|
procWglCreateContext = opengl32.NewProc("wglCreateContext")
|
||||||
|
procWglMakeCurrent = opengl32.NewProc("wglMakeCurrent")
|
||||||
|
procWglDeleteContext = opengl32.NewProc("wglDeleteContext")
|
||||||
|
procWglGetProcAddress = opengl32.NewProc("wglGetProcAddress")
|
||||||
|
glViewport = opengl32.NewProc("glViewport")
|
||||||
|
glDrawArrays = opengl32.NewProc("glDrawArrays")
|
||||||
|
glGetString = opengl32.NewProc("glGetString")
|
||||||
|
glReadPixels = opengl32.NewProc("glReadPixels")
|
||||||
|
glClear = opengl32.NewProc("glClear")
|
||||||
|
)
|
||||||
|
|
||||||
|
type rect struct{ Left, Top, Right, Bottom int32 }
|
||||||
|
type wndClassEx struct {
|
||||||
|
CbSize, Style uint32
|
||||||
|
LpfnWndProc uintptr
|
||||||
|
CbClsExtra, CbWndExtra int32
|
||||||
|
HInstance, HIcon, HCursor, HbrBackground uintptr
|
||||||
|
LpszMenuName, LpszClassName *uint16
|
||||||
|
HIconSm uintptr
|
||||||
|
}
|
||||||
|
type pfd struct {
|
||||||
|
Size, Version uint16
|
||||||
|
Flags uint32
|
||||||
|
PixelType, ColorBits, RedBits, RedShift, GreenBits, GreenShift,
|
||||||
|
BlueBits, BlueShift, AlphaBits, AlphaShift, AccumBits, AccumRed,
|
||||||
|
AccumGreen, AccumBlue, AccumAlpha, DepthBits, StencilBits,
|
||||||
|
AuxBuffers, LayerType, Reserved byte
|
||||||
|
LayerMask, VisibleMask, DamageMask uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
wallpaperHwnd uintptr
|
||||||
|
glDC uintptr
|
||||||
|
glCtx uintptr
|
||||||
|
mouseX, mouseY float32
|
||||||
|
clickX, clickY float32
|
||||||
|
clickTime float64
|
||||||
|
screenW, screenH int32
|
||||||
|
startTime = float64(time.Now().UnixNano()) / 1e9
|
||||||
|
frameCount int
|
||||||
|
pixelBuf []byte
|
||||||
|
bmi bitmapInfoHeader
|
||||||
|
glUseProgram, glUniform1fv, glUniform2fv uintptr
|
||||||
|
useSwap bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
||||||
|
procSetProcessDPIAware.Call()
|
||||||
|
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "probe" {
|
||||||
|
probe()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
screenW, screenH = getDesktopSize()
|
||||||
|
log.Printf("Desktop: %dx%d", screenW, screenH)
|
||||||
|
|
||||||
|
// Allocate pixel buffer for GDI blit
|
||||||
|
pixels := screenW * screenH * 4
|
||||||
|
pixelBuf = make([]byte, pixels)
|
||||||
|
log.Printf("Pixel buffer: %d bytes (%.1fMB)", pixels, float64(pixels)/1024/1024)
|
||||||
|
|
||||||
|
bmi = bitmapInfoHeader{
|
||||||
|
Size: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
|
||||||
|
Width: screenW,
|
||||||
|
Height: screenH, // positive = bottom-up (matches GL)
|
||||||
|
Planes: 1,
|
||||||
|
BitCount: 32,
|
||||||
|
Compression: 0, // BI_RGB
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := ""
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
mode = os.Args[1]
|
||||||
|
}
|
||||||
|
useSwap = len(os.Args) > 2 && os.Args[2] == "swap"
|
||||||
|
|
||||||
|
createWindow()
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "windowed":
|
||||||
|
// Diagnostic: skip embedding, show as normal fullscreen window
|
||||||
|
procMoveWindow.Call(wallpaperHwnd, 0, 0, uintptr(screenW), uintptr(screenH), 1)
|
||||||
|
log.Println("WINDOWED mode (no embed)")
|
||||||
|
default:
|
||||||
|
// 选择嵌入目标:progman 模式直接挂到 Progman(Win11 新内核),否则找 WorkerW
|
||||||
|
var target uintptr
|
||||||
|
switch mode {
|
||||||
|
case "progman", "progman-top":
|
||||||
|
target, _, _ = procFindWindowW.Call(uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman"))), 0)
|
||||||
|
var discard uintptr
|
||||||
|
procSendMsgTimeout.Call(target, 0x052C, 0xD, 0, 0x0000, 1000, uintptr(unsafe.Pointer(&discard)))
|
||||||
|
case "childww", "childww-top":
|
||||||
|
// Progman 的直接子 WorkerW —— 可能才是 DWM 合成的壁纸层
|
||||||
|
progman, _, _ := procFindWindowW.Call(uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman"))), 0)
|
||||||
|
var discard uintptr
|
||||||
|
procSendMsgTimeout.Call(progman, 0x052C, 0xD, 0, 0x0000, 1000, uintptr(unsafe.Pointer(&discard)))
|
||||||
|
target, _, _ = procFindWindowExW.Call(progman, 0, uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("WorkerW"))), 0)
|
||||||
|
default:
|
||||||
|
target = findWorkerW()
|
||||||
|
}
|
||||||
|
if target == 0 {
|
||||||
|
log.Fatal("embed target not found")
|
||||||
|
}
|
||||||
|
procSetParent.Call(wallpaperHwnd, target)
|
||||||
|
procSetWindowLongPtrW.Call(wallpaperHwnd, GWL_STYLE, uintptr(WS_CHILD|WS_VISIBLE|WS_CLIPCHILDREN|WS_CLIPSIBLINGS))
|
||||||
|
procMoveWindow.Call(wallpaperHwnd, 0, 0, uintptr(screenW), uintptr(screenH), 1)
|
||||||
|
// *-top: 不置底(默认 z-order,盖住图标也认)用于验证子窗口能否上屏
|
||||||
|
// 其它: 置底到图标之下
|
||||||
|
if mode != "progman-top" && mode != "childww-top" {
|
||||||
|
procSetWindowPos.Call(wallpaperHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE|SWP_NOSIZE|SWP_FRAMECHANGED)
|
||||||
|
}
|
||||||
|
log.Printf("Embedded into target=%x mode=%s", target, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
initGL()
|
||||||
|
loadExtensions()
|
||||||
|
progID := setupShaders()
|
||||||
|
|
||||||
|
log.Println("Rendering...")
|
||||||
|
targetFPS := 30.0
|
||||||
|
frameInterval := 1.0 / targetFPS
|
||||||
|
lastFrame := 0.0
|
||||||
|
lastMove := 0.0
|
||||||
|
|
||||||
|
type msg struct {
|
||||||
|
HWnd uintptr
|
||||||
|
Message uint32
|
||||||
|
WParam uintptr
|
||||||
|
LParam uintptr
|
||||||
|
Time uint32
|
||||||
|
Pt struct{ X, Y int32 }
|
||||||
|
}
|
||||||
|
var m msg
|
||||||
|
for {
|
||||||
|
for {
|
||||||
|
r, _, _ := procPeekMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0, 1)
|
||||||
|
if r == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if m.Message == WM_QUIT {
|
||||||
|
cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch m.Message {
|
||||||
|
case WM_MOUSEMOVE:
|
||||||
|
mouseX = float32(int16(m.LParam&0xFFFF)) / float32(screenW)
|
||||||
|
mouseY = 1.0 - float32(int16(m.LParam>>16)) / float32(screenH)
|
||||||
|
lastMove = now()
|
||||||
|
case WM_LBUTTONDOWN:
|
||||||
|
clickX = float32(int16(m.LParam&0xFFFF)) / float32(screenW)
|
||||||
|
clickY = 1.0 - float32(int16(m.LParam>>16)) / float32(screenH)
|
||||||
|
clickTime = now()
|
||||||
|
}
|
||||||
|
procTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
}
|
||||||
|
|
||||||
|
t := now()
|
||||||
|
if t-lastFrame < frameInterval {
|
||||||
|
time.Sleep(time.Duration((frameInterval - (t - lastFrame)) * 0.8 * float64(time.Second)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastFrame = t
|
||||||
|
if t-lastMove > 5.0 {
|
||||||
|
targetFPS = 10.0
|
||||||
|
} else {
|
||||||
|
targetFPS = 30.0
|
||||||
|
}
|
||||||
|
frameInterval = 1.0 / targetFPS
|
||||||
|
|
||||||
|
// GL render
|
||||||
|
glViewport.Call(0, 0, uintptr(screenW), uintptr(screenH))
|
||||||
|
glClear.Call(uintptr(GL_COLOR_BUFFER_BIT))
|
||||||
|
syscall.SyscallN(glUseProgram, uintptr(progID))
|
||||||
|
|
||||||
|
elapsed := float32(0)
|
||||||
|
if clickTime > 0 {
|
||||||
|
e := t - clickTime
|
||||||
|
if e < 3.0 {
|
||||||
|
elapsed = float32(e)
|
||||||
|
} else {
|
||||||
|
clickTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fTime := float32(t)
|
||||||
|
fScreen := [2]float32{float32(screenW), float32(screenH)}
|
||||||
|
syscall.SyscallN(glUniform1fv, uintptr(0), uintptr(1), uintptr(unsafe.Pointer(&fTime)))
|
||||||
|
syscall.SyscallN(glUniform2fv, uintptr(1), uintptr(1), uintptr(unsafe.Pointer(&fScreen[0])))
|
||||||
|
syscall.SyscallN(glUniform2fv, uintptr(2), uintptr(1), uintptr(unsafe.Pointer(&mouseX)))
|
||||||
|
syscall.SyscallN(glUniform1fv, uintptr(3), uintptr(1), uintptr(unsafe.Pointer(&elapsed)))
|
||||||
|
syscall.SyscallN(glUniform2fv, uintptr(4), uintptr(1), uintptr(unsafe.Pointer(&clickX)))
|
||||||
|
|
||||||
|
glDrawArrays.Call(GL_TRIANGLE_STRIP, 0, 4)
|
||||||
|
|
||||||
|
if useSwap {
|
||||||
|
// 双缓冲呈现:走 DWM flip,嵌入子窗口也能合成上屏
|
||||||
|
procSwapBuffers.Call(glDC)
|
||||||
|
} else {
|
||||||
|
glFinish.Call()
|
||||||
|
// Read framebuffer → GDI blit to window
|
||||||
|
glReadPixels.Call(0, 0, uintptr(screenW), uintptr(screenH), GL_RGBA, GL_UNSIGNED_BYTE, uintptr(unsafe.Pointer(&pixelBuf[0])))
|
||||||
|
winDC, _, _ := procGetDC.Call(wallpaperHwnd)
|
||||||
|
procSetDIBitsToDevice.Call(
|
||||||
|
winDC,
|
||||||
|
0, 0,
|
||||||
|
uintptr(screenW), uintptr(screenH),
|
||||||
|
0, 0,
|
||||||
|
0, uintptr(screenH),
|
||||||
|
uintptr(unsafe.Pointer(&pixelBuf[0])),
|
||||||
|
uintptr(unsafe.Pointer(&bmi)),
|
||||||
|
DIB_RGB_COLORS,
|
||||||
|
)
|
||||||
|
procReleaseDC.Call(wallpaperHwnd, winDC)
|
||||||
|
}
|
||||||
|
|
||||||
|
frameCount++
|
||||||
|
if frameCount <= 3 {
|
||||||
|
log.Printf("FRAME#%d swap=%v", frameCount, useSwap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadExt(name string) uintptr {
|
||||||
|
cname, _ := windows.BytePtrFromString(name)
|
||||||
|
ptr, _, _ := procWglGetProcAddress.Call(uintptr(unsafe.Pointer(cname)))
|
||||||
|
if ptr == 0 {
|
||||||
|
log.Fatalf("ext not found: %s", name)
|
||||||
|
}
|
||||||
|
return ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadExtensions() {
|
||||||
|
glUseProgram = loadExt("glUseProgram")
|
||||||
|
glUniform1fv = loadExt("glUniform1fv")
|
||||||
|
glUniform2fv = loadExt("glUniform2fv")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupShaders() uint32 {
|
||||||
|
glCreateShader := loadExt("glCreateShader")
|
||||||
|
glShaderSource := loadExt("glShaderSource")
|
||||||
|
glCompileShader := loadExt("glCompileShader")
|
||||||
|
glGetShaderiv := loadExt("glGetShaderiv")
|
||||||
|
glGetShaderInfoLog := loadExt("glGetShaderInfoLog")
|
||||||
|
|
||||||
|
compile := func(st uint32, src string) uint32 {
|
||||||
|
s, _, _ := syscall.SyscallN(glCreateShader, uintptr(st))
|
||||||
|
cstr, _ := windows.BytePtrFromString(src)
|
||||||
|
p := uintptr(unsafe.Pointer(cstr))
|
||||||
|
syscall.SyscallN(glShaderSource, s, 1, uintptr(unsafe.Pointer(&p)), 0)
|
||||||
|
syscall.SyscallN(glCompileShader, s)
|
||||||
|
var status int32
|
||||||
|
syscall.SyscallN(glGetShaderiv, s, uintptr(GL_COMPILE_STATUS), uintptr(unsafe.Pointer(&status)))
|
||||||
|
if status == 0 {
|
||||||
|
var logLen int32
|
||||||
|
syscall.SyscallN(glGetShaderiv, s, uintptr(GL_INFO_LOG_LENGTH), uintptr(unsafe.Pointer(&logLen)))
|
||||||
|
if logLen > 0 {
|
||||||
|
logBuf := make([]byte, logLen)
|
||||||
|
syscall.SyscallN(glGetShaderInfoLog, s, uintptr(logLen), 0, uintptr(unsafe.Pointer(&logBuf[0])))
|
||||||
|
log.Fatalf("shader compile failed:\n%s", string(logBuf[:logLen]))
|
||||||
|
}
|
||||||
|
log.Fatal("shader compile failed (no info log)")
|
||||||
|
}
|
||||||
|
return uint32(s)
|
||||||
|
}
|
||||||
|
vs := compile(GL_VERTEX_SHADER, vertexSrc)
|
||||||
|
fs := compile(GL_FRAGMENT_SHADER, fragmentSrc)
|
||||||
|
glCreateProgram := loadExt("glCreateProgram")
|
||||||
|
prog, _, _ := syscall.SyscallN(glCreateProgram)
|
||||||
|
syscall.SyscallN(loadExt("glAttachShader"), prog, uintptr(vs))
|
||||||
|
syscall.SyscallN(loadExt("glAttachShader"), prog, uintptr(fs))
|
||||||
|
syscall.SyscallN(loadExt("glLinkProgram"), prog)
|
||||||
|
log.Println("Program:", prog)
|
||||||
|
var vao, vbo uint32
|
||||||
|
syscall.SyscallN(loadExt("glGenVertexArrays"), 1, uintptr(unsafe.Pointer(&vao)))
|
||||||
|
syscall.SyscallN(loadExt("glGenBuffers"), 1, uintptr(unsafe.Pointer(&vbo)))
|
||||||
|
quad := [8]float32{-1, -1, 1, -1, -1, 1, 1, 1}
|
||||||
|
syscall.SyscallN(loadExt("glBindVertexArray"), uintptr(vao))
|
||||||
|
syscall.SyscallN(loadExt("glBindBuffer"), uintptr(GL_ARRAY_BUFFER), uintptr(vbo))
|
||||||
|
syscall.SyscallN(loadExt("glBufferData"), uintptr(GL_ARRAY_BUFFER), uintptr(len(quad)*4), uintptr(unsafe.Pointer(&quad[0])), uintptr(GL_STATIC_DRAW))
|
||||||
|
syscall.SyscallN(loadExt("glEnableVertexAttribArray"), 0)
|
||||||
|
syscall.SyscallN(loadExt("glVertexAttribPointer"), 0, 2, uintptr(GL_FLOAT), 0, 0, 0)
|
||||||
|
return uint32(prog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWindow() {
|
||||||
|
var hinstance windows.Handle
|
||||||
|
windows.GetModuleHandleEx(0, nil, &hinstance)
|
||||||
|
className, _ := windows.UTF16PtrFromString("GLWallpaper")
|
||||||
|
wndProc := windows.NewCallback(func(hwnd uintptr, msg uint32, wp, lp uintptr) uintptr {
|
||||||
|
if msg == WM_DESTROY || msg == WM_CLOSE {
|
||||||
|
procPostQuitMessage.Call(0)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
ret, _, _ := procDefWindowProcW.Call(hwnd, uintptr(msg), wp, lp)
|
||||||
|
return ret
|
||||||
|
})
|
||||||
|
wc := wndClassEx{
|
||||||
|
CbSize: uint32(unsafe.Sizeof(wndClassEx{})),
|
||||||
|
LpfnWndProc: wndProc,
|
||||||
|
HInstance: uintptr(hinstance),
|
||||||
|
LpszClassName: className,
|
||||||
|
}
|
||||||
|
procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc)))
|
||||||
|
windowName, _ := windows.UTF16PtrFromString("GLWallpaper")
|
||||||
|
wallpaperHwnd, _, _ = procCreateWindowExW.Call(
|
||||||
|
0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(windowName)),
|
||||||
|
WS_POPUP|WS_VISIBLE, 0, 0, uintptr(screenW), uintptr(screenH),
|
||||||
|
0, 0, uintptr(hinstance), 0,
|
||||||
|
)
|
||||||
|
if wallpaperHwnd == 0 {
|
||||||
|
log.Fatal("CreateWindow failed")
|
||||||
|
}
|
||||||
|
procShowWindow.Call(wallpaperHwnd, 5)
|
||||||
|
log.Println("Window:", wallpaperHwnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initGL() {
|
||||||
|
glDC, _, _ = procGetDC.Call(wallpaperHwnd)
|
||||||
|
if glDC == 0 {
|
||||||
|
log.Fatal("GetDC failed")
|
||||||
|
}
|
||||||
|
flags := uint32(PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL)
|
||||||
|
if useSwap {
|
||||||
|
flags |= PFD_DOUBLEBUFFER
|
||||||
|
}
|
||||||
|
desc := pfd{Size: uint16(unsafe.Sizeof(pfd{})), Version: 1, Flags: flags, PixelType: 0, ColorBits: 32, DepthBits: 24}
|
||||||
|
pf, _, _ := procChoosePixelFormat.Call(glDC, uintptr(unsafe.Pointer(&desc)))
|
||||||
|
if pf == 0 {
|
||||||
|
log.Fatal("ChoosePixelFormat failed")
|
||||||
|
}
|
||||||
|
ok, _, _ := procSetPixelFormat.Call(glDC, pf, uintptr(unsafe.Pointer(&desc)))
|
||||||
|
if ok == 0 {
|
||||||
|
log.Fatal("SetPixelFormat failed")
|
||||||
|
}
|
||||||
|
glCtx, _, _ = procWglCreateContext.Call(glDC)
|
||||||
|
if glCtx == 0 {
|
||||||
|
log.Fatal("wglCreateContext failed")
|
||||||
|
}
|
||||||
|
mcR, _, _ := procWglMakeCurrent.Call(glDC, glCtx)
|
||||||
|
ver, _, _ := glGetString.Call(GL_VERSION)
|
||||||
|
log.Printf("MakeCurrent=%d GL=%s", mcR, windows.BytePtrToString((*byte)(unsafe.Pointer(ver))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanup() {
|
||||||
|
if glCtx != 0 {
|
||||||
|
procWglMakeCurrent.Call(0, 0)
|
||||||
|
procWglDeleteContext.Call(glCtx)
|
||||||
|
}
|
||||||
|
if glDC != 0 {
|
||||||
|
procReleaseDC.Call(wallpaperHwnd, glDC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func classOf(hwnd uintptr) string {
|
||||||
|
buf := make([]uint16, 256)
|
||||||
|
procGetClassNameW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), 256)
|
||||||
|
return windows.UTF16ToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumTops(tag string) {
|
||||||
|
cb := windows.NewCallback(func(hwnd, _ uintptr) uintptr {
|
||||||
|
cls := classOf(hwnd)
|
||||||
|
if cls == "WorkerW" || cls == "Progman" {
|
||||||
|
dv, _, _ := procFindWindowExW.Call(hwnd, 0, uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("SHELLDLL_DefView"))), 0)
|
||||||
|
var r rect
|
||||||
|
procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&r)))
|
||||||
|
full := ""
|
||||||
|
if r.Right-r.Left == screenW && r.Bottom-r.Top == screenH {
|
||||||
|
full = " <FULLSCREEN>"
|
||||||
|
}
|
||||||
|
log.Printf("[%s] hwnd=%x class=%-8s DefView=%x rect=(%d,%d,%d,%d)%s", tag, hwnd, cls, dv, r.Left, r.Top, r.Right, r.Bottom, full)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
procEnumWindows.Call(cb, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probe 用权威参数 WPARAM=0xD 发消息,多轮观察 WorkerW 是否分离出来
|
||||||
|
func probe() {
|
||||||
|
screenW, screenH = getDesktopSize()
|
||||||
|
progman, _, _ := procFindWindowW.Call(uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman"))), 0)
|
||||||
|
log.Printf("Progman=%x screen=%dx%d", progman, screenW, screenH)
|
||||||
|
var discard uintptr
|
||||||
|
procSendMsgTimeout.Call(progman, 0x052C, 0xD, 0, 0x0000, 1000, uintptr(unsafe.Pointer(&discard)))
|
||||||
|
procSendMsgTimeout.Call(progman, 0x052C, 0xD, 1, 0x0000, 1000, uintptr(unsafe.Pointer(&discard)))
|
||||||
|
log.Println("sent 0x052C WPARAM=0xD (LPARAM 0 and 1)")
|
||||||
|
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
log.Printf("--- round %d ---", i)
|
||||||
|
enumTops("top")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWorkerW() uintptr {
|
||||||
|
progman, _, _ := procFindWindowW.Call(uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman"))), 0)
|
||||||
|
if progman == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var discard uintptr
|
||||||
|
procSendMsgTimeout.Call(progman, 0x052C, 0, 0, 0x0000, 1000, uintptr(unsafe.Pointer(&discard)))
|
||||||
|
sdv, _, _ := procFindWindowExW.Call(progman, 0, uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("SHELLDLL_DefView"))), 0)
|
||||||
|
if sdv != 0 {
|
||||||
|
ww, _, _ := procFindWindowExW.Call(progman, sdv, uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("WorkerW"))), 0)
|
||||||
|
if ww != 0 {
|
||||||
|
return ww
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ww, _, _ := procFindWindowExW.Call(progman, 0, uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("WorkerW"))), 0)
|
||||||
|
return ww
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDesktopSize() (int32, int32) {
|
||||||
|
var mr, mb int32
|
||||||
|
cb := windows.NewCallback(func(_, _, lprc, _ uintptr) uintptr {
|
||||||
|
r := (*rect)(unsafe.Pointer(lprc))
|
||||||
|
if r.Right > mr {
|
||||||
|
mr = r.Right
|
||||||
|
}
|
||||||
|
if r.Bottom > mb {
|
||||||
|
mb = r.Bottom
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
procEnumDisplayMonitors.Call(0, 0, cb, 0)
|
||||||
|
return mr, mb
|
||||||
|
}
|
||||||
|
|
||||||
|
func now() float64 { return float64(time.Now().UnixNano())/1e9 - startTime }
|
||||||
13
docs/backup-opengl/shader.go.bak
Normal file
13
docs/backup-opengl/shader.go.bak
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
const vertexSrc = `#version 430 core
|
||||||
|
in vec2 a_pos;
|
||||||
|
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
|
||||||
|
`
|
||||||
|
|
||||||
|
const fragmentSrc = `#version 430 core
|
||||||
|
out vec4 FragColor;
|
||||||
|
void main() {
|
||||||
|
FragColor = vec4(0.0, 1.0, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`
|
||||||
13
docs/backup-opengl/shader_opengl.go.txt
Normal file
13
docs/backup-opengl/shader_opengl.go.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
const vertexSrc = `#version 430 core
|
||||||
|
in vec2 a_pos;
|
||||||
|
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
|
||||||
|
`
|
||||||
|
|
||||||
|
const fragmentSrc = `#version 430 core
|
||||||
|
out vec4 FragColor;
|
||||||
|
void main() {
|
||||||
|
FragColor = vec4(0.0, 1.0, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`
|
||||||
208
docs/wallpaper-embedding.md
Normal file
208
docs/wallpaper-embedding.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Win11 桌面壁纸嵌入技术笔记
|
||||||
|
|
||||||
|
> 环境: Win11 Build 26200 (Germanium, 24H2+) | 3240x2160 @200% DPI | Go 1.26
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心结论
|
||||||
|
|
||||||
|
**Germanium 平台上,WorkerW 子窗口必须经 DirectComposition 合成才上屏。** 传统 GDI blt / OpenGL SwapBuffers 一律不被 DWM 合成。WebView2 能显示是因其内部走了 DComp 管线。
|
||||||
|
|
||||||
|
| 方案 | 是否可行 | 资源占用 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| WebView2 (DComp) | ✅ | ~704MB (全屏 WebGL, GPU 子进程 425MB) |
|
||||||
|
| 原生 OpenGL (SwapBuffers) | ❌ | 渲染正常但不可见 |
|
||||||
|
| D3D11 + DirectComposition | 理论可行,未实现 | 预估 ~30-60MB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 成功配方 (WebView2, 6 步缺一不可)
|
||||||
|
|
||||||
|
### 1. SetProcessDPIAware
|
||||||
|
|
||||||
|
```go
|
||||||
|
procSetProcessDPIAware.Call()
|
||||||
|
```
|
||||||
|
|
||||||
|
否则 200% DPI 下 `GetSystemMetrics` 返回逻辑像素 1620x1080,壁纸只占左上 1/4。必须在最开头调用。
|
||||||
|
|
||||||
|
### 2. 找到正确 WorkerW
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 1. 发送 0x052C 消息让 Progman 创建新 WorkerW
|
||||||
|
procSendMessageTimeoutW.Call(progman, 0x052C, 0, 0, 0x0000, 1000, ...)
|
||||||
|
|
||||||
|
// 2. 找 SHELLDLL_DefView
|
||||||
|
shellDefView := FindWindowExW(progman, 0, "SHELLDLL_DefView", 0)
|
||||||
|
|
||||||
|
// 3. 找 SHELLDLL_DefView 之后的 WorkerW ← 这是关键
|
||||||
|
workerw := FindWindowExW(progman, shellDefView, "WorkerW", 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:必须是 `SHELLDLL_DefView` **之后**的那个 WorkerW,不是随便一个 WorkerW。
|
||||||
|
|
||||||
|
### 3. SetParent 嵌入
|
||||||
|
|
||||||
|
```go
|
||||||
|
procSetParent.Call(wvHwnd, workerw)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 去边框 (GWL_STYLE)
|
||||||
|
|
||||||
|
```go
|
||||||
|
const GWL_STYLE = ^uintptr(15) // -16, 不是 ^uintptr(0)>>1-19
|
||||||
|
procSetWindowLongPtrW.Call(wvHwnd, GWL_STYLE,
|
||||||
|
uintptr(WS_POPUP|WS_VISIBLE|WS_CLIPCHILDREN))
|
||||||
|
```
|
||||||
|
|
||||||
|
**GWL_STYLE = -16 的正确写法是 `^uintptr(15)`**。常见错误 `^uint32(0)>>1-19` = `0x7FFFFFEC`(大正数),根本不是 -16,SetWindowLongPtrW 不会报错但修改的是错误的属性。
|
||||||
|
|
||||||
|
**切忌写成 -4**(那是 GWL_WNDPROC),会覆盖窗口过程指针导致 exit 127 崩溃。
|
||||||
|
|
||||||
|
### 5. 自定义消息循环替代 wv.Run()
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 不用 wv.Run(),用标准 GetMessage 循环
|
||||||
|
var m winMsg
|
||||||
|
for {
|
||||||
|
r, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
||||||
|
if r == 0 { return }
|
||||||
|
procTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么不能 wv.Run()**: go-webview2 的 Run() 内部用 `GetAncestor(GA_ROOT)` 判断消息路由。SetParent 到 WorkerW 后,窗口根祖先变成 Progman(explorer),Run() 的消息循环异常结束 → WebView2 停止渲染,壁纸冻结。
|
||||||
|
|
||||||
|
### 6. 延迟嵌入 + 直接调用
|
||||||
|
|
||||||
|
```go
|
||||||
|
go func() {
|
||||||
|
time.Sleep(3 * time.Second) // 等 WebView2 初始化完成
|
||||||
|
wvHwnd := uintptr(wv.Window())
|
||||||
|
procSetParent.Call(wvHwnd, workerw) // user32 调用,跨线程安全
|
||||||
|
procSetWindowLongPtrW.Call(...)
|
||||||
|
procMoveWindow.Call(...)
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
不能用 `wv.Dispatch` 嵌入——它依赖 `wv.Run()` 驱动,自定义消息循环不驱动它 → Dispatch 回调永不执行。SetParent/MoveWindow/SetWindowLongPtr 是 Win32 API,跨线程安全,直接调即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 踩坑记录
|
||||||
|
|
||||||
|
### explorer 桌面层损坏
|
||||||
|
|
||||||
|
反复 SetParent / kill 进程会把桌面 WorkerW 层搞坏(连之前能跑的产物都不显示)。
|
||||||
|
|
||||||
|
**症状**: 枚举找不到全屏 WorkerW、SHELLDLL_DefView 直接挂在 Progman 下。
|
||||||
|
|
||||||
|
**修复**: 重启 explorer.exe 重建干净结构。
|
||||||
|
|
||||||
|
### OpenGL 不可见
|
||||||
|
|
||||||
|
在干净环境下(explorer 重启后 + 正确 WorkerW + SwapBuffers 双缓冲 + 渲染像素验证 RGBA=0,255,0,255 正确),OpenGL 窗口仍不显示,透出系统壁纸。
|
||||||
|
|
||||||
|
**根因**: Germanium 上桌面壁纸层的子窗口必须经 DirectComposition 合成。OpenGL SwapBuffers 走传统 GDI blt 路径,DWM 不合成。
|
||||||
|
|
||||||
|
#### 尝试过的方案及结果
|
||||||
|
|
||||||
|
| 方案 | 结果 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| SwapBuffers(双缓冲) | ❌ 帧缓冲正确但不可见 | glReadPixels 确认 RGBA=0,255,0,255 |
|
||||||
|
| SetParent 前初始化 GL | ❌ | GL context 在 SetParent 后可能与 DC 断联 |
|
||||||
|
| SetParent 后初始化 GL | ❌ | 调换顺序无改善 |
|
||||||
|
| WS_CHILD 样式 + SetWindowPos | ❌ | 窗口样式调整不影响 DWM 合成 |
|
||||||
|
| glReadPixels → GDI SetDIBitsToDevice | ❌ | GDI blit 也不被 DWM 合成 |
|
||||||
|
| 去掉 PFD_DOUBLEBUFFER | ❌ | 单缓冲也无改善 |
|
||||||
|
|
||||||
|
**结论**: WorkerW 子窗口的呈现链路被 DWM 接管,只有通过 DirectComposition 提交的图面才能上屏。GDI BitBlt/SetDIBitsToDevice、OpenGL SwapBuffers 全部走传统路径,DWM 不处理。
|
||||||
|
|
||||||
|
#### Go + OpenGL 踩坑汇总
|
||||||
|
|
||||||
|
1. **Go syscall 不能传浮点参数**: Windows x64 用 XMM 寄存器传 float,但 Go 的 `syscall.SyscallN` 只设 GPR 寄存器 (RCX/RDX/R8/R9)。`glClearColor(1,0,0,1)` 实际传入 `(0,0,0,0)`。**必须用指针变体**: `glUniform1fv`, `glUniform2fv`, `glClearBufferfv`。
|
||||||
|
|
||||||
|
2. **glClearBufferfv 错误 1280 (GL_INVALID_ENUM)**: 第一个参数用了 `GL_COLOR_BUFFER_BIT` (0x4060),正确值是 `GL_COLOR` (0x1800)。改用 `glClear(GL_COLOR_BUFFER_BIT)` 更简单。
|
||||||
|
|
||||||
|
3. **wglGetProcAddress 不能加载 GL 1.1 函数**: `glGetError`, `glClear`, `glFlush` 等是 GL 1.1,直接从 `opengl32.dll` 导出,`wglGetProcAddress` 返回 0。GL 1.2+ 才用 `wglGetProcAddress`。
|
||||||
|
|
||||||
|
4. **PFD 结构体必须 40 字节**: Go 结构体布局要与 C 的 `PIXELFORMATDESCRIPTOR` 完全一致。关键字段: `Size` 和 `Version` 是 `uint16`,`Flags` 是 `uint32`,中间 20 个 `byte`,末尾 3 个 `uint32`。
|
||||||
|
|
||||||
|
5. **shader 用 `layout(location=N)`**: `glGetUniformLocation` 在 Intel GPU 上崩溃 (0xC0000005)。用 GLSL 430 的 `layout(location=N)` 绑定 uniform 位置避免调用此函数。
|
||||||
|
|
||||||
|
6. **wglCreateContext vs wglCreateContextAttribsARB**: 前者创建遗留 context,后者创建 Core Profile。`wglCreateContext` 在 Intel 驱动上也能拿到 GL 4.6 context。
|
||||||
|
|
||||||
|
### WebView2 降资源方案(未实现)
|
||||||
|
|
||||||
|
wallpaper.html 里把 `RENDER_SCALE` 从 1.0 降到 0.5,canvas 渲染分辨率减半再拉伸。GPU 子进程内存可从 425MB 大幅降低,视觉略糊。比重写 DComp 省事得多。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
u-desktop/
|
||||||
|
├── main.go # Go 层: WebView2 创建 + WorkerW 嵌入 + 系统托盘
|
||||||
|
├── wallpaper.html # 渲染层: WebGL 极光 + 天气组件 + 星座运势
|
||||||
|
├── backup-opengl/ # OpenGL 方案备份(已确证不可行)
|
||||||
|
├── go.mod / go.sum
|
||||||
|
└── docs/
|
||||||
|
└── wallpaper-embedding.md # 本文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
github.com/jchv/go-webview2 # WebView2 绑定
|
||||||
|
github.com/getlantern/systray # 系统托盘
|
||||||
|
golang.org/x/sys/windows # Win32 API
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开机自启
|
||||||
|
|
||||||
|
注册表 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\UDesktopWallpaper` = `u-desktop.exe 路径`
|
||||||
|
|
||||||
|
## 构建 & 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o u-desktop.exe .
|
||||||
|
.\u-desktop.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
托盘图标右键: 暂停/继续、星座设置(子菜单)、城市选择(点击天气文字)、退出。全屏应用自动暂停渲染。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2025-05-25 踩坑追加
|
||||||
|
|
||||||
|
### IP 定位在国内不准 / 永远显示上海
|
||||||
|
|
||||||
|
**症状**: 天气城市始终显示上海,IP 定位不准。
|
||||||
|
|
||||||
|
**根因链路**:
|
||||||
|
1. `ipify.org` 在国内被墙 → 获取公网 IP 失败
|
||||||
|
2. `fallbackLocation()` 按预置城市列表顺序逐个试天气 API
|
||||||
|
3. 上海排在第一位,且天气 API 返回成功 → 固定为上海
|
||||||
|
4. 即便 ipify 能用,和风天气 GeoAPI 对部分 IP 段(如移动 `111.60.x.x`)返回 404
|
||||||
|
|
||||||
|
**修复**: IP 定位改为 `myip.ipip.net`(国内可用,直接返回城市名文本),从文本提取城市名匹配预置列表。保留 ipify + QWeather GeoAPI 作为降级源。优先级:`localStorage 手动选择` > `ipip.net` > `QWeather GeoAPI` > `fallback 逐城尝试`
|
||||||
|
|
||||||
|
### WebGL requestAnimationFrame 导致电脑卡顿
|
||||||
|
|
||||||
|
**症状**: 启动后系统明显卡顿。
|
||||||
|
|
||||||
|
**根因**: WebGL 极光 shader 以 60fps 全屏渲染持续占 GPU。壁纸场景动画是慢波效果,不需要高帧率。
|
||||||
|
|
||||||
|
**修复**: 帧率限制为 15fps,视觉无差异,GPU 占用降 ~75%。
|
||||||
|
|
||||||
|
### 壁纸层 WebView 无法弹出 Modal 交互
|
||||||
|
|
||||||
|
**症状**: 托盘菜单"星座设置"点击无反应。
|
||||||
|
|
||||||
|
**根因链路**:
|
||||||
|
1. Go 调用的 JS 函数名 `showZodiacSettings()` 不存在,实际函数名是 `openModal()` → 修复后仍无效
|
||||||
|
2. WebView 已 `SetParent` 到 WorkerW(壁纸层),被桌面图标层遮挡
|
||||||
|
3. Modal 在壁纸层渲染,用户看不到也点不到
|
||||||
|
|
||||||
|
**修复**: 星座设置改为托盘子菜单直接选择(`AddSubMenuItem`),不依赖 WebView 交互。城市选择通过点击天气文字触发,因壁纸层可接收鼠标事件(WorkerW 的子窗口不在图标层之上但仍可点击)。
|
||||||
96
docs/wallpaper-test.html
Normal file
96
docs/wallpaper-test.html
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>测试</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; overflow: hidden; background: #000; }
|
||||||
|
#test {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #fff;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #5865f2;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 15px 30px;
|
||||||
|
font-size: 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
#result {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(76, 175, 80, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="test">
|
||||||
|
<div>🧪 WebView2 测试页面</div>
|
||||||
|
<button id="btn1">测试按钮1</button>
|
||||||
|
<button id="btn2">测试按钮2</button>
|
||||||
|
<div id="result">等待点击...</div>
|
||||||
|
<div id="time"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
console.log('=== 页面已加载 ===');
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
console.log('DOM loaded');
|
||||||
|
|
||||||
|
// 方法1:addEventListener(推荐)
|
||||||
|
const btn1 = document.getElementById('btn1');
|
||||||
|
btn1.addEventListener('click', function() {
|
||||||
|
console.log('按钮1被点击');
|
||||||
|
document.getElementById('result').innerHTML = '✅ 按钮1点击成功!<br>时间: ' + new Date().toLocaleTimeString();
|
||||||
|
document.getElementById('result').style.background = 'rgba(76, 175, 80, 0.6)';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 方法2:addEventListener
|
||||||
|
const btn2 = document.getElementById('btn2');
|
||||||
|
btn2.addEventListener('click', function() {
|
||||||
|
console.log('按钮2被点击');
|
||||||
|
document.getElementById('result').innerHTML = '✅ 按钮2点击成功!<br>时间: ' + new Date().toLocaleTimeString();
|
||||||
|
document.getElementById('result').style.background = 'rgba(33, 150, 243, 0.6)';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 全局点击测试
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
console.log('全局点击事件:', e.target.tagName, e.target.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('事件监听器已设置');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新时间
|
||||||
|
setInterval(() => {
|
||||||
|
const timeEl = document.getElementById('time');
|
||||||
|
if (timeEl) {
|
||||||
|
timeEl.textContent = '时间: ' + new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 鼠标移动测试
|
||||||
|
let moveCount = 0;
|
||||||
|
document.addEventListener('mousemove', function() {
|
||||||
|
moveCount++;
|
||||||
|
if (moveCount % 60 === 0) {
|
||||||
|
console.log('鼠标移动计数:', moveCount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('=== 初始化完成 ===');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
go.mod
Normal file
21
go.mod
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module u-desktop
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
|
||||||
|
golang.org/x/sys v0.45.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
|
||||||
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
||||||
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
|
||||||
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
|
||||||
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
|
||||||
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
||||||
|
github.com/getlantern/systray v1.2.2 // indirect
|
||||||
|
github.com/go-stack/stack v1.8.0 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||||
|
)
|
||||||
36
go.sum
Normal file
36
go.sum
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||||
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||||
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
||||||
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||||
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
|
||||||
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||||
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
|
||||||
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
|
||||||
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
|
||||||
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
|
||||||
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
|
||||||
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||||
|
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
|
||||||
|
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||||
|
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
|
||||||
|
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||||
|
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||||
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||||
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||||
729
main.go
Normal file
729
main.go
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/getlantern/systray"
|
||||||
|
"github.com/jchv/go-webview2"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed wallpaper.html
|
||||||
|
var fs embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||||
|
procFindWindowW = user32.NewProc("FindWindowW")
|
||||||
|
procFindWindowExW = user32.NewProc("FindWindowExW")
|
||||||
|
procSendMessageTimeoutW = user32.NewProc("SendMessageTimeoutW")
|
||||||
|
procSetParent = user32.NewProc("SetParent")
|
||||||
|
procGetForegroundWindow = user32.NewProc("GetForegroundWindow")
|
||||||
|
procGetWindowRect = user32.NewProc("GetWindowRect")
|
||||||
|
procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
|
||||||
|
procMoveWindow = user32.NewProc("MoveWindow")
|
||||||
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
|
procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware")
|
||||||
|
procSetWindowLongPtrW = user32.NewProc("SetWindowLongPtrW")
|
||||||
|
procGetMessageW = user32.NewProc("GetMessageW")
|
||||||
|
procPostMessageW = user32.NewProc("PostMessageW")
|
||||||
|
procTranslateMessage = user32.NewProc("TranslateMessage")
|
||||||
|
procDispatchMessageW = user32.NewProc("DispatchMessageW")
|
||||||
|
)
|
||||||
|
|
||||||
|
var wv webview2.WebView
|
||||||
|
var configPath string
|
||||||
|
var wvHwnd uintptr
|
||||||
|
var jsQueue = make(chan string, 64)
|
||||||
|
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
var paused int32 // atomic: 0=running, 1=paused
|
||||||
|
|
||||||
|
const wmEvalJS = 0x0401 // WM_USER + 1
|
||||||
|
|
||||||
|
func evalJS(js string) {
|
||||||
|
select {
|
||||||
|
case jsQueue <- js:
|
||||||
|
log.Println("📤 evalJS queued:", js[:min(60, len(js))])
|
||||||
|
default:
|
||||||
|
log.Println("⚠️ jsQueue full, dropping eval")
|
||||||
|
}
|
||||||
|
if wvHwnd != 0 {
|
||||||
|
ret, _, _ := procPostMessageW.Call(wvHwnd, wmEvalJS, 0, 0)
|
||||||
|
log.Printf("📤 PostMessageW ret=%d hwnd=0x%x", ret, wvHwnd)
|
||||||
|
} else {
|
||||||
|
log.Println("⚠️ wvHwnd is 0, skip PostMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config ──
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Zodiac string `json:"zodiac"`
|
||||||
|
City string `json:"city"` // QWeather city ID, 空=自动定位
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultZodiac = "射手座"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
||||||
|
|
||||||
|
// 单实例互斥锁,防止双托盘
|
||||||
|
mutexName, _ := windows.UTF16PtrFromString("Global\\u-desktop-single-instance")
|
||||||
|
mutex, err := windows.CreateMutex(nil, false, mutexName)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("创建互斥锁失败:", err)
|
||||||
|
}
|
||||||
|
if windows.GetLastError() == windows.ERROR_ALREADY_EXISTS {
|
||||||
|
log.Println("⚠️ 已有实例运行,退出")
|
||||||
|
windows.CloseHandle(mutex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(mutex)
|
||||||
|
|
||||||
|
exePath, _ := os.Executable()
|
||||||
|
configDir := filepath.Join(filepath.Dir(exePath), "config")
|
||||||
|
os.MkdirAll(configDir, 0755)
|
||||||
|
configPath = filepath.Join(configDir, "settings.json")
|
||||||
|
procSetProcessDPIAware.Call()
|
||||||
|
systray.Run(onSystrayReady, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() *Config {
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return &Config{Zodiac: defaultZodiac}
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
if json.Unmarshal(data, &cfg) != nil {
|
||||||
|
return &Config{Zodiac: defaultZodiac}
|
||||||
|
}
|
||||||
|
if cfg.Zodiac == "" {
|
||||||
|
cfg.Zodiac = defaultZodiac
|
||||||
|
}
|
||||||
|
return &cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveConfig(cfg *Config) error {
|
||||||
|
data, _ := json.MarshalIndent(cfg, "", " ")
|
||||||
|
return os.WriteFile(configPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Icon ──
|
||||||
|
|
||||||
|
func generateIcon() []byte {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 16, 16))
|
||||||
|
c := color.RGBA{R: 88, G: 101, B: 242, A: 255}
|
||||||
|
for y := 0; y < 16; y++ {
|
||||||
|
for x := 0; x < 16; x++ {
|
||||||
|
img.Set(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
png.Encode(&buf, img)
|
||||||
|
pngData := buf.Bytes()
|
||||||
|
ico := make([]byte, 22+len(pngData))
|
||||||
|
binary.LittleEndian.PutUint16(ico[0:], 0)
|
||||||
|
binary.LittleEndian.PutUint16(ico[2:], 1)
|
||||||
|
binary.LittleEndian.PutUint16(ico[4:], 1)
|
||||||
|
ico[6], ico[7], ico[8], ico[9] = 16, 16, 0, 0
|
||||||
|
binary.LittleEndian.PutUint16(ico[10:], 1)
|
||||||
|
binary.LittleEndian.PutUint16(ico[12:], 32)
|
||||||
|
binary.LittleEndian.PutUint32(ico[14:], uint32(len(pngData)))
|
||||||
|
binary.LittleEndian.PutUint32(ico[18:], 22)
|
||||||
|
copy(ico[22:], pngData)
|
||||||
|
return ico
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Weather ──
|
||||||
|
|
||||||
|
const qweatherKey = "3b67b65a53c04170b602d2d1a7e6096f"
|
||||||
|
const qweatherHost = "https://pb4nmv4qnu.re.qweatherapi.com"
|
||||||
|
|
||||||
|
type City struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Adm2 string `json:"adm2"`
|
||||||
|
Adm1 string `json:"adm1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var cities = []City{
|
||||||
|
{"101010100", "北京", "北京", "北京"},
|
||||||
|
{"101020100", "上海", "上海", "上海"},
|
||||||
|
{"101280101", "广州", "广州", "广东"},
|
||||||
|
{"101280601", "深圳", "深圳", "广东"},
|
||||||
|
{"101200101", "武汉", "武汉", "湖北"},
|
||||||
|
{"101110101", "西安", "西安", "陕西"},
|
||||||
|
{"101210101", "杭州", "杭州", "浙江"},
|
||||||
|
{"101190101", "南京", "南京", "江苏"},
|
||||||
|
{"101070101", "沈阳", "沈阳", "辽宁"},
|
||||||
|
{"101050101", "哈尔滨", "哈尔滨", "黑龙江"},
|
||||||
|
{"101250101", "长沙", "长沙", "湖南"},
|
||||||
|
{"101270101", "成都", "成都", "四川"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultCity = City{"101200101", "武汉", "武汉", "湖北"}
|
||||||
|
|
||||||
|
type hourlyItem struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Temp string `json:"temp"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyItem struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
TempMin string `json:"tempMin"`
|
||||||
|
TempMax string `json:"tempMax"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var weatherIcons = map[string]string{
|
||||||
|
"晴": "☀️", "多云": "⛅", "阴": "☁️",
|
||||||
|
"雨": "🌧️", "雪": "❄️", "雷": "⛈️",
|
||||||
|
"雾": "🌫️", "霾": "😷", "风": "💨",
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWeatherIcon(text string) string {
|
||||||
|
for k, v := range weatherIcons {
|
||||||
|
if strings.Contains(text, k) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "🌤️"
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpGet(url string) ([]byte, error) {
|
||||||
|
resp, err := httpClient.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLocation 自动定位,返回城市
|
||||||
|
func getLocation() City {
|
||||||
|
// 1. 尝试 ipip.net
|
||||||
|
if city := locateByIPIP(); city != nil {
|
||||||
|
return *city
|
||||||
|
}
|
||||||
|
// 2. 尝试 QWeather GeoAPI
|
||||||
|
if city := locateByQWeather(); city != nil {
|
||||||
|
return *city
|
||||||
|
}
|
||||||
|
// 3. fallback
|
||||||
|
log.Println("⚠️ 所有定位失败,使用默认城市:", defaultCity.Name)
|
||||||
|
return defaultCity
|
||||||
|
}
|
||||||
|
|
||||||
|
func locateByIPIP() *City {
|
||||||
|
data, err := httpGet("https://myip.ipip.net")
|
||||||
|
if err != nil {
|
||||||
|
log.Println("ipip.net 不可用:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
log.Println("ipip.net 响应:", text)
|
||||||
|
re := regexp.MustCompile(`来自于[::]\s*(.+?)\s*$`)
|
||||||
|
m := re.FindStringSubmatch(text)
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Fields(m[1])
|
||||||
|
cityName := parts[0]
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
cityName = parts[2]
|
||||||
|
} else if len(parts) >= 2 {
|
||||||
|
cityName = parts[1]
|
||||||
|
}
|
||||||
|
for _, c := range cities {
|
||||||
|
if c.Name == cityName {
|
||||||
|
log.Println("✅ ipip.net 匹配:", c.Name)
|
||||||
|
return &c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Println("ipip.net 城市", cityName, "不在预置列表中")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func locateByQWeather() *City {
|
||||||
|
data, err := httpGet("https://myip.ipip.net")
|
||||||
|
if err == nil {
|
||||||
|
re := regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`)
|
||||||
|
if m := re.FindStringSubmatch(string(data)); m != nil {
|
||||||
|
return geoLookup(m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func geoLookup(ip string) *City {
|
||||||
|
url := fmt.Sprintf(qweatherHost + "/v2/city/lookup?location=%s&key=%s", ip, qweatherKey)
|
||||||
|
data, err := httpGet(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Location []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Adm2 string `json:"adm2"`
|
||||||
|
Adm1 string `json:"adm1"`
|
||||||
|
} `json:"location"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &resp) != nil || resp.Code != "200" || len(resp.Location) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
loc := resp.Location[0]
|
||||||
|
// 优先匹配预置列表
|
||||||
|
for _, c := range cities {
|
||||||
|
if c.Name == loc.Name {
|
||||||
|
return &c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &City{loc.ID, loc.Name, loc.Adm2, loc.Adm1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCurrentCity 获取当前城市(手动优先 > 自动定位)
|
||||||
|
func getCurrentCity() City {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if cfg.City != "" {
|
||||||
|
for _, c := range cities {
|
||||||
|
if c.ID == cfg.City {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAndPushWeather 获取天气并推送给 JS
|
||||||
|
func fetchAndPushWeather(city City) {
|
||||||
|
type weatherData struct {
|
||||||
|
Current string `json:"current"`
|
||||||
|
Hourly []hourlyItem `json:"hourly"`
|
||||||
|
Daily []dailyItem `json:"daily"`
|
||||||
|
City string `json:"city"`
|
||||||
|
}
|
||||||
|
|
||||||
|
wd := weatherData{}
|
||||||
|
cityName := city.Name
|
||||||
|
if city.Adm2 != city.Name {
|
||||||
|
cityName = city.Adm2 + " " + city.Name
|
||||||
|
}
|
||||||
|
wd.City = cityName
|
||||||
|
|
||||||
|
// 当前天气
|
||||||
|
if now := fetchCurrentWeather(city.ID); now != nil {
|
||||||
|
icon := getWeatherIcon(now.Text)
|
||||||
|
wd.Current = fmt.Sprintf("📍 %s %s %s %s°C", cityName, icon, now.Text, now.Temp)
|
||||||
|
} else {
|
||||||
|
wd.Current = fmt.Sprintf("📍 %s ☀️ 晴 24°C", cityName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24h 预报
|
||||||
|
wd.Hourly = fetchHourlyForecast(city.ID)
|
||||||
|
|
||||||
|
// 7d 预报
|
||||||
|
wd.Daily = fetchDailyForecast(city.ID)
|
||||||
|
|
||||||
|
jsonData, _ := json.Marshal(wd)
|
||||||
|
evalJS(fmt.Sprintf(`if(window.updateWeatherFromGo) window.updateWeatherFromGo(%s)`, string(jsonData)))
|
||||||
|
log.Println("✅ 天气已推送:", wd.Current)
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentWeather struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Temp string `json:"temp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCurrentWeather(cityID string) *currentWeather {
|
||||||
|
url := fmt.Sprintf(qweatherHost + "/v7/weather/now?location=%s&key=%s", cityID, qweatherKey)
|
||||||
|
data, err := httpGet(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Now struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Temp string `json:"temp"`
|
||||||
|
} `json:"now"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &resp) != nil || resp.Code != "200" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ¤tWeather{resp.Now.Text, resp.Now.Temp}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchHourlyForecast(cityID string) []hourlyItem {
|
||||||
|
url := fmt.Sprintf(qweatherHost + "/v7/weather/24h?location=%s&key=%s", cityID, qweatherKey)
|
||||||
|
data, err := httpGet(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Hourly []struct {
|
||||||
|
FxTime string `json:"fxTime"`
|
||||||
|
Temp string `json:"temp"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"hourly"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &resp) != nil || resp.Code != "200" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var result []hourlyItem
|
||||||
|
for i, h := range resp.Hourly {
|
||||||
|
if i >= 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// fxTime: "2026-05-25T14:00+08:00" -> "14:00"
|
||||||
|
t := h.FxTime
|
||||||
|
if idx := strings.Index(t, "T"); idx >= 0 {
|
||||||
|
t = t[idx+1:]
|
||||||
|
if len(t) >= 5 {
|
||||||
|
t = t[:5]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, hourlyItem{t, h.Temp + "°", getWeatherIcon(h.Text)})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchDailyForecast(cityID string) []dailyItem {
|
||||||
|
url := fmt.Sprintf(qweatherHost + "/v7/weather/7d?location=%s&key=%s", cityID, qweatherKey)
|
||||||
|
data, err := httpGet(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Daily []struct {
|
||||||
|
FxDate string `json:"fxDate"`
|
||||||
|
TextDay string `json:"textDay"`
|
||||||
|
TempMin string `json:"tempMin"`
|
||||||
|
TempMax string `json:"tempMax"`
|
||||||
|
} `json:"daily"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &resp) != nil || resp.Code != "200" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
var result []dailyItem
|
||||||
|
for _, d := range resp.Daily {
|
||||||
|
date := d.FxDate
|
||||||
|
if date == today {
|
||||||
|
date = "今天"
|
||||||
|
} else {
|
||||||
|
if t, err := time.Parse("2006-01-02", date); err == nil {
|
||||||
|
date = weekdays[t.Weekday()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, dailyItem{date, getWeatherIcon(d.TextDay), d.TempMin, d.TempMax})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func weatherLoop() {
|
||||||
|
// 首次延迟等 WebView 初始化
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
city := getCurrentCity()
|
||||||
|
log.Println("🌤️ 初始城市:", city.Name)
|
||||||
|
fetchAndPushWeather(city)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(10 * time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
city := getCurrentCity()
|
||||||
|
fetchAndPushWeather(city)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Systray ──
|
||||||
|
|
||||||
|
var zodiacItems []*systray.MenuItem
|
||||||
|
var cityItems []*systray.MenuItem
|
||||||
|
|
||||||
|
func onSystrayReady() {
|
||||||
|
systray.SetIcon(generateIcon())
|
||||||
|
systray.SetTooltip("动态壁纸引擎")
|
||||||
|
|
||||||
|
mPause := systray.AddMenuItem("暂停", "暂停/继续")
|
||||||
|
systray.AddSeparator()
|
||||||
|
|
||||||
|
// 星座子菜单
|
||||||
|
mZodiac := systray.AddMenuItem("星座设置", "")
|
||||||
|
zodiacs := []string{
|
||||||
|
"白羊座", "金牛座", "双子座",
|
||||||
|
"巨蟹座", "狮子座", "处女座",
|
||||||
|
"天秤座", "天蝎座", "射手座",
|
||||||
|
"摩羯座", "水瓶座", "双鱼座",
|
||||||
|
}
|
||||||
|
cfg := loadConfig()
|
||||||
|
for _, z := range zodiacs {
|
||||||
|
item := mZodiac.AddSubMenuItem(z, z)
|
||||||
|
if z == cfg.Zodiac {
|
||||||
|
item.Check()
|
||||||
|
}
|
||||||
|
zodiacItems = append(zodiacItems, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
systray.AddSeparator()
|
||||||
|
|
||||||
|
// 城市子菜单
|
||||||
|
mCity := systray.AddMenuItem("城市设置", "")
|
||||||
|
for _, c := range cities {
|
||||||
|
item := mCity.AddSubMenuItem(c.Name, c.Adm1+" "+c.Name)
|
||||||
|
if cfg.City == c.ID {
|
||||||
|
item.Check()
|
||||||
|
}
|
||||||
|
cityItems = append(cityItems, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
systray.AddSeparator()
|
||||||
|
mQuit := systray.AddMenuItem("退出", "退出程序")
|
||||||
|
|
||||||
|
// 星座选择监听
|
||||||
|
for i, item := range zodiacItems {
|
||||||
|
go func(idx int, mi *systray.MenuItem) {
|
||||||
|
name := zodiacs[idx]
|
||||||
|
log.Printf("⭐ 星座监听启动: %s", name)
|
||||||
|
for {
|
||||||
|
<-mi.ClickedCh
|
||||||
|
log.Printf("⭐ 星座点击: %s", name)
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.Zodiac = name
|
||||||
|
saveConfig(cfg)
|
||||||
|
for _, it := range zodiacItems {
|
||||||
|
it.Uncheck()
|
||||||
|
}
|
||||||
|
mi.Check()
|
||||||
|
evalJS(fmt.Sprintf(`window.userZodiac = %q; if(window.updateTime) updateTime();`, name))
|
||||||
|
}
|
||||||
|
}(i, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 城市选择监听
|
||||||
|
for i, item := range cityItems {
|
||||||
|
go func(idx int, mi *systray.MenuItem) {
|
||||||
|
log.Printf("🏙️ 城市监听启动: %s", cities[idx].Name)
|
||||||
|
for {
|
||||||
|
<-mi.ClickedCh
|
||||||
|
city := cities[idx]
|
||||||
|
log.Printf("🏙️ 城市点击: %s", city.Name)
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.City = city.ID
|
||||||
|
saveConfig(cfg)
|
||||||
|
for _, it := range cityItems {
|
||||||
|
it.Uncheck()
|
||||||
|
}
|
||||||
|
mi.Check()
|
||||||
|
go fetchAndPushWeather(city)
|
||||||
|
}
|
||||||
|
}(i, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暂停
|
||||||
|
go func() {
|
||||||
|
log.Println("暂停监听启动")
|
||||||
|
for {
|
||||||
|
<-mPause.ClickedCh
|
||||||
|
newVal := 1 - atomic.LoadInt32(&paused)
|
||||||
|
atomic.StoreInt32(&paused, newVal)
|
||||||
|
isPaused := newVal == 1
|
||||||
|
log.Printf("暂停切换: paused=%v", isPaused)
|
||||||
|
if isPaused {
|
||||||
|
mPause.SetTitle("继续")
|
||||||
|
} else {
|
||||||
|
mPause.SetTitle("暂停")
|
||||||
|
}
|
||||||
|
evalJS("if(window.setPaused) setPaused(" + strconv.FormatBool(isPaused) + ")")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
|
||||||
|
// 退出
|
||||||
|
go func() {
|
||||||
|
<-mQuit.ClickedCh
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go startWebView()
|
||||||
|
go weatherLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebView ──
|
||||||
|
|
||||||
|
func startWebView() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
|
||||||
|
workerw := findWorkerW()
|
||||||
|
if workerw == 0 {
|
||||||
|
log.Fatal("WorkerW not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
screenW, screenH := getScreenSize()
|
||||||
|
log.Printf("Screen: %dx%d", screenW, screenH)
|
||||||
|
|
||||||
|
wv = webview2.NewWithOptions(webview2.WebViewOptions{
|
||||||
|
AutoFocus: false,
|
||||||
|
WindowOptions: webview2.WindowOptions{
|
||||||
|
Title: "动态壁纸",
|
||||||
|
Width: uint(screenW),
|
||||||
|
Height: uint(screenH),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if wv == nil {
|
||||||
|
log.Fatal("WebView2 create failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlData, err := fs.ReadFile("wallpaper.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("读取 wallpaper.html 失败:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wv.Bind("setZodiacFromGo", func(zodiac string) error {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.Zodiac = zodiac
|
||||||
|
return saveConfig(cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
wv.SetHtml(string(htmlData))
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
wvHwnd = uintptr(wv.Window())
|
||||||
|
procSetWindowLongPtrW.Call(wvHwnd, uintptr(0xFFFFFFF0), uintptr(0x80000000|0x10000000|0x02000000))
|
||||||
|
procShowWindow.Call(wvHwnd, 5)
|
||||||
|
procMoveWindow.Call(wvHwnd, uintptr(^uint(0)), uintptr(^uint(0)), uintptr(screenW+2), uintptr(screenH+2), 1)
|
||||||
|
log.Printf("✅ 壁纸已嵌入: HWND=0x%x, %dx%d", wvHwnd, screenW, screenH)
|
||||||
|
|
||||||
|
// 注入配置
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
cfg := loadConfig()
|
||||||
|
evalJS(fmt.Sprintf(`window.userZodiac = %q;`, cfg.Zodiac))
|
||||||
|
log.Printf("✅ 配置已注入: zodiac=%s", cfg.Zodiac)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go fullscreenMonitor()
|
||||||
|
|
||||||
|
// 延迟嵌入 WorkerW
|
||||||
|
go func() {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
workerw := findWorkerW()
|
||||||
|
if workerw != 0 {
|
||||||
|
oldParent, _, _ := procSetParent.Call(wvHwnd, workerw)
|
||||||
|
log.Printf("✅ SetParent: 0x%x -> 0x%x (old=0x%x)", wvHwnd, workerw, oldParent)
|
||||||
|
procMoveWindow.Call(wvHwnd, uintptr(^uint(0)), uintptr(^uint(0)), uintptr(screenW+2), uintptr(screenH+2), 1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 消息循环
|
||||||
|
type msg struct {
|
||||||
|
hwnd uintptr
|
||||||
|
message uint32
|
||||||
|
wParam uintptr
|
||||||
|
lParam uintptr
|
||||||
|
time uint32
|
||||||
|
pt struct{ x, y int32 }
|
||||||
|
}
|
||||||
|
var m msg
|
||||||
|
log.Println("启动自定义消息循环...")
|
||||||
|
for {
|
||||||
|
ret, _, _ := procGetMessageW.Call(
|
||||||
|
uintptr(unsafe.Pointer(&m)),
|
||||||
|
0, 0, 0,
|
||||||
|
)
|
||||||
|
if ret == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if m.message == wmEvalJS {
|
||||||
|
log.Println("收到 wmEvalJS, drain queue...")
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case js := <-jsQueue:
|
||||||
|
wv.Eval(js)
|
||||||
|
default:
|
||||||
|
goto nextMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextMsg:
|
||||||
|
procTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWorkerW() uintptr {
|
||||||
|
progman, _, _ := procFindWindowW.Call(
|
||||||
|
uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman"))), 0)
|
||||||
|
if progman == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var result uintptr
|
||||||
|
procSendMessageTimeoutW.Call(progman, 0x052C, 0, 0, 0x0000, 1000, uintptr(unsafe.Pointer(&result)))
|
||||||
|
shellDefView, _, _ := procFindWindowExW.Call(progman, 0,
|
||||||
|
uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("SHELLDLL_DefView"))), 0)
|
||||||
|
workerwAfterShell, _, _ := procFindWindowExW.Call(progman, shellDefView,
|
||||||
|
uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("WorkerW"))), 0)
|
||||||
|
if workerwAfterShell != 0 {
|
||||||
|
return workerwAfterShell
|
||||||
|
}
|
||||||
|
ww, _, _ := procFindWindowExW.Call(progman, 0,
|
||||||
|
uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("WorkerW"))), 0)
|
||||||
|
return ww
|
||||||
|
}
|
||||||
|
|
||||||
|
func getScreenSize() (int32, int32) {
|
||||||
|
w, _, _ := procGetSystemMetrics.Call(0)
|
||||||
|
h, _, _ := procGetSystemMetrics.Call(1)
|
||||||
|
return int32(w), int32(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullscreenMonitor() {
|
||||||
|
type rect struct{ Left, Top, Right, Bottom int32 }
|
||||||
|
var lastState string
|
||||||
|
for {
|
||||||
|
if atomic.LoadInt32(&paused) == 0 && wv != nil {
|
||||||
|
fg, _, _ := procGetForegroundWindow.Call()
|
||||||
|
if fg != 0 {
|
||||||
|
var r rect
|
||||||
|
procGetWindowRect.Call(fg, uintptr(unsafe.Pointer(&r)))
|
||||||
|
screenW, screenH := getScreenSize()
|
||||||
|
isFull := (r.Right-r.Left >= screenW) && (r.Bottom-r.Top >= screenH)
|
||||||
|
state := strconv.FormatBool(isFull)
|
||||||
|
if state != lastState {
|
||||||
|
lastState = state
|
||||||
|
evalJS("if(window.setFullscreen) setFullscreen(" + state + ")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
356
wallpaper.html
Normal file
356
wallpaper.html
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>动态壁纸</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; }
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#info {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
right: 80px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: "Microsoft YaHei", sans-serif;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
text-align: right;
|
||||||
|
min-width: 300px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.8);
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin-top: 6px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-weather {
|
||||||
|
font-size: 17px;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
font-weight: 400;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-forecast {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-item {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 55px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-time {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-temp {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-forecast {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-item {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 48px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zodiac {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
line-height: 1.5;
|
||||||
|
cursor: default;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-weather {
|
||||||
|
font-size: 17px;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
font-weight: 400;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-forecast::-webkit-scrollbar,
|
||||||
|
#info::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
|
||||||
|
<div id="info">
|
||||||
|
<div class="time" id="time">00:00</div>
|
||||||
|
<div class="date" id="date">1月1日 周一</div>
|
||||||
|
|
||||||
|
<div class="weather-section">
|
||||||
|
<div class="current-weather" id="currentWeather">🌤️ 加载中...</div>
|
||||||
|
|
||||||
|
<div class="forecast-title">未来24小时</div>
|
||||||
|
<div class="weather-forecast" id="hourlyForecast"></div>
|
||||||
|
|
||||||
|
<div class="forecast-title" style="margin-top: 12px;">未来7天</div>
|
||||||
|
<div class="daily-forecast" id="dailyForecast"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zodiac" id="zodiac">✨ 射手座运势</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
console.log('=== 页面已加载 ===');
|
||||||
|
|
||||||
|
let lastTimeStr = '';
|
||||||
|
let lastDateStr = '';
|
||||||
|
let lastZodiac = '';
|
||||||
|
|
||||||
|
// 天气渲染:由 Go 层推送数据
|
||||||
|
window.updateWeatherFromGo = function(data) {
|
||||||
|
if (typeof data === 'string') data = JSON.parse(data);
|
||||||
|
|
||||||
|
const currentEl = document.getElementById('currentWeather');
|
||||||
|
if (currentEl && data.current) currentEl.textContent = data.current;
|
||||||
|
|
||||||
|
const forecastEl = document.getElementById('hourlyForecast');
|
||||||
|
if (forecastEl) {
|
||||||
|
if (data.hourly && data.hourly.length > 0) {
|
||||||
|
forecastEl.innerHTML = data.hourly.map(item =>
|
||||||
|
'<div class="forecast-item"><div class="forecast-time">' + item.time + '</div><div>' + item.icon + '</div><div class="forecast-temp">' + item.temp + '</div></div>'
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
forecastEl.innerHTML = '<div style="font-size:11px; opacity:0.5;">暂无数据</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyEl = document.getElementById('dailyForecast');
|
||||||
|
if (dailyEl) {
|
||||||
|
if (data.daily && data.daily.length > 0) {
|
||||||
|
dailyEl.innerHTML = data.daily.map(item =>
|
||||||
|
'<div class="daily-item"><div style="opacity:0.8;margin-bottom:3px">' + item.date + '</div><div>' + item.icon + '</div><div class="forecast-temp">' + item.tempMin + '°~' + item.tempMax + '°</div></div>'
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
dailyEl.innerHTML = '<div style="font-size:11px; opacity:0.5;">暂无数据</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ 天气已更新:', data.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 星座运势
|
||||||
|
const fortunes = {
|
||||||
|
'白羊座': '今日运势旺盛,适合开展新计划。',
|
||||||
|
'金牛座': '财运不错,但需注意健康。',
|
||||||
|
'双子座': '人际关系活跃,社交运势佳。',
|
||||||
|
'巨蟹座': '情绪敏感,适合独处思考。',
|
||||||
|
'狮子座': '自信爆棚,工作表现突出。',
|
||||||
|
'处女座': '细节决定成败,专注当下。',
|
||||||
|
'天秤座': '感情运佳,单身者有机会。',
|
||||||
|
'天蝎座': '直觉敏锐,适合做决策。',
|
||||||
|
'射手座': '冒险精神旺盛,出行注意安全。',
|
||||||
|
'摩羯座': '事业运佳,工作效率高。',
|
||||||
|
'水瓶座': '创新思维活跃,灵感不断。',
|
||||||
|
'双鱼座': '艺术灵感丰富,适合创作。'
|
||||||
|
};
|
||||||
|
|
||||||
|
// WebGL 极光背景
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const gl = canvas.getContext('webgl');
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
resizeCanvas();
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
|
||||||
|
const vsSource = 'attribute vec4 aVertexPosition;void main(){gl_Position=aVertexPosition;}';
|
||||||
|
const fsSource = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform float uTime;
|
||||||
|
uniform vec2 uResolution;
|
||||||
|
void main() {
|
||||||
|
vec2 uv=gl_FragCoord.xy/uResolution.xy;
|
||||||
|
vec3 c1=vec3(0.1,0.4,0.8);
|
||||||
|
vec3 c2=vec3(0.4,0.2,0.8);
|
||||||
|
vec3 c3=vec3(0.1,0.6,0.4);
|
||||||
|
float w1=sin(uv.x*3.0+uTime*0.5)*0.5+0.5;
|
||||||
|
float w2=sin(uv.x*4.0+uTime*0.3+uv.y*2.0)*0.5+0.5;
|
||||||
|
float w3=sin(uv.x*2.0+uTime*0.7+uv.y*3.0)*0.5+0.5;
|
||||||
|
float d=uv.y*2.0-1.0;
|
||||||
|
float g=exp(-d*d*2.0);
|
||||||
|
vec3 a=(c1*w1+c2*w2+c3*w3)*g*0.6;
|
||||||
|
vec3 b=mix(vec3(0.02,0.02,0.08),vec3(0.05,0.05,0.15),uv.y);
|
||||||
|
gl_FragColor=vec4(b+a,1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function loadShader(gl,type,source){
|
||||||
|
const s=gl.createShader(type);
|
||||||
|
gl.shaderSource(s,source);
|
||||||
|
gl.compileShader(s);
|
||||||
|
if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)){
|
||||||
|
console.error('Shader error:',gl.getShaderInfoLog(s));
|
||||||
|
gl.deleteShader(s);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vs=loadShader(gl,gl.VERTEX_SHADER,vsSource);
|
||||||
|
const fs=loadShader(gl,gl.FRAGMENT_SHADER,fsSource);
|
||||||
|
const prog=gl.createProgram();
|
||||||
|
gl.attachShader(prog,vs);
|
||||||
|
gl.attachShader(prog,fs);
|
||||||
|
gl.linkProgram(prog);
|
||||||
|
|
||||||
|
if(!gl.getProgramParameter(prog,gl.LINK_STATUS)){
|
||||||
|
console.error('Program link error:',gl.getProgramInfoLog(prog));
|
||||||
|
}
|
||||||
|
|
||||||
|
const posLoc=gl.getAttribLocation(prog,'aVertexPosition');
|
||||||
|
const timeLoc=gl.getUniformLocation(prog,'uTime');
|
||||||
|
const resLoc=gl.getUniformLocation(prog,'uResolution');
|
||||||
|
|
||||||
|
const posBuf=gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER,posBuf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,1,1,1,-1,-1,1,-1]),gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
let startTime=Date.now();
|
||||||
|
const FPS = 15;
|
||||||
|
let lastFrame = 0;
|
||||||
|
|
||||||
|
function render(ts){
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
if (ts - lastFrame < 1000 / FPS) return;
|
||||||
|
lastFrame = ts;
|
||||||
|
gl.viewport(0,0,canvas.width,canvas.height);
|
||||||
|
gl.clearColor(0,0,0,1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
gl.useProgram(prog);
|
||||||
|
gl.enableVertexAttribArray(posLoc);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER,posBuf);
|
||||||
|
gl.vertexAttribPointer(posLoc,2,gl.FLOAT,false,0,0);
|
||||||
|
gl.uniform1f(timeLoc,(Date.now()-startTime)/1000);
|
||||||
|
gl.uniform2f(resLoc,canvas.width,canvas.height);
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
console.log('✅ WebGL 极光背景已启动');
|
||||||
|
|
||||||
|
// 时间和星座
|
||||||
|
function getUserZodiac() {
|
||||||
|
return window.userZodiac || '射手座';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateZodiacDisplay() {
|
||||||
|
var name = getUserZodiac();
|
||||||
|
if (name === lastZodiac) return;
|
||||||
|
lastZodiac = name;
|
||||||
|
var zodiacEl = document.getElementById('zodiac');
|
||||||
|
if (!zodiacEl) return;
|
||||||
|
var fortune = fortunes[name] || '运势平稳,保持平常心。';
|
||||||
|
zodiacEl.innerHTML = '✨ ' + name + '运势<br><small style="opacity:0.7">' + fortune + '</small>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTime() {
|
||||||
|
var now = new Date();
|
||||||
|
var hh = String(now.getHours()).padStart(2, '0');
|
||||||
|
var mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
var month = now.getMonth() + 1;
|
||||||
|
var day = now.getDate();
|
||||||
|
var week = ['周日','周一','周二','周三','周四','周五','周六'][now.getDay()];
|
||||||
|
|
||||||
|
var timeEl = document.getElementById('time');
|
||||||
|
var dateEl = document.getElementById('date');
|
||||||
|
|
||||||
|
var timeStr = hh + ':' + mm;
|
||||||
|
var dateStr = month + '月' + day + '日 ' + week;
|
||||||
|
|
||||||
|
if (timeEl && timeStr !== lastTimeStr) {
|
||||||
|
timeEl.textContent = timeStr;
|
||||||
|
lastTimeStr = timeStr;
|
||||||
|
}
|
||||||
|
if (dateEl && dateStr !== lastDateStr) {
|
||||||
|
dateEl.textContent = dateStr;
|
||||||
|
lastDateStr = dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateZodiacDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTime();
|
||||||
|
setInterval(updateTime, 1000);
|
||||||
|
|
||||||
|
console.log('=== 初始化完成 ===');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user