使用说明
# 运行时
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()