一、先看清:有三个主体在动布局
这是「改了没用」「莫名回弹」的根源 —— 不是 KScreen 一个人做主,nix 侧还有两个机制在抢方向盘。
| 主体 |
何时动手 |
做什么 |
数据落点 |
| KScreen(KDE 自带,内核外) |
每次布局被应用之后、登录、场景切换 |
把当前布局写进「连接显示器集合」对应的配置文件;下次同集合回来时套用它 |
~/.local/share/kscreen/<集合哈希>(另有 _lidOpened 变体) |
| kscreen-layout-guard(本机 nix) |
锁屏 / 解锁、熄屏 / 亮屏(ScreenSaver 信号 + RandR 事件 + DPMS 查询,无轮询) |
屏黑前抓快照并停 plasma-kscreen;亮后校验,不符则恢复快照并重启 KScreen;基准「只升不降」;观察窗口内一律不干预 |
~/.local/state/kscreen-layout-guard/{idle,lock}/(layout.norm.txt、kscreen-dir/、guard.log、mismatch.log) |
| kscreen-layout-replay(本机 nix) |
连接集合变化(插拔线、开合盖、连接器重编号、MST 分支重建);以及同场景下的布局改动(判是否用户意图) |
按用户意图 > 规则表摆位;拿不准时先挂 60 秒观察窗口,不动手 |
规则 /etc/kscreen-layout-rules.toml;用户意图 ~/.local/state/kscreen-layout-replay/intent.json;日志 replay.log |
三者的当前协作关系(ASCII 版):
连接集合变化 ──▶ replay:命中有没有目标? ──有──▶ 摆位 ──▶ guard accept(基准 = 新布局)
│
└──无──▶ 不干预
同场景、布局被改动(不是机制干的)
└──▶ replay:挂起 60s 观察窗口(写 hold 文件)──▶ settle 到期
├─ 仍在 ⇒ 记为该连接集合的用户意图 + guard accept
└─ 已回目标 / 机制噪声 ⇒ 只清窗口,不动手
屏黑 → 亮屏 ────▶ guard:基准 vs 当前 ──不一致──▶ 先看 hold;无 hold 才「纠正前 sleep 3 复核」→ 恢复快照 + 重启 KScreen
└──一致────▶ 不动
KScreen 自己 ───▶ 每次应用后写 <集合哈希> 文件(下次同集合自动套用)
二、nix 配置里的文件清单(按「动不动布局」分三层)
2.1 主动干预布局(运行时改 X 布局 / 管 KScreen)—— 只有 2 个 .nix
| 文件 |
启用状态 |
作用 |
运行时入口 / 单元 |
modules/system/service/kscreen-layout-guard.nix(+ 同目录 kscreen-layout-guard.sh) |
已启用(configuration.nix 第 125 行) |
保证「屏黑再亮之后布局还是原来那个」:锁屏/熄屏前存快照,亮后校验,不符才恢复。三条校正判据:① 基准里被启用的输出已不连接 ⇒ 视为换场景、重建基准;② 布局符合规则表 ⇒ 更新基准、不干预;③ 观察窗口有效 ⇒ 暂不干预(且纠正前会再等 3 秒复核窗口与 conforms) |
user 单元 kscreen-layout-guard-lock、kscreen-layout-guard-watch;命令 kscreen-layout-guard {status,accept,snapshot,on-lock,on-unlock,watch} |
modules/system/service/kscreen-layout-replay.nix(+ kscreen-layout-replay.py + kwin-data/kscreen-layout-rules.toml) |
已启用(configuration.nix 第 126 行) |
连接集合变化后按「用户意图 > 规则表」摆位;规则按「外屏数量 + 尺寸集合 + 内屏开关」匹配,不写连接器名;RandR 事件驱动、去抖 3 秒;同场景改动先开 60 秒观察窗口 |
user 单元 kscreen-layout-replay;命令 kscreen-layout-replay {status,apply,watch,conforms,settle,dump};规则落 /etc/kscreen-layout-rules.toml |
2.2 影响「屏能不能亮 / 能不能出图」,但不改布局 —— 4 个 .nix
| 文件 |
作用 |
动手时机 |
modules/hardware/gpu-amd-780m-mst-bpc.nix |
780M 侧每个 MST 下游连接器的色深上限(debugfs max_bpc);决定菊花链带宽够不够三屏同时亮 |
开机 oneshot amdgpu-mst-bpc + udev(热插拔 / 链路变化 / 连接器重建) |
modules/hardware/display-resume-wake.nix |
唤醒后外接屏黑屏的补救:X 侧 DPMS 关→开一次,让显示器自己醒(实测不动几何) |
powerManagement.resumeCommands → user 单元 display-refresh |
modules/hardware/gpu-nvidia-4060-egpu.nix |
eGPU 4060 驱动钉版 + PRIME 输出(prime.offload + reverseSync)—— 决定 4060 侧接的屏能否出图 |
内核 / 驱动层(开机) |
modules/hardware/gpu-mode.nix(+ gpu-mode.sh) |
显卡「宿主 / 直通」模式切换机制(状态在 /var/lib/gpu-mode/<BDF>.mode,重启生效);直通后宿主少一块卡 ⇒ 间接改变可用输出 |
开机 gpu-mode-apply(早于 display-manager) |
2.3 只取证 / 只提供能力 / 已停用 —— 4 个 .nix
| 文件 |
说明 |
modules/hardware/gpu-nvidia-4060-boot-probe.nix |
纯取证:开机后 T+0 / +20s / +60s 各采一次(两侧 card1-* 状态 + NVIDIA/DFP/MST 计数),写 /var/log/egpu-boot-probe.log,不改任何状态 |
modules/system/service/kwin.nix |
KWin 脚本(centerspan:Meta+Shift+X 居中 / Shift+Z 跨屏)+ 窗口规则(按激活窗口路由快捷键)。不碰输出布局 |
modules/system/service/displayserver.nix |
显示管理器 / greeter、默认会话(X11)、X11 键盘布局(xkbLayout = cn)。没有输出布局规则 |
modules/hardware/display-lenovo-p27h-edid-pin.nix |
已停用(configuration.nix 第 28 行注释,2026-10-18):曾给写死的 DP-9 喂固定 EDID 来断「EDID 被反复重读」的环路;该连接器号后来不存在 ⇒ 内核参数空转,故停 |
三、时机对照:什么时候谁会动手
| 时机 |
动手的主体 |
| 开机 | gpu-mode-apply(显卡绑定)→ amdgpu-mst-bpc(色深)→ display-manager;登录后 replay 跑一次 startup apply 并以当时的签名作为「本场景」基准、guard 起两个监听 |
| 热插拔 / 链路变化 | amdgpu-mst-bpc(udev)→ replay(按用户意图或规则表摆位)→ guard(校验、必要时纠正) |
| 合盖 / 开盖 | KScreen 按「集合 + 开盖变体」套用它自己记的那份;replay 按用户意图/规则表纠正;guard 校验 |
| 锁屏 / 解锁 | guard(lock 路径):锁屏抓快照 + 停 plasma-kscreen;解锁恢复快照 + 起 KScreen + 校验 |
| 熄屏 / 亮屏 | guard(idle 路径):屏关时停 KScreen(不抓快照);屏亮时校验,不符才纠正 |
| 睡眠唤醒 | display-refresh(DPMS 关→开,只唤 sink)→ replay / guard 各自按事件处理 |
| 用户手动改布局(显示设置里点应用) | replay 开 60 秒观察窗口(期间 guard 也不动)→ 窗口到期仍在 ⇒ 记为该连接集合的用户意图(见第五节) |
四、数据落点与排查命令
| 内容 |
位置 |
| 布局规则(声明式意图) | /etc/kscreen-layout-rules.toml(源:modules/system/service/kwin-data/kscreen-layout-rules.toml) |
| 用户意图(按连接集合签名,优先于规则表) | ~/.local/state/kscreen-layout-replay/intent.json |
| 观察窗口标记(guard 也读它) | ~/.local/state/kscreen-layout-hold(内容是到期 epoch 秒) |
| guard 的基准与配置快照 | ~/.local/state/kscreen-layout-guard/{idle,lock}/ |
| guard 日志 / 不一致记录 / 纠错节流戳 | ~/.local/state/kscreen-layout-guard/{guard.log,mismatch.log}、{idle,lock}/.last_fix |
| replay 日志 | ~/.local/state/kscreen-layout-replay/replay.log |
| KScreen 的场景记忆 | ~/.local/share/kscreen/<连接集合哈希>(每模式一份,另有 _lidOpened) |
| eGPU 开机取证 | /var/log/egpu-boot-probe.log |
常用排查命令:
kscreen-layout-replay status # 当前输出 + 命中的目标 + 是否已符合(不动任何东西)
kscreen-layout-replay conforms; echo $? # 0=当前布局正是目标(guard 纠正前问它)
kscreen-layout-guard status # 基准(idle/lock)+ 当前实际 + 最近日志
xrandr --query # 第一行 current = 虚拟屏尺寸
kscreen-doctor -o # KScreen 视角的输出表(含 mode id)
systemctl --user is-active plasma-kscreen kscreen-layout-guard-watch kscreen-layout-guard-lock kscreen-layout-replay
rm ~/.local/state/kscreen-layout-hold # 取消正在进行的观察窗口
五、用户手动改布局:从「改了没用」到「记住」(2026-10-19 21:0x 落地)
原现象:在「显示和监视器」里改布局 → 点「应用」→ 临时生效 → 确认倒计时点「接受」→ 变回老样子。(实测:把竖屏挪 100px,2 秒内被摆回。)
| 根因 |
证据 |
| ① 确认对话框还没走完,布局已被 replay / guard 改回去 ⇒ 点「接受」保存的是被改回的那份 | 挪 100px 后 2 秒回弹;replay.log「同一连接集合但布局与规则不符 ⇒ 重放」+ guard.log「与基准不符 ⇒ 纠正」 |
| ② 服务重启后第一条 RandR 事件被当成「换场景」⇒ 立刻按规则盖回 | cmd_watch 原写 last_sig = None;21:00:13 日志「[randr] 布局与规则…不符 ⇒ 应用」 |
| ③ guard 比 replay 先醒(去抖 2 s vs 3 s)⇒ 观察窗口还没落盘,guard 已经动手 | 21:04:42 guard.log「与基准不符但 10 分钟内已纠正过 ⇒ 节流跳过」(这次只是被节流救了) |
落地改动(gen263~gen265)
| 位置 |
改动 |
kscreen-layout-replay.py | ① 用户意图层 intent.json(键=连接集合签名,如 eDP|DisplayPort-10:1440x2560,DisplayPort-7:2560x1440),cmd_apply 与 cmd_conforms 都先查它 ⇒ 有意图时优先于规则表 ② 60 秒观察窗口 kscreen-layout-hold + 派生 settle 定时收尾 ③ 护栏:KScreen 刚重启(<60s)或 guard 刚纠正 ⇒ 判机制噪声,立即按目标重放、不开窗 ④ cmd_watch 启动用 startup 返回的签名做基准 |
kscreen-layout-guard.sh | ① verify_and_maybe_fix 开头读 hold 文件,窗口内「暂不干预」(覆盖 lock / idle / watch 三条路径)② 纠正分支前 sleep 3 复核窗口与 conforms,避开与 replay 的时序竞争 |
真机验证 A —— 机制侧(21:03~21:10,home 组合:eDP + P27H 横屏 + 竖屏)
| 步骤 |
结果 |
挪竖屏 +2560+900 → +2560+1000 | +3s / +13s / +28s / +43s 全部保持 1000(改前是 2 秒回弹) |
| replay 反应 | 「同场景下布局被改动(非本机制发起)⇒ 挂起 60s 观察」 |
| guard 反应(先清节流戳,让它真会纠正) | 「watch-on: 布局观察窗口内(用户可能正在显示设置里改布局)⇒ 暂不干预」 |
| 窗口到期 | 「观察窗口结束:布局仍保持 ⇒ 已把连接集合 … 记为用户意图」;intent.json 里 DisplayPort-10 2560,1000;随后 guard accept 抓新基准 |
| 收尾(删意图 + 回规则位) | conforms 退出码 0,布局回到 +2560+900 |
真机验证 B —— 用户在系统设置里真改(21:14~21:15,改后:eDP 1440x900+1440+0、DP-7 2560x1440+1440+900 primary、DP-10 1440x2560+0+305 left)
| 时刻 |
事件 |
| 21:14:13 | replay.log「同场景下布局被改动(非本机制发起)⇒ 挂起 60s 观察」——用户改动没有被立刻盖回 |
| 21:14:17 | guard.log「布局观察窗口内(用户可能正在显示设置里改布局)⇒ 暂不干预」×2(去抖带来的两次事件) |
| 21:15:14 | settle:「观察窗口结束:布局仍保持 ⇒ 已把连接集合 … 记为用户意图」;intent.json 三个输出与用户所改一致 |
| 之后 | kscreen-layout-replay conforms → 0;此时起 guard 不再纠正、replay 会按它恢复 |
运维口径
- 想让某个连接模式回到规则表:删
intent.json 里对应键(或整个文件)⇒ 下次事件起按规则表。
- 想取消正在进行的观察窗口:删
~/.local/state/kscreen-layout-hold。
- 意图只对「该连接集合」生效,换插法/换屏走别的键,互不干扰。
- 限制:意图只恢复 enable / position / rotation,不恢复分辨率(mode);改分辨率请进规则表。
- 系统设置若提示「没有找到 kscreen 后端」:见第七节(是旧窗口缓存,不是没装)。
六、规则表当前 4 条(/etc/kscreen-layout-rules.toml)
| 规则名 |
匹配条件 |
目标布局 |
office-3x-2560x1440-horizontal | 外屏 3 块,全是 2560x1440 | 内屏关;外屏 0,0 / 2560,0 / 5120,0 横排 |
home-p27h-plus-vertical | 外屏 2 块 = 2560x1440 + 1440x2560 | 内屏 0,0;横屏 0,900;竖屏 2560,900 左转 |
single-2560x1440-right | 外屏 1 块 2560x1440 | 内屏 0,0;外屏 1440,0 |
laptop-only | 外屏 0 块 | 内屏 on,0,0(虚拟屏随之收回到内屏尺寸) |
改规则的唯一入口:改 modules/system/service/kwin-data/kscreen-layout-rules.toml → nixos-rebuild switch;规则表本身不会被任何运行时动作改写。
七、系统设置提示「没有找到 kscreen 后端」
判据先行:不是后端丢了 —— plasma-kscreen active、DBus 上有 org.kde.KScreen、kscreen-doctor -o 能读到全部输出。
| 项 |
说明 |
| 真因 | 那个 systemsettings kcm_kscreen 窗口打开期间,guard 的纠正路径 restore_dir + systemctl --user restart plasma-kscreen 重启过后端 ⇒ KCM 缓存的 DBus 连接失效,窗口里就永远显示"没有后端" |
| 本次证据 | 窗口 20:14:01 打开(ps -o lstart),后端在 20:25:41、21:01:43 各重启一次 ⇒ 窗口报错;kill 后重开 systemsettings kcm_kscreen 正常 |
| 处置 | 关掉该设置窗口重新打开(不需要装任何包、不需要重启会话) |
| 何时会遇到 | 布局被 guard 纠正过(锁屏/熄屏后布局回弹)之后,若设置窗口一直开着 |