216 lines
6.7 KiB
Python
216 lines
6.7 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""stellar_gauge.py · 湖面映星 · 悬浮星球用量仪
|
|||
|
|
pywebview 无边框透明窗口,Canvas 手绘星球。
|
|||
|
|
数据 = 本地用量统计 + 用户手抄余量基准 + 本机内存/存储/CPU 实时投影。
|
|||
|
|
"""
|
|||
|
|
import json, os, sys, threading, time
|
|||
|
|
from pathlib import Path
|
|||
|
|
import webview
|
|||
|
|
|
|||
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|||
|
|
HOME_DIR = os.path.expanduser("~/.stellar-gauge")
|
|||
|
|
PLAN_FILE = os.path.join(HOME_DIR, "plan.json")
|
|||
|
|
USAGE_FILE = os.path.join(HOME_DIR, "usage.json")
|
|||
|
|
SYS_FILE = os.path.join(HOME_DIR, "sysmon.json")
|
|||
|
|
DIAG_FILE = os.path.join(HOME_DIR, "diag.txt")
|
|||
|
|
WIN_TITLE = "湖面映星 · 星"
|
|||
|
|
sys.path.insert(0, HERE)
|
|||
|
|
import collector # noqa: E402
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load(path, default):
|
|||
|
|
try:
|
|||
|
|
with open(path) as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
except Exception:
|
|||
|
|
return default
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _our_window():
|
|||
|
|
"""找到本应用的 NSWindow(按标题)。AppKit 调用必须回主线程。"""
|
|||
|
|
from AppKit import NSApp
|
|||
|
|
for w in NSApp.windows():
|
|||
|
|
try:
|
|||
|
|
if WIN_TITLE in str(w.title() or ""):
|
|||
|
|
return w
|
|||
|
|
except Exception:
|
|||
|
|
continue
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _on_main(fn):
|
|||
|
|
"""把 AppKit 操作派回主线程(子线程碰 NSWindow 会 trace trap)"""
|
|||
|
|
from PyObjCTools import AppHelper
|
|||
|
|
AppHelper.callAfter(fn)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def scan_system():
|
|||
|
|
"""本机内存 / 存储 / CPU 实时快照 → sysmon.json"""
|
|||
|
|
try:
|
|||
|
|
import psutil
|
|||
|
|
except Exception:
|
|||
|
|
return {}
|
|||
|
|
vm = psutil.virtual_memory()
|
|||
|
|
disk = psutil.disk_usage("/")
|
|||
|
|
snap = {
|
|||
|
|
"mem": {"used_pct": round(vm.percent, 1),
|
|||
|
|
"used_gb": round(vm.used / 2**30, 1),
|
|||
|
|
"total_gb": round(vm.total / 2**30, 1)},
|
|||
|
|
"disk": {"used_pct": round(disk.percent, 1),
|
|||
|
|
"free_gb": round(disk.free / 2**30, 1),
|
|||
|
|
"total_gb": round(disk.total / 2**30, 1)},
|
|||
|
|
"cpu": {"pct": round(psutil.cpu_percent(interval=None), 1)},
|
|||
|
|
"ts": time.strftime("%H:%M:%S"),
|
|||
|
|
}
|
|||
|
|
try:
|
|||
|
|
tmp = SYS_FILE + ".tmp"
|
|||
|
|
with open(tmp, "w") as f:
|
|||
|
|
json.dump(snap, f, ensure_ascii=False)
|
|||
|
|
os.replace(tmp, SYS_FILE)
|
|||
|
|
except Exception as e:
|
|||
|
|
print("sysmon write:", e, flush=True)
|
|||
|
|
return snap
|
|||
|
|
|
|||
|
|
|
|||
|
|
def diag(msg):
|
|||
|
|
"""诊断通道:open 拉起的进程 stdout 不落文件,直接写盘"""
|
|||
|
|
try:
|
|||
|
|
with open(DIAG_FILE, "a") as f:
|
|||
|
|
f.write(time.strftime("%H:%M:%S") + " " + str(msg)[:400] + "\n")
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Api:
|
|||
|
|
def log(self, msg):
|
|||
|
|
"""JS 侧自检日志回传"""
|
|||
|
|
diag("[js] " + str(msg))
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def get_data(self):
|
|||
|
|
usage = load(USAGE_FILE, {})
|
|||
|
|
plan = load(PLAN_FILE, {})
|
|||
|
|
sysm = load(SYS_FILE, {})
|
|||
|
|
return {"usage": usage, "plan": plan, "sys": sysm,
|
|||
|
|
"now": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|||
|
|
|
|||
|
|
def set_plan(self, plan_json):
|
|||
|
|
try:
|
|||
|
|
plan = json.loads(plan_json)
|
|||
|
|
old = load(PLAN_FILE, {})
|
|||
|
|
old.update(plan)
|
|||
|
|
tmp = PLAN_FILE + ".tmp"
|
|||
|
|
with open(tmp, "w") as f:
|
|||
|
|
json.dump(old, f, ensure_ascii=False, indent=1)
|
|||
|
|
os.replace(tmp, PLAN_FILE)
|
|||
|
|
return {"ok": True}
|
|||
|
|
except Exception as e:
|
|||
|
|
return {"ok": False, "error": str(e)}
|
|||
|
|
|
|||
|
|
def drag_by(self, dx, dy):
|
|||
|
|
"""JS 端拖拽 → 移动整颗星球窗口(macOS 屏幕坐标 y 向上)"""
|
|||
|
|
try:
|
|||
|
|
def move():
|
|||
|
|
w = _our_window()
|
|||
|
|
if w:
|
|||
|
|
f = w.frame()
|
|||
|
|
w.setFrameOrigin_((f.origin.x + dx, f.origin.y - dy))
|
|||
|
|
_on_main(move)
|
|||
|
|
return True
|
|||
|
|
except Exception as e:
|
|||
|
|
print("drag:", e, flush=True)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def collector_loop():
|
|||
|
|
while True:
|
|||
|
|
try:
|
|||
|
|
collector.scan()
|
|||
|
|
except Exception as e:
|
|||
|
|
print("collector error:", e, flush=True)
|
|||
|
|
time.sleep(300)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sysmon_loop():
|
|||
|
|
psutil_first = True
|
|||
|
|
while True:
|
|||
|
|
try:
|
|||
|
|
if psutil_first:
|
|||
|
|
import psutil
|
|||
|
|
psutil.cpu_percent(interval=None) # 首次调用丢弃基准
|
|||
|
|
psutil_first = False
|
|||
|
|
scan_system()
|
|||
|
|
except Exception as e:
|
|||
|
|
print("sysmon error:", e, flush=True)
|
|||
|
|
time.sleep(5)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
os.makedirs(HOME_DIR, exist_ok=True)
|
|||
|
|
diag("main start pid=" + str(os.getpid()))
|
|||
|
|
try:
|
|||
|
|
collector.scan()
|
|||
|
|
except Exception as e:
|
|||
|
|
print("initial scan error:", e, flush=True)
|
|||
|
|
scan_system()
|
|||
|
|
threading.Thread(target=collector_loop, daemon=True).start()
|
|||
|
|
threading.Thread(target=sysmon_loop, daemon=True).start()
|
|||
|
|
|
|||
|
|
# 从磁盘文件加载:pywebview 的 html= 字符串通道会静默吞掉内联脚本
|
|||
|
|
page_url = Path(os.path.join(HERE, "index.html")).as_uri()
|
|||
|
|
|
|||
|
|
win = webview.create_window(
|
|||
|
|
WIN_TITLE, url=page_url, js_api=Api(),
|
|||
|
|
width=500, height=500, x=2310, y=1100,
|
|||
|
|
frameless=True, resizable=False,
|
|||
|
|
background_color="#04060f")
|
|||
|
|
|
|||
|
|
def on_shown():
|
|||
|
|
def raise_level():
|
|||
|
|
from AppKit import NSApp, NSFloatingWindowLevel
|
|||
|
|
w = _our_window()
|
|||
|
|
if w:
|
|||
|
|
w.setLevel_(NSFloatingWindowLevel)
|
|||
|
|
# launchd 拉起的裸 Python 不激活就不渲染:主动激活应用
|
|||
|
|
try:
|
|||
|
|
NSApp.activateWithOptions_(3) # NSApplicationActivateIgnoringOtherApps
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
try:
|
|||
|
|
_on_main(raise_level)
|
|||
|
|
except Exception as e:
|
|||
|
|
print("on_top:", e, flush=True)
|
|||
|
|
|
|||
|
|
win.events.shown += on_shown
|
|||
|
|
|
|||
|
|
def on_loaded():
|
|||
|
|
try:
|
|||
|
|
r = win.evaluate_js(
|
|||
|
|
"JSON.stringify({boot:window.__boot||'NO',"
|
|||
|
|
"draw:typeof draw,api:typeof window.pywebview,"
|
|||
|
|
"alive:document.getElementById('alive')?'y':'n',"
|
|||
|
|
"ua:navigator.userAgent.slice(0,60)})")
|
|||
|
|
diag("loaded probe=" + str(r))
|
|||
|
|
except Exception as e:
|
|||
|
|
diag("loaded probe error: " + str(e))
|
|||
|
|
|
|||
|
|
def pixel_check():
|
|||
|
|
# 自验:采样球心像素,非透明才说明 Canvas 真的画出来了
|
|||
|
|
try:
|
|||
|
|
px = win.evaluate_js(
|
|||
|
|
"(function(){try{var d=cv.getContext('2d')"
|
|||
|
|
".getImageData(250,250,1,1).data;"
|
|||
|
|
"return d[0]+','+d[1]+','+d[2]+','+d[3];}"
|
|||
|
|
"catch(e){return 'ERR '+e;}})()")
|
|||
|
|
diag("canvas center pixel rgba=" + str(px))
|
|||
|
|
except Exception as e:
|
|||
|
|
diag("pixel check error: " + str(e))
|
|||
|
|
|
|||
|
|
threading.Timer(4.0, pixel_check).start()
|
|||
|
|
|
|||
|
|
win.events.loaded += on_loaded
|
|||
|
|
webview.start(debug=False)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|