暗无天日

=============>DarkSun的个人博客

浏览器文件选择框弹不出来——xdg-desktop-portal 启动时序陷阱

故障现象

我在自己写的打字练习网站后台管理页面(AdminPage)上加了一个文件上传功能——点击按钮选择本地视频文件。代码写好后,点击按钮……什么也没发生。没有文件选择框,没有报错,没有反应。

不只是 Chrome ,Firefox 也一样——点击按钮毫无反应。两个浏览器都弹不出文件选择框。

排查过程

第一步:排除代码问题

我当时还没意识到问题的严重性,以为是前端代码的问题,先后尝试了四种不同的 CSS + JS 方案来触发 <input type"file">= :

  1. display:none + JS .click() → 不弹
  2. clip:rect(0,0,0,0) + JS .click() → 不弹
  3. <label> + 离屏 input → 不弹
  4. 透明 overlay 直接盖在按钮上(用户点的是真正的 <input> ) → 还是不弹!

结果四种方案全部失败,而且两个浏览器都不行。

  • 只凭"多浏览器不行"→ 可能是代码 bug ,也可能是系统问题
  • 只凭"多方案不行"→ 可能是某个浏览器对所有方案都不兼容
  • 两者叠加 → 如果是代码 bug ,不可能四种方案全写错;如果是浏览器兼容性,不可能两个浏览器同时排斥所有方案。问题一定在更底层

小知识:Linux 下浏览器的文件选择框是谁弹出来的?

在 Windows 和 macOS 上,浏览器直接调用操作系统的原生文件对话框 API ,简单直接。

但在 Linux 上,浏览器不自己画文件对话框。Chrome 和 Firefox 都通过一个叫 xdg-desktop-portal 的中间层来请求桌面环境弹出对话框。这个 portal 的设计初衷是让沙盒应用(Flatpak、Snap)能安全地访问系统资源,后来主流浏览器都采用了这套机制。

流程是:

浏览器需要 file dialog
  → 通过 D-Bus 询问 xdg-desktop-portal
    → portal 找到当前桌面环境的后端(如 portal-gtk)
      → 后端调用 GTK/Qt 弹出原生文件对话框
        → 用户选择文件,结果传回浏览器

如果 portal 或它的后端挂了,浏览器需要"弹文件对话框"时就什么都做不了——而且不会报错,因为从浏览器的角度看,它只是发了一个 D-Bus 请求,没收到回复而已。

第二步:检查 portal 服务状态

systemctl --user status xdg-desktop-portal-gtk
× xdg-desktop-portal-gtk.service - Portal service (GTK/GNOME implementation)
     Loaded: loaded (/usr/lib/systemd/user/xdg-desktop-portal-gtk.service; static)
     Active: failed (Result: exit-code)
     Process: 1277 ExecStart=/usr/lib/xdg-desktop-portal-gtk (code=exited, status=1/FAILURE)
Main PID: 1277 (code=exited, status=1/FAILURE)

6月 06 04:30 systemd[1277]: Started Portal service (GTK/GNOME implementation).
6月 06 04:30 xdg-desktop-portal-gtk[1277]: cannot open display:
6月 06 04:30 systemd[1277]: xdg-desktop-portal-gtk.service: Failed with result 'exit-code'.

BINGO! 服务失败了,错误信息是 cannot open display: ——它找不到显示服务器!这也解释了为什么 Chrome 和 Firefox 都弹不出文件选择框——它们依赖的是同一个已死掉的 portal 后端。

第三步:为什么找不到 display?

这个服务在开机时就尝试启动了(PID 1277 ,非常小的数字,说明是系统启动早期)。但我的系统是通过 startx 启动图形界面的:

时间线:
─────────────────────────────────────────────────────────→

T1: 在 tty 输入用户名密码登录
    │
    ├─ systemd --user 启动(PID ~1277)
    │   └─ xdg-desktop-portal-gtk.service 尝试启动
    │       → 报错 "cannot open display:"
    │       → 此时还没有 X11,DISPLAY 不存在!
    │
T2: 在 tty 里输入 startx
    │
    ├─ Xorg 启动 → DISPLAY=:0 出现
    │   但只存在于 startx 的子进程环境中
    │   systemd user session 仍然没有 DISPLAY
    │
T3: awesome WM 启动,桌面出现
    │
T4: 打开浏览器 → 需要 portal → portal 后端已挂 → 文件对话框弹不出来

问题出在 启动时序 :portal 服务在 X11 启动之前就尝试运行了,自然打不开 display 。

第四步:为什么 portal-gtk 会这么早启动?

查看它的 service unit :

systemctl --user cat xdg-desktop-portal-gtk.service
[Unit]
Description=Portal service (GTK/GNOME implementation)
PartOf=graphical-session.target
After=graphical-session.target

[Service]
Type=dbus
BusName=org.freedesktop.impl.portal.desktop.gtk
ExecStart=/usr/lib/xdg-desktop-portal-gtk

它声明了 After=graphical-session.target ,意思是"应该在图形会话准备好之后再启动"。但关键是—— graphical-session.target 在我的系统上 从来没被激活过

systemctl --user is-active graphical-session.target
# inactive

小知识:=graphical-session.target= 是谁激活的?

在 Linux 桌面的生态中,=graphical-session.target= 是一个 systemd target ,代表"图形用户会话已就绪"。它不是一个普通服务,而是一个 里程碑标记 ,告诉其他依赖图形环境的服务"现在可以安全启动了"。

正常情况下,谁负责激活它?

  • GDM(GNOME Display Manager) :用户在图形登录界面输入密码后,GDM 启动 X11/Wayland ,然后通过 PAM 模块激活 graphical-session.target
  • SDDM(KDE) 、*LightDM* 等:同理,显示管理器负责整个启动链路

关键点:显示管理器保证 先有图形环境,再激活 target 。服务单元只需要声明 After=graphical-session.target 就能确保启动时 DISPLAY 已经存在。

但如果你用 startx 启动桌面,没有显示管理器来激活这个 target ,它就一直是 inactive

第五步:D-Bus 激活绕过了时序保护

那 portal-gtk 是怎么被启动的呢?它是通过 D-Bus 自动激活的:

cat /usr/share/dbus-1/services/org.freedesktop.impl.portal.desktop.gtk.service
[D-BUS Service]
Name=org.freedesktop.impl.portal.desktop.gtk
Exec=/usr/lib/xdg-desktop-portal-gtk
SystemdService=xdg-desktop-portal-gtk.service

当主 portal 服务(=xdg-desktop-portal=)尝试通过 D-Bus 访问 GTK 后端时,D-Bus 守护进程发现这个服务没运行,就自动让 systemd 启动它。这个激活过程 不受 After=graphical-session.target 约束 ,因为 systemd 认为这个 target 已经处于终态(inactive ),不需要等待。

小知识:D-Bus 自动激活(D-Bus Activation)

D-Bus 有一个很方便的机制:当某个 D-Bus 服务名被请求但没有人提供时,D-Bus 守护进程会查找 /usr/share/dbus-1/services/ 下的配置文件,找到对应的 SystemdService ,然后让 systemd 启动它。

这意味着:即使一个服务没有被 enable ,只要它的 D-Bus 配置文件在,有其他程序请求它提供的服务时,它就会被自动拉起。这就是为什么 portal-gtk 虽然没有被手动 enable ,却在开机时就被启动了。

第六步:发现 service unit 的设计缺陷

对比 GNOME 版的 portal service :

systemctl --user cat xdg-desktop-portal-gnome.service
[Unit]
Description=Portal service (GNOME implementation)
After=graphical-session.target
Requisite=graphical-session.target    ← 注意这行!
PartOf=graphical-session.target

[Service]
Type=dbus
BusName=org.freedesktop.impl.portal.desktop.gnome
ExecStart=/usr/lib/xdg-desktop-portal-gnome

GNOME 版多了一行 Requisite=graphical-session.target ,意思是"如果 graphical-session.target 没激活,就 拒绝启动 "。这是正确的防御措施——在纯 tty 环境下,GNOME portal 不会尝试启动,避免了无意义的失败。

小知识:=After= vs Requisite vs PartOf 的区别

systemd 的依赖指令很多,容易混淆:

指令 含义
After 排序约束:如果两者同时要启动,先启动目标单元
Requires 硬依赖:目标失败时,自己也跟着停
Requisite 门卫:目标没启动就直接拒绝启动自己(比 Requires 更严格)
PartOf 跟随:目标的 stop/restart 会传播给自己
Wants 软依赖:建议启动目标,但目标失败了不影响自己

在本案中,GTK portal 只有 AfterPartOf ,缺少 Requisite 。这导致在 graphical-session.target 未激活的情况下,D-Bus 激活仍然能把它拉起来,然后它去打开一个不存在的 display ,就崩溃了。

根因分析(5 Whys)

Why #1:为什么文件选择框弹不出来?

**回答**:=xdg-desktop-portal-gtk= 服务启动失败(=cannot open display:=),Chrome 和 Firefox 都依赖这个服务来弹出文件选择框。

**证据**:=systemctl --user status xdg-desktop-portal-gtk= 显示 failed (exit-code) ,两个浏览器都无反应。

---

Why #2:为什么 portal-gtk 启动失败?

**回答**:服务启动时 systemd user session 中不存在 DISPLAY 环境变量。没有 DISPLAY ,GTK 程序无法连接显示服务器。

**证据**:错误日志 cannot open display: ;服务启动时间(PID 1277 )早于 startx 执行时间。

---

Why #3:为什么 systemd user session 里没有 DISPLAY?

**回答**:因为用户通过 startx 启动图形界面。=systemd --user= 在 tty 登录时就启动了(那时没有 DISPLAY ),而 startx 在之后才创建 DISPLAY=:0 ,但这个变量只存在于 startx 的子进程环境中,不会自动传递给 systemd user session 。

**证据**:=startx= 不像 GDM/SDDM 那样调用 systemctl --user import-environment

---

Why #4:为什么 portal-gtk 会在没有 DISPLAY 的时候被启动?

**回答**:因为 D-Bus 自动激活机制绕过了 After=graphical-session.target 的排序约束。当 graphical-session.target 处于 inactive 状态时,=After= 指令不产生阻止效果——systemd 认为"不需要等待一个已经处于终态的单元"。

**证据**:=graphical-session.target= 的状态是 inactive (dead) ,而 portal-gtk 仍然被 D-Bus 激活拉起了。

---

Why #5:为什么 unit 设计没有阻止这种情况?

**回答**:=xdg-desktop-portal-gtk.service= 缺少 Requisite=graphical-session.target 。对比 xdg-desktop-portal-gnome.service ,GNOME 版有 Requisite 保护(target 没激活就拒绝启动),而 GTK 版漏掉了这个防御。这是 upstream 的设计疏漏。

**证据**:GNOME 版 unit 文件有 Requisite=graphical-session.target ,GTK 版没有。

---

识别出的根因

层级 根因
直接 portal-gtk 因缺少 DISPLAY 启动失败
系统 startx 不像显示管理器那样将 DISPLAY 导入 systemd user session
设计 xdg-desktop-portal-gtk.service 缺少 Requisite=graphical-session.target

解决方案

已采用的修复

~/.xinitrc 中添加两行,在 X11 启动后、窗口管理器启动前执行:

# 将 DISPLAY 传递给 systemd user session
systemctl --user import-environment DISPLAY 2>/dev/null
# 重新启动 portal 后端(现在 DISPLAY 已存在)
systemctl --user restart xdg-desktop-portal-gtk 2>/dev/null

小知识:=systemctl --user import-environment= 是什么?

每个进程都有自己的环境变量副本。=startx= 启动的子进程(包括 awesome WM 、浏览器等)继承了 shell 的 DISPLAY:0 。但 systemd --user 实例是你在 tty 登录时就启动的,那时 DISPLAY 还不存在。

import-environment DISPLAY 的作用是把当前 shell 的 DISPLAY 变量"导入"到 systemd user session 的环境中。这样 systemd 启动的所有用户服务就都能看到 DISPLAY:0 了。

修改后完整的 ~/.xinitrc 相关部分:

# 将 DISPLAY 传递给 systemd user session,让 xdg-desktop-portal-gtk 正常工作
systemctl --user import-environment DISPLAY 2>/dev/null
systemctl --user restart xdg-desktop-portal-gtk 2>/dev/null

# setup fcitx
export GTK_IM_MODULE=fcitx5
export QT_IM_MODULE=fcitx5
export XMODIFIERS="@im=fcitx5"
fcitx5 &

exec awesome

理想的 upstream 修复

xdg-desktop-portal-gtk.service 应该加上 Requisite=graphical-session.target ,与 GNOME 版保持一致:

[Unit]
Description=Portal service (GTK/GNOME implementation)
Requisite=graphical-session.target    ← 加上这一行
PartOf=graphical-session.target
After=graphical-session.target

这样在没有图形环境时,portal-gtk 不会尝试启动。等到 .xinitrcimport-environmentrestart 执行时,它才能正常工作。

异闻录 : Linux : Chrome : Firefox : X11 : systemd : xdg-desktop-portal : startx