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) } }