Files
u-desktop/main.go
绝尘 196d59269d 初始提交
Win11 动态壁纸引擎:WebView2 + systray + 和风天气
- WebGL 极光背景动画
- 实时天气(24h/7d预报)
- 星座运势(托盘切换)
- 暂停/继续控制
- 单实例互斥锁防双开
- vendor systray 修复 ClickedCh 静默丢弃
2026-05-25 19:03:21 +08:00

730 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &currentWeather{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)
}
}