Sunshine + KDE Wayland 虚拟显示器配置教程

本教程建立一个长期存在的 KWin 虚拟显示器,让 Sunshine 捕获它,并让笔记本内置屏幕复制虚拟桌面。这样可以在打开内屏时看到同一个桌面,合盖后继续串流,也能调整串流桌面的分辨率。

本文包含完整脚本和服务配置,只需这份 Markdown 即可部署。执行命令时请使用登录 KDE 桌面的普通用户;只有安装软件包需要 sudo。

1. 适用环境与已经验证的范围

记录日期:2026-10-08。

项目实测环境
系统CachyOS,Arch 系发行版
桌面KDE Plasma,Wayland 会话
KWin / KScreen / libkscreen6.7.5
krfb26.08.1
Sunshine2026.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-2kscreen-doctor -o 的输出名称
Wayland socketwayland-0当前 WAYLAND_DISPLAY
虚拟屏名称Virtual-sunshine-virtual服务使用 sunshine-virtual 名称,KWin 加 Virtual- 前缀
Sunshine 用户服务app-dev.lizardbyte.app.Sunshine.servicesystemctl –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×10802384×1080
2556×11792552×1179,本教程最终未保留
2360×16402360×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.jsonKDE 的显示布局存储删除会影响已保存的显示配置,不能当普通副本清理

正在运行的进程和已经加载的模式通常不会因文件删除立即消失,但下一次登录、服务启动或新串流会话会读取依赖。不要靠“删完当前画面仍在”判断文件不重要。

如果把实际脚本做成指向下载目录的符号链接,下载目录也成了运行依赖。本文要求保存独立文件到 ~/.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 密码。

上一篇

评论

    发表评论