- 桌面应用
- 即时通讯
【免费下载链接】teams-for-linux
Unofficial Microsoft Teams for Linux client
导读
本文围绕 teams-for-linux 仓库中的 Spike 方案文档 2026-05-13-spike-2534-wayland-picker.md 展开,深入剖析一个真实存在的问题:在 Wayland 会话下,由于 Chromium 的WebRTCPipeWireCapturer会绕过 Electron 的setDisplayMediaRequestHandler,应用内自绘的屏幕选择器(StreamSelector)与分享预览小窗口在 Wayland 上双双失效。该 Spike 提出了一条可行的工程路径——在注入脚本中拦截navigator.mediaDevices.getDisplayMedia,先通过新增 IPC 唤起应用内拾取器,再用getUserMedia的chromeMediaSource: 'desktop'约束合成媒体流。读完本文,你将掌握这一方案的整体架构、四个文件的具体改动方式、完整可复制的代码,以及验证假设是否成立的测试步骤与判定标准。
背景:Wayland 下 Chromium 原生拾取器绕过了应用内选择器
问题根源:WebRTCPipeWireCapturer与setDisplayMediaRequestHandler的旁路
Electron 应用可以通过session.setDisplayMediaRequestHandler()拦截getDisplayMedia,从而提供自绘的源选择界面。但在 Wayland 且启用WebRTCPipeWireCapturer(TFL 的默认配置)时,Chromium 的原生getDisplayMedia会直接走xdg-desktop-portal完成源选择,完全跳过Electron 注册的setDisplayMediaRequestHandler。这就造成了一个割裂的局面:
- X11 / Windows / macOS:Chromium 调用应用的
setDisplayMediaRequestHandler,进而打开应用内 StreamSelector; - Wayland + PipeWire:Chromium 直接调用 xdg-desktop-portal,应用内拾取器被旁路,预览小窗口也随之中断。
仓库中对这一行为有明确注释佐证,见 app/screenSharing/injectedScreenSharing.js 第 87–93 行:
Delegate picking to the platform's native flow: X11 / Win / macOS: Chromium calls our
setDisplayMediaRequestHandler, which opens the in-app StreamSelector. Wayland with WebRTCPipeWireCapturer: Chromium calls xdg-desktop-portal directly (setDisplayMediaRequestHandleris bypassed).
为什么不用 Chromium/Electron 的系统拾取器:ADR 008 的结论
仓库在 ADR 008: useSystemPicker Feature for Electron 38 中曾评估过 Electron 38 提供的useSystemPicker: true选项,结论是Rejected(拒绝):
- Linux(Wayland + PipeWire)为 ❌ BLOCKER:
useSystemPicker在 Linux 上并不会真正调用系统拾取器,应用仍会被迫走自定义 handler 路径(对应 Electron Issue #48223); - macOS(Sonoma+)存在
applyConstraints()切换挂起(toggle-hang)的已知缺陷(Electron Issue #45306); - 维护双代码路径(自定义 + 系统拾取器)会带来平台检测、特性开关、配置持久化和成倍的测试矩阵;
- 现有基于
desktopCapturer的自定义拾取器在所有平台都可工作。
因此仓库维持"单一统一代码路径"的决策,而 #2534 的 Spike 正是为了补上 Wayland 这条唯一缺口:既然系统侧无法提供统一体验,那就把 Wayland 的源选择也强行拉回应用内拾取器。
预览窗口的触发路径:只有 in-app picker 路径才会打开
预览窗口(preview window)只在应用内拾取器路径下才会触发。Spike 文档明确指出:"The preview window only fires from the in-app picker path"。对应的主进程实现是 app/mainAppWindow/index.js 中bindDisplayMediaHandler()(第 81–98 行)回调 →handleScreenSourceSelection(source, callback)(第 100–130 行)→setupScreenSharing(source)(第 59–62 行)→createScreenSharePreviewWindow()(第 132 行起)这一调用链。Wayland 下这个链条根本不会被走到,预览窗口自然也就无法出现。
Spike 目标与技术假设
Spike #2534 的核心目标用一句话概括:
验证在 Wayland 上,将
getDisplayMedia路由到 TFL 的应用内StreamSelector(替代 Chromium 原生 handler),能否在不破坏屏幕捕获本身的前提下,恢复分享预览窗口。
其技术依据来自一条关键事实:预览窗口在 Wayland 上已经能通过getUserMedia({ video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId } } })这一约束形态正常工作,对应实现见 app/screenSharing/previewWindow.html 第 68–69 行的注释(该文件现已演进为 ImageBitmap 快照管线,但约束形态的历史依据仍在文档中注明)。因此 Spike 的思路是:拦截渲染进程的getDisplayMedia,先调用新增 IPC 唤起应用内StreamSelector,拿到用户选择的源 ID 后,再用上述约束形态通过getUserMedia合成媒体流——与预览窗口已在使用的形态保持一致,复用同一套已验证可行的 WebRTC 管线。
技术栈
- Electron(主进程 + 渲染进程双端);
- WebRTC
getUserMedia,携带 legacy 约束chromeMediaSource: 'desktop'; - 现成的
StreamSelectorBrowserWindow(实际为挂载在主窗口上的WebContentsView全窗覆盖层,见 app/screenSharing/index.js 与 app/screenSharing/README.md)。
明确的范围界定(Out of scope)
作为 Spike,本次刻意不做以下事项,避免扩大验证面:
- 配置开关(config flag):不做可切换的特性配置;
- 按 profile 的会话处理(multi-account):多账户分区会话的场景不在本次范围;
- 自动化测试:不引入自动化测试;
- X11 fallback:应用内拾取器在 X11 上经由现有 handler 已可工作,但本次注入脚本的拦截逻辑在 X11 上同样会执行,需要顺带确认它不会破坏 X11 的分享流程。
总体设计与文件改动清单
Spike 的改动横跨 4 个文件,形成一条完整的调用链:
| 文件 | 职责 |
|---|---|
| app/security/ipcValidator.js | 将新 IPC 通道show-tfl-stream-picker加入屏幕共享白名单 |
| app/browser/preload.js | 在electronAPI上暴露showTflStreamPicker()方法 |
| app/mainAppWindow/index.js | 注册主进程ipcMain.handle("show-tfl-stream-picker", ...) |
| app/screenSharing/injectedScreenSharing.js | 重写getDisplayMedia包装器,接入新路径 |
数据流如下:
Teams 调用 getDisplayMedia │ ▼ 注入脚本拦截,调用 electronAPI.showTflStreamPicker() │ ▼(IPC: show-tfl-stream-picker) 主进程:streamSelector.show(callback) 显示应用内拾取器 │ ▼(用户选择 source) handleScreenSourceSelection(source, cb) → setupScreenSharing(创建预览窗口) ▼ 返回 source.id 渲染进程:getUserMedia({ video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId } } }) ▼ handleScreenShareStream(stream, "getDisplayMedia-via-tfl")(复用现有 UI/IPC 通知)Task 1:将 IPC 通道加入安全白名单
文件:app/security/ipcValidator.js
由于仓库为补偿禁用 contextIsolation 与 sandbox 的场景实现了 IPC 通道白名单机制(见该文件头部注释 "Provides security validation for IPC channels as a compensating control"),任何新增 IPC 通道都必须显式登记,否则会被拒绝。
Step 1:插入通道名。在
allowedChannels的屏幕共享区块(当前包含cancel-desktop-media、choose-desktop-media、desktop-capturer-get-sources、screen-sharing-started等,见 app/security/ipcValidator.js 第 24–42 行)中按字母序插入'show-tfl-stream-picker'。该区块注释明确要求 "sorted alphabetically"(按字母排序),插入后show-tfl-stream-picker应位于screen-sharing-stopped与stop-screen-sharing-from-thumbnail之间。Step 2:提交:
git add app/security/ipcValidator.js git commit -m "chore(security): allowlist show-tfl-stream-picker IPC for spike #2534"值得留意的是,该文件同时维护了appPageOnlyChannels(仅限应用页面使用的通道集合,见 app/security/ipcValidator.js 第 231–239 行,包含close-view、desktop-capturer-get-sources等),它对应 GHSA-3vg9-cwq9-p773 的安全约束。Spike 文档只要求将新通道加入allowedChannels,是否同时纳入appPageOnlyChannels需要结合show-tfl-stream-picker的实际触发上下文(仅由注入到 Teams 页面中的脚本调用)评估——从代码结构看,注入脚本仅运行在受信任的 Teams 页面中,但这一点应在实现时按ipcValidator.js的既有规则确认。
Task 2:在 preload 中暴露 IPC 方法
文件:app/browser/preload.js
- Step 1:在 electronAPI 对象中新增方法:
// Spike #2534: ask main to show the in-app StreamSelector and return the chosen source ID. showTflStreamPicker: () => ipcRenderer.invoke("show-tfl-stream-picker"),该方法通过ipcRenderer.invoke发起调用,invoke返回的 Promise 会在主进程 handler 解析后收到选中的源 ID(或取消时的null)。
- Step 2:提交:
git add app/browser/preload.js git commit -m "feat(preload): expose showTflStreamPicker for spike #2534"从现有实现看,app/browser/preload.js 中已经有一批屏幕共享相关方法(sendScreenSharingStarted、sendScreenSharingStopped、stopSharing、sendSelectSource等,位于该文件第 95–109 行附近),showTflStreamPicker与它们并列放置即可,保持命名与注释风格一致。
Task 3:注册主进程 IPC handler
文件:app/mainAppWindow/index.js,靠近现有setDisplayMediaRequestHandler注册处(即bindDisplayMediaHandler附近)。
- Step 1:注册 handler:
// Spike #2534: alternative entry point to the in-app StreamSelector for the // Wayland case, where setDisplayMediaRequestHandler is bypassed by Chromium's // native getDisplayMedia + WebRTCPipeWireCapturer. Returns the chosen source // ID so the renderer can synthesise the stream via getUserMedia. ipcMain.handle("show-tfl-stream-picker", () => new Promise((resolve) => { streamSelector.show((source) => { if (!source) { resolve(null); return; } handleScreenSourceSelection(source, () => resolve(source.id)); }); }) );这段代码有三层关键逻辑:
- 返回一个 Promise,其解析值要么是用户选中的源 ID,要么在取消时解析为
null; - 包装
streamSelector.show(callback):StreamSelector的show()方法会在用户选择或关闭视图后回调一个 source 对象(取消时回调null),实现见 app/screenSharing/index.js 第 25–65 行; - 在拿到非空 source 时调用
handleScreenSourceSelection(source, () => resolve(source.id)):这一步会触发setupScreenSharing(从而创建预览窗口),并在完成后把source.id解析给渲染进程。
handleScreenSourceSelection之所以存在,是因为仓库经历了 #2713 的教训:Wayland/PipeWire 下每次desktopCapturer.getSources()都会开启全新的 portal 会话并产生新的源 ID,拾取器枚举得到的 ID 永远无法匹配第二次枚举的结果。因此实现采用"直接用拾取器返回的 source,绝不重新查询"的策略(见 app/mainAppWindow/index.js 第 100–130 行注释,以及 app/screenSharing/README.md 的 "Single enumeration rule" 一节)。
- Step 2:运行 lint:
npm run lint预期结果:PASS。
- Step 3:提交:
git add app/mainAppWindow/index.js git commit -m "feat(screenSharing): add show-tfl-stream-picker IPC handler for spike #2534"Task 4:在渲染进程拦截 getDisplayMedia
文件:app/screenSharing/injectedScreenSharing.js(第 60–81 行的monitorScreenSharing包装区域)。
这是整个 Spike 的核心改动:把原有的getDisplayMedia包装器替换为"先走应用内拾取器、失败则回退原生"的新逻辑。
- Step 1:替换 getDisplayMedia 包装器,新逻辑必须满足五个行为要求:
- 先调用
electronAPI.showTflStreamPicker(); - 若拿到源 ID,则调用
originalGetUserMedia({ audio: false, video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: id } } }); - 若拾取器返回
null(用户取消),抛出DOMException('Permission denied', 'NotAllowedError'); - 若 IPC 本身出错(如
electronAPI不存在),回退到originalGetDisplayMedia(constraints),保证分享仍可用; - 任何成功的流都继续调用
handleScreenShareStream(stream, "getDisplayMedia-via-tfl"),保持现有 UI 与 IPC 通知的一致性。
完整代码:
navigator.mediaDevices.getDisplayMedia = function (constraints) { console.debug("[SCREEN_SHARE_DIAG] getDisplayMedia intercepted (spike #2534 path)"); disableAudioInConstraints(constraints, "getDisplayMedia"); const fallback = () => originalGetDisplayMedia(constraints) .then((stream) => { console.debug(`[SCREEN_SHARE_DIAG] Screen sharing started via getDisplayMedia native (${stream.getAudioTracks().length}a/${stream.getVideoTracks().length}v)`); handleScreenShareStream(stream, "getDisplayMedia"); return stream; }); const electronAPI = globalThis.electronAPI; if (!electronAPI?.showTflStreamPicker) { console.debug("[SCREEN_SHARE_DIAG] showTflStreamPicker unavailable, falling back to native"); return fallback(); } return electronAPI.showTflStreamPicker() .then((sourceId) => { if (!sourceId) { throw new DOMException("User cancelled screen share", "NotAllowedError"); } console.debug(`[SCREEN_SHARE_DIAG] TFL picker returned source ${sourceId}, calling getUserMedia`); return originalGetUserMedia({ audio: false, video: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: sourceId, }, }, }); }) .then((stream) => { console.debug(`[SCREEN_SHARE_DIAG] Screen sharing started via TFL picker (${stream.getAudioTracks().length}a/${stream.getVideoTracks().length}v)`); handleScreenShareStream(stream, "getDisplayMedia-via-tfl"); return stream; }) .catch((error) => { if (error?.name === "NotAllowedError") { throw error; } console.error(`[SCREEN_SHARE_DIAG] TFL picker path failed (${error?.name}/${error?.message}), falling back to native`); return fallback(); }); };- Step 2:运行 lint:
npm run lint预期结果:PASS。
- Step 3:提交:
git add app/screenSharing/injectedScreenSharing.js git commit -m "spike(screenSharing): route getDisplayMedia through in-app picker for #2534"与现有注入脚本的衔接:防重复注入与流处理一致性
injectedScreenSharing.js并非从零开始,它已经有完整的运行框架,Spike 只是替换其中的getDisplayMedia包装器。理解现有实现有助于把握改动的边界:
- 防重复注入守卫:脚本顶部通过
globalThis.__tflScreenShareInjected标记防止重复注入。mainAppWindow的injectScreenSharingLogic在每次did-finish-load时都会执行(登录跳转、内部导航、iframe 加载都会触发多次),若没有这个守卫,每轮注入都会在旧的getDisplayMedia包装器上再叠一层,导致一次分享调用扇出成 N 个流处理,预览端端口竞争(见 app/screenSharing/injectedScreenSharing.js 第 1–13 行); disableAudioInConstraints:强制关闭分享流的音频,防止回声(第 55–63 行);handleScreenShareStream:注册流、发送screen-sharing-started事件(主进程据此打开预览窗口并建立 MessagePort 连接)、启动快照中继与停止按钮监控(第 148–204 行)。Spike 路径复用它,保证"经应用内拾取器"与"原生路径"产出的流行为完全一致;- 快照中继(startVideoFrameRelay):以 5fps 的频率将分享轨道抽帧为 ImageBitmap,通过 MessagePort 传给预览窗口(第 214–354 行)。这个机制本身就是 #2534 相关工作的产物——预览不再二次调用
getUserMedia/portal(Wayland 下无法复用 PipeWire token),而是"一次捕获,两端消费"。
预览窗口侧的配合机制
预览窗口侧(app/screenSharing/previewWindow.html)接收主进程经screen-share-port通道投递的 MessagePort,用bitmaprenderer上下文将 ImageBitmap 逐帧绘制到 canvas 上,并随首帧自适应窗口尺寸(最大 400×300、最小 200×120,见该文件第 71–97 行)。端口转发由 app/screenSharing/previewWindowPreload.js 完成——MessagePort无法通过 contextBridge 函数参数序列化,只能经window.postMessage配合 transfer 跨越 contextIsolation 边界,并限定目标为window.location.origin以满足跨源安全检查。主进程侧的端口接线逻辑位于 app/mainAppWindow/index.js 第 766–801 行的ipcMain.on("screen-sharing-started")广播监听器中:它同时向分享方渲染进程(sender)与预览窗口投递一对MessageChannelMain端口。Spike 路径由于同样调用了handleScreenShareStream,会自动走通这条已建好的预览管线。
相关配置项
预览窗口是否显示、是否置顶,由配置项screenSharing.thumbnail控制,定义于 app/config/options.js 第 50–77 行:
screenSharing.thumbnail.enabled(boolean,默认true):分享开始时自动显示预览小窗;screenSharing.thumbnail.alwaysOnTop(boolean,默认true):预览小窗保持置顶;screenSharing.lockInhibitionMethod("Electron"/"WakeLockSentinel",默认"Electron"):分享期间的锁屏抑制方式。
验证 Spike 时若不想被预览窗口干扰,或想专门确认预览窗口行为,可以通过关闭thumbnail.enabled隔离变量。
Task 5 & 6:推送分支、打开草稿 PR 并在 issue 上留评
- Step 1:推送分支:
git push -u origin spike/2534-wayland-in-app-picker- Step 2:创建草稿 PR:
gh pr create --draft --base main --title "spike(#2534): route getDisplayMedia through in-app picker" --body "<body with test plan>"PR 描述必须包含:
指向 #2534 的链接(注意:由于这是 Spike,不能用
closes关键字自动关 issue);假设陈述(hypothesis);
构建产物(build artifact)的指引;
给 piotrleszczynski 的测试计划(test plan)。
Step 3:在 #2534 上评论,请 piotrleszczynski 拉取 PR 构建产物进行测试,并给出精确的操作路径与日志中应观察的关键信息。
验证计划:Wayland 会话上的判定性测试
Spike 的成败取决于一套明确的验证步骤。报告人(reporter)在 Wayland 会话上进行测试,环境参考为KDE Plasma + xdg-desktop-portal-kde(TUXEDO OS):
- 安装 PR 构建的 deb 包;
- 正常启动(无需
--ozone-platform=x11参数); - 开始或加入一个会议;
- 点击 Share → Screen(分享 → 屏幕);
- 确认出现 TFL 应用内拾取器网格(而不是 KDE/portal 原生拾取器);
- 选择一个屏幕;
- 确认右下角出现小的缩略预览窗口;
- 确认会议中的实际分享正常(其他与会者能看到你的屏幕);
- 在预览窗或 Teams 中点击 "Stop sharing",确认预览窗口关闭。
判定标准(如何解读结果)
- 若步骤 5 与 7 均通过→ 假设成立,可以将该方案从 Spike 升级为完整特性(届时需要补上:配置开关、多账户会话处理、X11 验证、自动化测试);
- 若步骤 5 失败→ 需要做日志分析(说明应用内拾取器在 Wayland 上没有被唤起);
- 若步骤 8 失败→ 说明
getUserMedia+chromeMediaSource: 'desktop'在 Wayland 上对 Teams 的 WebRTC 管线并不等价于原生getDisplayMedia,Spike 将证伪该方案。
这条判定链条设计得非常清晰:每一步失败都对应一个明确的结论方向,避免"部分成功"带来的模糊判断。
从 Spike 到正式特性的后续路线
Spike 文档明确给出了升级为完整特性的路线图。当验证假设成立后,需要补齐以下内容:
- 配置开关:为"Wayland 走应用内拾取器"的行为增加 config flag(参照 app/config/options.js 的
screenSharing配置块模式,为type: "object"+fields结构,并可参照 docs-site/docs/configuration.md 的文档约定); - 多账户会话处理:现有代码中,
setDisplayMediaRequestHandler是 per-session 的,多账户 profile 视图(运行在各自分区会话中)需要单独的绑定,这已在 #2529 中处理(见 app/mainAppWindow/profileViewManager.js 第 526–529 行附近)。新的show-tfl-stream-picker路径同样需要评估多账户场景下streamSelector与handleScreenSourceSelection的绑定范围; - X11 验证:确认拦截逻辑在 X11 上不破坏现有 handler 路径;
- 自动化测试:参照 tests/unit 下的既有模式(如
screenSharingInjection.test.js、meetingStartDetector.test.js等)为拦截逻辑补充单元测试。
风险与注意事项汇总
综合 Spike 文档与仓库源码,实施本方案时有几个关键风险点需要牢记:
- 单次枚举规则(#2713):拾取器返回的源 ID 必须原样传递,绝不能重新调用
desktopCapturer.getSources()"刷新",否则 Wayland/PipeWire 的新 portal 会话会产生新 ID 导致分享失败; - 源 ID 格式约束(ADR 001):Wayland 下需要
screen:x:y或window:x:y格式的 desktopCapturer 源 ID,MediaStream UUID 会失败,详见 ADR 001: DesktopCapturer Source ID Format。同时 app/screenSharing/service.js 第 131–146 行也体现了这一约束:screen-sharing-started事件只接受screen:/window:前缀的源 ID; - 取消与错误语义:用户取消必须以
NotAllowedError抛出(与getDisplayMedia的规范语义一致),其他错误则静默回退原生路径,保证功能韧性; - 音频剥离:新路径的
getUserMedia约束中audio: false必须保留,同时disableAudioInConstraints依旧会作用于getDisplayMedia的原始约束,避免回声(相关历史问题见 app/screenSharing/injectedScreenSharing.js 第 119–122 行注释中提及的 #1871、#1896); - 重复注入与重复调用:
__tflScreenShareInjected守卫和activeFrameRelay单例守卫都是防止"一份分享、N 条管线"的关键,改动时不能破坏。
结语
#2534 Spike 是 teams-for-linux 在 Wayland 屏幕分享问题上的一次精准工程尝试:它不试图与 Chromium 的原生 portal 路径对抗,而是通过注入脚本 + 新 IPC + 复用已验证的getUserMedia约束形态,把源选择权"接回"应用内拾取器,从而让预览窗口这条唯一缺失的功能链在 Wayland 上重新贯通。本文梳理的 4 个文件改动、完整代码与 9 步验证计划均直接继承自仓库中的 Spike 文档,并结合 app/screenSharing 模块、app/mainAppWindow/index.js、app/security/ipcValidator.js 与 ADR 008 等源码与决策记录做了纵深展开,读者可以沿着这些路径继续深入阅读仓库实现,验证每一步结论。
- 桌面应用
- 即时通讯
【免费下载链接】teams-for-linux
Unofficial Microsoft Teams for Linux client
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考