面板任务栏管理

使用说明

# 运行时
python3 ~/.hermes/scripts/plasma-panel.py list                                   # 真实面板: id/屏幕/位置/组件数 (只读)

# 画像: export  运行时 ──▶ 画像      apply   画像 ──▶ 运行时
python3 ~/.hermes/scripts/plasma-panel.py export                                 # 最多的面板导出为默认画像
python3 ~/.hermes/scripts/plasma-panel.py export --panel 81                      # 指定面板导出为默认画像
python3 ~/.hermes/scripts/plasma-panel.py export --dry-run                       # 预导出
python3 ~/.hermes/scripts/plasma-panel.py images                                 # 画像列表
python3 ~/.hermes/scripts/plasma-panel.py export --name org.cat.panel-3screen    # 最多的面板导出为自定义画像
python3 ~/.hermes/scripts/plasma-panel.py apply                                  # 画像 -> 当前桌面(重建面板)
python3 ~/.hermes/scripts/plasma-panel.py apply --screen 0 --replace             # 先删该屏旧面板再重建
python3 ~/.hermes/scripts/plasma-panel.py apply --screen 0 --dry-run             # 预演

# 默认面板: 运行时 ──▶ 默认面板 存疑
kreadconfig6 --file kdeglobals --group KDE --key LookAndFeelPackage              # 看当前生效主题
plasma-apply-lookandfeel -l                                                      # 列可用全局主题
plasma-apply-lookandfeel -a org.cat.custompanel                                  # 默认面板源换成 运行时custom
plasma-apply-lookandfeel -a org.kde.breezedark.desktop                           # 默认面板源换成 初始

# 快照: backup  运行时 ──▶ 快照      restore 快照 ──▶ 运行时
python3 ~/.hermes/scripts/plasma-panel.py backup                                 # 拍快照
python3 ~/.hermes/scripts/plasma-panel.py snapshots                              # 列快照
python3 ~/.hermes/scripts/plasma-panel.py verify                                 # 校验最新快照 sha256
python3 ~/.hermes/scripts/plasma-panel.py verify --snap plasma-panel-snap-xxx    # 校验指定快照
python3 ~/.hermes/scripts/plasma-panel.py restore --dry-run                      # 预演: 校验完整性 + 列将写入的文件
python3 ~/.hermes/scripts/plasma-panel.py restore                                # 用最新快照 (raw 写回, 会重启 plasmashell)
python3 ~/.hermes/scripts/plasma-panel.py restore --only appletsrc               # 只恢复面板配置
python3 ~/.hermes/scripts/plasma-panel.py restore --snap plasma-panel-snap-xxx

# 手工脚本
systemctl --user restart plasma-plasmashell                                      # 重启面板外壳
~/.config/plasma-org.kde.plasma.desktop-appletsrc                                # 面板运行时配置(全部真相)
~/.config/plasmashellrc                                                          # plasmashell 自身设置
~/.config/kdeglobals                                                             # LookAndFeelPackage = 当前生效主题
~/.local/share/plasma/look-and-feel/org.cat.custompanel/                         # 画像包(默认面板蓝图 layout.js)
~/.hermes/notes/plasma-panel-snap-*/                                             # 快照包(含 manifest.json)
~/.hermes/scripts/plasma-panel.py                                                # 工具本体
~/.hermes/scripts/export-plasma-panel-to-lookandfeel.py                          # 画像生成器
~/.hermes/scripts/plasma-panel-cmds.txt                                          # 本手册

~/.hermes/scripts

#!/usr/bin/env python3
"""Plasma 面板:快照 / 恢复 / 导出画像(raw 优先)

面板的"全部真相"不只是一个文件,而是这几处(快照=逐个 raw 复制,不做任何解析/裁剪):
  1) ~/.config/plasma-org.kde.plasma.desktop-appletsrc   面板/组件全部配置(布局+所有设置)
  2) ~/.config/plasmashellrc                              plasmashell 自身设置
  3) ~/.local/share/kactivitymanagerd/                    收藏夹/使用频次库(kicker 收藏夹在这)
  4) ~/.local/share/plasma_notes/                         便签正文
快照 = ~/.hermes/notes/plasma-panel-snap-<时间>/ (含 manifest.json,记录每个文件的 sha256)

安全保证(防"恢复反而丢设置"):
  * 快照是 raw 复制(cmp/sha256 可验证逐字节相同),恢复也是 raw 写回(shutil.copy2)
  * 恢复前校验 manifest 里每个文件的 sha256,不一致就中止
  * 恢复前**自动先给当前状态再拍一次快照**(pre-restore),所以任何误操作都可回退
  * 恢复用 --dry-run 可先看"会写哪些文件、哈希对不对",不动你的桌面

用法:
  plasma-panel.py backup                     # 拍快照(raw,含上面 4 处)
  plasma-panel.py snapshots                  # 列快照
  plasma-panel.py verify [--snap 名字]        # 校验快照完整性(对 manifest 重算 sha256)
  plasma-panel.py restore [--snap 名字] [--only appletsrc] [--dry-run]
  plasma-panel.py list                       # 真实面板(id/屏幕/位置/组件数)
  plasma-panel.py export [--panel 81]        # 生成 L&F 画像 layout.js(新屏幕的默认样子,非 raw)
"""
import argparse, glob, hashlib, json, os, shutil, subprocess, sys, time

HOME = os.path.expanduser("~")
NOTES = f"{HOME}/.hermes/notes"
BUS = f"unix:path=/run/user/{os.getuid()}/bus"
ENV = {**os.environ, "DBUS_SESSION_BUS_ADDRESS": BUS, "PATH": "/run/current-system/sw/bin:/usr/bin:/bin"}
RC = f"{HOME}/.config/plasma-org.kde.plasma.desktop-appletsrc"
PKG = f"{HOME}/.local/share/plasma/look-and-feel/org.cat.custompanel"
JSF = f"{PKG}/contents/layouts/org.kde.plasma.desktop-layout.js"
GEN = f"{HOME}/.hermes/scripts/export-plasma-panel-to-lookandfeel.py"

ITEMS = [
    ("appletsrc", "file", RC),
    ("plasmashellrc", "file", f"{HOME}/.config/plasmashellrc"),
    ("kactivitymanagerd", "dir", f"{HOME}/.local/share/kactivitymanagerd"),
    ("plasma_notes", "dir", f"{HOME}/.local/share/plasma_notes"),
]

def sha(p):
    h = hashlib.sha256()
    with open(p, "rb") as f:
        for b in iter(lambda: f.read(1 << 20), b""):
            h.update(b)
    return h.hexdigest()

def tree_hashes(d):
    out = {}
    for root, _, files in os.walk(d):
        for f in sorted(files):
            p = os.path.join(root, f)
            out[os.path.relpath(p, d)] = sha(p)
    return out

def snap_dir(name):
    return f"{NOTES}/{name}"

def latest_snap():
    ds = sorted(glob.glob(f"{NOTES}/plasma-panel-snap-*"))
    return ds[-1] if ds else None

def do_backup(tag="snap"):
    name = f"plasma-panel-{tag}-{time.strftime('%Y%m%d-%H%M%S')}" if tag != "snap" else f"plasma-panel-snap-{time.strftime('%Y%m%d-%H%M%S')}"
    d = snap_dir(name); os.makedirs(d, exist_ok=True)
    man = {"created": time.strftime("%Y-%m-%d %H:%M:%S"), "items": []}
    for key, kind, src in ITEMS:
        if not os.path.exists(src):
            man["items"].append({"key": key, "kind": kind, "src": src, "present": False}); continue
        if kind == "file":
            dst = os.path.join(d, key)
            shutil.copy2(src, dst)
            man["items"].append({"key": key, "kind": "file", "src": src, "present": True,
                                 "size": os.path.getsize(src), "sha256": sha(dst)})
        else:
            dst = os.path.join(d, key)
            if os.path.exists(dst): shutil.rmtree(dst)
            shutil.copytree(src, dst)
            man["items"].append({"key": key, "kind": "dir", "src": src, "present": True,
                                 "files": tree_hashes(dst)})
    json.dump(man, open(os.path.join(d, "manifest.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2)
    print(f"快照: {d}")
    for it in man["items"]:
        if not it["present"]:
            print(f"   - {it['key']:<18} (不存在,跳过)"); continue
        if it["kind"] == "file":
            print(f"   - {it['key']:<18} {it['size']:>7}B  sha256={it['sha256'][:24]}…")
        else:
            n = len(it["files"]); tot = sum(os.path.getsize(os.path.join(d, it['key'], k)) for k in it['files'])
            print(f"   - {it['key']:<18} {n} 文件  {tot}B")
    return d

def verify_snap(d, verbose=True):
    man = json.load(open(os.path.join(d, "manifest.json"), encoding="utf-8"))
    ok = True
    if verbose: print(f"校验快照完整性: {os.path.basename(d)}")
    for it in man["items"]:
        if not it["present"]: continue
        if it["kind"] == "file":
            p = os.path.join(d, it["key"])
            good = os.path.exists(p) and sha(p) == it["sha256"]
            if verbose: print(f"   {'✓' if good else '✗'} {it['key']:<18} sha256 {'一致' if good else '不一致(快照损坏)'}")
            ok &= good
        else:
            cur = tree_hashes(os.path.join(d, it["key"]))
            good = cur == it["files"]
            if verbose:
                diff = [k for k in set(cur) ^ set(it["files"])] + [k for k in cur if k in it["files"] and cur[k] != it["files"][k]]
                print(f"   {'✓' if good else '✗'} {it['key']:<18} {len(it['files'])} 文件 {'一致' if good else '不一致: '+str(diff[:5])}")
            ok &= good
    return ok

def eval_js(js):
    r = subprocess.run(["busctl","--user","call","org.kde.plasmashell","/PlasmaShell",
                        "org.kde.PlasmaShell","evaluateScript","s",js],
                       capture_output=True, text=True, env=ENV, timeout=90)
    if r.returncode: return "ERR:"+r.stderr.strip()[:150]
    o = r.stdout.strip()
    if o.startswith('s "'): o = o[3:-1]
    return o.replace('\\"','"')

def live_panels():
    js = ("var r=[];var ps=panels();for(var i=0;i<ps.length;i++){r.push({id:ps[i].id"
          ",screen:ps[i].screen,location:ps[i].location,widgets:ps[i].widgetIds.length});}"
          "print(JSON.stringify(r));")
    return json.loads(eval_js(js))

def pick_panel(want="auto"):
    ps = live_panels()
    if want and want != "auto":
        for p in ps:
            if p["id"] == int(want): return p
        sys.exit(f"!! 没有 id={want} 的面板,先 list")
    return max(ps, key=lambda p: p["widgets"])

def show_panels(title="当前面板"):
    print(title)
    for p in live_panels():
        print(f"   id={p['id']} 屏幕={p['screen']} 位置={p['location']} 组件={p['widgets']}")

def main():
    ap = argparse.ArgumentParser(description="Plasma 面板 快照/恢复/画像")
    sub = ap.add_subparsers(dest="cmd", required=True)
    sub.add_parser("backup"); sub.add_parser("list"); sub.add_parser("snapshots")
    pv = sub.add_parser("verify"); pv.add_argument("--snap")
    pr = sub.add_parser("restore")
    pr.add_argument("--snap"); pr.add_argument("--only", help="只恢复某项(如 appletsrc)")
    pr.add_argument("--dry-run", action="store_true")
    pe = sub.add_parser("export"); pe.add_argument("--panel", default="auto")
    pe.add_argument("--dry-run", action="store_true", help="只预览(打印 JS,不写画像)")
    pa = sub.add_parser("apply")
    pa.add_argument("--panel", default="auto")
    pa.add_argument("--screen", type=int, help="目标屏幕索引")
    pa.add_argument("--replace", action="store_true", help="先删除该屏现有面板")
    pa.add_argument("--dry-run", action="store_true")
    a = ap.parse_args()

    if a.cmd == "list": show_panels(); return
    if a.cmd == "backup": do_backup(); return
    if a.cmd == "snapshots":
        for d in sorted(glob.glob(f"{NOTES}/plasma-panel-snap-*")) + sorted(glob.glob(f"{NOTES}/plasma-panel-*.appletsrc")):
            tag = "快照包" if d.endswith(".appletsrc") is False else "旧版(仅appletsrc)"
            print(f"  {time.strftime('%m-%d %H:%M', time.localtime(os.path.getmtime(d)))}  {tag}  {os.path.basename(d)}")
        return
    if a.cmd == "verify":
        d = a.snap or latest_snap()
        if not d: sys.exit("!! 没有快照包(先 backup)")
        sys.exit(0 if verify_snap(d) else "!! 快照完整性校验失败")
    if a.cmd == "export":
        argv = [sys.executable, GEN]
        if a.panel and a.panel != "auto": argv += ["--panel", str(a.panel)]
        if not a.dry_run:
            p = pick_panel(a.panel)
            argv += ["--panel", str(p["id"]), "--location", p["location"]]
            print(f"导出面板 id={p['id']}(屏幕 {p['screen']},{p['widgets']} 组件,位置 {p['location']})")
        else:
            argv += ["--js-only"]
        r = subprocess.run(argv, capture_output=True, text=True, timeout=120)
        print(r.stdout.strip() or r.stderr.strip())
        if r.returncode: sys.exit("!! 导出失败")
        if a.dry_run: print("\n(--dry-run:画像未写入,你的桌面没有任何变化)")
        else: print("→ 默认画像已更新:新屏幕/新显示方案创建的面板就是这个布局")
        return

    if a.cmd == "apply":
        if not os.path.exists(JSF): sys.exit(f"!! 还没有画像({JSF}),先跑 export")
        p = pick_panel(a.panel)
        js = open(JSF, encoding="utf-8").read()
        scr = a.screen if a.screen is not None else p["screen"]
        if scr is not None and scr >= 0:
            js = js.replace('var panel = new Panel("org.kde.panel");',
                            f'var panel = new Panel("org.kde.panel");\npanel.screen = {scr};')
        print(f"画像 → 当前桌面:目标屏幕 {scr},{'先删该屏旧面板再重建' if a.replace else '不删旧面板(直接新建)'}")
        if a.dry_run:
            print(f"将执行的 JS(前 6 行 / 共 {len(js.splitlines())} 行):")
            for l in js.splitlines()[:6]: print("   " + l)
            print("\n(--dry-run:没有执行,你的桌面没有任何变化)"); return
        do_backup("pre-apply")
        if a.replace:
            if a.screen is None: sys.exit("!! --replace 需要 --screen")
            rm = ("var ps=panels();var n=0;for(var i=0;i<ps.length;i++){"
                  f"if(ps[i].screen === {a.screen}){{ps[i].remove();n++;}}}}" "print('removed '+n);")
            print("  删除该屏旧面板:", eval_js(rm)); time.sleep(1)
        print("  执行画像:", eval_js(js)[:300]); time.sleep(1)
        show_panels("apply 后面板:"); return

    # restore
    d = a.snap or latest_snap()
    if not d or not os.path.isdir(d):
        sys.exit("!! 没有快照包(先 plasma-panel.py backup);旧版单文件快照请用 --snap <文件>")
    man = json.load(open(os.path.join(d, "manifest.json"), encoding="utf-8"))
    print(f"恢复源: {d}\n拍于: {man['created']}")
    print("① 校验快照完整性…")
    if not verify_snap(d):
        sys.exit("!! 快照校验失败,已中止(未动你的任何文件)")
    items = [it for it in man["items"] if it["present"] and (not a.only or it["key"] == a.only)]
    print("② 将写入:")
    for it in items:
        print(f"   {it['src']}   ← {it['key']}")
    print("③ 恢复前会先给当前状态再拍一次快照(pre-restore),可回退")
    print("④ 需要暂停的服务: plasma-plasmashell(写 appletsrc/plasmashellrc)"
          + ("、plasma-kactivitymanagerd(写收藏夹库)" if any(i['key']=='kactivitymanagerd' for i in items) else ""))
    if a.dry_run:
        print("\n(--dry-run:以上都不执行,你的桌面没有任何变化)"); return
    print("\n⑤ 执行中…")
    pre = do_backup("pre-restore")
    units = ["plasma-kactivitymanagerd.service"] if any(i['key']=='kactivitymanagerd' for i in items) else []
    units.append("plasma-plasmashell.service")
    for u in units:
        subprocess.run(["systemctl","--user","stop",u], env=ENV, timeout=60)
    time.sleep(2)
    for it in items:
        src, dst = os.path.join(d, it["key"]), it["src"]
        if it["kind"] == "file":
            shutil.copy2(src, dst)
        else:
            if os.path.exists(dst): shutil.rmtree(dst)
            shutil.copytree(src, dst)
        print(f"   写入 {dst}")
    for u in reversed(units):
        subprocess.run(["systemctl","--user","start",u], env=ENV, timeout=90)
    time.sleep(6)
    print(f"\n回退用: {pre}")
    show_panels("恢复后面板:")

main()