From 196d59269db1f26092215b894a98e5e1c7074a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 25 May 2026 19:03:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Win11 动态壁纸引擎:WebView2 + systray + 和风天气 - WebGL 极光背景动画 - 实时天气(24h/7d预报) - 星座运势(托盘切换) - 暂停/继续控制 - 单实例互斥锁防双开 - vendor systray 修复 ClickedCh 静默丢弃 --- .gitignore | 24 + docs/backup-opengl/go_opengl.mod.txt | 5 + docs/backup-opengl/main_opengl.go.txt | 530 +++++++++++++++++ docs/backup-opengl/shader.go.bak | 13 + docs/backup-opengl/shader_opengl.go.txt | 13 + docs/wallpaper-embedding.md | 208 +++++++ docs/wallpaper-test.html | 96 ++++ go.mod | 21 + go.sum | 36 ++ main.go | 729 ++++++++++++++++++++++++ wallpaper.html | 356 ++++++++++++ 11 files changed, 2031 insertions(+) create mode 100644 .gitignore create mode 100644 docs/backup-opengl/go_opengl.mod.txt create mode 100644 docs/backup-opengl/main_opengl.go.txt create mode 100644 docs/backup-opengl/shader.go.bak create mode 100644 docs/backup-opengl/shader_opengl.go.txt create mode 100644 docs/wallpaper-embedding.md create mode 100644 docs/wallpaper-test.html create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 wallpaper.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0a43ef --- /dev/null +++ b/.gitignore @@ -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 diff --git a/docs/backup-opengl/go_opengl.mod.txt b/docs/backup-opengl/go_opengl.mod.txt new file mode 100644 index 0000000..fe97200 --- /dev/null +++ b/docs/backup-opengl/go_opengl.mod.txt @@ -0,0 +1,5 @@ +module u-desktop + +go 1.26.3 + +require golang.org/x/sys v0.45.0 diff --git a/docs/backup-opengl/main_opengl.go.txt b/docs/backup-opengl/main_opengl.go.txt new file mode 100644 index 0000000..22c388c --- /dev/null +++ b/docs/backup-opengl/main_opengl.go.txt @@ -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 = " " + } + 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 } diff --git a/docs/backup-opengl/shader.go.bak b/docs/backup-opengl/shader.go.bak new file mode 100644 index 0000000..d4d5977 --- /dev/null +++ b/docs/backup-opengl/shader.go.bak @@ -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); +} +` diff --git a/docs/backup-opengl/shader_opengl.go.txt b/docs/backup-opengl/shader_opengl.go.txt new file mode 100644 index 0000000..d4d5977 --- /dev/null +++ b/docs/backup-opengl/shader_opengl.go.txt @@ -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); +} +` diff --git a/docs/wallpaper-embedding.md b/docs/wallpaper-embedding.md new file mode 100644 index 0000000..0bf77b3 --- /dev/null +++ b/docs/wallpaper-embedding.md @@ -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 的子窗口不在图标层之上但仍可点击)。 diff --git a/docs/wallpaper-test.html b/docs/wallpaper-test.html new file mode 100644 index 0000000..ec5a4a7 --- /dev/null +++ b/docs/wallpaper-test.html @@ -0,0 +1,96 @@ + + + + +测试 + + + +
+
🧪 WebView2 测试页面
+ + +
等待点击...
+
+
+ + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3753988 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..beeb2a3 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..33c31c0 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/wallpaper.html b/wallpaper.html new file mode 100644 index 0000000..a43ccc5 --- /dev/null +++ b/wallpaper.html @@ -0,0 +1,356 @@ + + + + +动态壁纸 + + + + + +
+
00:00
+
1月1日 周一
+ +
+
🌤️ 加载中...
+ +
未来24小时
+
+ +
未来7天
+
+
+ +
✨ 射手座运势
+
+ + + +