本教程建立一个长期存在的 KWin 虚拟显示器,让 Sunshine 捕获它,并让笔记本内置屏幕复制虚拟桌面。这样可以在打开内屏时看到同一个桌面,合盖后继续串流,也能调整串流桌面的分辨率。
本文包含完整脚本和服务配置,只需这份 Markdown 即可部署。执行命令时请使用登录 KDE 桌面的普通用户;只有安装软件包需要 sudo。
1. 适用环境与已经验证的范围
记录日期:2026-10-08。
| 项目 | 实测环境 |
|---|---|
| 系统 | CachyOS,Arch 系发行版 |
| 桌面 | KDE Plasma,Wayland 会话 |
| KWin / KScreen / libkscreen | 6.7.5 |
| krfb | 26.08.1 |
| Sunshine | 2026.1006.152353 |
| 显卡 | NVIDIA RTX 4060 Laptop |
| NVIDIA 驱动 | 615.71.09 |
| 物理内屏 | eDP-2,2560×1600,60 / 165 Hz |
其他版本或发行版需要确认三项能力:Sunshine 支持 kwin 捕获、KScreen 支持 addCustomMode、KDE 支持设置镜像来源。本文没有实测其他显卡、Flatpak Sunshine、X11 会话或其他桌面环境。
虚拟输出存在于当前已登录的 KDE 会话中。它不是登录界面的虚拟屏,也不会在没有图形会话时自动成为独立的无头桌面。
2. 先理解镜像方向
正确关系如下:
krfb-virtualmonitor 请求 KWin 创建虚拟输出
↓
Virtual-sunshine-virtual:独立桌面源、主输出
├── Sunshine 通过 KWin / PipeWire 捕获 → NVENC → Moonlight
└── 内置屏幕 eDP-2 复制它,按自身物理模式显示
关键设置是:内屏复制虚拟屏,虚拟屏的镜像来源为“无”。
如果反过来让虚拟屏复制内屏,KWin 会把镜像输出对应到源输出的逻辑桌面。此时即使改了虚拟输出模式,捕获仍可能沿用内屏的原生桌面尺寸。本机此前就出现过“虚拟屏设置为 1080p,实际捕获依然是 2560×1600”的问题。对应逻辑可见 KWin 输出与镜像实现 和 KWin 屏幕捕获实现。
这种布局下:
- 调整桌面分辨率,应调整虚拟屏。
- 物理内屏可以保持原生 2560×1600@165 Hz,显示缩放后的虚拟桌面。
- 两块输出共享桌面内容,不能同时各自显示一个独立分辨率的桌面。
- 虚拟桌面与内屏长宽比不同时,可能留黑。
- 虚拟屏长期保留;客户端断开后不删除它,也不恢复错误的镜像方向。
虚拟输出由 KWin 创建;脚本只负责启动服务、添加模式和维护布局。KWin 接到创建虚拟屏的请求后生成虚拟输出,创建它的连接结束时会移除该输出。创建与销毁流程
3. 安装依赖、检查会话并备份
3.1 依赖
Sunshine 和 NVIDIA 驱动应已安装,并能正常串流普通桌面。不要把虚拟显示器配置与显卡驱动重装混在一起。
CachyOS / Arch 系统可安装所需工具:
sudo pacman -S krfb kscreen python dbus qt6-tools
检查命令是否存在:
command -v krfb-virtualmonitor
command -v kscreen-doctor
command -v python3
command -v qdbus6
command -v dbus-monitor
3.2 读取本机参数
在 KDE 桌面终端执行:
printf '%s\n' "$XDG_SESSION_TYPE" "$WAYLAND_DISPLAY"
kscreen-doctor -o
kscreen-doctor --help
krfb-virtualmonitor --help
systemctl --user list-unit-files '*Sunshine*' '*sunshine*'
预期会话类型为 wayland。记下以下信息:
| 需要替换的参数 | 本教程示例 | 如何确定 |
|---|---|---|
| 内屏名称 | eDP-2 | kscreen-doctor -o 的输出名称 |
| Wayland socket | wayland-0 | 当前 WAYLAND_DISPLAY |
| 虚拟屏名称 | Virtual-sunshine-virtual | 服务使用 sunshine-virtual 名称,KWin 加 Virtual- 前缀 |
| Sunshine 用户服务 | app-dev.lizardbyte.app.Sunshine.service | systemctl –user list-unit-files 查询 |
| 用户家目录 | /home/你的用户名 | 当前用户实际路径 |
本教程两个服务文件使用 wayland-0。如果本机打印的是 wayland-1,应在两个文件中一并替换。脚本会优先使用继承的环境变量。
KScreen 帮助中应有 addCustomMode 和 removeCustomMode。如果版本缺少这些能力,不能直接照搬模式脚本。先核对或更新发行版提供的 KDE 组件。
3.3 备份已有设置
sunshine_backup_dir="$HOME/sunshine-display-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$sunshine_backup_dir"
chmod 700 "$sunshine_backup_dir"
for sunshine_config_file in "$HOME/.config/kwinoutputconfig.json" "$HOME/.config/powerdevilrc" "$HOME/.config/sunshine/sunshine.conf" "$HOME/.config/sunshine/apps.json"; do
if [ -f "$sunshine_config_file" ]; then
cp -p "$sunshine_config_file" "$sunshine_backup_dir/"
fi
done
printf '备份目录:%s\n' "$sunshine_backup_dir"
如果以前已经安装同名脚本和服务,也把它们复制进备份目录。后续编辑 Sunshine 配置时应合并已有设置,而不是用本文的几行配置覆盖整个文件。
4. 安装三个辅助脚本
先创建目录:
mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user"
分别新建下面的三个文件,把各自代码块的全部内容保存进去。代码块里的 Python 内容就是完整文件,不需要依赖下载地址。
4.1 模式管理脚本
文件:~/.local/bin/sunshine-display-modes
作用:添加分辨率和刷新率预设,或按参数切换模式。添加模式和选择模式分两次操作,避免刚添加的模式还没有可用 ID 时就选择它。
默认 11 种分辨率:1280×720、1280×800、1600×900、1600×1000、1920×1080、1920×1200、2560×1440、2560×1600、3840×2160、2360×1640、2384×1080。
每种提供 24、30、60、90、120、144、165、240 Hz,共 88 种模式。可以编辑代码顶部的 RESOLUTIONS 和 REFRESH_RATES。
#!/usr/bin/env python3
"""Populate KWin virtual modes and select them only after they exist."""
import argparse
import json
import os
import re
import subprocess
import sys
import time
OUTPUT = 'Virtual-sunshine-virtual'
RESOLUTIONS = [
(1280, 720), (1280, 800), (1600, 900), (1600, 1000),
(1920, 1080), (1920, 1200), (2560, 1440), (2560, 1600),
(3840, 2160), (2360, 1640), (2384, 1080),
]
REFRESH_RATES = [24, 30, 60, 90, 120, 144, 165, 240]
PRESETS = [(width, height, fps) for width, height in RESOLUTIONS
for fps in REFRESH_RATES]
def doctor(*args):
result = subprocess.run(['kscreen-doctor', *args], capture_output=True,
text=True, timeout=15)
# Some libkscreen versions print an apply failure but still exit zero.
if result.returncode or 'applying config failed' in result.stdout.lower():
raise RuntimeError((result.stdout + result.stderr).strip()
or 'kscreen-doctor failed')
return result.stdout
def output(wait=0):
deadline = time.monotonic() + wait
while True:
data = json.loads(doctor('-j'))
found = next((o for o in data['outputs'] if o['name'] == OUTPUT), None)
if found:
return found
if time.monotonic() >= deadline:
raise RuntimeError(f'{OUTPUT} is not available')
time.sleep(0.2)
def matching_mode(out, width, height, fps):
matches = [m for m in out['modes']
if m['size'] == {'width': width, 'height': height}
and abs(m['refreshRate'] - fps) < 0.5]
return min(matches, key=lambda m: abs(m['refreshRate'] - fps)) if matches else None
def custom_modes():
# JSON omits customModes; the text output exposes the removal indices.
text = re.sub(r'\x1b\[[0-9;]*m', '', doctor('-o'))
section = next(s for s in text.split('Output: ')[1:] if s.splitlines()[0].split()[1] == OUTPUT)
block = section.split('Custom modes:', 1)[1].split('Geometry:', 1)[0]
return [(int(i), int(w), int(h), float(fps))
for i, w, h, fps in re.findall(r'(\d+): (\d+)x(\d+)@([\d.]+)', block)]
def nominal_rate(w, h, fps):
known = [f for pw, ph, f in PRESETS if (pw, ph) == (w, h)]
return min(known, key=lambda f: abs(f - fps)) if known and min(abs(f-fps) for f in known) < 2 else round(fps)
def add_modes(out, modes, replace=False):
# libkscreen sends CVT-rounded rates back on every configuration change.
# Rebuild using nominal integer rates so retries cannot accumulate drift.
existing = custom_modes()
desired = set(modes)
if not replace:
desired.update((w, h, nominal_rate(w, h, fps)) for _, w, h, fps in existing)
elif current := next((m for m in out['modes'] if m['id'] == out['currentModeId']), None):
# Prune old presets without removing the mode currently in use.
desired.add((current['size']['width'], current['size']['height'],
nominal_rate(current['size']['width'], current['size']['height'],
current['refreshRate'])))
native = [m for m in out['modes'] if m['id'] in out['preferredModes']]
desired = {mode for mode in desired if not any(
n['size'] == {'width': mode[0], 'height': mode[1]} and
abs(n['refreshRate'] - mode[2]) < 0.5 for n in native)}
args = [f'output.{OUTPUT}.removeCustomMode.{i}' for i, *_ in sorted(existing, reverse=True)]
args += [f'output.{OUTPUT}.addCustomMode.{w}.{h}.{fps * 1000}.reduced'
for w, h, fps in sorted(desired)]
if args:
doctor(*args)
out = output()
for w, h, fps in modes:
if not matching_mode(out, w, h, fps):
raise RuntimeError(f'KWin did not add {w}x{h}@{fps}')
return out
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--defaults', action='store_true', help='Replace presets without switching the current mode')
parser.add_argument('--wait', type=float, default=0)
parser.add_argument('--set', nargs=3, type=int, metavar=('WIDTH', 'HEIGHT', 'FPS'))
args = parser.parse_args()
if not args.defaults and args.set is None:
parser.error('use --defaults and/or --set WIDTH HEIGHT FPS')
if args.set:
w, h, fps = args.set
if not (320 <= w <= 16384 and 320 <= h <= 16384 and w * h <= 50000000 and 1 <= fps <= 240):
parser.error('invalid resolution or frame rate')
os.environ.setdefault('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
os.environ.setdefault('WAYLAND_DISPLAY', 'wayland-0')
os.environ.setdefault('DBUS_SESSION_BUS_ADDRESS', 'unix:path=' + os.environ['XDG_RUNTIME_DIR'] + '/bus')
out = output(args.wait)
modes = PRESETS.copy() if args.defaults else []
if args.set and tuple(args.set) not in modes:
modes.append(tuple(args.set))
out = add_modes(out, modes, replace=args.defaults)
if args.set:
mode = matching_mode(out, *args.set)
doctor(f'output.{OUTPUT}.mode.{mode["id"]}')
actual = output()
current = next(m for m in actual['modes'] if m['id'] == actual['currentModeId'])
if current['size'] != mode['size'] or abs(current['refreshRate'] - mode['refreshRate']) > 0.1:
raise RuntimeError('KWin did not switch to the requested mode')
print(f'{OUTPUT}: {current["size"]["width"]}x{current["size"]["height"]}@{current["refreshRate"]:.2f}')
else:
print(f'{OUTPUT}: {len(out["modes"])} modes available')
if __name__ == '__main__':
try:
main()
except (RuntimeError, ValueError, subprocess.SubprocessError, OSError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
运行 –defaults 会按当前列表重建自定义预设,删除列表中已移除的旧预设,但保留当前正在使用的模式。–set 会保留已有模式并添加所需模式,然后切换。
4.2 Sunshine 会话与镜像脚本
文件:~/.local/bin/sunshine-virtual-display
保存后先把 PANEL = ’eDP-2’ 改成自己内屏的名称。 三个脚本、Sunshine 配置与服务中的虚拟屏名称应保持一致。
作用:确保虚拟屏是独立源和主输出、内屏复制它;新串流会话按 Sunshine 提供的客户端尺寸及 FPS 调整虚拟模式。stop 只记录客户端结束,不删除虚拟屏。
#!/usr/bin/env python3
"""Make the virtual output the capture source; the panel mirrors it."""
import datetime
import json
import os
from pathlib import Path
import subprocess
import sys
VIRTUAL = 'Virtual-sunshine-virtual'
PANEL = 'eDP-2'
UNIT = 'sunshine-virtual-monitor.service'
BIN = Path.home() / '.local/bin'
STATE = Path(os.environ.get('XDG_STATE_HOME', str(Path.home() / '.local/state'))) / 'sunshine-virtual-display'
def run(*cmd):
r = subprocess.run(cmd, capture_output=True, text=True, timeout=25)
if r.returncode or 'applying config failed' in r.stdout.lower():
raise RuntimeError((r.stdout + r.stderr).strip() or f'{cmd[0]} failed')
return r.stdout
def snapshot():
return json.loads(run('kscreen-doctor', '-j'))['outputs']
def current_mode(out):
return next(m for m in out['modes'] if m['id'] == out['currentModeId'])
def record(message):
STATE.mkdir(parents=True, exist_ok=True)
with (STATE / 'hook.log').open('a') as log:
log.write(f'[{datetime.datetime.now():%Y-%m-%d %H:%M:%S}] {message}\n')
def configure(requested=None):
outputs = snapshot()
virtual = next((o for o in outputs if o['name'] == VIRTUAL), None)
if not virtual:
run('systemctl', '--user', 'start', UNIT)
outputs = snapshot()
virtual = next((o for o in outputs if o['name'] == VIRTUAL), None)
if not virtual:
raise RuntimeError('persistent virtual output is unavailable')
panel = next((o for o in outputs if o['name'] == PANEL and o['connected']), None)
closed = run('qdbus6', 'org.kde.Solid.PowerManagement',
'/org/kde/Solid/PowerManagement', 'isLidClosed').strip() == 'true'
args = [f'output.{VIRTUAL}.enable', f'output.{VIRTUAL}.mirror.none', f'output.{VIRTUAL}.primary']
ready = virtual['enabled'] and virtual['replicationSource'] == 0 and virtual['priority'] == 1
if panel:
# Do not disable an open panel; let KWin manage its power on lid close.
if not closed:
args.append(f'output.{PANEL}.enable')
args.append(f'output.{PANEL}.mirror.{VIRTUAL}')
ready = ready and panel['replicationSource'] == virtual['id'] and (closed or panel['enabled'])
if not ready:
run('kscreen-doctor', *args)
if requested:
width, height, fps = requested
actual = current_mode(next(o for o in snapshot() if o['name'] == VIRTUAL))
if actual['size'] != {'width': width, 'height': height} or abs(actual['refreshRate'] - fps) >= 0.5:
run(str(BIN / 'sunshine-display-modes'), '--set', str(width), str(height), str(fps))
final = snapshot()
v = next(o for o in final if o['name'] == VIRTUAL)
if not v['enabled'] or v['replicationSource'] != 0 or v['priority'] != 1:
raise RuntimeError('virtual output did not become the independent capture source')
if panel:
p = next(o for o in final if o['name'] == PANEL)
if p['replicationSource'] != v['id'] or (not closed and not p['enabled']):
raise RuntimeError('panel mirror was not preserved')
mode = current_mode(v)
if requested and (mode['size'] != {'width': requested[0], 'height': requested[1]} or abs(mode['refreshRate']-requested[2]) >= 0.5):
raise RuntimeError('actual virtual resolution does not match the client request')
message = f'virtual capture source {mode["size"]["width"]}x{mode["size"]["height"]}@{mode["refreshRate"]:.2f}; panel mirrors virtual; lid closed={closed}'
record(message)
print(message)
def client_mode():
w, h = os.environ.get('SUNSHINE_CLIENT_WIDTH'), os.environ.get('SUNSHINE_CLIENT_HEIGHT')
if w is None and h is None:
return None # Manual invocation preserves the user's chosen mode.
try:
width, height = int(w), int(h)
fps = int(os.environ.get('SUNSHINE_CLIENT_FPS', '60'))
except (TypeError, ValueError):
raise RuntimeError('invalid client mode')
if not (320 <= width <= 16384 and 320 <= height <= 16384 and width*height <= 50000000 and 1 <= fps <= 240):
raise RuntimeError('invalid client mode')
return width, height, fps
if __name__ == '__main__':
os.environ.setdefault('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
os.environ.setdefault('WAYLAND_DISPLAY', 'wayland-0')
os.environ.setdefault('DBUS_SESSION_BUS_ADDRESS', 'unix:path=' + os.environ['XDG_RUNTIME_DIR'] + '/bus')
action = sys.argv[1] if len(sys.argv) == 2 else ''
if action not in ('start','stop','mirror'):
print(f'Usage: {sys.argv[0]} {{start|stop|mirror}}', file=sys.stderr)
sys.exit(2)
try:
if action == 'stop':
record('client disconnected; retaining virtual capture source and selected resolution')
else:
configure(client_mode() if action == 'start' else None)
except (RuntimeError, ValueError, subprocess.SubprocessError, OSError) as exc:
record(f'ERROR: {exc}')
print(str(exc), file=sys.stderr)
sys.exit(1)
当前脚本针对一块内置屏幕。其他外接显示器不由它统一管理;台式机或多屏工作站需要按自己的布局调整 PANEL 和镜像逻辑。
4.3 开合盖监听脚本
文件:~/.local/bin/sunshine-mirror-lid-watch
KDE 可能在开合盖时重新载入保存的显示布局。这个脚本监听电源管理的合盖状态信号,稍后恢复镜像方向,避免旧布局再次把内屏设成桌面源。
#!/usr/bin/env python3
"""Repair the capture source after KWin loads a saved lid layout."""
from pathlib import Path
import subprocess
import time
hook = str(Path.home() / '.local/bin/sunshine-virtual-display')
subprocess.run([hook, 'mirror'], check=True)
monitor = subprocess.Popen([
'/usr/bin/dbus-monitor', '--session',
"type='signal',interface='org.kde.Solid.PowerManagement',member='lidClosedChanged'"
], stdout=subprocess.PIPE, text=True, bufsize=1)
try:
for line in monitor.stdout:
if 'member=lidClosedChanged' in line:
# Both PowerDevil and KWin react to the hardware lid event.
# Reapply after their saved layout has been loaded.
time.sleep(0.5)
subprocess.run([hook, 'mirror'], check=True)
time.sleep(0.5)
subprocess.run([hook, 'mirror'], check=True)
finally:
monitor.terminate()
raise SystemExit('lid signal monitor exited')
4.4 设置执行权限并检查语法
chmod 755 "$HOME/.local/bin/sunshine-display-modes" "$HOME/.local/bin/sunshine-virtual-display" "$HOME/.local/bin/sunshine-mirror-lid-watch"
python3 -m py_compile "$HOME/.local/bin/sunshine-display-modes" "$HOME/.local/bin/sunshine-virtual-display" "$HOME/.local/bin/sunshine-mirror-lid-watch"
5. 配置持久虚拟屏服务
新建 ~/.config/systemd/user/sunshine-virtual-monitor.service:
[Unit]
Description=Persistent KWin virtual display for Sunshine
After=graphical-session.target
PartOf=graphical-session.target
Before=app-dev.lizardbyte.app.Sunshine.service
[Service]
Type=simple
Environment=QT_QPA_PLATFORM=wayland
Environment=WAYLAND_DISPLAY=wayland-0
Environment=XDG_RUNTIME_DIR=%t
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus
ExecStart=/usr/bin/krfb-virtualmonitor --resolution 2560x1600 --name sunshine-virtual --password CHANGE_ME_RANDOM_VNC_PASSWORD --desktopfile org.kde.krfb.virtualmonitor --scale 1 --port 5915
ExecStartPost=-%h/.local/bin/sunshine-display-modes --defaults --wait 10
Restart=on-failure
RestartSec=3
[Install]
WantedBy=graphical-session.target
需要修改的内容:
- CHANGE_ME_RANDOM_VNC_PASSWORD:替换为自己设置的随机 VNC 密码;不要照用这个占位值。
- 2560x1600:虚拟屏刚创建时的初始尺寸,可按自己的需求修改;后续可以切换模式。
- wayland-0:如与本机会话不同,替换为实际值。
- Before 中的 Sunshine 服务名:如本机不同,替换成实际用户服务名。
- 5915:krfb 的 VNC 监听端口;如被占用,换一个未占用的端口。
krfb-virtualmonitor 同时提供 VNC 服务,但 Moonlight 的画面是从 Sunshine 接收的。VNC 密码与 Sunshine Web UI 密码、Moonlight 配对 PIN 是不同的;Moonlight 串流不需要连接 5915。保存服务文件时不要把自己的实际密码写入公开教程或分享包。
–scale 1 让初始逻辑尺寸与像素尺寸保持直接对应;后续桌面缩放由 KDE 显示设置管理。
ExecStartPost 前的减号表示预设加载失败不会直接终止虚拟屏服务。因此“服务 active”不等于“所有预设已成功加载”,还要检查模式列表。
这个服务属于当前用户的图形会话。登录桌面时启动,图形会话结束时跟随停止。
6. 配置镜像与合盖监听服务
新建 ~/.config/systemd/user/sunshine-mirror-lid.service:
[Unit]
Description=Keep Sunshine virtual capture source across lid changes
After=graphical-session.target sunshine-virtual-monitor.service
PartOf=graphical-session.target
[Service]
Type=simple
Environment=QT_QPA_PLATFORM=wayland
Environment=WAYLAND_DISPLAY=wayland-0
Environment=XDG_RUNTIME_DIR=%t
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus
ExecStart=%h/.local/bin/sunshine-mirror-lid-watch
Restart=on-failure
RestartSec=3
[Install]
WantedBy=graphical-session.target
先启动虚拟屏,确认出现,再启动镜像维护服务:
systemctl --user daemon-reload
systemctl --user enable --now sunshine-virtual-monitor.service
kscreen-doctor -o
systemctl --user enable --now sunshine-mirror-lid.service
检查:
systemctl --user is-enabled sunshine-virtual-monitor.service sunshine-mirror-lid.service
systemctl --user is-active sunshine-virtual-monitor.service sunshine-mirror-lid.service
应分别显示 enabled 和 active。如果虚拟屏未出现,先查第 12 节,不要继续修改 Sunshine。
7. KDE 显示设置:确认布局和缩放
打开“系统设置 → 显示与监视器 → 显示配置”(具体名称随版本略有变化)。
| 输出 | 应设置成什么 |
|---|---|
| Virtual-sunshine-virtual | 启用、主屏幕、镜像来源为无 |
| 内屏,例如 eDP-2 | 启用,复制 Virtual-sunshine-virtual |
| 内屏物理模式 | 保留自己的原生分辨率和所需刷新率 |
| 虚拟屏模式 | 选择希望串流的桌面尺寸和刷新率 |
服务启动后脚本已设置镜像方向,这一步主要用来确认结果及调整缩放。也可以手动恢复正确布局:
"$HOME/.local/bin/sunshine-virtual-display" mirror
本机采用 150% 缩放,两块输出都设为 1.5。别人应根据文字大小自行选择;不要无条件复制 1.5。像素分辨率与逻辑桌面尺寸不同:例如 2560×1600 使用 150% 缩放时,逻辑尺寸约为 1707×1067,不能凭逻辑尺寸判断串流像素是否错误。
本机物理内屏的降低分辨率自定义模式被 NVIDIA 拒绝。因此本方案让物理屏保持原生模式,调整虚拟桌面;添加物理屏模式不是必需步骤,也不保证能成功。
8. KDE 电源设置:合盖时保持会话运行
打开“系统设置 → 电源管理”。在需要串流的电源方案中设置:
| 项目 | 建议设置 |
|---|---|
| 合上笔记本盖时 | 不执行任何操作 |
| 自动睡眠 / 自动挂起 | 串流使用的电源方案中关闭 |
| 插电时屏幕空闲关闭 | 如导致捕获中断,可在串流方案中关闭并验证 |
本机对“插电”“使用电池”“低电量”三种方案都设置了合盖不操作;插电方案还关闭了自动挂起。若要电池合盖串流,电池方案中的自动挂起也应按需求检查。
合盖不操作只解决系统挂起;第 6 节的监听服务解决的是 KDE 载入旧显示布局,二者作用不同。物理屏因合盖而停止输出时,虚拟屏应继续作为独立源存在。
本机 Plasma 6.7 的 ~/.config/powerdevilrc 使用这些键表示合盖不操作:
[AC][SuspendAndShutdown]
LidAction=0
[Battery][SuspendAndShutdown]
LidAction=0
[LowBattery][SuspendAndShutdown]
LidAction=0
这些是相关键的示例,不是完整配置文件。优先在图形界面修改并应用;不要用这些片段覆盖已有 powerdevilrc,也不要在文件里重复创建同名组。数字值及界面结构可能随版本变化。
如果使用了其他电源管理工具、自定义 systemd-logind 规则或另一个桌面组件,也检查是否有额外的合盖挂起规则。本机方案无需修改 /etc/systemd/logind.conf。
9. Sunshine 配置
9.1 捕获与编码
在 Sunshine 配置中设置以下三项。原生安装常见配置文件为 ~/.config/sunshine/sunshine.conf,本机也是这个位置;若启动参数指定了其他配置路径,以实际路径为准。
capture = kwin
encoder = nvenc
output_name = Virtual-sunshine-virtual
kwin 捕获适用于 KDE Wayland;nvenc 是 NVIDIA 硬件编码器。其他显卡需要选择适用的编码器,不要直接保留 nvenc。配置项含义参见 Sunshine 官方配置说明。
output_name 要使用完整输出名称,大小写一致。当前 Sunshine 的 KWin 捕获实现按名称查找输出,找不到时可能回退到其他输出,因此不能只看配置文件就断定正在捕获虚拟屏。Sunshine KWin 捕获源码
9.2 设置全局准备命令
在 Sunshine 的全局准备命令中添加一组:
| 字段 | 内容 |
|---|---|
| Do | /home/你的用户名/.local/bin/sunshine-virtual-display start |
| Undo | /home/你的用户名/.local/bin/sunshine-virtual-display stop |
使用自己的真实绝对路径。下面这段只打印本用户可直接使用的配置行,不会修改文件:
python3 - <<'PY_CONFIG'
import json
from pathlib import Path
script = str(Path.home() / '.local/bin/sunshine-virtual-display')
commands = [{'do': script + ' start', 'undo': script + ' stop'}]
print('global_prep_cmd = ' + json.dumps(commands, ensure_ascii=False))
PY_CONFIG
把输出合并进 sunshine.conf。如果原来已有全局准备命令,保留原条目,只把新对象加入同一个 JSON 数组;不要重复写多个 global_prep_cmd。路径包含空格时,应确保 Do/Undo 命令里的路径正确引用。
准备命令失败会导致应用启动中止,因此这个脚本会检查虚拟屏是否存在、模式是否生效和镜像方向是否正确。全局准备命令说明
9.3 检查应用自己的准备命令
先用普通 Desktop 应用验证。若某个应用另有强制分辨率命令,可能覆盖全局命令的结果。
本机的 Low Res Desktop 有自己的 1920×1080@60 准备命令,因而始终强制该模式。别人若希望自动跟随 Moonlight,应使用没有固定模式覆盖的 Desktop,或调整应用专属准备命令。
无需覆盖整个 apps.json,也无需删除 Steam Big Picture 的启动/退出命令。
9.4 重启 Sunshine
保存配置后,在当前串流已经结束、可以重新连接时重启:
systemctl --user restart app-dev.lizardbyte.app.Sunshine.service
把服务名换成本机实际名称。重启会断开正在进行的串流。本教程的虚拟屏由独立服务维持,Sunshine 重启后虚拟屏服务仍应运行。
10. 分辨率、刷新率与日常使用
10.1 手动切换
添加或刷新默认列表,不主动切换当前模式:
"$HOME/.local/bin/sunshine-display-modes" --defaults
切换到一个指定模式:
"$HOME/.local/bin/sunshine-display-modes" --set 1920 1080 60
"$HOME/.local/bin/sunshine-display-modes" --set 2560 1600 165
"$HOME/.local/bin/sunshine-display-modes" --set 2384 1080 120
"$HOME/.local/bin/sunshine-display-modes" --set 2360 1640 90
每条 –set 都会切换模式;按需选择一条,不必把四条连续执行。修改默认列表后运行 –defaults 即可,通常不需要重启虚拟屏服务。
10.2 自动跟随 Moonlight
脚本读取 SUNSHINE_CLIENT_WIDTH、SUNSHINE_CLIENT_HEIGHT、SUNSHINE_CLIENT_FPS。Sunshine 的应用准备命令可使用这些变量。官方应用准备命令示例
改变 Moonlight 设置后,结束当前应用会话,再重新启动 Desktop。仅断开连接并恢复旧会话,可能不会重新执行准备命令。
手工调用 sunshine-virtual-display start 时,没有客户端尺寸环境变量就保持当前模式;mirror 也只修复布局,不主动更改分辨率。
10.3 为什么刷新率仍是小数
预设用整数请求,例如 24 / 60 / 165 Hz。KWin 的自定义模式接口通过 libxcvt 计算时序,然后由像素时钟和扫描总尺寸反算刷新率,所以会显示成 23.979、59.934、164.932 等小数。模式生成实现
这不是在预设中故意选择 23.976,也不是显示器只能用这些特殊帧率。当前接口无法直接绕过时序生成、保证任意尺寸下的精确整数刷新率。本教程保留正常时序结果,不把小数改个名字伪装成整数。
10.4 非标准分辨率为何会对齐
本机所用 CVT 库会将横向尺寸按 8 像素向下对齐,实测:
| 原始需求 | 可以加入当前模式列表的尺寸 |
|---|---|
| 2388×1080 | 2384×1080 |
| 2556×1179 | 2552×1179,本教程最终未保留 |
| 2360×1640 | 2360×1640 |
Moonlight 的自定义请求应尽量使用实际支持的尺寸。当前脚本会核对请求尺寸和实际尺寸;如果 Moonlight 请求 2388×1080,CVT 却生成 2384×1080,准备命令会报错,而不会悄悄宣称精确尺寸已生效。设备不允许选择近似尺寸时,应先使用常见的 1920×1080 等模式验证。
10.5 内屏刷新率、虚拟屏刷新率、视频 FPS 是三件事
内屏 165 Hz 表示物理输出刷新频率;虚拟屏刷新率表示桌面源的模式;Moonlight FPS 表示串流视频的目标帧率。
虚拟屏是逻辑桌面源,游戏可能依据它选择刷新率或垂直同步上限。内屏仍显示 165 Hz,并不能保证开启垂直同步的游戏运行在 165 FPS。关闭帧率限制的游戏可能渲染得更快,实际效果仍需按游戏和呈现方式验证。
本文原样脚本在新会话启动时,会把虚拟屏刷新率也设成客户端请求的 FPS。 添加 165 / 240 Hz 预设只增加可选模式,不会自动保持本地游戏为 165 FPS。手动改成 165 Hz 后,下次新会话还可能被客户端的 60 / 120 FPS 覆盖。
若需求是“本机按 165 Hz 玩游戏,串流单独用 60 / 120 FPS”,需要另外改变准备脚本的刷新率策略,将虚拟模式刷新率与客户端视频 FPS 分开。本教程记录当前已经部署的方案,未把这种策略改动混入原样脚本。
11. 验证:不要只看配置项
11.1 检查显示输出的实际状态
kscreen-doctor -j | python3 -c 'import json,sys; d=json.load(sys.stdin); print([(o["name"],o["enabled"],o.get("replicationSource"),next((m for m in o["modes"] if m["id"]==o["currentModeId"]),None)) for o in d["outputs"]])'
应看到虚拟屏启用、replicationSource 为 0;内屏的 replicationSource 应指向虚拟屏的输出 ID。模式字段中的 size 是像素尺寸。
再执行 kscreen-doctor -o 检查虚拟屏是否具有预设列表。原样安装的默认列表有 88 种模式;如果保留了列表以外的当前模式或用 –set 添加过其他模式,数量可能多于 88。
11.2 检查 Sunshine 实际捕获
按本机服务名读取日志:
journalctl --user -u app-dev.lizardbyte.app.Sunshine.service -b --no-pager | rg 'kwingrab|Screencasting output|resolution|frame pacing|CLIENT'
若 Sunshine 另写日志文件,也检查 ~/.config/sunshine/sunshine.log 或实际配置的日志路径。
重点看实际捕获输出名称和 resolution。例如本机活动串流日志出现过:
Screencasting output name Virtual-sunshine-virtual ... resolution 1600x900
Screencasting output name Virtual-sunshine-virtual ... resolution 1920x1080
同时内屏保持启用、物理模式为 2560×1600@165 Hz,证明降低的是虚拟桌面与实际捕获尺寸,而不是物理输出尺寸。
还要看 Moonlight 的统计信息,核对远端接收到的视频尺寸和帧率。主机日志确认捕获源,不能完全替代客户端解码与显示统计。
11.3 验证合盖
先在开盖状态下确认普通 Desktop 能连接、正确分辨率已生效,再真实合盖验证:串流是否持续、系统是否挂起、虚拟输出是否仍为桌面源。开盖后检查镜像方向是否仍正确。
本机此前验证过短暂停用物理内屏时虚拟源和捕获尺寸保持不变,也验证过合盖监听器对测试信号的响应;这些结果不能代替每台设备的真实合盖测试。
12. 常见问题与日志
| 现象 | 检查及处理 |
|---|---|
| 看不到虚拟屏 | 确认 Wayland 会话、WAYLAND_DISPLAY、krfb 安装、用户服务状态;查看虚拟屏服务日志 |
| 服务 active,但只有一个模式 | ExecStartPost 的失败被忽略了;手动运行 –defaults 查看报错,检查 KScreen 版本与执行权限 |
| Sunshine 仍捕获物理屏 | 确认虚拟屏是独立源,内屏复制虚拟屏;核对输出名称、capture=kwin 和实际捕获日志 |
| 改了 Moonlight 设置,尺寸没变 | 结束应用会话再重启 Desktop;检查应用专属准备命令是否强制了模式 |
| 请求非标准尺寸后无法启动 | 核对 CVT 实际尺寸,使用 8 像素对齐的宽度;先以 1920×1080 验证 |
| 准备命令报错 | 查看 hook.log;任何准备命令失败都可能阻止应用启动 |
| 合盖后挂起 | 检查当前电源方案的合盖动作与自动挂起,以及其他电源管理工具 |
| 合盖/开盖后镜像方向变回去 | 检查 sunshine-mirror-lid.service、PANEL 名称和电源管理信号监听日志 |
| 内屏选择低分辨率失败 | 物理面板/驱动不一定接受自定义模式;保留原生物理模式,改虚拟屏 |
| 刷新率显示小数 | 正常的 CVT 时序结果,参见第 10.3 节 |
| 不同长宽比出现黑边 | 镜像缩放与画面比例差异;按设备比例选择虚拟桌面尺寸 |
常用日志:
journalctl --user -u sunshine-virtual-monitor.service -b -n 80 --no-pager
journalctl --user -u sunshine-mirror-lid.service -b -n 80 --no-pager
tail -n 50 "$HOME/.local/state/sunshine-virtual-display/hook.log"
最后一条路径适用于未自定义 XDG_STATE_HOME 的情况;设置过该变量时,日志位于相应目录的 sunshine-virtual-display/hook.log。
若日志提示 KWin 截屏协议权限问题,先检查原生 Sunshine 安装的桌面授权及官方故障排查,必要时重新登录桌面。本教程没有设置全局禁用 KWin 权限检查的环境变量。Sunshine 官方故障排查
13. 文件能否删除、重启后为什么仍有效
| 文件或目录 | 是否为运行依赖 | 删除的影响 |
|---|---|---|
| 这份 Markdown、自己的源码副本、备份目录 | 否,前提是脚本已独立安装到下面的位置 | 删除不影响已安装方案,但会丢失说明或备份 |
| ~/.local/bin/sunshine-display-modes | 是 | 预设加载与模式切换失败 |
| ~/.local/bin/sunshine-virtual-display | 是 | Sunshine 准备命令和镜像维护失败 |
| ~/.local/bin/sunshine-mirror-lid-watch | 是 | 开合盖后的布局维护失效 |
| ~/.config/systemd/user/sunshine-virtual-monitor.service | 是 | 后续自动创建虚拟屏受影响 |
| ~/.config/systemd/user/sunshine-mirror-lid.service | 是 | 后续自动启动布局维护受影响 |
| ~/.config/sunshine/sunshine.conf | 是 | 捕获输出、准备命令等设置受影响 |
| ~/.config/powerdevilrc | 是 | 电源管理设置可能恢复默认或丢失 |
| ~/.config/kwinoutputconfig.json | KDE 的显示布局存储 | 删除会影响已保存的显示配置,不能当普通副本清理 |
正在运行的进程和已经加载的模式通常不会因文件删除立即消失,但下一次登录、服务启动或新串流会话会读取依赖。不要靠“删完当前画面仍在”判断文件不重要。
如果把实际脚本做成指向下载目录的符号链接,下载目录也成了运行依赖。本文要求保存独立文件到 ~/.local/bin。
14. 停用和回退
要停用方案,先结束 Moonlight 会话,并从 Sunshine 配置中移除本文添加的全局准备命令,恢复原先 capture、encoder、output_name 设置;保留原有其他准备命令。然后重启 Sunshine。
在内屏打开的情况下停止两个服务:
systemctl --user disable --now sunshine-mirror-lid.service
systemctl --user disable --now sunshine-virtual-monitor.service
虚拟屏退出后,在 KDE 显示设置中把物理内屏恢复为启用、独立源和主屏。如果需要用命令恢复,下面的 eDP-2 必须换成实际内屏名:
kscreen-doctor output.eDP-2.enable output.eDP-2.mirror.none output.eDP-2.primary
确认物理桌面正常,再删除自己安装的三个脚本和两个用户服务文件,执行 systemctl –user daemon-reload。电源动作按此前备份或原先需求恢复。
不要在活动 KDE 会话中直接覆盖 kwinoutputconfig.json 并期待立即生效;KDE 可能再写回当前状态。优先用显示设置恢复布局,需要完整恢复保存文件时在图形会话退出后处理。
本机安装后的源码副本与历史备份位于 daily 项目的 sunshine-display-fix 目录。分享这份 Markdown 已足以复现,分享备份文件前需确认没有包含个人 Sunshine 配置或 VNC 密码。
评论