优化更新

This commit is contained in:
2026-09-23 16:51:40 +08:00
parent cd4ffbfa90
commit 68e8eb91a7
15 changed files with 1858 additions and 193 deletions
+244
View File
@@ -0,0 +1,244 @@
"""硬件温度数据源组件的获取与启动。
Windows 用户态没有任何 API 能直接读 CPU 核心温度(需要内核级 MSR 访问),
因此本模块负责一键获取并启动开源组件 LibreHardwareMonitor(MPL-2.0):
它由内核驱动读取传感器,再通过 WMI(root\\LibreHardwareMonitor)暴露出来,
温度窗口会自动读取并展示每个核心的温度。
下载策略:
1. 先查官方 GitHub API 拿到最新版本的精确地址与 SHA-256 摘要;
2. 优先直连官方地址;失败则改用公共加速镜像(仅在有官方摘要时使用);
3. 无论走了哪条通道,都按官方 SHA-256 校验文件完整性后才解压。
组件安装到 %LOCALAPPDATA%\\niumasoftware\\tools\\LibreHardwareMonitor。
"""
from __future__ import annotations
import ctypes
import hashlib
import json
import os
import shutil
import tempfile
import urllib.request
import zipfile
from ctypes import wintypes
from typing import Any, Callable
API_URL = (
"https://api.github.com/repos/LibreHardwareMonitor/LibreHardwareMonitor/releases/latest"
)
ASSET_NAME = "LibreHardwareMonitor.zip"
LATEST_DOWNLOAD_URL = (
"https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases/latest/"
f"download/{ASSET_NAME}"
)
RELEASE_PAGE = (
"https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases/latest"
)
# 公共 GitHub 加速前缀:仅用于「有官方摘要可校验」时的兜底通道
MIRROR_PREFIXES = (
"https://ghfast.top/",
"https://gh-proxy.com/",
"https://ghproxy.net/",
)
EXE_NAME = "librehardwaremonitor.exe"
PROCESS_NAME = "librehardwaremonitor"
_release_cache: dict[str, Any] = {}
def tool_dir() -> str:
base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
return os.path.join(base, "niumasoftware", "tools", "LibreHardwareMonitor")
def find_exe() -> str:
"""返回已安装的 LibreHardwareMonitor.exe 路径,没有则返回空串。"""
root = tool_dir()
if not os.path.isdir(root):
return ""
for current, _dirs, files in os.walk(root):
for name in files:
if name.lower() == EXE_NAME:
return os.path.join(current, name)
return ""
def is_running() -> bool:
try:
import psutil
for proc in psutil.process_iter(["name"]):
name = (proc.info.get("name") or "").lower()
if name.startswith(PROCESS_NAME):
return True
except Exception:
pass
return False
def _release_info() -> dict:
"""查询官方 release 信息:{url, digest, tag}。失败时返回只有 latest 地址的兜底值。"""
if _release_cache:
return _release_cache
info: dict[str, Any] = {"url": LATEST_DOWNLOAD_URL, "digest": "", "tag": ""}
try:
request = urllib.request.Request(
API_URL,
headers={"User-Agent": "niumasoftware-temperature", "Accept": "application/vnd.github+json"},
)
with urllib.request.urlopen(request, timeout=12) as resp:
data = json.loads(resp.read().decode("utf-8"))
for asset in data.get("assets") or []:
if str(asset.get("name")) != ASSET_NAME:
continue
info["url"] = str(asset.get("browser_download_url") or LATEST_DOWNLOAD_URL)
digest = str(asset.get("digest") or "")
if digest.startswith("sha256:"):
info["digest"] = digest.split(":", 1)[1].strip().lower()
info["tag"] = str(data.get("tag_name") or "")
break
except Exception:
pass
_release_cache.update(info)
return _release_cache
def _sha256(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as fh:
while True:
chunk = fh.read(1 << 20)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def _download_one(
url: str,
dest_zip: str,
progress: Callable[[int, int], None] | None,
is_cancelled: Callable[[], bool] | None,
) -> None:
request = urllib.request.Request(
url, headers={"User-Agent": "niumasoftware-temperature"}
)
with urllib.request.urlopen(request, timeout=30) as resp, open(dest_zip, "wb") as fh:
total = int(resp.headers.get("Content-Length") or 0)
if progress is not None:
progress(0, total)
got = 0
while True:
if is_cancelled is not None and is_cancelled():
raise RuntimeError("已取消")
chunk = resp.read(65536)
if not chunk:
break
fh.write(chunk)
got += len(chunk)
if progress is not None:
progress(got, total)
def download(
dest_zip: str,
progress: Callable[[int, int], None] | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> None:
"""下载组件压缩包,自动在官方源与镜像之间切换,并按官方摘要校验。"""
info = _release_info()
official = str(info.get("url") or LATEST_DOWNLOAD_URL)
digest = str(info.get("digest") or "")
candidates: list[tuple[str, str]] = [("官方源", official)]
if digest:
# 只有在能按官方 SHA-256 校验时才允许走第三方镜像
for prefix in MIRROR_PREFIXES:
candidates.append(("镜像", f"{prefix}{official}"))
errors: list[str] = []
for kind, url in candidates:
try:
_download_one(url, dest_zip, progress, is_cancelled)
except Exception as exc: # noqa: BLE001
errors.append(f"{kind}:{exc}")
continue
if digest:
actual = _sha256(dest_zip)
if actual != digest:
errors.append(f"{kind}:文件校验不一致")
continue
return
raise RuntimeError(";".join(errors) if errors else "下载失败")
def install(
progress: Callable[[int, int], None] | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> str:
"""下载并解压组件,返回可执行文件路径。失败抛异常。"""
target = tool_dir()
if os.path.isdir(target):
shutil.rmtree(target, ignore_errors=True)
os.makedirs(target, exist_ok=True)
fd, temp_zip = tempfile.mkstemp(suffix=".zip", prefix="lhm_")
os.close(fd)
try:
download(temp_zip, progress, is_cancelled)
with zipfile.ZipFile(temp_zip) as zf:
for member in zf.infolist():
name = member.filename.replace("\\", "/")
if name.startswith("/") or ".." in name.split("/"):
raise RuntimeError("下载包内容异常,已中止解压")
zf.extractall(target)
finally:
try:
os.remove(temp_zip)
except OSError:
pass
exe = find_exe()
if not exe:
raise RuntimeError("下载包中未找到 LibreHardwareMonitor.exe")
return exe
def launch(exe_path: str, *, elevated: bool = True) -> tuple[bool, str]:
"""启动组件;elevated=True 会请求管理员权限(读取硬件传感器必需)。"""
if not exe_path or not os.path.exists(exe_path):
return False, "未找到可执行文件"
try:
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
shell32.ShellExecuteW.restype = ctypes.c_ssize_t
shell32.ShellExecuteW.argtypes = [
wintypes.HWND,
wintypes.LPCWSTR,
wintypes.LPCWSTR,
wintypes.LPCWSTR,
wintypes.LPCWSTR,
ctypes.c_int,
]
op = "runas" if elevated else "open"
result = int(
shell32.ShellExecuteW(
None, op, exe_path, None, os.path.dirname(exe_path), 1
)
)
except Exception as exc: # noqa: BLE001
return False, str(exc)
if result > 32:
return True, ""
if result == 5: # ERROR_ACCESS_DENIED:用户在 UAC 中点了“否”
return False, "已取消管理员授权(读取硬件传感器需要管理员权限)"
if result == 2:
return False, "文件不存在"
return False, f"启动失败(错误码 {result})"