diff --git a/config.go b/config.go new file mode 100644 index 0000000..15c68c6 --- /dev/null +++ b/config.go @@ -0,0 +1,64 @@ +package main + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "image" + "image/color" + "image/png" + "os" +) + +type Config struct { + Zodiac string `json:"zodiac"` + City string `json:"city"` +} + +const defaultZodiac = "射手座" + +var configPath string + +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) +} + +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 +} diff --git a/main.go b/main.go index 33c31c0..d819ddf 100644 --- a/main.go +++ b/main.go @@ -1,105 +1,24 @@ 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("⚠️ 已有实例运行,退出") + log.Println("已有实例运行,退出") windows.CloseHandle(mutex) return } @@ -112,618 +31,3 @@ func main() { 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/systray.go b/systray.go new file mode 100644 index 0000000..c9c3756 --- /dev/null +++ b/systray.go @@ -0,0 +1,244 @@ +package main + +import ( + "fmt" + "log" + "os" + "runtime" + "strconv" + "sync/atomic" + "time" + "unsafe" + + "github.com/getlantern/systray" + "github.com/jchv/go-webview2" +) + +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() +} + +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("web/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 { + 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 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/weather.go b/weather.go new file mode 100644 index 0000000..83ca515 --- /dev/null +++ b/weather.go @@ -0,0 +1,306 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strings" + "time" +) + +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", "武汉", "武汉", "湖北"} + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +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) +} + +// ── 定位 ── + +func getLocation() City { + if city := locateByIPIP(); city != nil { + return *city + } + if city := locateByQWeather(); city != nil { + return *city + } + log.Println("所有定位失败,使用默认城市:", defaultCity.Name) + return defaultCity +} + +func locateByIPIP() *City { + data, err := httpGet("https://myip.ipip.net") + if err != nil { + return nil + } + text := string(data) + 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 + } + } + 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} +} + +func getCurrentCity() City { + cfg := loadConfig() + if cfg.City != "" { + for _, c := range cities { + if c.ID == cfg.City { + return c + } + } + } + return getLocation() +} + +// ── 天气数据 ── + +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"` +} + +type currentWeather struct { + Text string `json:"text"` + Temp string `json:"temp"` +} + +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) + } + + wd.Hourly = fetchHourlyForecast(city.ID) + wd.Daily = fetchDailyForecast(city.ID) + + jsonData, _ := json.Marshal(wd) + evalJS(fmt.Sprintf(`if(window.updateWeatherFromGo) window.updateWeatherFromGo(%s)`, string(jsonData))) + log.Println("天气已推送:", wd.Current) +} + +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 + } + 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() { + 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) + } +} diff --git a/wallpaper.html b/web/wallpaper.html similarity index 100% rename from wallpaper.html rename to web/wallpaper.html diff --git a/win32.go b/win32.go new file mode 100644 index 0000000..19d2234 --- /dev/null +++ b/win32.go @@ -0,0 +1,84 @@ +package main + +import ( + "embed" + "log" + "unsafe" + + "github.com/jchv/go-webview2" + "golang.org/x/sys/windows" +) + +//go:embed web/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 wvHwnd uintptr +var jsQueue = make(chan string, 64) +var paused int32 + +const wmEvalJS = 0x0401 + +func evalJS(js string) { + select { + case jsQueue <- js: + log.Println("evalJS queued:", js[:min(len(js), 60)]) + default: + log.Println("jsQueue full, dropping eval") + } + if wvHwnd != 0 { + procPostMessageW.Call(wvHwnd, wmEvalJS, 0, 0) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +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) +}