喵's StackHarbor
Whisper to the World
https://sh.alynx.one/images/Mikoto-Karon-White.webp
2024-03-14T07:46:04.682Z
https://sh.alynx.one/
Alynx Zhou
alynx.zhou@gmail.com
Hikaru Generator Feed
构建和运行 Xen HVM 和 PV
https://sh.alynx.one/posts/Xen-HVM-PV/
Alynx Zhou
alynx.zhou@gmail.com
2024-03-07T15:19:19.000Z
2024-03-12T09:59:48.000Z
<div class="alert-red">请注意本文并不是推荐读者使用 Xen 作为虚拟化方案,相反,KVM 才是目前更合适大部分读者的方案。</div>
<h1 id="%E7%AE%80%E4%BB%8B"><a class="heading-link header-link" href="/posts/Xen-HVM-PV/#%E7%AE%80%E4%BB%8B"></a>简介</h1>
<p>由于工作需要,最近我需要搞一个 Xen PV 来进行测试,在此之前我一直使用 qemu/KVM,只是听说 Xen 是 KVM 之前流行过的虚拟化方案。比起几乎什么都不需要做交给 libvirt 包办就可以的 KVM,Xen 的设置相对要复杂一点。</p>
<div class="alert-red">请注意本文并不是推荐读者使用 Xen 作为虚拟化方案,相反,KVM 才是目前更合适大部分读者的方案。</div>
<h1 id="%E7%AE%80%E4%BB%8B"><a class="heading-link header-link" href="/posts/Xen-HVM-PV/#%E7%AE%80%E4%BB%8B"></a>简介</h1>
<p>由于工作需要,最近我需要搞一个 Xen PV 来进行测试,在此之前我一直使用 qemu/KVM,只是听说 Xen 是 KVM 之前流行过的虚拟化方案。比起几乎什么都不需要做交给 libvirt 包办就可以的 KVM,Xen 的设置相对要复杂一点。</p>
<a id="more"></a>
<p>首先 Xen 分为 HVM 和 PV 两种常见的虚拟方案(PVH 我也没用过),HVM 依赖于硬件虚拟化,和常见的虚拟机没什么区别,而 PV 并不依赖硬件虚拟化,是通过虚拟机 Linux 内核中特殊的驱动,将请求转给宿主机的内核代为操作,但配置起来也更加复杂。目前 PV 所需的代码已经并入 Linux 内核上游,你能安装的发行版大部分都可以直接作为 PV 的虚拟机运行。</p>
<p>无论是 HVM 还是 PV,Xen 都用 domU(domain 的缩写,U 可以是 1、2、3……)代表虚拟机,然后用 dom0 代表宿主机,当然文档里也会叫宿主机 Hypervisor,这里我也可能直接将虚拟机叫做 VM。</p>
<h1 id="%E6%9E%84%E5%BB%BA%E5%92%8C%E8%BF%90%E8%A1%8C-dom0"><a class="heading-link header-link" href="/posts/Xen-HVM-PV/#%E6%9E%84%E5%BB%BA%E5%92%8C%E8%BF%90%E8%A1%8C-dom0"></a>构建和运行 dom0</h1>
<div class="alert-blue">温馨提示:根据我的经验,在 Xen Hypervsior 上运行的 GNOME 桌面会由于未知原因在未知操作时卡住,建议不要用你平时使用的开发机作为 Xen Hypervisor,而是另找一台机器作为服务器运行 Xen Hypervisor,然后远程连接上去操作。</div>
<p>和 KVM 不需要什么操作就能用不一样,Xen 需要你构建一个单独的 boot loader,在加载 Linux 内核之前先加载它,从而实现 Xen 的支持。我这里用的 Arch Linux,需要构建 <code>xen</code> 这个 AUR 包,根据 wiki 所说推荐用下面的指令构建:</p>
<figure data-raw="$ build_stubdom=true efi_dir="/boot" makepkg -si
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ build_stubdom=true efi_dir="/boot" makepkg -si
</code></pre></figure>
<p>我这里直接把 ESP 挂载到 <code>/boot</code>,你可能需要按情况修改。</p>
<p>你还需要 <code>xen-qemu</code> 这个 AUR 包提供 qemu 前端对 Xen 的支持,但和一般的 AUR 包不同的是如果你直接构建这个包,得到的包很可能和你已经安装的 qemu 文件冲突。这是我遇到的唯一一个必须在 clean chroot 才能构建的包。然后由于它依赖 <code>xen</code> 这个 AUR 包,你必须手动操作。</p>
<p>首先安装 <code>devtools</code>:</p>
<figure data-raw="# pacman -S devtools
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># pacman -S devtools
</code></pre></figure>
<p>然后创建一个用于构建 clean chroot 的目录:</p>
<figure data-raw="$ mkdir ~/chroot
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ mkdir ~/chroot
</code></pre></figure>
<p>然后在里面安装基础依赖:</p>
<figure data-raw="$ mkarchroot ~/chroot/root base-devel
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ mkarchroot ~/chroot/root base-devel
</code></pre></figure>
<p>一般这时候 chroot 里面应该已经是最新的了,但也可以用下面的命令更新:</p>
<figure data-raw="$ arch-nspawn ~/chroot/root pacman -Syu
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ arch-nspawn ~/chroot/root pacman -Syu
</code></pre></figure>
<p>然后切换到你包含 <code>xen-qemu</code> 的 <code>PKGBUILD</code> 的目录,你可能需要的两个依赖是 <code>xen</code> 和 <code>numactl</code>,前者我们刚刚构建过,后者我们可以直接从官方仓库通过 <code>pacman -S numactl</code> 安装,然后在命令行参数里指定这两个文件的位置:</p>
<figure data-raw="$ makechrootpkg -c -r ~/chroot -I ../xen/xen-4.18.1pre-1-x86_64.pkg.tar.zst -I /var/cache/pacman/pkg/numactl-2.0.18-1-x86_64.pkg.tar.zst
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ makechrootpkg -c -r ~/chroot -I ../xen/xen-4.18.1pre-1-x86_64.pkg.tar.zst -I /var/cache/pacman/pkg/numactl-2.0.18-1-x86_64.pkg.tar.zst
</code></pre></figure>
<p>然后用 <code>pacman -U xen-qemu-*.tar.pkg.zst</code> 安装你刚刚构建好的包,此时应该没有文件冲突了。</p>
<p>你还需要安装下面的包提供虚拟机内的 BIOS 和 UEFI 引导支持:</p>
<figure data-raw="# pacman -S seabios edk2-ovmf
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># pacman -S seabios edk2-ovmf
</code></pre></figure>
<p>然后你需要加载构建好的 Xen boot loader,让它在 Linux 内核之前启动,我使用的 systemd-boot,所以下面就简单写 systemd-boot 的配置方式,逻辑上是完全一致的,如果你使用 GRUB,建议参考 Arch Wiki。</p>
<p>首先添加一个 systemd-boot 启动项文件,我这里使用 <code>/boot/loader/entries/xen.conf</code>:</p>
<figure data-raw="title Xen Hypervisor
sort-key xen
efi /xen.efi
" data-info="language-conf" data-lang="conf" class="code-block"><pre class="code"><code class="language-conf">title Xen Hypervisor
sort-key xen
efi /xen.efi
</code></pre></figure>
<p>如果你也用 systemd-boot,这个文件对你来说应该非常简单,构建 <code>xen</code> 包的时候已经将支持 EFI 的 Xen boot loader 也就是 <code>xen.efi</code> 这个文件安装到了 ESP,只要引导它就可以。</p>
<p>接下来我们编写 Xen 的配置文件让它可以正确找到你的 initramfs 和内核,并传递内核参数,配置文件 <code>xen.cfg</code> 需要和 <code>xen.efi</code> 位于同一个目录,这里就是 <code>/boot/xen.cfg</code>:</p>
<figure data-raw="[global]
default=xen
[xen]
options=console=vga loglvl=all noreboot
kernel=vmlinuz-linux root="UUID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" rootfstype="btrfs" rootflags="rw,defaults,noatime,compress=zstd:3,ssd,space_cache,subvolid=257,subvol=/@" rw add_efi_memmap threadirqs nvidia_drm.modeset=1
ramdisk=initramfs-linux.img
ucode=amd-ucode.img
" data-info="language-conf" data-lang="conf" class="code-block"><pre class="code"><code class="language-conf">[global]
default=xen
[xen]
options=console=vga loglvl=all noreboot
kernel=vmlinuz-linux root="UUID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" rootfstype="btrfs" rootflags="rw,defaults,noatime,compress=zstd:3,ssd,space_cache,subvolid=257,subvol=/@" rw add_efi_memmap threadirqs nvidia_drm.modeset=1
ramdisk=initramfs-linux.img
ucode=amd-ucode.img
</code></pre></figure>
<p>以上的内核参数是我所用的,你可以从你当前的 boot loader 启动项文件里复制出你正在用的内核参数。如果你复制了 <code>xen</code> 包自带的示例文件而不是我的,需要注意里面包含限制宿主机可用内存的参数,这是为了避免在创建虚拟机时再限制宿主机内存,影响宿主机各种缓存的策略,默认的值给的很小,可能导致无法正常启动桌面,你可能需要改大一点。不过对于我们这种简单 debug 用,可以直接忽略此参数,影响不大。</p>
<p>然后你需要让 Xen 所需的一些守护进程开机启动:</p>
<figure data-raw="# systemctl enable xenconsoled.service xen-init-dom0.service xen-qemu-dom0-disk-backend.service xendomains.service
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># systemctl enable xenconsoled.service xen-init-dom0.service xen-qemu-dom0-disk-backend.service xendomains.service
</code></pre></figure>
<p>然后重启系统,选择名字是 <code>Xen Hypervisor</code> 的启动项,你应该就有一个可以运行 Xen 的环境了。</p>
<h1 id="%E9%85%8D%E7%BD%AE%E5%92%8C%E8%BF%90%E8%A1%8C-domU"><a class="heading-link header-link" href="/posts/Xen-HVM-PV/#%E9%85%8D%E7%BD%AE%E5%92%8C%E8%BF%90%E8%A1%8C-domU"></a>配置和运行 domU</h1>
<p>然后按道理说既然 Xen 和 KVM 都是用 qemu 作为前端,那完全可以交给我常用的 virt-manager 操办一切,但我尝试构建了带 Xen 支持的 libvirt,结果运行起来发现由于某个 bug,它并不能真的支持 Xen,所以只能全手动操作了。</p>
<h2 id="%E6%9E%84%E5%BB%BA%E8%99%9A%E6%8B%9F%E6%9C%BA%E7%94%A8%E7%9A%84-NAT-%E7%BD%91%E7%BB%9C"><a class="heading-link header-link" href="/posts/Xen-HVM-PV/#%E6%9E%84%E5%BB%BA%E8%99%9A%E6%8B%9F%E6%9C%BA%E7%94%A8%E7%9A%84-NAT-%E7%BD%91%E7%BB%9C"></a>构建虚拟机用的 NAT 网络</h2>
<p>virt-manager 会帮我自动创建一个 NAT 网络使得虚拟机之间可以互相联系并且通过宿主机访问外网,我相信大部分人都需要让虚拟机联网,但只能我自己解决这件事了。</p>
<p>具体的操作包含以下几步:添加一个桥接接口;然后给它分配一个 IP;再开启系统的 NAT 转发(包含 <code>sysctl</code> 和 <code>iptables</code> 两部分,我自己也不是 <code>iptables</code> 高手所以我也不能给你解释);然后在这个接口上启动 DHCP 服务器,给虚拟机提供 IP 地址和 DNS 服务器。这样就完成了宿主机的部分,Xen 可以根据虚拟机的配置文件自动把虚拟机添加到你刚才创建的桥接接口上。</p>
<p>为了简化这个操作我编写了一个脚本:</p>
<figure data-raw="#!/bin/bash
set -x
OUT_IF="${1}"
BR_IF="${2}"
BR_IP="192.168.123.1"
BR_IP_RANGE="192.168.123.100,192.168.123.200"
BR_DNS="1.1.1.1,8.8.8.8"
[[ -z "${OUT_IF}" ]] && exit 1
[[ -z "${BR_IF}" ]] && BR_IF="vmbr0"
ip link add name "${BR_IF}" type bridge
ip link set dev "${BR_IF}" up
ip address add dev "${BR_IF}" "${BR_IP}/24"
# Enable NAT, so VMs can accept Internet.
FORWARD=$(sysctl --values net.ipv4.ip_forward)
# See <https://www.karlrupp.net/en/computer/nat_tutorial>.
sysctl net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o "${OUT_IF}" -j MASQUERADE
# Start DHCP server so we don't need to manually assign IP addresses for VMs.
# `--no-daemon` starts dnsmasq in debug mode, so it won't overload SIGINT.
# It is hard to prevent `dnsmasq` from listening `127.0.0.1:53` as a DNS server,
# so just disable its DNS server, and send DNS server to VMs via DHCP.
# It seems you need to set `--bind-interfaces` with `--interface` to make it
# bind to a interface only, otherwise it will try to bind to other interfaces
# and conflict with other dnsmasq instance (if exists).
dnsmasq --no-daemon \
--port=0 \
--interface="${BR_IF}" \
--bind-interfaces \
--dhcp-range="${BR_IP_RANGE}" \
--dhcp-option="option:dns-server,${BR_DNS}"
iptables -t nat -D POSTROUTING -o "${OUT_IF}" -j MASQUERADE
sysctl "net.ipv4.ip_forward=${FORWARD}"
ip link set dev "${BR_IF}" down
ip link delete "${BR_IF}"
" data-info="language-bash" data-lang="bash" class="code-block"><pre class="code"><code class="language-bash">#!/bin/bash
set -x
OUT_IF="${1}"
BR_IF="${2}"
BR_IP="192.168.123.1"
BR_IP_RANGE="192.168.123.100,192.168.123.200"
BR_DNS="1.1.1.1,8.8.8.8"
[[ -z "${OUT_IF}" ]] && exit 1
[[ -z "${BR_IF}" ]] && BR_IF="vmbr0"
ip link add name "${BR_IF}" type bridge
ip link set dev "${BR_IF}" up
ip address add dev "${BR_IF}" "${BR_IP}/24"
# Enable NAT, so VMs can accept Internet.
FORWARD=$(sysctl --values net.ipv4.ip_forward)
# See <https://www.karlrupp.net/en/computer/nat_tutorial>.
sysctl net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o "${OUT_IF}" -j MASQUERADE
# Start DHCP server so we don't need to manually assign IP addresses for VMs.
# `--no-daemon` starts dnsmasq in debug mode, so it won't overload SIGINT.
# It is hard to prevent `dnsmasq` from listening `127.0.0.1:53` as a DNS server,
# so just disable its DNS server, and send DNS server to VMs via DHCP.
# It seems you need to set `--bind-interfaces` with `--interface` to make it
# bind to a interface only, otherwise it will try to bind to other interfaces
# and conflict with other dnsmasq instance (if exists).
dnsmasq --no-daemon \
--port=0 \
--interface="${BR_IF}" \
--bind-interfaces \
--dhcp-range="${BR_IP_RANGE}" \
--dhcp-option="option:dns-server,${BR_DNS}"
iptables -t nat -D POSTROUTING -o "${OUT_IF}" -j MASQUERADE
sysctl "net.ipv4.ip_forward=${FORWARD}"
ip link set dev "${BR_IF}" down
ip link delete "${BR_IF}"
</code></pre></figure>
<p>你可以把这个脚本保存成 <code>mkvmbr0.sh</code>,然后 <code>chmod +x mkvmbr0.sh</code>,然后用 <code>ip a</code> 查看你当前联网所用的端口名,比如我的是 <code>wlp5s0</code>,就可以 <code>sudo ./mkvmbr0.sh wlp5s0</code> 创建一个运行在 <code>vmbr0</code> 端口上的 NAT 网络,脚本会启动 <code>dnsmasq</code> 作为 DHCP 服务器并通过 DHCP 服务器给虚拟机下发 DNS 服务器地址,如果你已经用完了虚拟机,<code>Ctrl+C</code> 打断 <code>dnsmasq</code> 它就会进行后续的清理工作并退出。</p>
<h2 id="%E7%BC%96%E5%86%99-HVM-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6"><a class="heading-link header-link" href="/posts/Xen-HVM-PV/#%E7%BC%96%E5%86%99-HVM-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6"></a>编写 HVM 配置文件</h2>
<p>HVM 实在是没什么复杂的,这么说是相对于 PV 而言,比如说构建一个 openSUSE Tumbleweed 的 HVM 可以写一个叫做 <code>hvm-tumbleweed.cfg</code> 的文件:</p>
<figure data-raw="name = 'hvm-tumbleweed'
builder = 'hvm'
memory = 2048
vcpus = 4
disk = [ 'file:/home/alynx/xen/disk-tumbleweed.img,xvda,rw', 'file:/home/alynx/xen/openSUSE-Tumbleweed-DVD-x86_64-Current.iso,sdb:cdrom,r' ]
vif = [ 'mac=00:16:3e:00:00:02,bridge=vmbr0' ]
vnc = 1
vnclisten = '0.0.0.0'
vncdisplay = 1
" data-info="language-conf" data-lang="conf" class="code-block"><pre class="code"><code class="language-conf">name = 'hvm-tumbleweed'
builder = 'hvm'
memory = 2048
vcpus = 4
disk = [ 'file:/home/alynx/xen/disk-tumbleweed.img,xvda,rw', 'file:/home/alynx/xen/openSUSE-Tumbleweed-DVD-x86_64-Current.iso,sdb:cdrom,r' ]
vif = [ 'mac=00:16:3e:00:00:02,bridge=vmbr0' ]
vnc = 1
vnclisten = '0.0.0.0'
vncdisplay = 1
</code></pre></figure>
<p>这里没什么要注意的,无非是 <code>bridge=</code> 后面接你 NAT 网络的桥接端口。以及你在配置文件里写 <code>xvda</code> 在虚拟机里会变成 <code>sda</code>,所以不要再写另一个叫做 <code>sda</code> 的设备了。如果你有多个虚拟机,记得修改 MAC 地址和 VNC 端口。</p>
<p>创建磁盘文件可以用下面的命令:</p>
<figure data-raw="$ truncate -s 20G disk-tumbleweed.img
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ truncate -s 20G disk-tumbleweed.img
</code></pre></figure>
<p>然后用下面的命令就可以启动这个虚拟机:</p>
<figure data-raw="# xl create hvm-tumblweed.cfg
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># xl create hvm-tumblweed.cfg
</code></pre></figure>
<p>其他的命令可以直接 <code>man xl</code> 查看手册。可以使用 <code>vncviewer YOUR_HOST_IP:1</code> 连接虚拟机。关于这个配置文件的具体语法可以参考 <a href="https://xenbits.xen.org/docs/unstable/man/xl.cfg.5.html" target="_blank" rel="external nofollow noreferrer noopener">官方文档</a>。</p>
<h2 id="%E7%BC%96%E5%86%99-PV-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6"><a class="heading-link header-link" href="/posts/Xen-HVM-PV/#%E7%BC%96%E5%86%99-PV-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6"></a>编写 PV 配置文件</h2>
<p>PV 比起 HVM 可就复杂太多了,最痛苦的一个问题是由于它不是硬件虚拟化,所以你没有办法运行虚拟机磁盘上的 boot loader!解决方案有两个,要么是直接在配置文件里写好内核和 initramfs 的路径(这样你就得想办法把虚拟机的内核和 initramfs 搞到宿主机磁盘上),要么是在宿主机上构建一个 GRUB 镜像,然后让这个 GRUB 去找虚拟机磁盘里的 <code>grub.cfg</code> 并执行,然后引导虚拟机里面的内核(这都哪跟哪啊)。</p>
<p>当然,如果你虚拟机的系统并不使用 GRUB 作为 boot loader,那你就只能使用第一种方案了。对于 openSUSE Tumbleweed,我摸索通了后面的方案,因此我在这里介绍这个方案如何操作。</p>
<p>首先你需要克隆 GRUB 的源码,因为你得专门构建一个能在 Xen 虚拟机里运行的 GRUB:</p>
<figure data-raw="$ git clone git://git.savannah.gnu.org/grub.git
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ git clone git://git.savannah.gnu.org/grub.git
</code></pre></figure>
<p>然后构建(如果你的 Xen 是 32 位机器上的,把 <code>amd64</code> 换成 <code>i386</code>:</p>
<figure data-raw="$ ./autogen.sh
$ ./configure --prefix=/opt/grub-xen --target=amd64 --with-platform=xen
$ make
# make install
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ ./autogen.sh
$ ./configure --prefix=/opt/grub-xen --target=amd64 --with-platform=xen
$ make
# make install
</code></pre></figure>
<p>这会将 Xen 版本的 GRUB 安装到 <code>/opt/grub-xen</code>,接下来我们需要利用 GRUB 可以将一个 tar 作为 memdisk 的特性,在里面写一个 <code>grub.cfg</code> 让 GRUB 去按我们指定的路径搜索实际的 <code>grub.cfg</code> 并加载它(别问我为什么,这鬼东西简直太邪门了)。</p>
<p>首先写一个 <code>grub-bootstrap.cfg</code>,这个文件的唯一作用就是让 GRUB 加载 memdisk 里面的 <code>grub.cfg</code>:</p>
<figure data-raw="normal (memdisk)/grub.cfg
" class="code-block"><pre class="code"><code>normal (memdisk)/grub.cfg
</code></pre></figure>
<p>然后写一个 <code>grub.cfg</code>,下面关键的问题来了,你怎么知道要搜索的真正的 <code>grub.cfg</code> 的路径呢,当然是想办法挂载出来自己看了,我这个文件里写了常见的安装好的系统的 <code>grub.cfg</code> 的位置和我自己看到的 openSUSE 安装 iso 里面的 <code>grub.cfg</code> 的位置,所以可以同时支持安装和启动系统:</p>
<figure data-raw="if search -s -f /boot/grub/grub.cfg ; then
echo "Reading (${root})/boot/grub/grub.cfg"
configfile /boot/grub/grub.cfg
fi
if search -s -f /boot/grub2/grub.cfg ; then
echo "Reading (${root})/boot/grub2/grub.cfg"
configfile /boot/grub2/grub.cfg
fi
if search -s -f /grub/grub.cfg ; then
echo "Reading (${root})/grub/grub.cfg"
configfile /grub/grub.cfg
fi
if search -s -f /grub2/grub.cfg ; then
echo "Reading (${root})/grub2/grub.cfg"
configfile /grub2/grub.cfg
fi
if search -s -f /EFI/BOOT/grub.cfg ; then
echo "Reading (${root})/EFI/BOOT/grub.cfg"
configfile /EFI/BOOT/grub.cfg
fi
" class="code-block"><pre class="code"><code>if search -s -f /boot/grub/grub.cfg ; then
echo "Reading (${root})/boot/grub/grub.cfg"
configfile /boot/grub/grub.cfg
fi
if search -s -f /boot/grub2/grub.cfg ; then
echo "Reading (${root})/boot/grub2/grub.cfg"
configfile /boot/grub2/grub.cfg
fi
if search -s -f /grub/grub.cfg ; then
echo "Reading (${root})/grub/grub.cfg"
configfile /grub/grub.cfg
fi
if search -s -f /grub2/grub.cfg ; then
echo "Reading (${root})/grub2/grub.cfg"
configfile /grub2/grub.cfg
fi
if search -s -f /EFI/BOOT/grub.cfg ; then
echo "Reading (${root})/EFI/BOOT/grub.cfg"
configfile /EFI/BOOT/grub.cfg
fi
</code></pre></figure>
<p>然后把它打包成 tar:</p>
<figure data-raw="$ tar -cf memdisk.tar grub.cfg
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ tar -cf memdisk.tar grub.cfg
</code></pre></figure>
<p>然后创建一个支持 Xen 的包含所有 GRUB 模块的 GRUB 镜像,我们将把它当作真正的虚拟机的 boot loader 运行,使用如下命令:</p>
<figure data-raw="$ /opt/grub-xen/bin/grub-mkimage -O x86_64-xen \
-c grub-bootstrap.cfg \
-m memdisk.tar \
-o grub-x86_64-xen.bin \
/opt/grub-xen/lib/grub/x86_64-xen/*.mod
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ /opt/grub-xen/bin/grub-mkimage -O x86_64-xen \
-c grub-bootstrap.cfg \
-m memdisk.tar \
-o grub-x86_64-xen.bin \
/opt/grub-xen/lib/grub/x86_64-xen/*.mod
</code></pre></figure>
<p>然后你就可以编写一个 <code>pv-tumbleweed.cfg</code> 的配置文件:</p>
<figure data-raw="name = 'pv-tumbleweed'
memory = 2048
vcpus = 4
kernel = "grub-x86_64-xen.bin"
disk = [ 'file:/home/alynx/xen/disk-tumbleweed.img,sda,rw', 'file:/home/alynx/xen/openSUSE-Tumbleweed-DVD-x86_64-Current.iso,sdb:cdrom,r' ]
vif = [ 'mac=00:16:3e:00:00:01,bridge=vmbr0' ]
vnc = 1
vnclisten = '0.0.0.0'
vncdisplay = 1
" class="code-block"><pre class="code"><code>name = 'pv-tumbleweed'
memory = 2048
vcpus = 4
kernel = "grub-x86_64-xen.bin"
disk = [ 'file:/home/alynx/xen/disk-tumbleweed.img,sda,rw', 'file:/home/alynx/xen/openSUSE-Tumbleweed-DVD-x86_64-Current.iso,sdb:cdrom,r' ]
vif = [ 'mac=00:16:3e:00:00:01,bridge=vmbr0' ]
vnc = 1
vnclisten = '0.0.0.0'
vncdisplay = 1
</code></pre></figure>
<p>没错我们这里把我们刚刚生成的 boot loader 作为内核首先拉起来,然后不出意外你应该就能看到安装程序启动了,但是如果你火急火燎的一路下一步安装,你就会掉进下一个坑:openSUSE 默认使用 snapper 管理 btrfs 快照,默认的配置方案把 <code>/boot</code> 也放在 btrfs 子卷上。而上游的 GRUB 会从 btrfs 的根子卷而不是默认子卷开始访问,我是没能搞清楚该如何简单直接的访问 snapper 最新的快照所在的子卷,openSUSE 的 GRUB 则是打了一大堆 patch 让 GRUB 支持查找和加载默认的 btrfs 子卷。总之你直接安装之后我们的 <code>grub.cfg</code> 是查找不到真正的 <code>grub.cfg</code> 的,最简单的方案就是安装时干脆不要用 btrfs 从而不用 snapper,或者把 <code>/boot</code> 单独分区单独格式化单独挂载。</p>
<p>然后你应该可以用下面的命令启动并链接到虚拟机的终端了:</p>
<figure data-raw="# xl create -c pv-tumblweed.cfg
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># xl create -c pv-tumblweed.cfg
</code></pre></figure>
<p>关于这个配置文件的具体语法可以参考 <a href="https://xenbits.xen.org/docs/unstable/man/xl.cfg.5.html" target="_blank" rel="external nofollow noreferrer noopener">官方文档</a>。</p>
<h1 id="%E8%BF%99%E4%B8%80%E5%88%87%E5%80%BC%E5%BE%97%E5%90%97%EF%BC%9F"><a class="heading-link header-link" href="/posts/Xen-HVM-PV/#%E8%BF%99%E4%B8%80%E5%88%87%E5%80%BC%E5%BE%97%E5%90%97%EF%BC%9F"></a>这一切值得吗?</h1>
<p>在经历这一系列不知道是什么鬼东西的操作之后你终于有了一个可以用的 Xen PV,也许它唯一的优势就是可以在没有硬件虚拟化的机器上跑虚拟机,为此你付出的代价是一个没法用桌面的宿主机,一个说不定哪个新系统就没法引导的虚拟机 boot loader。但现在的设备有几个没有硬件虚拟化支持呢?这就意味着对于大多数人你可以简单地使用 qemu/KVM 几乎不需要任何额外的配置,并且还有 virt-manager 这样的程序全程帮你图形化配置虚拟机和 NAT 网络。</p>
<p>而比如你想用上面的办法手动构建网络并启动一些 SLES Minimal OS 或者 openSUSE JeOS,这个过程也更简单,首先在 <code>/etc/qemu/bridge.conf</code> 里加入一行 <code>allow vmbr0</code>,然后用之前的 NAT 网络脚本 <code>sudo ./mkvmbr0.sh wlp5s0</code>,再用下面的 qemu 命令:</p>
<figure data-raw="$ qemu-system-x86_64 \
-enable-kvm \
-m 1G \
-smp 1 \
-drive if=virtio,format=qcow2,file=SLES15-SP5-Minimal-VM.x86_64-kvm-and-xen-GM.qcow2 \
-nographic \
-netdev bridge,id=eth0,br=vmbr0 \
-device virtio-net,netdev=eth0
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ qemu-system-x86_64 \
-enable-kvm \
-m 1G \
-smp 1 \
-drive if=virtio,format=qcow2,file=SLES15-SP5-Minimal-VM.x86_64-kvm-and-xen-GM.qcow2 \
-nographic \
-netdev bridge,id=eth0,br=vmbr0 \
-device virtio-net,netdev=eth0
</code></pre></figure>
<p>它会调用 <code>qemu-bridge-helper</code> 自动将虚拟机加入你构建的桥接 NAT 网络,和 Xen 比起来简单很多,也不需要额外的配置。因此如果你只是需要一个自己的虚拟化平台,完全没有必要使用已经不再流行且可能存在更多问题的 Xen,使用 KVM 就足够了。这篇文章仅仅是为了在读者不得不需要构建一个 Xen 环境 debug 时作为参考。</p>
kmsvnc 但是无头
https://sh.alynx.one/posts/Headless-kmsvnc/
Alynx Zhou
alynx.zhou@gmail.com
2024-03-06T09:38:48.000Z
2024-03-06T09:38:48.000Z
由于 Wayland 并不存在像 X11 一样一个单独管理用于最终显示的 framebuffer 的进程,因此远程桌面需要各个桌面做自己的实现。而因为登录界面和用户会话一般是两个会话,远程登录过程中的会话切换就变得非常复杂,虽然 GNOME Remote Desktop 已经有了…
<p>由于 Wayland 并不存在像 X11 一样一个单独管理用于最终显示的 framebuffer 的进程,因此远程桌面需要各个桌面做自己的实现。而因为登录界面和用户会话一般是两个会话,远程登录过程中的会话切换就变得非常复杂,虽然 GNOME Remote Desktop 已经有了一个初步实现,但我对那个的逻辑不是很满意,而且他们目前依赖 RDP 的 server rediection,所以并不支持 VNC。</p>
<p>按照当初我做 Show Me The Key 的逻辑,如果一样东西在混成器层面上不好实现,那应该考虑在下一层更统一的层面上实现,<a href="https://github.com/isjerryxiao/" target="_blank" rel="external nofollow noreferrer noopener">@Jerry Xiao</a> 老师做了一个叫做 <a href="https://github.com/isjerryxiao/kmsvnc/" target="_blank" rel="external nofollow noreferrer noopener">kmsvnc</a> 的项目,通过 DRM/KMS 获取 framebuffer 并转给 VNC 服务器,虽然它也有一些自己的小问题,但我很喜欢这个方案。</p>
<p>实际上我需要用到远程桌面的场景不多,毕竟 ssh 大部分时候更可靠也更高效,不过有时候需要修改 NAS 上一些桌面设置的时候会用到。但此时就有第一个问题,我的 NAS 是无头(就是没接显示器)的,没有显示器的情况下桌面没有输出,也就获取不到 framebuffer。请教了 <a href="https://github.com/isjerryxiao/" target="_blank" rel="external nofollow noreferrer noopener">@Jerry Xiao</a> 老师之后得知可以通过内核参数实现强制让内核以为我们有个显示器,就可以解决这个问题。</p>
<p>这个目的需要两个内核参数,分别是 <code>video=</code> 指定强制开启哪个显卡输出端口,和 <code>drm.edid_firmware=</code> 给这个端口指定一个 EDID 从而指定分辨率,正常的显示器会通过端口汇报自己的 EDID,而我们这里没有,所以需要手动指定。</p>
<p>具体这两个参数都支持什么我就不细说了,因为很复杂,建议自己看文档。首先我们得选择一个显卡已经有的输出端口用来做这个,可以用下面的命令:</p>
<figure data-raw="$ for p in /sys/class/drm/*/status; do con=${p%/status}; echo -n "${con#*/card?-}: "; cat $p; done
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ for p in /sys/class/drm/*/status; do con=${p%/status}; echo -n "${con#*/card?-}: "; cat $p; done
</code></pre></figure>
<p>它会列出端口和它们的连接状态,比如我这里有三个:</p>
<figure data-raw="DP-1: disconnected
HDMI-A-1: connected
HDMI-A-2: disconnected
" class="code-block"><pre class="code"><code>DP-1: disconnected
HDMI-A-1: connected
HDMI-A-2: disconnected
</code></pre></figure>
<p>很神奇的是我的主板上实际只有一个 DP 和一个 HDMI(我的是核芯显卡),经过我的测试主板的 HDMI 输出是 <code>HDMI-A-2</code>,至于 <code>HDMI-A-1</code> 到底在哪我不清楚,可能是 USB-C,但如果它并没有物理输出,那就更好了,所以我这里选择它。另外我建议不要选择 DP 输出,因为内核内置的 EDID 固件似乎并不支持 DP,导致无法指定分辨率。</p>
<p>然后我们通过 <code>video="HDMI-A-1:D"</code> 强制开启这个 HDMI 端口,一般你可能会查到用 <code>e</code> 表示强制启用一个端口,不过 <code>D</code> 表示强制启用一个数字输出端口,应该用哪个都无所谓。然后我们用 <code>drm.edid_firmware="HDMI-A-1:edid/1920x1080.bin"</code> 给这个端口指定一个 EDID 固件,这里我们使用内核内置的 <code>1920x1080</code> 分辨率的固件,你也可以选择其它内置的,可以在内核源码 <code>drivers/gpu/drm/drm_edid_load.c</code> 文件中找到它们的名字和内容。</p>
<p>然后重新启动系统,再用上面的命令,应该就像我一样有一个 <code>connected</code> 的输出了。然后我们启动 kmsvnc:</p>
<figure data-raw="# kmsvnc -p 5901 -b 0.0.0.0 -4 -d /dev/dri/card0
" class="code-block"><pre class="code"><code># kmsvnc -p 5901 -b 0.0.0.0 -4 -d /dev/dri/card0
</code></pre></figure>
<p>再从另一台机器上访问:</p>
<figure data-raw="$ vncviewer YOUR_SERVER_IP:5901
" class="code-block"><pre class="code"><code>$ vncviewer YOUR_SERVER_IP:5901
</code></pre></figure>
<p>应该就能看到登录界面了。不过如果你也用 GDM,你可能会发现登录之后屏幕一直是 GDM 的背景色没有切换到用户会话,这个其实是因为 GDM 会选择另一个空闲的 tty 启动 GNOME,而 <a href="https://github.com/isjerryxiao/" target="_blank" rel="external nofollow noreferrer noopener">@Jerry Xiao</a> 老师表示还没有找到一个普适的方案检测 tty 切换,所以这时候需要你手动 <code>Ctrl+C</code> 打断 kmsvnc,然后再重新运行上面的命令,应该就会选择 GNOME 桌面所在的 plane 了。</p>
<p>如果你不是刚开机就启动 kmsvnc,可能会发现它报告找不到 plane 就退出了,这个目测是因为 GDM 检测不到用户输入就会自动停止显示器输出,只要 <code>systemctl restart gdm</code> 重新启动一下 GDM 就可以了。</p>
谁动了我的 DNS 解析?(重制版)
https://sh.alynx.one/posts/Who-Moved-My-DNS-Resolving-Remastered/
Alynx Zhou
alynx.zhou@gmail.com
2024-02-06T03:53:30.000Z
2024-02-06T03:53:30.000Z
<p>这一篇是之前 <a href="/posts/Who-Moved-My-DNS-Resolving/">谁动了我的 DNS 解析?</a> 的重制版,因为那一篇杂糅了关于设置 Zeroconf 的 mDNS 的需求和关于 Linux 下面 DNS 解析到底是怎么工作的描述,我怀疑大部分读者对前者不感兴趣(因为我自己后来也发现这玩意不是很可靠),而更想了解后者,所以打算拉出来单写一篇。</p>
<p>标题显然是化用自《谁动了我的奶酪?》,即使我并没有读过这本书。</p>
<p>这一篇是之前 <a href="/posts/Who-Moved-My-DNS-Resolving/">谁动了我的 DNS 解析?</a> 的重制版,因为那一篇杂糅了关于设置 Zeroconf 的 mDNS 的需求和关于 Linux 下面 DNS 解析到底是怎么工作的描述,我怀疑大部分读者对前者不感兴趣(因为我自己后来也发现这玩意不是很可靠),而更想了解后者,所以打算拉出来单写一篇。</p>
<p>标题显然是化用自《谁动了我的奶酪?》,即使我并没有读过这本书。</p>
<a id="more"></a>
<h1 id="long-long-ago"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving-Remastered/#long-long-ago"></a>long long ago</h1>
<p>一般要讲故事,开头都是“很久很久以前……”,不过计算机领域也没什么太古老的故事可讲,毕竟公认的互联网前身 ARPANET 也就是二十世纪的事情。那个时候能互联的机器一共也就那么几个,所以解决的办法简单粗暴:我们每个机器都保存一个文件,里面记录所有人对应的域名和 IP 不就行了?这个优良传统一直留了下来,也就是现在所有系统里都有的 hosts 文件——不管你写的对不对,它的优先级都比后来出现的 DNS 查询要高。</p>
<p>然后随着加入网络的机器越来越多,这个办法不好用了,毕竟每来一个新人就要所有人更新自己的文件,这复杂度也太高了。所以干脆我们搞一个集中的服务器专门放这个列表,其它机器都向它查询就好了,这就是 DNS 服务器的原理。然后在局域网里,一般路由器和 DNS 服务器以及 DHCP 服务器都是同一台机器,因为很自然的所有设备都会连到路由器上,而 DHCP 服务器恰好知道它分配出去的 IP 地址,所以如果你输入内网设备的主机名恰好能解析,那通常是你的路由器做了这些工作。</p>
<p>但既然有了 DNS 服务器,那问题就变得复杂起来,比如我该将我的 DNS 服务器设置成哪一个?特别地,你可能会发现有很多不同的程序在试图修改你的 DNS 服务器设置,导致你打开某些网站本该秒开却不停地转圈圈,事情为什么会这么复杂?</p>
<h1 id="chattr-+i--etc-resolv-conf"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving-Remastered/#chattr-+i--etc-resolv-conf"></a>chattr +i /etc/resolv.conf</h1>
<p>很多 Linux 用户都知道修改 DNS 服务器可以通过编辑 <code>/etc/resolv.conf</code> 实现,很多 Linux 用户也被 <code>/etc/resolv.conf</code> 困扰,一些人发现自己的这个文件是个软链接,而另一些人发现这个文件总被 Network Manager 覆盖,还有些人的发行版让他们用一个叫 <code>resolvconf</code> 的工具处理,然后现在 systemd 又搞了个叫 systemd-resolved 的东西来插一脚……我说的这些已经足够让一些不想学新东西同时又神经紧张的人开始大喊“fuck systemd, fuck network manager, fuck desktop environment and fuck the whole modern world”然后执行 <code>chattr +i /etc/resolv.conf</code> 了。不过别着急小炸药包们,也许这个世界上新出现的各种东西目的并不只是惹恼你们这群大笨蛋,哦是的,没错,我说,大笨蛋,恐龙勇士(停停停不要翻译腔了),你不需要的功能并不意味着别人也不需要。总之,不要觉得世界都围着你转,至少读一下这些东西的文档,会告诉你怎么阻止它们修改你的 <code>/etc/resolv.conf</code> 的。</p>
<p>在 DNS 服务器设置这件事上并不是一个 <code>/etc/resolv.conf</code> 搞定所有,有关这个的故事也是 long long ago,但毕竟是 UNIX 纪元之后的事情,没有太久,大概确实上古时代的程序都是直接读这个获取 DNS 服务器然后再做 DNS 解析的,但实际上这也不一定 OK,比如像之前说的 hosts 文件也需要考虑。所以就有了更复杂的解决方案,大部分程序做 DNS 解析实际上是调用 glibc 里面 <code>getaddrinfo</code> 这个 API,所以在它后面我们就可以做一些工作。一个叫做 Name Service Switch 的东西发明出来就是干这个的,它是基于插件的,我们可以通过阅读 <code>/etc/nsswitch.conf</code> 里面的 <code>hosts</code> 这一行来理解,比如我这里默认是这样的:</p>
<figure data-raw="hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns
" class="code-block"><pre class="code"><code>hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns
</code></pre></figure>
<p>简单翻译一下的话意思就是查询一个域名的时候首先看看是不是 systemd-machined 的容器(<code>mymachines</code> 模块),不是的话再问问 systemd-resolved 能不能解析(<code>resolve</code> 模块),如果 systemd-resolved 可用,那到这也就完事了,后面的就不管了(<code>[!UNAVAIL=return]</code>),至于为什么我一会解释,然后 <code>files</code> 模块会读 hosts 文件,所以它优先级总是高于 DNS 服务器,然后看看是不是本机(<code>myhostname</code> 模块),然后再读 <code>/etc/resolv.conf</code> 里面的 DNS 服务器进行查询。</p>
<p>按照这个顺序,如果你处在一个极其简单的网络环境:只有一个网络连接(这里包含各种有线无线 VPN 隧道在内都只能有一个)并且完全不会移动到其它网络连接下使用,那确实只要在 <code>/etc/resolv.conf</code> 里面写死一个公开的 DNS 服务器就可以满足你的所有查询需求。但可惜并不是所有人的使用环境都这么简单,所以每个工具都有额外的策略并试图修改 <code>/etc/resolv.conf</code>。</p>
<h1 id="%E5%BD%93%E4%BD%A0%E6%98%AF%E4%B8%AA%E9%9C%80%E8%A6%81%E6%9D%A5%E5%9B%9E%E8%B7%91%E7%9A%84%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%A8%E6%88%B7%E2%80%A6%E2%80%A6"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving-Remastered/#%E5%BD%93%E4%BD%A0%E6%98%AF%E4%B8%AA%E9%9C%80%E8%A6%81%E6%9D%A5%E5%9B%9E%E8%B7%91%E7%9A%84%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%A8%E6%88%B7%E2%80%A6%E2%80%A6"></a>当你是个需要来回跑的笔记本用户……</h1>
<p>下面让我们考虑一个比那些觉得自己手搓一个 DHCP 客户端就能联网的大脑皮层极其光滑的只要 <code>chattr +i /etc/resolv.conf</code> 就能解决问题的小笨蛋们的场景稍微复杂一点的场景:你是一个背着笔记本来回跑的上班族,公司 WiFi 和家里 WiFi 的网段并不一样,而你需要在公司的时候将 DNS 服务器设置为公司的路由器,在家的时候将 DNS 服务器设置为家的路由器(什么水晶室女),以便在两地都可以通过内网设备的主机名访问对应的内网设备,显然你不可能靠 <code>chattr +i /etc/resolv.conf</code> 解决问题。</p>
<p>这就是为什么 Network Manager 需要修改你的 <code>/etc/resolv.conf</code>(其它网络管理器我就不考虑了因为我没用过,而且对于所有这种可以帮你自动连接 WiFi 的网络管理器而言,设置 DNS 服务器的逻辑都应该是相同的),对于每个不同的网络连接,它都会记录或者自动获取该局域网的 DNS 服务器,然后根据你当前激活的连接把这个 DNS 服务器写入 <code>/etc/resolv.conf</code>,保证无论是使用 <code>getaddrinfo</code> 的程序还是自己读取 <code>/etc/resolv.conf</code> 的老古董程序都可以获取到正确的局域网 DNS 服务器从而访问内网里的设备。</p>
<h1 id="%E5%BD%93%E4%BD%A0%E6%98%AF%E4%B8%AA%E9%9C%80%E8%A6%81%E6%9D%A5%E5%9B%9E%E8%B7%91%E7%9A%84%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%A8%E6%88%B7%EF%BC%8C%E5%90%8C%E6%97%B6%E4%BD%A0%E8%BF%98%E9%9C%80%E8%A6%81%E9%80%9A%E8%BF%87-VPN-%E8%BF%9C%E7%A8%8B%E5%8A%9E%E5%85%AC%E2%80%A6%E2%80%A6"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving-Remastered/#%E5%BD%93%E4%BD%A0%E6%98%AF%E4%B8%AA%E9%9C%80%E8%A6%81%E6%9D%A5%E5%9B%9E%E8%B7%91%E7%9A%84%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%A8%E6%88%B7%EF%BC%8C%E5%90%8C%E6%97%B6%E4%BD%A0%E8%BF%98%E9%9C%80%E8%A6%81%E9%80%9A%E8%BF%87-VPN-%E8%BF%9C%E7%A8%8B%E5%8A%9E%E5%85%AC%E2%80%A6%E2%80%A6"></a>当你是个需要来回跑的笔记本用户,同时你还需要通过 VPN 远程办公……</h1>
<p>如果你完全理解了上面那一段,恭喜你已经脱离了草履虫进化成了脊椎动物了!但你又遇到了更复杂的场景:你的工作要求你连接公司的 VPN,从而通过内部的 DNS 服务器解析一些内网才能访问的网站,当你打开了 VPN 下载一些内网才能下载的工具时你想同时放一些音乐打发时间,但你最爱的音乐网站现在要转 3 秒的圈才能访问!Network Manager 可以帮你解决这个问题吗?答案是在某些情况下可以!</p>
<p>尝试分析这个新的需求,你会发现问题在于 VPN 服务也要设置一个 DNS 服务器,如果你的 VPN 服务尝试自己覆盖 <code>/etc/resolv.conf</code>,那么 Network Manager 之前按照你的连接帮你设置的“正常的”的 DNS 服务器就会消失,你所有的 DNS 解析就会全部绕到公司内网的 DNS 服务器上跑一圈,导致所有网站的解析都变得很慢。这个时候你可能又打算大喊 <code>chattr +i /etc/resolv.conf</code> 退化成草履虫,但这样你就没办法解析公司内网的域名了,也许更好的办法是只让 Network Manager 管理 <code>/etc/resolv.conf</code>。因为大部分的 VPN 都已经有 Network Manager 的插件了,所以你只要在 Network Manager 里添加你的 VPN 连接,它就会像管理 WiFi 一样管理这个连接,此时你会发现你的 <code>/etc/resolv.conf</code> 里已经同时有了家里路由器的 DNS 服务器和 VPN 的 DNS 服务器。</p>
<p>理想状态下,你的 VPN 应该会自动通知客户端对于哪些域名使用这个 VPN 的 DNS 服务器查询,如果你勾选了“仅对此网络上的资源使用此连接”的话。这样访问不在内网上的网站就不会到这个 DNS 服务器上转一圈,而是直接跑到“正常”的 DNS 服务器上查询。但如果不幸这个自动配置的过程出了问题,你可以通过 <code>nmcli</code> 修改这个 VPN 连接的 <code>ipv4.dns-search</code> 项,把需要在内网查询的域名列表手动设置好。</p>
<p>对于绝大部分用户,这样的配置应该已经可以满足他们了!但实际情况永远只有更复杂,所以我们还要继续!</p>
<h1 id="%E5%BD%93%E4%BD%A0%E6%98%AF%E4%B8%AA%E9%9C%80%E8%A6%81%E6%9D%A5%E5%9B%9E%E8%B7%91%E7%9A%84%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%A8%E6%88%B7%EF%BC%8C%E5%90%8C%E6%97%B6%E4%BD%A0%E8%BF%98%E9%9C%80%E8%A6%81%E9%80%9A%E8%BF%87-VPN-%E8%BF%9C%E7%A8%8B%E5%8A%9E%E5%85%AC%EF%BC%8C%E4%BD%86%E4%BD%A0%E7%9A%84-VPN-%E5%B9%B6%E6%B2%A1%E6%9C%89-Network-Manager-%E7%9A%84%E6%8F%92%E4%BB%B6%E2%80%A6%E2%80%A6"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving-Remastered/#%E5%BD%93%E4%BD%A0%E6%98%AF%E4%B8%AA%E9%9C%80%E8%A6%81%E6%9D%A5%E5%9B%9E%E8%B7%91%E7%9A%84%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%A8%E6%88%B7%EF%BC%8C%E5%90%8C%E6%97%B6%E4%BD%A0%E8%BF%98%E9%9C%80%E8%A6%81%E9%80%9A%E8%BF%87-VPN-%E8%BF%9C%E7%A8%8B%E5%8A%9E%E5%85%AC%EF%BC%8C%E4%BD%86%E4%BD%A0%E7%9A%84-VPN-%E5%B9%B6%E6%B2%A1%E6%9C%89-Network-Manager-%E7%9A%84%E6%8F%92%E4%BB%B6%E2%80%A6%E2%80%A6"></a>当你是个需要来回跑的笔记本用户,同时你还需要通过 VPN 远程办公,但你的 VPN 并没有 Network Manager 的插件……</h1>
<p>欢迎刚刚进化完成的灵长类!坐稳了!我们要向现代人的方向冲刺了!现在我有一个更加复杂的需求:我给自己的各种内网设备搭建了一个 VPN,这样我即使身在外面也能访问我家里的服务器,但这个 VPN 使用 Tailscale,Network Manager 并没有相关的插件,于是 Tailscale 也来试图覆盖我的 <code>/etc/resolv.conf</code>,听个音乐又需要转 3 秒的圈,怎么办?</p>
<p>最好在那边手握 <code>chattr +i /etc/resolv.conf</code> 的草履虫嘲笑之前堵上他的嘴,因为这同样会导致我失去解析 VPN 内网域名的能力,我们是现代人,我们要用 systemd-resolved 解决这个问题。</p>
<p>systemd-resolved 并不仅仅是一个管理 <code>/etc/resolv.conf</code> 的工具,实际上它本身是一个自带缓存的 DNS 服务器,然后向上接管各种不同的 DNS 查询逻辑,向下为各种需要设置 DNS 服务器的工具提供接口。因此如果你的各种网络连接工具都支持 systemd-resolved 的接口,那它们就不需要自己修改 <code>/etc/resolv.conf</code>,而是改为配合 systemd-resolved 工作,恰好 Network Manager 和 Tailscale 都支持 systemd-resolved。</p>
<p>在接管 DNS 查询这个目的上 systemd-resolved 提供了三种不同的接口:首先是自己实现了一个 D-Bus 接口,其它程序可以通过这个接口来实现。然后是在 Name Service Switch 里添加了属于自己的模块以接管 <code>getaddrinfo</code>,如果检测到 systemd-resolved 已启用,那它的缓存 DNS 服务器就会接管所有的处理,包括 hosts 和 hostname,以及如果没有缓存到,就会主动向上级 DNS 服务器查询,因此在 <code>/etc/nsswitch.conf</code> 里面写了如果检测到 systemd-resolved 就直接返回,跳过后面的模块。最后对于那些自己读取 <code>/etc/resolv.conf</code> 的老古董,它也会修改这个文件接管这类程序,这时这个文件只是个软链接,里面只有一句就是把 DNS 服务器设置为 systemd-resolved 自己的 DNS 缓存服务器。</p>
<p>如果你要启用 systemd-resolved,务必保证你的 <code>/etc/resolv.conf</code> 是指向 systemd-resolved 管理的文件的软链接:</p>
<figure data-raw="# ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
</code></pre></figure>
<p>这样如果 Network Manager 和 Tailscale 启动时检测到 <code>/etc/resolv.conf</code> 是软链接,就会知道自己需要配合 systemd-resolved 工作(如果你正在配置这个,那就手动重启它们!)。此时执行 <code>resolvectl status</code>,应该能看到对于不同的网络接口,都配置了不同的 DNS 服务器,以及需要通过这个服务器查询的域名(如果你配置过的话!)。</p>
<p>当然,你也可以通过 systemd-resolved 的配置添加一个全局的 DNS 服务器。systemd-resolved 支持 unicast,也就是说如果你查询的域名不符合任何一个网络接口设置的要通过这个接口的 DNS 服务器查询的域名的话,它就会通过所有网络接口的 DNS 服务器查询(也包含你设置的全局 DNS 服务器),然后取最快返回的结果。</p>
<h1 id="%E5%BD%93%E4%BD%A0%E6%98%AF%E4%B8%AA%E9%9C%80%E8%A6%81%E6%9D%A5%E5%9B%9E%E8%B7%91%E7%9A%84%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%A8%E6%88%B7%EF%BC%8C%E5%90%8C%E6%97%B6%E4%BD%A0%E8%BF%98%E9%9C%80%E8%A6%81%E9%80%9A%E8%BF%87-VPN-%E8%BF%9C%E7%A8%8B%E5%8A%9E%E5%85%AC%EF%BC%8C%E4%BD%86%E4%BD%A0%E7%9A%84-VPN-%E5%B9%B6%E6%B2%A1%E6%9C%89-Network-Manager-%E7%9A%84%E6%8F%92%E4%BB%B6%EF%BC%8C%E5%AE%83%E4%B9%9F%E4%B8%8D%E6%94%AF%E6%8C%81-systemd-resolved%E2%80%A6%E2%80%A6"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving-Remastered/#%E5%BD%93%E4%BD%A0%E6%98%AF%E4%B8%AA%E9%9C%80%E8%A6%81%E6%9D%A5%E5%9B%9E%E8%B7%91%E7%9A%84%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%A8%E6%88%B7%EF%BC%8C%E5%90%8C%E6%97%B6%E4%BD%A0%E8%BF%98%E9%9C%80%E8%A6%81%E9%80%9A%E8%BF%87-VPN-%E8%BF%9C%E7%A8%8B%E5%8A%9E%E5%85%AC%EF%BC%8C%E4%BD%86%E4%BD%A0%E7%9A%84-VPN-%E5%B9%B6%E6%B2%A1%E6%9C%89-Network-Manager-%E7%9A%84%E6%8F%92%E4%BB%B6%EF%BC%8C%E5%AE%83%E4%B9%9F%E4%B8%8D%E6%94%AF%E6%8C%81-systemd-resolved%E2%80%A6%E2%80%A6"></a>当你是个需要来回跑的笔记本用户,同时你还需要通过 VPN 远程办公,但你的 VPN 并没有 Network Manager 的插件,它也不支持 systemd-resolved……</h1>
<p>我们的口号是不做草履虫!实际上这种场景已经非常非常少见了,绝大多数的场景都能被 Network Manager + systemd-resolved 覆盖,这也就是为什么越来越多的通用发行版都使用这一套进行网络管理和 DNS 解析的原因。但如果你真的遇到了,也许你听说过一个叫做 <code>resolvconf</code> 的工具,在以前某些发行版会预装它。它的逻辑似乎就是有各种程序都打算自己修改 <code>/etc/resolv.conf</code>,那你们干脆都别管了,我来管,听我的(现在有 N + 1 种解决方案了)。至于它到底是怎么工作的,如何配置,这些都别问我!因为我也没有用过!我觉得你不如去建议不支持 systemd-resolved 的项目支持 systemd-resolved 比较实际……</p>
<p>总之无论如何如果你搜到一篇老旧的教程告诉你设置这个 <code>resolvconf</code>,那你都该留个心眼查一下,万一你这个需求已经可以用 Network Manager 的插件或者它已经支持 systemd-resolved 了呢?</p>
<h1 id="%E5%A4%AA%E9%95%BF%E4%B8%8D%E7%9C%8B%EF%BC%81"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving-Remastered/#%E5%A4%AA%E9%95%BF%E4%B8%8D%E7%9C%8B%EF%BC%81"></a>太长不看!</h1>
<p>我准备了一张图,让你知道 DNS 解析在 Linux 上都发生了什么,以及 Network Manager 和 systemd-resolved 各自都扮演什么角色……</p>
<p><img src="/posts/Who-Moved-My-DNS-Resolving-Remastered/dns.png" alt="dns.png"></p>
使用 Headscale 和 Tailscale 构建虚拟专用网
https://sh.alynx.one/posts/Build-VPN-with-Headscale-and-Tailscale/
Alynx Zhou
alynx.zhou@gmail.com
2024-02-04T11:39:26.000Z
2024-02-04T11:39:26.000Z
需求 很多在家里装了 NAS 的人都有一个相似的需求,那就是出门在外如何访问内网的 NAS 上运行的服务。很多人会选择公网 IP + 端口映射把需要的服务直接暴露到公网上,或者通过公网的 VPS 进行反向代理。但这些我都不放心,首先我的目的只是自己访问,而不是给别人访问,其次对于…
<h1 id="%E9%9C%80%E6%B1%82"><a class="heading-link header-link" href="/posts/Build-VPN-with-Headscale-and-Tailscale/#%E9%9C%80%E6%B1%82"></a>需求</h1>
<p>很多在家里装了 NAS 的人都有一个相似的需求,那就是出门在外如何访问内网的 NAS 上运行的服务。很多人会选择公网 IP + 端口映射把需要的服务直接暴露到公网上,或者通过公网的 VPS 进行反向代理。但这些我都不放心,首先我的目的只是自己访问,而不是给别人访问,其次对于一些简易的 WebUI,暴露在公网上也容易被无聊的人扫端口并尝试入侵。实际上这个需求更倾向于 VPN(这里指的是它本来的意思也就是虚拟专用网,而不是佛跳墙),我曾经尝试过使用 WireGuard 和公网 VPS 构建一个简单的 VPN,但效果并不好,首先是我的 VPS 并不在国内,作为所有流量的中继实在是太不合适,实际使用起来几乎卡到不能自理,其次是 WireGuard 用作 VPN 服务器的话需要把其它所有 peers 都添加到服务器里,实在是太过麻烦。</p>
<p>第一点我其实没想到什么好的解决办法,能想到最好的也就是利用家里有公网 IP 的特点把 VPN 服务器改到家里。而第二点我差点就想改成自建 OpenVPN 了,但这时我偶然找到一些资料,说不应该手动组建 WireGuard 网络,而是使用一些基于 WireGuard 的工具帮你自动组网。比较之后我决定使用 Tailscale。</p>
<p>Tailscale 能做的并不仅仅是帮你建立一个 VPN 服务器然后自动添加客户端,在此之上它有一些更妙的功能,比如 WireGuard 实际上并不是服务端/客户端架构,peers 之间是对等的,于是 Tailscale 可以尝试通过 NAT 穿透建立点对点的 WireGuard 连接,如果无法穿透才会通过服务端中继(Tailscale 官网有一篇关于如何实现较为可靠的 NAT 穿透的文章,至少我是没怎么看懂),这听起来很适合我的需求并且在实际使用中极大的提升了我的体验。但 Tailscale 本身只是客户端,它们通过销售自己的服务提供服务器供用户连接,客户端是开源的但服务端是闭源的。而我显然更希望自己搭建服务端,幸好有 Headscale 这个开源项目自己实现了一个 Tailscale 服务端,可以自己搭建。但 Headscale 自己的文档非常的简陋,所以我决定写篇博客记录一下具体配置的过程。</p>
<h1 id="Headscale"><a class="heading-link header-link" href="/posts/Build-VPN-with-Headscale-and-Tailscale/#Headscale"></a>Headscale</h1>
<p>首先在公网能访问到的服务器上安装 Headscale,Arch Linux 的官方仓库里已经打包了:</p>
<figure data-raw="# pacman -S headscale
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># pacman -S headscale
</code></pre></figure>
<p>然后需要修改配置文件 <code>/etc/headscale/config.yaml</code>,里面需要修改的只有几处,我这里简单介绍一下:</p>
<p>首先第一个要修改的是 <code>server_url</code>,这个就是客户端连接服务器时使用的地址和端口,Headscale 使用的是 HTTP 协议,如果你不想明文在公网上裸奔,那可以在后面添加 HTTPS 证书和密钥使它支持 HTTPS。</p>
<p>同样还需要修改 <code>listen_addr</code>,控制 Headscale 监听的网段和端口,这里端口要和上面的一致。</p>
<p>下面其它的控制数据库和 gRPC 都保持默认即可,然后你可以修改想要给子网设备分配 IP 的网段,只要修改 <code>ip_prefixes</code> 就可以,要注意的是并不是所有网段都可以用,Tailscale 本身已经限制了一部分,你只能选择这个网段的子网段。我这里注释掉了 IPv6 因为我不需要。</p>
<p>如果你想设置 HTTPS,Headscale 本身支持通过 ACME 帮你自动申请证书,这当然是最好的,但它并不支持通过 DNS 的方式验证域名所有权,也就意味着需要你能够监听 80 或者 443 端口,如果你是公网 IP 的家宽,这基本等于被 ISP 查水表,而如果是 VPS,你也大概率可能在这些端口上运行了其它的 HTTP 服务,所以我没有用这个功能。但它下面还提供了手动指定证书和密钥的选项,你可以使用 certbot 或者 acme.sh 之类的功能帮你处理好证书(和 certbot 搏斗实在是太痛苦了所以我省略了),然后将 <code>tls_cert_path</code> 设置为 <code>fullchain.pem</code> 所在的路径,<code>tls_key_path</code> 设置为 <code>privkey.pem</code> 所在的路径就可以。(需要注意 certbot 放置证书的路径只有 root 能读写,而 Headscale 并不是以 root 用户运行的,所以你还需要写 hooks 把文件复制出来并修改权限……)</p>
<p>然后还有一个关于 DNS 的部分需要修改,Tailscale 提供了一个叫做 MagicDNS 的机制,当你连接上这个网络之后,就可以像在家用路由器后面一样通过主机名直接访问对应的设备,或者使用主机名 + 你定义的域名后缀,MagicDNS 会帮你解析到正确的 IP。但这里有一个问题,Headscale 默认的配置会让你运行 Tailscale 的设备将自己的 MagicDNS 服务器设置为 systemd-resolved 对所有域名使用的默认服务器(对没错 Tailscale 客户端上的 DNS 逻辑是被 Headscale 服务端控制的,什么奇怪的脑回路),这其实很不方便,特别是对于国内的一些网站比如 B 站会解析很慢并解析到离你比较远的 CDN 上,所以需要关闭这个功能,只优先对 Tailscale 的域名使用 MagicDNS 服务器。只要将 <code>dns_config</code> 下面 <code>override_local_dns</code> 设置为 <code>false</code> 即可。</p>
<p>然后你还需要修改 <code>dns_config</code> 下面 <code>base_domain</code> 这一项,这个是 MagicDNS 里内部域名的后缀。</p>
<p>解决了这些之后你就可以启动守护进程:</p>
<figure data-raw="# systemctl enable --now headscale
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># systemctl enable --now headscale
</code></pre></figure>
<p>Headscale 的进程和相关配置都属于 <code>headscale</code> 用户和 <code>headscale</code> 组,因此如果你想直接修改相关配置,可以将自己加入 <code>headscale</code> 组:</p>
<figure data-raw="# gpasswd -a alynx headscale
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># gpasswd -a alynx headscale
</code></pre></figure>
<p>然后你需要创建一个 Headscale 的 user,说是 user 其实更像是 namespace:</p>
<figure data-raw="$ headscale users create azvpn
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ headscale users create azvpn
</code></pre></figure>
<p>上面提到的内部域名的逻辑就是 <code>主机名.用户名.内部域名后缀</code>,比如我设置的 <code>base_domain</code> 是 <code>alynx.one</code>,那 timbersaw 这台主机的内部域名就是 <code>timbersaw.azvpn.alynx.one</code>。</p>
<p>后面我们会把设备添加到这个 namespaces,添加的时候自然需要验证权限,一般是 Tailscale 发起请求,Headscale 返回一个链接,打开链接之后是一条指令,你需要将里面的 USERNAME 换成你想要的,然后在 Headscale 所在的机器上运行这个指令。当然如果你不方便 ssh 连到 Headscale 所在的服务器,你也可以创建 preauthkey,直接在 Tailscale 连接时提供即可:</p>
<figure data-raw="$ headscale preauthkeys create --user azvpn --reusable --expiration 12h
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">$ headscale preauthkeys create --user azvpn --reusable --expiration 12h
</code></pre></figure>
<h1 id="Tailscale--Linux-"><a class="heading-link header-link" href="/posts/Build-VPN-with-Headscale-and-Tailscale/#Tailscale--Linux-"></a>Tailscale (Linux)</h1>
<p>这个同样也在 Arch Linux 的官方仓库,直接安装即可:</p>
<figure data-raw="# pacman -S tailscale
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># pacman -S tailscale
</code></pre></figure>
<p>稍微复杂的一个部分是 DNS,显然 MagicDNS 会修改你的 <code>/etc/resolv.conf</code> 设置为自己的 DNS
服务器,但如果你和我的配置相同,那应该这个文件也是由 NetworkManager 管理的。如果你已经理解了 Linux 下面 DNS 解析的逻辑,你应该清楚无论何时都只应该有一个进程管理这个文件。解决方法要么是使用 NetworkManager 的插件来运行 Tailscale 从而只让 NetworkManager 管理 <code>/etc/resolv.conf</code>(并没有这样的插件),要么是两者全部放弃自己管理 DNS,交给第三者管理。</p>
<p>无论是 Tailscale 还是 NetworkManager 都能自动检测 systemd-resolved 并配合它工作,所以我们启用这个代替 NetworkManager 管理 <code>/etc/resolv.conf</code>,过程很简单也很好理解。</p>
<p>首先把 <code>/etc/resolv.conf</code> 链接到 systemd-resolved 的 stub 文件,这个文件的作用只有一个就是把 DNS 服务器设置成 systemd-resolved 运行的 DNS 服务器,这样所有的 DNS 查询就都被传给 systemd-resolved 进行处理:</p>
<figure data-raw="# ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
</code></pre></figure>
<p>然后启动 systemd-resolved:</p>
<figure data-raw="# systemctl enable --now systemd-resolved
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># systemctl enable --now systemd-resolved
</code></pre></figure>
<p>接下来重启 NetworkManager,当它启动时检测到 <code>/etc/resolv.conf</code> 是指向 systemd-resolved 的 stub 文件的软链接,就不会尝试修改该文件而是自动配合 systemd-resolved 工作:</p>
<figure data-raw="# systemctl restart NetworkManager
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># systemctl restart NetworkManager
</code></pre></figure>
<p>然后启动 Tailscale 的守护进程:</p>
<figure data-raw="# systemctl enable --now tailscaled
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># systemctl enable --now tailscaled
</code></pre></figure>
<p>接下来就可以尝试连接到 Headscale 服务器:</p>
<figure data-raw="# tailscale up --login-server https://YOURSERVER:YOURPORT
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># tailscale up --login-server https://YOURSERVER:YOURPORT
</code></pre></figure>
<p>如果你不想进行上面的手动验证流程,这一步可以直接附加上刚才创建的 preauthkey:</p>
<figure data-raw="# tailscale up --login-server https://YOURSERVER:YOURPORT --auth-key YOURPREAUTHKEY
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># tailscale up --login-server https://YOURSERVER:YOURPORT --auth-key YOURPREAUTHKEY
</code></pre></figure>
<p>此时运行 <code>ip a</code>,应该可以看到多了一个叫 <code>tailscale0</code> 的网络接口。使用 <code>resolvectl status</code> 则可以看到这个接口有自己的 DNS 服务器,并且对 <code>azvpn.alynx.one</code> 的域名使用此服务器查询。此时已经可以使用 Tailscale 内网分配的 IP 或者 MagicDNS 提供的域名像在物理路由器后面一样访问内网的各种设备。</p>
<h1 id="Tailscale--Android-"><a class="heading-link header-link" href="/posts/Build-VPN-with-Headscale-and-Tailscale/#Tailscale--Android-"></a>Tailscale (Android)</h1>
<p>Tailscale 也有开源的 Android 客户端并且已经上架了 Google Play Store,但你安装之后可能会发现没有自定义服务器的选项,你需要点开并关闭右上角三个点菜单多次,然后菜单里就会多出一项 Change Server,设置成你自建的 Headscale 服务器,然后就可以使用主界面第二个登录选项进行交互登录了。目前似乎 Android 客户端还不支持使用 preauthkey 登录。</p>
Node 的 http.request() 需要对 response 进行错误处理
https://sh.alynx.one/posts/Node-HTTP-Request-Needs-to-Handle-Response-Error/
Alynx Zhou
alynx.zhou@gmail.com
2024-01-03T07:21:05.000Z
2024-01-03T07:21:05.000Z
<p>我发现有些时候 Telegram bot 很适合用来 host 一些我自己要用的服务,因为只要通过手机上的聊天框就可以控制了,不需要我自己写一些什么后台页面。为了让构建和安装一个新 bot 的过程尽量简单,我自己用 Node 写了一个 <a href="https://github.com/AlynxZhou/azbot-telegram/" target="_blank" rel="external nofollow noreferrer noopener">没有外部依赖的 Telegram bot 框架</a>。完全使用 Node 自带的模块比较麻烦的一点就是你需要自己基于 <code>http.request()</code> 进行封装,因为原版基于 <code>EventEmitter</code> 的接口写起来实在是太复杂了。</p>
<p>我发现有些时候 Telegram bot 很适合用来 host 一些我自己要用的服务,因为只要通过手机上的聊天框就可以控制了,不需要我自己写一些什么后台页面。为了让构建和安装一个新 bot 的过程尽量简单,我自己用 Node 写了一个 <a href="https://github.com/AlynxZhou/azbot-telegram/" target="_blank" rel="external nofollow noreferrer noopener">没有外部依赖的 Telegram bot 框架</a>。完全使用 Node 自带的模块比较麻烦的一点就是你需要自己基于 <code>http.request()</code> 进行封装,因为原版基于 <code>EventEmitter</code> 的接口写起来实在是太复杂了。</p>
<a id="more"></a>
<p>把 <code>http.request()</code> 封装成 Promise 比一般的 API 要难一点,但也不是完全做不到,比如 <a href="https://nodejs.org/api/http.html#httprequestoptions-callback" target="_blank" rel="external nofollow noreferrer noopener">官方文档上的示例代码</a> 是这样写的(复制这么长一段不是我要占字数而是我真的被它坑了):</p>
<figure data-raw="import http from 'node:http';
import { Buffer } from 'node:buffer';
const postData = JSON.stringify({
'msg': 'Hello World!',
});
const options = {
hostname: 'www.google.com',
port: 80,
path: '/upload',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
};
const req = http.request(options, (res) => {
console.log(`STATUS: ${res.statusCode}`);
console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
res.setEncoding('utf8');
res.on('data', (chunk) => {
console.log(`BODY: ${chunk}`);
});
res.on('end', () => {
console.log('No more data in response.');
});
});
req.on('error', (e) => {
console.error(`problem with request: ${e.message}`);
});
// Write data to request body
req.write(postData);
req.end();
" data-info="language-JavaScript" data-lang="JavaScript" class="code-block"><pre class="code"><code class="language-JavaScript">import http from 'node:http';
import { Buffer } from 'node:buffer';
const postData = JSON.stringify({
'msg': 'Hello World!',
});
const options = {
hostname: 'www.google.com',
port: 80,
path: '/upload',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
};
const req = http.request(options, (res) => {
console.log(`STATUS: ${res.statusCode}`);
console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
res.setEncoding('utf8');
res.on('data', (chunk) => {
console.log(`BODY: ${chunk}`);
});
res.on('end', () => {
console.log('No more data in response.');
});
});
req.on('error', (e) => {
console.error(`problem with request: ${e.message}`);
});
// Write data to request body
req.write(postData);
req.end();
</code></pre></figure>
<p>那对于一个 POST 请求,我就可以这样封装:</p>
<figure data-raw="import * as http from "node:http";
import {Buffer} from "node:buffer";
const post = (url, body, headers = {}) => {
const opts = {
"method": "POST",
"timeout": 1500,
"headers": {}
};
for (const [k, v] of Object.entries(headers)) {
opts["headers"][k.toLowerCase()] = v;
}
if (!(isBuffer(body) || isString(body))) {
body = JSON.stringify(body);
opts["headers"]["content-type"] = "application/json";
opts["headers"]["content-length"] = `${Buffer.byteLength(body)}`;
}
return new Promise((resolve, reject) => {
const req = http.request(url, opts, (res) => {
const chunks = [];
res.on("data", (chunk) => {
chunks.push(chunk);
});
res.on("end", () => {
resolve(Buffer.concat(chunks));
});
});
req.on("error", reject);
req.write(body);
req.end();
});
};
" data-info="language-JavaScript" data-lang="JavaScript" class="code-block"><pre class="code"><code class="language-JavaScript">import * as http from "node:http";
import {Buffer} from "node:buffer";
const post = (url, body, headers = {}) => {
const opts = {
"method": "POST",
"timeout": 1500,
"headers": {}
};
for (const [k, v] of Object.entries(headers)) {
opts["headers"][k.toLowerCase()] = v;
}
if (!(isBuffer(body) || isString(body))) {
body = JSON.stringify(body);
opts["headers"]["content-type"] = "application/json";
opts["headers"]["content-length"] = `${Buffer.byteLength(body)}`;
}
return new Promise((resolve, reject) => {
const req = http.request(url, opts, (res) => {
const chunks = [];
res.on("data", (chunk) => {
chunks.push(chunk);
});
res.on("end", () => {
resolve(Buffer.concat(chunks));
});
});
req.on("error", reject);
req.write(body);
req.end();
});
};
</code></pre></figure>
<p>反正流程无非是创建 request,然后在 response 里面收集 data 到 buffer,然后处理 request 的 error,再把 body 写到 request 里面。看起来很简单毕竟官方文档也这么写的对吧!然后就掉进坑里了。</p>
<p>我的 Telegram bot 设置是要不停通过 HTTP 轮询获取更新,为了保证能一直轮询下去,就要在遇到错误的时候 catch 住简单处理,然后继续进行下次轮询。但明明我已经在可能出现错误的时候都处理了,bot 还是会在跑了几天以后遇到错误(通常是 <code>read ETIMEOUT</code>)然后完全停住,只能手动重启。我对此绞尽脑汁,但是想不出哪里有问题,同时因为这个要 bot 跑一段时间才能复现,也很难 debug,我甚至手动打了 log 来看是轮询停住了还是轮询没有停但却一直得到空的结果,实际证明是遇到错误停住了,但我不是已经进行错误处理了吗?</p>
<p>这个问题实在是找不到什么参考,我尝试了一些没有意义的办法,最后差点去翻什么 axios 之类的代码看他们如何解决的了。不过我在此之前想了一下,会不会是因为不仅要写 <code>req.on("error", reject);</code>,还要写 <code>res.on("error", reject);</code> 来处理 response 的错误,否则 Node 就会直接把相关的错误抛出来停掉?其实我心里觉得不太可能,毕竟 <strong>示例代码里根本都没有写这句</strong>,但我还是本着没办法的办法写上去了:</p>
<figure data-raw="diff --git a/azbot-telegram/bot-utils.js b/azbot-telegram/bot-utils.js
index 42e002e..f90a8eb 100644
--- a/azbot-telegram/bot-utils.js
+++ b/azbot-telegram/bot-utils.js
@@ -360,13 +360,13 @@ const post = (url, body, headers = {}) => {
return new Promise((resolve, reject) => {
const req = https.request(url, opts, (res) => {
const chunks = [];
+ res.on("error", reject);
res.on("data", (chunk) => {
chunks.push(chunk);
});
res.on("end", () => {
resolve(Buffer.concat(chunks));
});
});
req.on("error", reject);
req.write(body);
" data-info="language-patch" data-lang="patch" class="code-block"><pre class="code"><code class="language-patch">diff --git a/azbot-telegram/bot-utils.js b/azbot-telegram/bot-utils.js
index 42e002e..f90a8eb 100644
--- a/azbot-telegram/bot-utils.js
+++ b/azbot-telegram/bot-utils.js
@@ -360,13 +360,13 @@ const post = (url, body, headers = {}) => {
return new Promise((resolve, reject) => {
const req = https.request(url, opts, (res) => {
const chunks = [];
+ res.on("error", reject);
res.on("data", (chunk) => {
chunks.push(chunk);
});
res.on("end", () => {
resolve(Buffer.concat(chunks));
});
});
req.on("error", reject);
req.write(body);
</code></pre></figure>
<p>然后问题就神奇的解!决!了!我的 bot 连续跑了十天半个月也没有挂,我心里这个气啊,为什么官方文档里的示例一点没提到要对 response 的 error 事件进行处理呢?甚至在网上也很难找到相关的信息,我推测是大部分人并不从头自己封装 HTTP 模块而是直接使用现成的库比如 axios,然后可能有人发现过这问题就简单地给 axios 提了这么一个 fix,就再也没人提起过这件事了。总之还是希望官方文档能更新一下示例代码吧。</p>
2023 年的 Arch Linux 安装指南
https://sh.alynx.one/posts/2023-Arch-Install/
Alynx Zhou
alynx.zhou@gmail.com
2023-07-28T12:42:53.000Z
2023-07-28T12:42:53.000Z
在安装 Arch Linux 之前,首先要准备 Arch Linux 的安装媒介。如果你打算安装在虚拟机里,那你并不需要一个实体的存储介质,因为虚拟机可以直接加载 iso 文件。但不管你在哪里安装,你都需要获取这个 iso 文件,引导进入其中的临时系统才能继续安装。 Arch L…
<p>在安装 Arch Linux 之前,首先要准备 Arch Linux 的安装媒介。如果你打算安装在虚拟机里,那你并不需要一个实体的存储介质,因为虚拟机可以直接加载 iso 文件。但不管你在哪里安装,你都需要获取这个 iso 文件,引导进入其中的临时系统才能继续安装。</p>
<p>Arch Linux 的安装镜像每月更新一次,如果你点开官网的下载页面,你会发现没有直接的下载链接,而是推荐你使用种子下载或者镜像站下载。这是一个非常有必要的要求,因为官方的服务器不能承受世界各地所有的请求流量,以上两种方法通过将单一的下载来源转换为多个下载来源有效的减轻了官方服务器的压力。</p>
<p>考虑到当今种子下载并不是一个流行的下载方式,对于部分读者而言可能难以掌握,我们这里就选择镜像站下载。所谓的镜像站就是将官方服务器上的文件原样下载到自己的服务器上,然后给别人提供下载服务的服务器。有了镜像站,世界各地的用户就不必连接相对较远较慢的官方服务器,而可以就近选择镜像站,获取到完全一样的文件。</p>
<p>我们可以打开某个镜像站同步 <code>archlinux</code> 的目录,然后找到 <code>iso/latest</code> 目录,里面的 <code>archlinux-x86_64.iso</code> 就是我们需要的,以清华大学的镜像站为例,链接就是 <a href="https://mirrors.tuna.tsinghua.edu.cn/archlinux/iso/latest/archlinux-x86_64.iso" target="_blank" rel="external nofollow noreferrer noopener">https://mirrors.tuna.tsinghua.edu.cn/archlinux/iso/latest/archlinux-x86_64.iso</a>。</p>
<p>注意!Arch Linux 官方只对 x86-64 架构提供支持,如果你的设备不是该架构(可能性很低,如果你不是该架构,你应该已经有足够的经验自己解决问题了),可能需要使用其它分支项目并参阅相关的文档。</p>
<p>当下载好 iso 文件之后,需要准备对应的安装媒介,这需要一个实体的存储介质,光盘是最传统的安装媒介,这也导致了各种系统的安装程序都以光盘镜像(iso)的格式打包。但想必当今的用户寻找光盘和光驱可能有些难度,所以 U 盘成为了更流行的安装媒介,找出一个你没有使用的 U 盘,备份好原本的数据,然后连接到你下载了安装镜像的电脑上。</p>
<p>如果你的电脑上已经运行了 Linux,那你可以通过 <code>dd</code> 命令将 iso 文件写入到 U 盘里,Arch Linux 的安装镜像经过特殊处理,可以支持这样的 U 盘引导。首先通过 <code>lsblk -f</code> 查看你的 U 盘对应的设备文件是什么,然后使用 <code>dd if=/PATH/TO/archlinux-x86_64.iso of=/dev/sdX</code>,记得把 iso 和设备文件的路径改成你的实际路径,并且不要使用 U 盘分区的设备文件,而是使用代表整个 U 盘的设备文件。然后执行 <code>sync</code>,让内核把内存里缓冲的数据写回磁盘,保证安装镜像完全写进 U 盘里面。</p>
<p>但你也可能会说如果我有 Linux,我为什么要装 Linux?这种情况下我们推荐 Windows 用户使用 Rufus 创建安装 U 盘,这个软件下载即可运行,不需要安装,然后在软件里分别选择 iso 文件的位置和 U 盘设备,点击写入即可获得一个安装 U 盘。</p>
<p>无论你使用哪种方式,接下来弹出 U 盘,准备重启电脑。不过要保证重启的时候 U 盘仍然在你的电脑上。现在的电脑应该都支持 UEFI 引导,你需要搜索你的主板型号得知你的电脑应该按什么按键进入启动设备选单,反正无非是 F8、DEL、Enter 中的一个,在显示主板 logo 的时候狂按,直到出现一个让你选择的菜单,使用键盘上的方向键选择你刚刚做好的安装 U 盘,然后按下 Enter 选择。等屏幕上走完启动流程,你应该就会自动登录进一个 Arch Linux 的环境。如果你开启了安全启动,那你需要关掉,因为 Arch Linux 的安装镜像并没有进行安全启动需要的签名,这里就不介绍具体如何关闭了,因为各家主板的界面都不一样,建议搜索引擎搜索自己的主板型号+关闭安全启动。</p>
<p>Arch Linux 的安装环境是没有桌面的,你需要在命令行里自己调用各种命令完成一系列安装相关的操作,这样看起来比较难,但是也很灵活,可以根据自己的需要调整。首先你要做的是确定自己已经联网了,最简单的就是从路由器插一根网线到你的电脑上,这样应该就能上网了。如果你没有网线——那现在就该去买一根,比起现在给你讲清楚怎么在命令行下面连接无线网络,买网线更简单,真的。</p>
<p>然后你应该使用 <code>date</code> 命令查看系统时间是否重要,许多加密方式依赖时间正确,比如 https,因此如果它不正确,你应该改正它,不过大部分情况都是正确的。</p>
<p>接下来你应该准备安装系统的磁盘分区,首先你得通过 <code>lsblk -f</code> 找到要安装的硬盘,如果是 SATA 硬盘,它可能是 <code>/dev/sda</code> 或者 <code>/dev/sdb</code> 之类,如果是 NVMe 硬盘,那可能是 <code>/dev/nvme0n1</code>,一般来说根据容量判断是不会错的。你的目标磁盘上应当留有一定的未分配空间给新系统使用。注意如果分区和格式化时操作错误,可能会让你丢失已有的重要数据,因此在进行操作前务必仔细确认。</p>
<p>UEFI 引导的机器大部分都使用 GPT 分区表,当然这其实主要是 Windows 的限制,因此我们使用 <code>gdisk</code> 进行分区,如果你使用的不是 GPT 分区表,那你可能需要自行了解一些相关知识。当你不知道该做什么的时候,输入 <code>?</code> 可以显示帮助,输入 <code>p</code> 可以打印当前的分区表,输入 <code>q</code> 可以退出 <code>gdisk</code>,只有输入 <code>w</code> 才会真正修改硬盘上的分区表,所以如果你不确定就不要输入 <code>w</code>。</p>
<p>输入 <code>p</code> 打出当前的分区表之后,你应该首先找到一个小的 FAT32 分区,一般会在磁盘的开头,容量不会超过 1G,这是你的 ESP 系统分区,UEFI 要求把引导文件放在这里。然后你应该按 <code>n</code> 新建一个分区,一般它会自动计算未分配空间的开头,不过你也可以手动输入来纠正,然后输入新分区的结尾位置,也可以用 <code>+100G</code> 的方式表示从开头位置创建一个 100G 的分区。一般只要分一个分区做 Linux 的根分区就好了,不过你有需要也可以创建更多的分区,比如你可能需要一个 swap 分区,那就用相同的办法创建一个。创建完你需要的分区之后,输入 <code>p</code> 确认一下新的分区表,然后按 <code>w</code> 写入分区表。</p>
<p>接下来你需要在分好的分区上创建文件系统也就是格式化,因为分区表只是标记“从哪里到哪里属于哪个分区”,并没有在对应的位置创建实际的结构。比如你可以用 <code>mkfs.ext4 /dev/sdXY</code> 格式化你刚创建的根分区,然后用 <code>mkswap /dev/sdXY</code> 格式化你刚才创建的 swap 分区。记得在执行命令之前确认你使用的设备文件正确。然后你需要挂载你创建的分区到 <code>/mnt</code>,稍候会向里面写入系统文件。首先用 <code>mount /dev/sdXY /mnt</code>,把根分区挂载上,然后你需要创建其它分区的挂载点,比如 EFI 系统分区,对于这个如何挂载有很多种说法,不过我一般直接把它当作 boot 分区挂载,这样内核也会被安装到这个分区,有些预装 Windows 系统的电脑可能会分一个极小的 EFI 系统分区以至于放不下内核,那你可能需要查找更多资料,这不在这篇文章的讨论范围之内。总之先 <code>mkdir /mnt/boot</code> 然后 <code>mount /dev/sdXY /mnt/boot</code>。最后可以用 <code>swapon /dev/sdXY</code> 启用你刚才创建的 swap 分区,这样记录新系统挂载点的时候就会记录这个 swap 分区。</p>
<p>然后需要修改镜像站列表,和之前下载 iso 一样,系统需要的各种软件包也依靠镜像站提高分发效率。你需要用一个编辑器编辑 <code>/etc/pacman.d/mirrorlist</code>,如果你没有熟悉的编辑器,那 <code>nano</code> 应该是个适合新手的选择,因为各种操作需要的快捷键都会显示在屏幕底部,<code>^</code> 代表 Ctrl,<code>M</code> 代表 Alt,在列表里找到离你地理位置比较近的几个镜像站,然后删除对应的 <code>Server = </code> 前面的 <code>#</code> 来启用这个镜像站,一般启用两三个就足够了。</p>
<p>然后就可以正式安装软件包到创建的分区了!使用 <code>pacstrap -K /mnt base base-devel linux linux-firmware</code> 安装软件包到 <code>/mnt</code>,你可以在后面附加更多你需要的软件包以便一并安装,甚至如果你不想第一次启动新系统还是命令行的话,也可以在这一步直接附加桌面环境进去。这里我贴一个基于我常用软件总结的列表作为参考:</p>
<figure data-raw="base base-devel linux linux-firmware man-db man-pages btrfs-progs vim nano git rsync gnome gdm networkmanager firefox meson ninja efibootmgr haveged ibus-rime ffmpeg noto-fonts noto-fonts-cjk noto-fonts-emoji ntfs-3g btop p7zip parallel tree ttf-roboto unrar unarchiver wget usbutils bind
" class="code-block"><pre class="code"><code>base base-devel linux linux-firmware man-db man-pages btrfs-progs vim nano git rsync gnome gdm networkmanager firefox meson ninja efibootmgr haveged ibus-rime ffmpeg noto-fonts noto-fonts-cjk noto-fonts-emoji ntfs-3g btop p7zip parallel tree ttf-roboto unrar unarchiver wget usbutils bind
</code></pre></figure>
<p>如果你和我有不同的偏好,你应该已经清楚如何安装你需要的软件,我在这里只以我自己使用的软件作为例子。为了方便使用,我在这一步直接安装了桌面环境,但桌面环境需要有可用的显卡驱动,对于 Intel 和 AMD,它们的开源驱动已经足够好用,应该会自动引入 <code>mesa</code> 所以没什么需要额外操作的,但是对于 NVIDIA,你还需要安装 <code>nvidia</code> 这个包来引入 NVIDIA 的闭源驱动。</p>
<p>然后等待下载安装即可,现在大家的带宽都很高,如果确实选了离自己近的镜像站,这个步骤应该花不了多少时间。</p>
<p>然后读取你对新系统的挂载信息并写入到新系统里,以便新系统基于这个数据挂载硬盘,使用 <code>genfstab -U /mnt >> /mnt/etc/fstab</code> 即可。</p>
<p>现在你的新分区里应该有一个新系统需要的各种文件了,但是你还需要对它进行各种设置,首先需要 chroot 到新的系统,这是一个 Linux 内核的功能,可以让你以另一个文件系统作为根目录从而操作其中的各种文件,这里使用 <code>arch-chroot /mnt</code> 进入新系统的根目录。</p>
<p>然后你要指定自己新系统的时区,比如你使用的时区是 <code>Asia/Shanghai</code>,那可以执行 <code>ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime</code>,你也可以将 <code>Asia/Shanghai</code> 修改为其它的时区,所有可用的时区都以目录和文件的形式列在 <code>/usr/share/zoneinfo/</code> 下面。</p>
<p>然后你需要执行 <code>hwclock --systohc</code>,这会假设你的 BIOS 时间是 UTC,这和 Windows 默认的假设不一致,Windows 认为你的 BIOS 时间就是本地时间。可以让 Linux 认为 BIOS 时间是本地时间,但是可能会导致各种问题,同样也有办法让 Windows 认为 BIOS 时间是 UTC 时间,只需要随便新建一个文本文档,写入如下内容:</p>
<figure data-raw="Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\TimeZoneInformation]
"RealTimeIsUniversal"=dword:00000001
" class="code-block"><pre class="code"><code>Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\TimeZoneInformation]
"RealTimeIsUniversal"=dword:00000001
</code></pre></figure>
<p>然后保存并修改扩展名为 <code>.reg</code>,然后双击导入注册表项并重启。</p>
<p>接下来修改本地化相关内容,首先是 glibc 需要对不同的语言生成不同的配置,需要用编辑器编辑 <code>/etc/locale.gen</code> 文件,必须要启用的是 <code>en_US.UTF-8 UTF-8</code>,别的可以按需求启用你需要的,只要删掉开头的 <code>#</code>,注意只要启用带 <code>UTF-8</code> 的就可以。不过这一步也可以略过其他的只启用英语,然后在桌面环境里修改语言的话桌面环境应该会自动处理相关的文件。然后运行 <code>locale-gen</code>,它会根据上述文件的内容具体生成对应文件。</p>
<p>然后创建 <code>/etc/locale.conf</code> 文件,写入你当前使用的 <code>LANG</code> 变量,不过其实 tty 不能显示中文,所以这一步推荐设置成英文,等到桌面起来了再改也来得及,因此推荐写入 <code>LANG=en_US.UTF-8</code>。</p>
<p>网络相关的配置首先需要设置 hostname,这一步只要打开 <code>/etc/hostname</code> 文件写入你想要的主机名就行了。我的习惯是使用 NetworkManager 管理网络连接,因此需要设置让系统下次启动时启用 NetworkManager,只要 <code>systemctl enable NetworkManager</code> 就可以。NetworkManager 会自动管理你的网卡,比如有线网卡就会自动尝试 DHCP,同时也提供和桌面环境的集成,方便使用无线网卡。</p>
<p>一些无线网卡需要的固件可能被单独划分在别的包里,此时你可以安装这些包,比如高通的网卡就是 <code>pacman -S linux-firmware-qcom</code>。</p>
<p>然后你需要进行启动相关的设置,首先你得生成 initramfs,这个东西解决的问题是“需要加载模块才能读取对应的文件系统,但模块就存在那个文件系统上”这种问题,为了打破鸡生蛋还是蛋生鸡的循环,解决方法就是创建一个非常小的包含必要模块的文件,和内核放在一起,保证启动时可以加载这个文件。生成这个文件很简单,因为我们没有什么特殊的配置,只要执行 <code>mkinitcpio -P</code> 就行了。</p>
<p>同时现代的 CPU 都支持加载微码来热修复 CPU 的 bug,这也是通过在启动时加载相关的文件实现,首先需要安装对应的微码包,如果是 Intel 就 <code>pacman -S intel-ucode</code>,AMD 就 <code>pacman -S amd-ucode</code>。</p>
<p>然后你还需要一个 bootloader 加载你的内核和 initramfs,最流行功能最全的是 GRUB,但我觉得 systemd-boot 也完全够用了,所以我选择 systemd-boot。因为已经安装了 systemd 所以就不需要额外安装什么了,只要 <code>bootctl install</code>,就可以安装引导需要的文件。</p>
<p>但我们仍然需要告诉 loader 去哪里加载内核,加载哪个内核。systemd-boot 需要我们手动编写配置文件记录这些内容。首先需要 <code>mkdir /boot/loader/entries</code> 建立用于放置不同内核启动项的文件,然后再编辑 <code>/boot/loader/entries/arch.conf</code> 给默认的内核编写一个文件。</p>
<p>一个配置文件推荐包含以下内容:</p>
<figure data-raw="title Arch Linux
linux /vmlinuz-linux
initrd /amd-ucode.img
initrd /initramfs-linux.img
options root="UUID=xxxx-xxxx-xxxx" rw add_efi_memmap
" class="code-block"><pre class="code"><code>title Arch Linux
linux /vmlinuz-linux
initrd /amd-ucode.img
initrd /initramfs-linux.img
options root="UUID=xxxx-xxxx-xxxx" rw add_efi_memmap
</code></pre></figure>
<p>基本上你需要改的有两处,一个是如果是 Intel 就把 <code>amd-ucode</code> 改成 <code>intel-ucode</code>,另一个是要把内核参数里 <code>root=</code> 的值设置为你的根分区,以便内核找到你真正的根分区。这个可以通过打开 <code>/etc/fstab</code> 找到里面挂载到 <code>/</code> 的设备得到需要的值。</p>
<p>然后你需要编辑 <code>/boot/loader/loader.conf</code>,这是给 loader 的配置,其实只需要一行 <code>default arch.conf</code>,告诉 loader 默认加载 arch 内核的配置就可以了。</p>
<p>最后需要进行密码配置,首先执行 <code>passwd</code> 设置 root 密码。由于 root 权限太高,平时不建议使用 root 操作,所以我们可以通过 <code>useradd -m newuser</code> 创建一个普通用户,<code>-m</code> 的意思是会自动给用户创建同名的 home 目录存储用户相关的文件,你也可以把 <code>newuser</code> 改成任何你想要的用户名。然后执行 <code>passwd newuser</code> 给这个新用户设置密码。同时为了方便进行高权限操作,我们需要允许新用户执行 <code>sudo</code>,首先执行 <code>EDITOR=nano visudo</code> 编辑 sudoers 文件,找到 <code>root ALL=(ALL) ALL</code> 一行,然后在下面插入 <code>newuser ALL=(ALL) ALL</code>(记得用你想要的用户名),保存即可。</p>
<p>然后运行 <code>systemctl enable gdm</code>,这会告诉系统启动时启用 GNOME 显示管理器,下次启动时你就会得到图形界面并可以直接登录进桌面。此时关于新系统的设置都已基本完成,执行 <code>exit</code> 退出 chroot,然后执行 <code>reboot</code> 重启电脑,你应该可以在 UEFI 启动选单里找到 Linux Boot Manager,选择就会启动新安装的 Arch Linux。</p>
<p>由于此时应该已经启动图形界面了,对于桌面的各种设置只要在图形界面的设置程序里设置即可,就不需要专门讲述怎么用了。</p>
DaVinci Resolve 奇怪的素材位置计算逻辑
https://sh.alynx.one/posts/Strange-Logic-of-DaVinci-Resolve-on-Calculating-Clip-Position/
Alynx Zhou
alynx.zhou@gmail.com
2023-07-28T07:20:02.000Z
2023-07-28T07:20:02.000Z
<p>上一篇文章提到了 DaVinci Resolve 对于素材位置的计算逻辑非常奇怪并且不肯修改,这篇我试图概括一下具体的计算逻辑方便自己使用。如果你也遇到了同样的问题并且希望他们改进,可以去支持 <a href="https://forum.blackmagicdesign.com/viewtopic.php?f=33&t=179153" target="_blank" rel="external nofollow noreferrer noopener">我发的帖子(英语)</a>。</p>
<p>上一篇文章提到了 DaVinci Resolve 对于素材位置的计算逻辑非常奇怪并且不肯修改,这篇我试图概括一下具体的计算逻辑方便自己使用。如果你也遇到了同样的问题并且希望他们改进,可以去支持 <a href="https://forum.blackmagicdesign.com/viewtopic.php?f=33&t=179153" target="_blank" rel="external nofollow noreferrer noopener">我发的帖子(英语)</a>。</p>
<a id="more"></a>
<h1 id="%E8%AE%A1%E7%AE%97%E5%9F%BA%E5%87%86"><a class="heading-link header-link" href="/posts/Strange-Logic-of-DaVinci-Resolve-on-Calculating-Clip-Position/#%E8%AE%A1%E7%AE%97%E5%9F%BA%E5%87%86"></a>计算基准</h1>
<p>缩放/裁切/位置永远以原图大小作为基准,不会互相影响。</p>
<p>项目设置里有“缩放原图至适配大小且不出现裁切”和“不调整原图大小并裁切超出部分”两个比较合理的选项,“缩放原图至适配大小且不出现裁切”可以理解为插入时间线之前就改变了原图大小。例如画布尺寸 1920x1080,素材尺寸 512x512,选择“缩放原图至适配大小且不出现裁切”,相当于用外部命令把素材缩放到 1080x1080 然后再插入时间线,后续缩放/裁切/位置均以 1080x1080 作为基准。</p>
<h1 id="%E7%BC%A9%E6%94%BE"><a class="heading-link header-link" href="/posts/Strange-Logic-of-DaVinci-Resolve-on-Calculating-Clip-Position/#%E7%BC%A9%E6%94%BE"></a>缩放</h1>
<p>缩放变换只计算原图大小。</p>
<p>缩放变换默认以素材中心作为基准。由于位置变换计算太复杂了,不考虑改变锚点参数的情况。</p>
<h1 id="%E8%A3%81%E5%88%87"><a class="heading-link header-link" href="/posts/Strange-Logic-of-DaVinci-Resolve-on-Calculating-Clip-Position/#%E8%A3%81%E5%88%87"></a>裁切</h1>
<p>裁切只计算原图大小。无论素材的缩放变换参数是多少,都使用原图大小计算结果。</p>
<p>例:画布 1920x1080,素材 512x512。</p>
<ul>
<li>选择“缩放原图至适配大小且不出现裁切”,此时原图大小是 1080x1080,左侧裁切 50% 应输入 <code>1080 * 50% = 512</code>。</li>
<li>选择“不调整原图大小并裁切超出部分”,此时原图大小是 512x512,左侧裁切 50% 应输入 <code>512 * 50% = 256</code>。</li>
</ul>
<p>由于位置变换计算太复杂了,不考虑勾选“保留图片位置”的情况。</p>
<h1 id="%E4%BD%8D%E7%BD%AE"><a class="heading-link header-link" href="/posts/Strange-Logic-of-DaVinci-Resolve-on-Calculating-Clip-Position/#%E4%BD%8D%E7%BD%AE"></a>位置</h1>
<p>位置变换只计算原图大小。无论素材的缩放变换参数和裁切参数是多少,都使用原图大小计算结果。</p>
<p>DaVinci Resolve 对于素材位置输入框使用特殊的计算逻辑(怀疑是 bug),假设画布宽度为 <code>canvas_width</code>,画布高度为 <code>canvas_height</code>,原图宽度为 <code>clip_width</code>, 原图高度为 <code>clip_height</code>,如果想将素材水平方向移动 <code>x</code> 像素,垂直方向移动 <code>y</code> 像素,则需要填入的数据需要按照 <code>f(x) = x / clip_width * canvas_width</code> 和 <code>f(y) = y / clip_height * canvas_height</code> 进行计算。注意,按此计算逻辑,填入的两个数据的比例显然和实际移动的像素比例不同。</p>
<p>例:画布 1920x1080,素材 512x512。</p>
<ul>
<li>选择“缩放原图至适配大小且不出现裁切”,此时原图大小是 1080x1080,向左移动 540 像素应输入 <code>540 / 1080 * 1920 = 960</code>。</li>
<li>选择“不调整原图大小并裁切超出部分”,此时原图大小是 512x512,向左移动 540 像素应输入 <code>540 / 512 * 1920 = 2025</code>。</li>
</ul>
首先是犯下傲慢之罪的闭源拖拉机
https://sh.alynx.one/posts/Firstly-The-Arrogant-Closed-Source-Tractor/
Alynx Zhou
alynx.zhou@gmail.com
2023-07-28T03:20:51.000Z
2022-07-28T11:26:00.000Z
<p>很遗憾的,我们没有生活在可以完全只使用开源软件的世界里,所以有时候不得不忍受一些闭源拖拉机的傲慢。一个经典的情况就是明明用户花了钱,还是得当孙子——我指的是用户反馈完全没有开发者看这件事情。或者更进一步,我认为 GitHub 或者 GitLab 的 issue (或者 bugzilla)是比用户论坛更好的反馈工具。</p>
<p>很遗憾的,我们没有生活在可以完全只使用开源软件的世界里,所以有时候不得不忍受一些闭源拖拉机的傲慢。一个经典的情况就是明明用户花了钱,还是得当孙子——我指的是用户反馈完全没有开发者看这件事情。或者更进一步,我认为 GitHub 或者 GitLab 的 issue (或者 bugzilla)是比用户论坛更好的反馈工具。</p>
<a id="more"></a>
<p>一个发生在我身上的例子是 PowerAMP,我曾经是它的付费用户(现在也是付费用户,但并不是活跃用户)。PowerAMP 有这样一个问题,当你搜索一首歌的时候,你必须输入两个或以上的字符搜索才会启动。这对于英语歌来说不是什么问题,因为大部分单词都是两个字符以上,几乎不会出现需要依靠一个字符查找歌曲的情况。但假如你是一个不会假名的日语歌爱好者,你想要依赖标题里某个你认识的汉字搜索到你想要的歌,这时候 PowerAMP 的搜索就是完完全全的废物。至少对于我自己来说这个需求曾经切实存在了很长一段时间。我不是没有尝试给开发者反馈,PowerAMP 有一个自己的论坛用作用户反馈工具,但过了两年也没有人回复 <a href="https://forum.powerampapp.com/topic/20907-start-searching-after-just-typing-1-character-instead-of-2/" target="_blank" rel="external nofollow noreferrer noopener">我的帖子</a>。如果开发者完全不看用户反馈,或者只看热度高的用户反馈,那这论坛还有什么意义呢?</p>
<p>所以非常搞笑的结论发生了,我作为一个付费用户,最终的解决方案是我去学了五十音,不过这也没有完全解决问题,我意识到即使这样,仍然存在无法解决的情况:比如标题只有一个汉字的歌,你永远也不可能在 PowerAMP 里通过标题搜索到它(那英点了个赞并评论“最烦装逼的播放器作者”)。我甚至已经脑补出了作者洋洋自得地写下 <code>if (str.length < 2) return;</code> 以为自己对搜索做了天才般的优化,但在他可怜的脑袋里却找不到高德纳那句著名的“过早优化是万恶之源”,甚至用户的反馈也被他忽略掉了。最终我选择放弃这个自以为是的闭源拖拉机,换了一些功能没这么多,但却没有这个过早优化的替代品。</p>
<p>更新(2022-07-28T19:26:00):Arch Linux CN 社区里的 @weearc⚝ 跟我说他的 PowerAMP 输入单个字符是可以搜索的,这让我很疑惑,我又重新下载安装了一个 PowerAMP,但我的却仍然不行。我们俩研究一番后才发现问题所在:如果安装过后直接点导航栏的搜索,必须要输入两个或以上字符才能开始搜索。但如果在媒体库页面点击所有歌曲,然后点击列表标题下的搜索按钮,此时输入一个字符就会开始搜索。具体的原因可能是因为这时搜索添加了一个“所有歌曲”的过滤器,只搜索本地歌曲。大概是作者自以为是的决定限制包含在线源的搜索的启动条件来减少在线搜索的启动次数,不管怎么说这也属于一个过早优化,而且对我这种完全不使用在线功能的用户而言除了徒增迷惑之外没有任何的好处。虽然添加了“所有歌曲”的过滤器之后再从导航栏启动搜索仍然会带这个过滤器,可以作为一个 workaround 解决我的问题,我还是认为这对中文歌曲不是很友好。这次我发现 app 内的“联系我们”功能会给作者发邮件,于是我写了封邮件反馈这个不一致,希望他记得查看自己的邮箱。</p>
<p>另一个我亲身经历的问题是 DaVinci Resolve,但是好话说在前头,比起大部分不友好的闭源软件开发商,BlackMagic
Design 已经是班级里的三好学生了,我们就不提比如官方支持 Linux 这样大家都知道的优点,而是说一下同样的论坛反馈问题。Linux 版本的 DaVinci Resolve 不支持 Linux 上两种常见的输入法,而作为购买了 Studio 版的付费用户,我自然是积极的在官方论坛反馈了这个问题,结果嘛比 PowerAMP 好那么一点,一个 BlackMagic Design 的员工看到了我的帖子并把它移动到了 Feature Request 分区,然后就没有然后了。</p>
<p>在某些人试图为拿走他们钱的闭源软件开发商找“也许是 Linux 输入法太多支持起来太麻烦他们真的没有足够人力做”的借口之前,我要先发制人说明一下,这其实也是个“一行代码”就能解决的问题,甚至并不需要 BlackMagic Design 写实际的代码。DaVinci Resolve 使用 Qt 作为界面库,Qt 本身就做好了 Linux 下面常见输入法的支持,只要在构建时候打开开关就可以,所以问题的关键在于他们的团队里没有人意识到 Linux 下面的 CJK 用户需要打开这个开关,也没有人愿意去做打开构建开关这个简单的工作,只是让反馈的帖子烂在论坛里。甚至更进一步,为什么我这么确定只需要做这么简单的工作呢?因为我自己发现了一个 workaround,只要把系统里 Qt 输入法插件的 <code>.so</code> 文件复制到 DaVinci Resolve 自带的 Qt 的对应目录,一切就完全工作了,所以可以充分说明并不是存在什么难以克服的障碍。(如果你也需要解决这个问题,具体的操作请阅读 <a href="/posts/Input-Method-Support-for-DaVinci-Resolve-on-Linux/">DaVinci Resolve 在 Linux 下的输入法支持</a>。)</p>
<p>说到 DaVinci Resolve,他们还有一个令人迷惑的坐标计算问题。比如你在尺寸为 3840x2160 的画布里放进一个 200x200 的图片素材,然后想让这个图片向右移动这个图片的宽度,你应该输入多少呢,答案并不是 200,而是 3840。具体的计算逻辑大概是 <code>f(x) = x / clip_width * canvas_width</code>(计算纵坐标则需要换成高度,这样你横纵坐标看起来和实际位移完全不成比例),而这只是最简单的情况,如果你再对素材进行缩放,然后再进行裁切,那计算逻辑我也说不清楚是怎么回事了。如果你访问它的用户论坛,你会发现需要精确输入坐标的用户都对这个计算逻辑感到迷惑(相关的内容聚集在 <a href="https://forum.blackmagicdesign.com/viewtopic.php?f=21&t=166202&sid=be6eee42737d87cb59463a6f3f3069c0" target="_blank" rel="external nofollow noreferrer noopener">这个帖子(英语)</a>),并且这还导致了其它问题(用这个算法你可能需要输入极大的数值来挪动一个很小的素材,于是就会撞到输入框的数字上限)。但 BlackMagic Design 完全没有修改这个逻辑的想法。我可以理解为是怕影响兼容性,但完全可以添加一个设置项,如果勾选就保持以前的计算逻辑。这又是一个用户反馈了却被忽略的例子。</p>
<p>但如果你读到这里觉得闭源软件的用户论坛就是最烂的反馈工具,那你还是太高估了闭源软件开发商的下限了。另一个我亲身经历的例子来自亲爱的 Micro$oft,作为 RDP 的标准制定者,微软的 Android RDP 客户端基本可以认为是实质上的官方实现,但 gnome-remote-desktop 的开发者遇到了 <a href="https://gitlab.gnome.org/GNOME/gnome-remote-desktop/-/issues/99" target="_blank" rel="external nofollow noreferrer noopener">客户端内不能正确显示视频(英语)</a> 的问题,导致这个问题的原因是 Microsoft 的 Android RDP 客户端写死了 image stride,导致读取错误,老实说这不是什么大不了的问题,改掉就好了嘛。于是我就积极主动的去该 app 的 Play Store 页面打算写评论,但是我看到应用简介里说“我们不会看 Play Store 评论,如果你要反馈问题,请发送至 <code>rdandr@microsoft.com</code>”,于是我又写了封邮件描述相关的问题,然后被气了个半死:一封自动回复的邮件告诉我你应该到这个链接反馈问题,点开那个链接我得到一个大大的 <code>Error 404 This UserVoice instance is no longer available.</code>。说不定这些傲慢的开发者还在沾沾自喜:我们的软件质量真好,竟然没有用户反馈问题!(不过我刚才又查看了一下 Play Store 页面,现在他们换成了一个反馈链接,我暂时还没有测试这个链接是否可用。)</p>
<p>所以这其实是我推崇开源软件的原因之一:通常来说开源软件的开发者都很重视用户反馈,并且会建立良好的反馈渠道。即使你遇到了一个“知道错了,但我不改”的开发者,你也可以尝试自己动手修改代码解决问题,然后提交给他或者自己维护 patch。但对于闭源软件这显然不现实,你只能希望开发者大发慈悲常来看用户论坛并注意到你的反馈。</p>
<p>另一方面,issue 大概是比用户论坛更好的反馈工具。虽然对于闭源软件开发商,很难找到相关的例子,不过 Valve 就是这么一个独特的例子。以前他们使用 GitHub Issue 作为 DOTA 2 和 CSGO 的 Linux / macOS 版本的反馈渠道,后来更进一步鼓励玩家在 GitHub Issue 上反馈 DOTA 2 的 bug。虽然 Valve 也存在“知道错了,但我不改”这种情况(比如 Steam Linux 版的输入法支持,但这个和他们使用了自己编写的 UI 框架有关系,大概解决起来有难度),但他们的开发者确实会看 GitHub Issue,并处理玩家的反馈(比如在官网 <a href="https://www.dota2.com/newsentry/3640648066072340345" target="_blank" rel="external nofollow noreferrer noopener">最新的一篇文章(英语)</a> 里提到很多用户在 GitHub 上反馈了炸弹人的新 bug)。</p>
<div class="center">
<img alt="1.png" title="一位 DOTA 2 玩家正在学习使用 GitHub Issue……呃,这可能是个错误示范……" src="/posts/Firstly-The-Arrogant-Closed-Source-Tractor/1.png">
</div>
<p>但,是什么导致 issue 在用户反馈上比用户论坛效果更好呢?毕竟本质上二者都是“发帖”“回复”的流程,我尝试分析一下其中的不同。</p>
<p>一个我认为很重要的区别是社区文化,或者说是谁在使用相关的工具:2023 年,完全没有接触过开源项目的程序员应该是不存在的,也就是说如果你是开发者,你大概率早就使用过 GitHub 或者 GitLab 这样的工具,issue 对你来说是一个你会去重视或至少会去看的东西。而论坛更大概率不是开发者直接运营,也许是什么专门的论坛客服在处理,他们可能并不懂技术,或者就算开发者会参与进用户论坛,论坛帖子对程序员来说也不是什么一定要看的东西。当用户遇到问题的时候,向开发者直接反馈应当是解决问题最直接的途径,如果在用户和开发者之间插入一层不懂技术的客服,那大概率是场灾难。(我真的没有在针对什么“微软社区支持专员”哦,真的没有。)</p>
<p>另一个可能的原因也许是排序方式:issue 默认是按发布时间而不是回复时间排列的,因此开发者大概率会逐个查看新出现的 issue 并处理。但论坛通常默认以回复时间排列,更加重视“热度”,于是很容易出现一个问题不太热门就完全被忽略掉的情况,但对于软件开发而言,不应该因为一个问题热度不高就完全置之不理,因为热度可能受很多其它因素影响,比如用户主要使用的语言和开发者不同,反馈时存在语言或者网络障碍,热度不能直观地反映出程序本身的问题。</p>
<p>但也许上述区别只决定了开发者能不能看到用户反馈,另一个决定性因素是开发者想不想看到用户反馈。我个人是觉得对于软件质量的追求应该是程序员自发的而不是被迫的,所以对用户反馈更应当积极处理。不得不承认,相似体量的项目,开源项目的开发者比起闭源项目的开发者好像确实是更有责任感一点。</p>
PipeWire 和 HDMI 音频和虚拟设备和复合/分离通道
https://sh.alynx.one/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/
Alynx Zhou
alynx.zhou@gmail.com
2023-05-11T13:25:05.000Z
2023-06-07T08:47:45.000Z
<p>这篇文章同时有 <a href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#%E4%B8%AD%E6%96%87%E7%89%88%E6%9C%AC">中文版本</a> 和 <a href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#EnglishVersion">英文版本</a>。</p>
<p>This post is both available in <a href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#%E4%B8%AD%E6%96%87%E7%89%88%E6%9C%AC">Chinese version</a> and <a href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#EnglishVersion">English version</a>.</p>
<p>这篇文章同时有 <a href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#%E4%B8%AD%E6%96%87%E7%89%88%E6%9C%AC">中文版本</a> 和 <a href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#EnglishVersion">英文版本</a>。</p>
<p>This post is both available in <a href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#%E4%B8%AD%E6%96%87%E7%89%88%E6%9C%AC">Chinese version</a> and <a href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#EnglishVersion">English version</a>.</p>
<a id="more"></a>
<h1 id="%E4%B8%AD%E6%96%87%E7%89%88%E6%9C%AC"><a class="heading-link header-link" href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#%E4%B8%AD%E6%96%87%E7%89%88%E6%9C%AC"></a>中文版本</h1>
<p>认识的朋友里很少有人有像我这么复杂的音频系统。长话短说,为了能让 PS4、Switch 和电脑分享一个扬声器,我把它接在了显示器上而不是电脑的内置声卡上,这样所有设备都通过 HDMI/DP 输出音频到扬声器。一开始这也没什么,后来我又添置了一块显示器,我发现在 Linux 下面经常搞不清楚究竟哪一个音频设备才是连接着扬声器的显示器,可能上周还是 HDMI 1,这周就变成 HDMI 2,而且也不是每次都会变,导致我经常需要试试才知道哪一个是我需要的。直到前天我忍不了了,决定发挥动手能力解决这个问题。</p>
<p>一开始我以为是 PipeWire 给设备排序的时候是随机排序的,那简单,只要我找到每个设备对应的 ID,然后关掉没有扬声器的那个 HDMI 输出就可以了。但是我发现似乎 PipeWire 只是按照 ALSA 给出的设备编号来排序,并没有自己编号,于是就算关闭一个设备,下次被关闭的也可能是另一个。然后我就在想难道 ALSA 没有固定 HDMI 音频设备的功能吗?毕竟就算是显示器也是有 EDID 这种东西的,于是我查了各种 ALSA 的资料,确实是可以通过 udev 指定不同声卡的顺序,但对于 HDMI 这种属于同一个声卡的不同端口的设备没什么办法。我甚至查到了 NVIDIA 关于显卡音频的文档,里面说每个端口会有一个叫做 ELD 的数据,描述了连接的显示器信息,不过通过 <code>cat /proc/asound/cardX/eld*</code> 查看之后我发现这个标准最多只给到显示器的型号,而我为了不在多显示器缩放上浪费精力,买了两台同样型号的显示器,没有序列号字段就还是没办法分辨不同的显示器!当然如果你的 HDMI 设备的型号不同,那其实就简单了,ALSA 现在会读取 ELD 里面的显示器型号,然后 PipeWire 会把这个作为 <code>node.nick</code> 属性,你可以直接通过这个属性分辨设备,也可以利用这个属性写 WirePlumber 重命名规则修改你的桌面环境会用到的属性,就可以固定名称了。不过我还得继续寻求帮助。</p>
<p>于是我就在公司的 research 邮件列表发了封邮件讲述了我的设备连接方式和需求,结果 Takashi Iwai(内核音频子系统的维护者之一)回复我说确实没有什么办法,音频驱动只是按照显卡给的顺序分配编号,所以大概率是随机的。特别是我还发现这玩意好像也不一定按照显示器输出的顺序来排号,于是 Plan A 是彻底行不通了。那我还有 Plan B 和 Plan C。</p>
<p>和其它同事给的建议一样,其中一个想法是购买一个硬件的混合器,把两台显示器的音频输出硬件连接到同一个扬声器的输入,甚至一个同事还给我画了电路图说你只要这样就能自己做一个了。不过这个方案既有优点也优缺点,优点是电脑和游戏机可以同时发声,缺点是我要在电脑上修改音量就得始终记得把两个音量都改成一样的。我对前者需求不大,所以打算最后再尝试这个。</p>
<p>当然有硬件的解法就有软件的解法,PipeWire 和 JACK 一样可以进行基于图的连接,那我只要搞一个虚拟的输出设备然后把两个 HDMI 设备跟它连一起不就行了?Arch Wiki 上恰好有一段 <a href="https://wiki.archlinux.org/title/WirePlumber#Simultaneous_output_to_multiple_sinks_on_the_same_sound_card" target="_blank" rel="external nofollow noreferrer noopener">同时向一块声卡上的不同端口输出音频</a> 的文档,我本来以为照做即可,但发现还是不对,并没有出现我想象中的一个新音频设备。不过后来我仔细研究,搞懂了里面各种术语,才知道是怎么回事。</p>
<p>首先我发现这一段文档其实只是描述如何创建一个“能同时显示两个 mapping 的 profile”,那到底什么是 mapping 什么是 profile?Mapping 可以理解成声卡上的某一种输入/输出组合,然后 profile 决定当前可以在哪几种组合中选择。举例来说就是假如你有一个 2 进 4 出的音频设备,那它可以是只有双声道输出,只有四声道输出,或者双声道输入四声道输出等等组合,这就是不同的 profile。为什么要同时输出不同端口需要创建一个 profile 呢,因为默认 ALSA 采用的是 auto-profile,会给每一个 mapping 创建一个 profile,而默认的一个 mapping 就是一个 HDMI 端口,因此假如你打开 pavucontrol 或者 Helvum,会发现如果不切换 profile,两个 HDMI 设备只能显示一个,也就没法给它们同时连接。当然你可能又会问为什么 GNOME Shell 里面又能显示两个 HDMI 设备?因为 <code>libgnome-volume-control</code> 是先枚举设备然后枚举端口,并不是直接枚举端口(受 profile 影响),选择端口的时候再自动切换 profile。</p>
<p>所以第一步是创建一个新的 profile sets,比如我创建的是 <code>/usr/share/alsa-card-profile/mixer/profile-sets/hdmi-multiple.conf</code>:</p>
<figure data-raw="[General]
auto-profiles = no
[Mapping hdmi-stereo]
description = Digital Stereo (HDMI)
device-strings = hdmi:%f
paths-output = hdmi-output-0
channel-map = left,right
priority = 9
direction = output
[Mapping hdmi-stereo-extra1]
description = Digital Stereo (HDMI 2)
device-strings = hdmi:%f,1
paths-output = hdmi-output-1
channel-map = left,right
priority = 7
direction = output
# If you have more HDMI devices, add them here.
# Show multiple HDMI mappings so I could connect to them all.
[Profile hdmi-multiple]
description = Multiple Digital Stereo (HDMI)
output-mappings = hdmi-stereo hdmi-stereo-extra1
" class="code-block"><pre class="code"><code>[General]
auto-profiles = no
[Mapping hdmi-stereo]
description = Digital Stereo (HDMI)
device-strings = hdmi:%f
paths-output = hdmi-output-0
channel-map = left,right
priority = 9
direction = output
[Mapping hdmi-stereo-extra1]
description = Digital Stereo (HDMI 2)
device-strings = hdmi:%f,1
paths-output = hdmi-output-1
channel-map = left,right
priority = 7
direction = output
# If you have more HDMI devices, add them here.
# Show multiple HDMI mappings so I could connect to them all.
[Profile hdmi-multiple]
description = Multiple Digital Stereo (HDMI)
output-mappings = hdmi-stereo hdmi-stereo-extra1
</code></pre></figure>
<p>上面的 mapping 是直接从 <code>default.conf</code> 里面抄的,下面那个 profile 就是包含上面的两个 mapping,然后需要写 WirePlumber 规则来给显卡上的声卡套用这个 profile。我把它写到 <code>/etc/wireplumber/main.lua.d/51-hdmi-multiple.lua</code>:</p>
<figure data-raw="rule = {
matches = {
{
-- Sometimes PCI sound card name has `.1` or other suffix, so it's better
-- to use description to match it.
{ "device.description", "matches", "TU104 HD Audio Controller" },
},
},
apply_properties = {
["api.alsa.use-acp"] = true,
-- By default, it creates profiles for each mappings, so one profile has one
-- mapping, but I want to combine 2 mappings, so I have to manually create
-- a profile to show 2 mappings.
["api.acp.auto-profile"] = false,
["api.acp.auto-port"] = false,
["device.profile-set"] = "hdmi-multiple.conf",
["device.profile"] = "hdmi-multiple",
},
}
table.insert(alsa_monitor.rules, rule)
" class="code-block"><pre class="code"><code>rule = {
matches = {
{
-- Sometimes PCI sound card name has `.1` or other suffix, so it's better
-- to use description to match it.
{ "device.description", "matches", "TU104 HD Audio Controller" },
},
},
apply_properties = {
["api.alsa.use-acp"] = true,
-- By default, it creates profiles for each mappings, so one profile has one
-- mapping, but I want to combine 2 mappings, so I have to manually create
-- a profile to show 2 mappings.
["api.acp.auto-profile"] = false,
["api.acp.auto-port"] = false,
["device.profile-set"] = "hdmi-multiple.conf",
["device.profile"] = "hdmi-multiple",
},
}
table.insert(alsa_monitor.rules, rule)
</code></pre></figure>
<p>然后执行 <code>systemctl --user restart wireplumber</code>,Helvum 里面应该就能同时看到两个显示器的 HDMI 音频设备了。</p>
<p>接下来是 Arch Wiki 里面没有提到的部分,如何同时向两个设备输出音频?最简单的就是像 JACK 一样直接把输出的程序同时连接到两个音频设备上就行了,但这样既不能持久化,也不能在桌面环境里调节音量。阅读了 PipeWire 的文档之后发现这部分可以通过虚拟设备来解决,有一个叫做 combine-stream 的模块就可以创建这样的复合设备,于是参考 <a href="https://docs.pipewire.org/page_module_combine_stream.html" target="_blank" rel="external nofollow noreferrer noopener">combine-stream 的文档</a>,我创建 <code>/etc/pipewire/pipewire.conf.d/10-hdmi-combined-sink.conf</code> 并写入如下内容:</p>
<figure data-raw="context.modules = [
{ name = libpipewire-module-combine-stream
args = {
combine.mode = sink
node.name = "combined-hdmi-stereo"
node.description = "Combined HDMI / DisplayPort"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "TU104 HD Audio Controller"
#device.icon-name = "audio-card-analog-pci"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts glob. Match all HDMI devices on TU104.
media.class = "Audio/Sink"
# Sometimes PCI sound card name has `.1` or other
# suffix, so it's better to use description to
# match it.
node.description = "~TU104 HD Audio Controller Digital Stereo *"
}
]
actions = {
create-stream = {
combine.audio.position = [ FL FR ]
audio.position = [ FL FR ]
}
}
}
]
}
}
]
" class="code-block"><pre class="code"><code>context.modules = [
{ name = libpipewire-module-combine-stream
args = {
combine.mode = sink
node.name = "combined-hdmi-stereo"
node.description = "Combined HDMI / DisplayPort"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "TU104 HD Audio Controller"
#device.icon-name = "audio-card-analog-pci"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts glob. Match all HDMI devices on TU104.
media.class = "Audio/Sink"
# Sometimes PCI sound card name has `.1` or other
# suffix, so it's better to use description to
# match it.
node.description = "~TU104 HD Audio Controller Digital Stereo *"
}
]
actions = {
create-stream = {
combine.audio.position = [ FL FR ]
audio.position = [ FL FR ]
}
}
}
]
}
}
]
</code></pre></figure>
<p>逻辑很简单,就是创建一个复合设备,输入到该设备的音频会输出给给定显卡上的所有 HDMI 输出,然后 <code>systemctl --user restart pipewire wireplumber</code> 就可以在 GNOME 里选择这个输出设备并调节音量了,不管扬声器插在哪个 HDMI/DP 显示器上,都能工作。</p>
<p>解决了这个问题之后我发现利用 PipeWire 的虚拟设备还可以解决我 USB 声卡的通道问题。我现在用的是我上高三时候买的 Scarlett 2i4,有两个输入和四个输出,而 auto-profile 就会自动把它设置成一个四声道环绕声输出和一个立体声输入,但实际上这个四个输出是双声道的耳机和双声道的扬声器,两个输入通常会分别用来输入话筒和乐器,而不是作为双声道输入。于是长久以来我只能在各种软件里手动设置单声道音频解决这个问题。而这次读文档我发现 PipeWire 早就给出了例子,虽然是针对另一款声卡(<a href="https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-speakersheadphones-virtual-sinks" target="_blank" rel="external nofollow noreferrer noopener">UMC404HD 的扬声器/耳机分离</a> 和 <a href="https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-microphoneguitar-virtual-sources" target="_blank" rel="external nofollow noreferrer noopener">UMC404HD 的话筒/乐器分离</a>),不过总而言之是大同小异,我也参照着弄了一下我的声卡。</p>
<p>首先你想要手动分离声卡的各个通道,仍然需要换掉默认的 profile,不过这次不需要手动编写了,PipeWire 给所有音频设备都提供了一个叫做 <code>pro-audio</code> 的 profile,这个会直接暴露声卡的所有通道而不做额外的假设(显然桌面环境对于这种裸配置的支持并不好),而后我们就可以为所欲为了,所以先创建 <code>/etc/wireplumber/main.lua.d/51-scarlett-2i4.lua</code> 写入规则让它默认使用 <code>pro-audio</code>:</p>
<figure data-raw="rule = {
matches = {
{
{ "device.name", "matches", "alsa_card.usb-Focusrite_Scarlett_2i4_USB-00" },
},
},
apply_properties = {
["audio.rate"] = 48000,
["audio.allowed-rates"] = "44100,48000,88200,96000",
--["api.alsa.period-size"] = 2048,
--["api.alsa.headroom"] = 1024,
["api.alsa.use-acp"] = true,
-- By default, it creates profiles for stereo input and surround 4.0 output,
-- but actually the card is 2 inputs, stereo headphones output and stereo
-- speakers output, so we disable auto profile here, and use the Pro Audio
-- profile to expose all ports, and combine them manually.
["api.acp.auto-profile"] = false,
["api.acp.auto-port"] = false,
["device.profile"] = "pro-audio",
},
},
table.insert(alsa_monitor.rules, rule)
" class="code-block"><pre class="code"><code>rule = {
matches = {
{
{ "device.name", "matches", "alsa_card.usb-Focusrite_Scarlett_2i4_USB-00" },
},
},
apply_properties = {
["audio.rate"] = 48000,
["audio.allowed-rates"] = "44100,48000,88200,96000",
--["api.alsa.period-size"] = 2048,
--["api.alsa.headroom"] = 1024,
["api.alsa.use-acp"] = true,
-- By default, it creates profiles for stereo input and surround 4.0 output,
-- but actually the card is 2 inputs, stereo headphones output and stereo
-- speakers output, so we disable auto profile here, and use the Pro Audio
-- profile to expose all ports, and combine them manually.
["api.acp.auto-profile"] = false,
["api.acp.auto-port"] = false,
["device.profile"] = "pro-audio",
},
},
table.insert(alsa_monitor.rules, rule)
</code></pre></figure>
<p>然后 <code>systemctl --user restart wireplumber</code>,再打开 Helvum 应该能看到声卡不再被瞎推测为什么 LR RR 之类的声道,而是直接显示 AUX0~3,接下来就可以创建虚拟设备分别映射不同的通道了。</p>
<p>首先对于输出,我分离出耳机/扬声器两个不同的双声道虚拟输出设备,平时我只用耳机。这里和官方文档示例里声卡不同的地方是那款声卡后两个通道是耳机,而 Scarlett 2i4 前两个通道就是耳机,这也是为什么就算默认被当成四通道环绕声也能用的原因。总之在 <code>/etc/pipewire/pipewire.conf.d/10-scarlett-2i4-sinks.conf</code> 里面写入如下的配置就可以了:</p>
<figure data-raw="context.modules = [
# See <https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-speakersheadphones-virtual-sinks>.
#
# Differs from UMC404HD, Scarlett 2i4 uses the first two outputs for headphones.
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Headphones"
capture.props = {
node.name = "Scarlett_2i4_Headphones"
media.class = "Audio/Sink"
audio.position = [ FL FR ]
}
playback.props = {
node.name = "playback.Scarlett_2i4_Headphones"
audio.position = [ AUX0 AUX1 ]
target.object = "alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0"
stream.dont-remix = true
node.passive = true
}
}
}
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Speakers"
capture.props = {
node.name = "Scarlett_2i4_Speakers"
media.class = "Audio/Sink"
audio.position = [ FL FR ]
}
playback.props = {
node.name = "playback.Scarlett_2i4_Speakers"
audio.position = [ AUX2 AUX3 ]
target.object = "alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0"
stream.dont-remix = true
node.passive = true
}
}
}
]
" class="code-block"><pre class="code"><code>context.modules = [
# See <https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-speakersheadphones-virtual-sinks>.
#
# Differs from UMC404HD, Scarlett 2i4 uses the first two outputs for headphones.
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Headphones"
capture.props = {
node.name = "Scarlett_2i4_Headphones"
media.class = "Audio/Sink"
audio.position = [ FL FR ]
}
playback.props = {
node.name = "playback.Scarlett_2i4_Headphones"
audio.position = [ AUX0 AUX1 ]
target.object = "alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0"
stream.dont-remix = true
node.passive = true
}
}
}
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Speakers"
capture.props = {
node.name = "Scarlett_2i4_Speakers"
media.class = "Audio/Sink"
audio.position = [ FL FR ]
}
playback.props = {
node.name = "playback.Scarlett_2i4_Speakers"
audio.position = [ AUX2 AUX3 ]
target.object = "alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0"
stream.dont-remix = true
node.passive = true
}
}
}
]
</code></pre></figure>
<p>执行 <code>systemctl --user restart pipewire wireplumber</code> 应该可以看到多了两个分别是 Scarlett 2i4 Headphones 和 Scarlett 2i4 Speakers 的音频输出设备。对于输入通道,我们也同理将它映射成两个单独的单声道虚拟输入设备,写到 <code>/etc/pipewire/pipewire.conf.d/10-scarlett-2i4-sources.conf</code>:</p>
<figure data-raw="context.modules = [
# See <https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-microphoneguitar-virtual-sources>.
#
# Differs from UMC404HD, Scarlett 2i4 can be two mono inputs or one stereo
# input, depends on how we wire it in software.
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Left Mono Input"
capture.props = {
node.name = "capture.Scarlett_2i4_Left_Mono_Input"
audio.position = [ AUX0 ]
stream.dont-remix = true
target.object = "alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0"
node.passive = true
}
playback.props = {
node.name = "Scarlett_2i4_Left_Mono_Input"
media.class = "Audio/Source"
audio.position = [ MONO ]
}
}
}
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Right Mono Inputt"
capture.props = {
node.name = "capture.Scarlett_2i4_Right_Mono_Input"
audio.position = [ AUX1 ]
stream.dont-remix = true
target.object = "alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0"
node.passive = true
}
playback.props = {
node.name = "Scarlett_2i4_Right_Mono_Input"
media.class = "Audio/Source"
audio.position = [ MONO ]
}
}
}
]
" class="code-block"><pre class="code"><code>context.modules = [
# See <https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-microphoneguitar-virtual-sources>.
#
# Differs from UMC404HD, Scarlett 2i4 can be two mono inputs or one stereo
# input, depends on how we wire it in software.
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Left Mono Input"
capture.props = {
node.name = "capture.Scarlett_2i4_Left_Mono_Input"
audio.position = [ AUX0 ]
stream.dont-remix = true
target.object = "alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0"
node.passive = true
}
playback.props = {
node.name = "Scarlett_2i4_Left_Mono_Input"
media.class = "Audio/Source"
audio.position = [ MONO ]
}
}
}
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Right Mono Inputt"
capture.props = {
node.name = "capture.Scarlett_2i4_Right_Mono_Input"
audio.position = [ AUX1 ]
stream.dont-remix = true
target.object = "alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0"
node.passive = true
}
playback.props = {
node.name = "Scarlett_2i4_Right_Mono_Input"
media.class = "Audio/Source"
audio.position = [ MONO ]
}
}
}
]
</code></pre></figure>
<p>按理说到这里就结束了,但以防万一真的有人想用这款声卡做四声道环绕声输出,或者立体声输入,同理可以使用之前的 combine-stream 再把这些虚拟设备复合起来,可以在 <code>/etc/pipewire/pipewire.conf.d/10-scarlett-2i4-combined.conf</code> 写入如下配置:</p>
<figure data-raw="context.modules = [
# Is there anyone who really uses Scarlett 2i4 for surround 4.0 output?
# Anyway, we could achieve this with PipeWire's combine stream.
{ name = libpipewire-module-combine-stream
args = {
combine.mode = sink
node.name = "Scarlett_2i4_Surround_4_0_Output"
node.description = "Scarlett 2i4 Surround 4.0 Output"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR RL RR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "Scarlett 2i4"
#device.icon-name = "audio-card-analog-usb"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
# To get better effect, treat headphones output as rear output.
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Speakers.
media.class = "Audio/Sink"
node.name = "Scarlett_2i4_Speakers"
}
]
actions = {
create-stream = {
audio.position = [ FL FR ]
combine.audio.position = [ FL FR ]
}
}
}
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Headphones.
media.class = "Audio/Sink"
node.name = "Scarlett_2i4_Headphones"
}
]
actions = {
create-stream = {
audio.position = [ FL FR ]
combine.audio.position = [ RL RR ]
}
}
}
]
}
}
# To make it easier, we also use PipeWire's combine stream to make a stereo
# input, so we don't need to wire manually.
{ name = libpipewire-module-combine-stream
args = {
combine.mode = source
node.name = "Scarlett_2i4_Stereo_Input"
node.description = "Scarlett 2i4 Stereo Input"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "Scarlett 2i4"
#device.icon-name = "audio-card-analog-usb"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Left Mono Input.
media.class = "Audio/Source"
node.name = "Scarlett_2i4_Left_Mono_Input"
}
]
actions = {
create-stream = {
audio.position = [ MONO ]
combine.audio.position = [ FL ]
}
}
}
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Right Mono Input.
media.class = "Audio/Source"
node.name = "Scarlett_2i4_Right_Mono_Input"
}
]
actions = {
create-stream = {
audio.position = [ MONO ]
combine.audio.position = [ FR ]
}
}
}
]
}
}
]
" class="code-block"><pre class="code"><code>context.modules = [
# Is there anyone who really uses Scarlett 2i4 for surround 4.0 output?
# Anyway, we could achieve this with PipeWire's combine stream.
{ name = libpipewire-module-combine-stream
args = {
combine.mode = sink
node.name = "Scarlett_2i4_Surround_4_0_Output"
node.description = "Scarlett 2i4 Surround 4.0 Output"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR RL RR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "Scarlett 2i4"
#device.icon-name = "audio-card-analog-usb"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
# To get better effect, treat headphones output as rear output.
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Speakers.
media.class = "Audio/Sink"
node.name = "Scarlett_2i4_Speakers"
}
]
actions = {
create-stream = {
audio.position = [ FL FR ]
combine.audio.position = [ FL FR ]
}
}
}
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Headphones.
media.class = "Audio/Sink"
node.name = "Scarlett_2i4_Headphones"
}
]
actions = {
create-stream = {
audio.position = [ FL FR ]
combine.audio.position = [ RL RR ]
}
}
}
]
}
}
# To make it easier, we also use PipeWire's combine stream to make a stereo
# input, so we don't need to wire manually.
{ name = libpipewire-module-combine-stream
args = {
combine.mode = source
node.name = "Scarlett_2i4_Stereo_Input"
node.description = "Scarlett 2i4 Stereo Input"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "Scarlett 2i4"
#device.icon-name = "audio-card-analog-usb"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Left Mono Input.
media.class = "Audio/Source"
node.name = "Scarlett_2i4_Left_Mono_Input"
}
]
actions = {
create-stream = {
audio.position = [ MONO ]
combine.audio.position = [ FL ]
}
}
}
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Right Mono Input.
media.class = "Audio/Source"
node.name = "Scarlett_2i4_Right_Mono_Input"
}
]
actions = {
create-stream = {
audio.position = [ MONO ]
combine.audio.position = [ FR ]
}
}
}
]
}
}
]
</code></pre></figure>
<p>理论上来说,再创建虚拟设备直接连到物理通道应该也是可行的,但我尝试过之后连接图乱掉了,所以我换成 combine-stream 实现了。有一个要注意的点是我在环绕声里交换了一下通道,扬声器输出被我当作前面的音源,而耳机输出被我当作后面的音源,这样应该效果会更好,不过是和 auto-profile 假设的相反。</p>
<p>于是在购买这款声卡七八年之后我终于在 Linux 下面把它按我想要的用法划分了通道,同时发现 PipeWire 对复杂音频设备的处理确实比 PulseAudio 更加灵活,而和同样基于图和连接的 JACK 相比,又能同时控制不同的声卡,对于我这种设备复杂需求却不复杂的用户而言显然更加方便。</p>
<p><img src="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/screenshot-1.png" alt="screenshot-1">
<img src="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/screenshot-2.png" alt="screenshot-2">
<img src="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/screenshot-3.png" alt="screenshot-3"></p>
<h1 id="English-Version"><a class="heading-link header-link" href="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/#English-Version"></a>English Version</h1>
<p>I might be the only one who owns a complex audio setup among my friends. TL;DR: To share the only pair of speakers between PS4, Switch and computer I connect it to my monitor instead of internal sound card of my computer, so all devices can output audio to speakers via HDMI/DP. It's fine until I bought another monitor, it's hard to find which monitor is the one with speakers, maybe it's HDMI 1 this week and become HDMI 2 next week, so I always need to test before playing audio. I'm too angry to accept this recently, so I try to fix it by hand.</p>
<p>At first I guess PipeWire just randomly sorts audio ports, so it's easy to fix it, what I need to do is finding ID for each port and disabling the HDMI port without speakers. But soon I see PipeWire just sorts ports via ALSA's device number, so if I disable a port, the port might be the other monitor on next boot. Is there no way to let ALSA do a fixed mapping for HDMI audio devices? We all know monitors report EDID to system, and I read ALSA's document, it contains how to handle sequence of different sound cards via udev, but no way to handle ports on the same sound card like HDMI ports. I even find document of GPU audio from NVIDIA, it says each port has a ELD file, which contains monitor info, but if you try to read it with <code>cat /proc/asound/cardX/eld*</code>, you'll find it only contains model, not serial number, and I have two monitors of the same model in order to save time on dual-monitor scale, so they looks the same. But if your monitors/TVs are of different models, it is easier, ALSA will read model in ELD and you can access it via <code>node.nick</code> property of a PipeWire device, you can just read it, or write some WirePlumber rules to rename properties that your desktop environment uses, so you get a fixed name. But I need more help.</p>
<p>Then I send a Email to our company's research mailing list of my setup and demand, and Takashi Iwai (maintainer of kernel's audio subsystem) tell me there is no better way, audio driver just assign number when GPU driver notifies a new port, so it's dynamic. And I also find GPU driver may not emit ports as display probing sequence, so Plan A fails, but I have Plan B and Plan C.</p>
<p>Another colleague suggests me to buy a mixer hardware so I can connect two monitors into one pair of speakers, and he even draws a circuit diagram and says you could make one by yourself like this. I also considered this, it allows PC and game consoles play audio at the same time, but I have to manually sync volume of two audio devices on my PC. I don't need to play audio at the same time but I am lazy, so I decide to try this last.</p>
<p>If there is a hardware solution, there should be a software solution. PipeWire supports graph-based connection like JACK, then I could just create a virtual output device, and wire two HDMI devices to it. There is a section called <a href="https://wiki.archlinux.org/title/WirePlumber#Simultaneous_output_to_multiple_sinks_on_the_same_sound_card" target="_blank" rel="external nofollow noreferrer noopener">Simultaneous output to multiple sinks on the same sound card</a> on Arch Wiki, I thought I just need to follow it, but I was wrong, there is no new audio device. The I read more documents to understand the term and totally understand it.</p>
<p>First I find that section is only about how to create a "profile that shows both two mappings", but what is mapping and what is profile? Mapping is like one kine of combination of input/output on a sound card, and profile controls which kind of combination you could use. For example, if you have a sound device which has 2 input channels and 4 output channels, it could be a stereo output, or surround 4.0 output, or stereo input + surround 4.0 output, those are different profiles. And why you need to manually create a profile to simultaneously output to two sinks? Because by default ALSA does auto-profile which creates a profile for each mapping, and by default one mapping is for one HDMI port, so if you launch pavucontrol or Helvum, you'll find you can only see 1 of 2 HDMI devices if you don't switch profile, so you cannot wire them both. But you may also ask why GNOME Shell shows both of 2 HDMI sinks? Because <code>libgnome-volume-control</code> iterates sound cards first, and then ports on a sound card, not directly iterate ports (which could be effected by profile), and it will switch profile when you choose ports.</p>
<p>So the first step to do is create a new profile sets, I use <code>/usr/share/alsa-card-profile/mixer/profile-sets/hdmi-multiple.conf</code>:</p>
<figure data-raw="[General]
auto-profiles = no
[Mapping hdmi-stereo]
description = Digital Stereo (HDMI)
device-strings = hdmi:%f
paths-output = hdmi-output-0
channel-map = left,right
priority = 9
direction = output
[Mapping hdmi-stereo-extra1]
description = Digital Stereo (HDMI 2)
device-strings = hdmi:%f,1
paths-output = hdmi-output-1
channel-map = left,right
priority = 7
direction = output
# If you have more HDMI devices, add them here.
# Show multiple HDMI mappings so I could connect to them all.
[Profile hdmi-multiple]
description = Multiple Digital Stereo (HDMI)
output-mappings = hdmi-stereo hdmi-stereo-extra1
" class="code-block"><pre class="code"><code>[General]
auto-profiles = no
[Mapping hdmi-stereo]
description = Digital Stereo (HDMI)
device-strings = hdmi:%f
paths-output = hdmi-output-0
channel-map = left,right
priority = 9
direction = output
[Mapping hdmi-stereo-extra1]
description = Digital Stereo (HDMI 2)
device-strings = hdmi:%f,1
paths-output = hdmi-output-1
channel-map = left,right
priority = 7
direction = output
# If you have more HDMI devices, add them here.
# Show multiple HDMI mappings so I could connect to them all.
[Profile hdmi-multiple]
description = Multiple Digital Stereo (HDMI)
output-mappings = hdmi-stereo hdmi-stereo-extra1
</code></pre></figure>
<p>I just copy mapping from <code>default.conf</code>, and the profile just contains those two mappings, and then write a WirePlumber rule to use this profile for GPU sound card. I write the rule into <code>/etc/wireplumber/main.lua.d/51-hdmi-multiple.lua</code>:</p>
<figure data-raw="rule = {
matches = {
{
-- Sometimes PCI sound card name has `.1` or other suffix, so it's better
-- to use description to match it.
{ "device.description", "matches", "TU104 HD Audio Controller" },
},
},
apply_properties = {
["api.alsa.use-acp"] = true,
-- By default, it creates profiles for each mappings, so one profile has one
-- mapping, but I want to combine 2 mappings, so I have to manually create
-- a profile to show 2 mappings.
["api.acp.auto-profile"] = false,
["api.acp.auto-port"] = false,
["device.profile-set"] = "hdmi-multiple.conf",
["device.profile"] = "hdmi-multiple",
},
}
table.insert(alsa_monitor.rules, rule)
" class="code-block"><pre class="code"><code>rule = {
matches = {
{
-- Sometimes PCI sound card name has `.1` or other suffix, so it's better
-- to use description to match it.
{ "device.description", "matches", "TU104 HD Audio Controller" },
},
},
apply_properties = {
["api.alsa.use-acp"] = true,
-- By default, it creates profiles for each mappings, so one profile has one
-- mapping, but I want to combine 2 mappings, so I have to manually create
-- a profile to show 2 mappings.
["api.acp.auto-profile"] = false,
["api.acp.auto-port"] = false,
["device.profile-set"] = "hdmi-multiple.conf",
["device.profile"] = "hdmi-multiple",
},
}
table.insert(alsa_monitor.rules, rule)
</code></pre></figure>
<p>And then run <code>systemctl --user restart wireplumber</code>, you should see both 2 HDMI sinks in Helvum now.</p>
<p>Then let's do steps which Arch Wiki does not contain, how to output audio to 2 sinks? The easiest way is manually wire output program to both 2 sinks, but that's not persistent, and you cannot control volume in desktop environment. After reading PipeWire's document, I find I could solve this via virtual devices, there is a module called combine-stream which could create such a combination device, so I just follow <a href="https://docs.pipewire.org/page_module_combine_stream.html" target="_blank" rel="external nofollow noreferrer noopener">combine-stream's document</a>, write following content into <code>/etc/pipewire/pipewire.conf.d/10-hdmi-combined-sink.conf</code>:</p>
<figure data-raw="context.modules = [
{ name = libpipewire-module-combine-stream
args = {
combine.mode = sink
node.name = "combined-hdmi-stereo"
node.description = "Combined HDMI / DisplayPort"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "TU104 HD Audio Controller"
#device.icon-name = "audio-card-analog-pci"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts glob. Match all HDMI devices on TU104.
media.class = "Audio/Sink"
# Sometimes PCI sound card name has `.1` or other
# suffix, so it's better to use description to
# match it.
node.description = "~TU104 HD Audio Controller Digital Stereo *"
}
]
actions = {
create-stream = {
combine.audio.position = [ FL FR ]
audio.position = [ FL FR ]
}
}
}
]
}
}
]
" class="code-block"><pre class="code"><code>context.modules = [
{ name = libpipewire-module-combine-stream
args = {
combine.mode = sink
node.name = "combined-hdmi-stereo"
node.description = "Combined HDMI / DisplayPort"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "TU104 HD Audio Controller"
#device.icon-name = "audio-card-analog-pci"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts glob. Match all HDMI devices on TU104.
media.class = "Audio/Sink"
# Sometimes PCI sound card name has `.1` or other
# suffix, so it's better to use description to
# match it.
node.description = "~TU104 HD Audio Controller Digital Stereo *"
}
]
actions = {
create-stream = {
combine.audio.position = [ FL FR ]
audio.position = [ FL FR ]
}
}
}
]
}
}
]
</code></pre></figure>
<p>It's fairly easy to understand, just create a combination device, all audio streams point to this device will be send to all HDMI sinks on a GPU sound card, and run <code>systemctl --user restart pipewire wireplumber</code> you should be able to choose it as output sink and control its volume. No matter speakers are connected to which monitor, it should work.</p>
<p>Then I find I could solve the channel problem of my USB sound card. I still uses Scarlett 2i4 bought when I was in high school, it has 2 input channels and 4 output channels, and auto-profile will set it to a surround 4.0 output and stereo input, but those 4 output channels is made of a stereo headphone output and a stereo speaker output, the 2 input channels typically are used as mono microphone and mono instructment. I used to set mono input in different software to fix my microphone. But now I find there is a example of another sound card in PipeWire's document (<a href="https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-speakersheadphones-virtual-sinks" target="_blank" rel="external nofollow noreferrer noopener">Split speakers/headphones of UMC404HD</a> 和 <a href="https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-microphoneguitar-virtual-sources" target="_blank" rel="external nofollow noreferrer noopener">Split speakers/headphones of UMC404HD</a>), but mostly they are the same, so I also tweak my sound card.</p>
<p>The same thing is to replace default profile in order to split each channels, but this time manually creating profile is not needed, PipeWire provides a <code>pro-audio</code> profile for all audio devices, it will expose all channels without assuming their usage (obviously, your desktop environment supports this badly), and then we could do what we need, so just create a rule to use <code>pro-audio</code> by default in <code>/etc/wireplumber/main.lua.d/51-scarlett-2i4.lua</code>:</p>
<figure data-raw="rule = {
matches = {
{
{ "device.name", "matches", "alsa_card.usb-Focusrite_Scarlett_2i4_USB-00" },
},
},
apply_properties = {
["audio.rate"] = 48000,
["audio.allowed-rates"] = "44100,48000,88200,96000",
--["api.alsa.period-size"] = 2048,
--["api.alsa.headroom"] = 1024,
["api.alsa.use-acp"] = true,
-- By default, it creates profiles for stereo input and surround 4.0 output,
-- but actually the card is 2 inputs, stereo headphones output and stereo
-- speakers output, so we disable auto profile here, and use the Pro Audio
-- profile to expose all ports, and combine them manually.
["api.acp.auto-profile"] = false,
["api.acp.auto-port"] = false,
["device.profile"] = "pro-audio",
},
},
table.insert(alsa_monitor.rules, rule)
" class="code-block"><pre class="code"><code>rule = {
matches = {
{
{ "device.name", "matches", "alsa_card.usb-Focusrite_Scarlett_2i4_USB-00" },
},
},
apply_properties = {
["audio.rate"] = 48000,
["audio.allowed-rates"] = "44100,48000,88200,96000",
--["api.alsa.period-size"] = 2048,
--["api.alsa.headroom"] = 1024,
["api.alsa.use-acp"] = true,
-- By default, it creates profiles for stereo input and surround 4.0 output,
-- but actually the card is 2 inputs, stereo headphones output and stereo
-- speakers output, so we disable auto profile here, and use the Pro Audio
-- profile to expose all ports, and combine them manually.
["api.acp.auto-profile"] = false,
["api.acp.auto-port"] = false,
["device.profile"] = "pro-audio",
},
},
table.insert(alsa_monitor.rules, rule)
</code></pre></figure>
<p>Then run <code>systemctl --user restart wireplumber</code>, and launch Helvum, the sound card now should has AUX0~3 instead of LR RR, and then create virtual devices that map to different channels.</p>
<p>For output channels, I create two devices for headphones and speakers, I typically only uses headphones. Which differs from the example is Scarlett 2i4 uses AUX0/1 for headphones instead of AUX2/3, and that's the reason why is works in surround 4.0 output profile. Anyway, just write those configuration into <code>/etc/pipewire/pipewire.conf.d/10-scarlett-2i4-sinks.conf</code>:</p>
<figure data-raw="context.modules = [
# See <https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-speakersheadphones-virtual-sinks>.
#
# Differs from UMC404HD, Scarlett 2i4 uses the first two outputs for headphones.
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Headphones"
capture.props = {
node.name = "Scarlett_2i4_Headphones"
media.class = "Audio/Sink"
audio.position = [ FL FR ]
}
playback.props = {
node.name = "playback.Scarlett_2i4_Headphones"
audio.position = [ AUX0 AUX1 ]
target.object = "alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0"
stream.dont-remix = true
node.passive = true
}
}
}
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Speakers"
capture.props = {
node.name = "Scarlett_2i4_Speakers"
media.class = "Audio/Sink"
audio.position = [ FL FR ]
}
playback.props = {
node.name = "playback.Scarlett_2i4_Speakers"
audio.position = [ AUX2 AUX3 ]
target.object = "alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0"
stream.dont-remix = true
node.passive = true
}
}
}
]
" class="code-block"><pre class="code"><code>context.modules = [
# See <https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-speakersheadphones-virtual-sinks>.
#
# Differs from UMC404HD, Scarlett 2i4 uses the first two outputs for headphones.
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Headphones"
capture.props = {
node.name = "Scarlett_2i4_Headphones"
media.class = "Audio/Sink"
audio.position = [ FL FR ]
}
playback.props = {
node.name = "playback.Scarlett_2i4_Headphones"
audio.position = [ AUX0 AUX1 ]
target.object = "alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0"
stream.dont-remix = true
node.passive = true
}
}
}
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Speakers"
capture.props = {
node.name = "Scarlett_2i4_Speakers"
media.class = "Audio/Sink"
audio.position = [ FL FR ]
}
playback.props = {
node.name = "playback.Scarlett_2i4_Speakers"
audio.position = [ AUX2 AUX3 ]
target.object = "alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0"
stream.dont-remix = true
node.passive = true
}
}
}
]
</code></pre></figure>
<p>Then run <code>systemctl --user restart pipewire wireplumber</code> there should be 2 sinks called Scarlett 2i4 Headphones and Scarlett 2i4 Speakers. For input channels, I also map them into 2 mono virtual input devices, write configuration into <code>/etc/pipewire/pipewire.conf.d/10-scarlett-2i4-sources.conf</code>:</p>
<figure data-raw="context.modules = [
# See <https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-microphoneguitar-virtual-sources>.
#
# Differs from UMC404HD, Scarlett 2i4 can be two mono inputs or one stereo
# input, depends on how we wire it in software.
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Left Mono Input"
capture.props = {
node.name = "capture.Scarlett_2i4_Left_Mono_Input"
audio.position = [ AUX0 ]
stream.dont-remix = true
target.object = "alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0"
node.passive = true
}
playback.props = {
node.name = "Scarlett_2i4_Left_Mono_Input"
media.class = "Audio/Source"
audio.position = [ MONO ]
}
}
}
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Right Mono Inputt"
capture.props = {
node.name = "capture.Scarlett_2i4_Right_Mono_Input"
audio.position = [ AUX1 ]
stream.dont-remix = true
target.object = "alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0"
node.passive = true
}
playback.props = {
node.name = "Scarlett_2i4_Right_Mono_Input"
media.class = "Audio/Source"
audio.position = [ MONO ]
}
}
}
]
" class="code-block"><pre class="code"><code>context.modules = [
# See <https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-microphoneguitar-virtual-sources>.
#
# Differs from UMC404HD, Scarlett 2i4 can be two mono inputs or one stereo
# input, depends on how we wire it in software.
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Left Mono Input"
capture.props = {
node.name = "capture.Scarlett_2i4_Left_Mono_Input"
audio.position = [ AUX0 ]
stream.dont-remix = true
target.object = "alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0"
node.passive = true
}
playback.props = {
node.name = "Scarlett_2i4_Left_Mono_Input"
media.class = "Audio/Source"
audio.position = [ MONO ]
}
}
}
{ name = libpipewire-module-loopback
args = {
node.description = "Scarlett 2i4 Right Mono Inputt"
capture.props = {
node.name = "capture.Scarlett_2i4_Right_Mono_Input"
audio.position = [ AUX1 ]
stream.dont-remix = true
target.object = "alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0"
node.passive = true
}
playback.props = {
node.name = "Scarlett_2i4_Right_Mono_Input"
media.class = "Audio/Source"
audio.position = [ MONO ]
}
}
}
]
</code></pre></figure>
<p>Every thing should be done here, but just in case someone really uses this sound card for surround 4.0 output or stereo input, combine-stream also could be used to combine those virtual devices, it could be done via writing those contents into <code>/etc/pipewire/pipewire.conf.d/10-scarlett-2i4-combined.conf</code>:</p>
<figure data-raw="context.modules = [
# Is there anyone who really uses Scarlett 2i4 for surround 4.0 output?
# Anyway, we could achieve this with PipeWire's combine stream.
{ name = libpipewire-module-combine-stream
args = {
combine.mode = sink
node.name = "Scarlett_2i4_Surround_4_0_Output"
node.description = "Scarlett 2i4 Surround 4.0 Output"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR RL RR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "Scarlett 2i4"
#device.icon-name = "audio-card-analog-usb"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
# To get better effect, treat headphones output as rear output.
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Speakers.
media.class = "Audio/Sink"
node.name = "Scarlett_2i4_Speakers"
}
]
actions = {
create-stream = {
audio.position = [ FL FR ]
combine.audio.position = [ FL FR ]
}
}
}
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Headphones.
media.class = "Audio/Sink"
node.name = "Scarlett_2i4_Headphones"
}
]
actions = {
create-stream = {
audio.position = [ FL FR ]
combine.audio.position = [ RL RR ]
}
}
}
]
}
}
# To make it easier, we also use PipeWire's combine stream to make a stereo
# input, so we don't need to wire manually.
{ name = libpipewire-module-combine-stream
args = {
combine.mode = source
node.name = "Scarlett_2i4_Stereo_Input"
node.description = "Scarlett 2i4 Stereo Input"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "Scarlett 2i4"
#device.icon-name = "audio-card-analog-usb"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Left Mono Input.
media.class = "Audio/Source"
node.name = "Scarlett_2i4_Left_Mono_Input"
}
]
actions = {
create-stream = {
audio.position = [ MONO ]
combine.audio.position = [ FL ]
}
}
}
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Right Mono Input.
media.class = "Audio/Source"
node.name = "Scarlett_2i4_Right_Mono_Input"
}
]
actions = {
create-stream = {
audio.position = [ MONO ]
combine.audio.position = [ FR ]
}
}
}
]
}
}
]
" class="code-block"><pre class="code"><code>context.modules = [
# Is there anyone who really uses Scarlett 2i4 for surround 4.0 output?
# Anyway, we could achieve this with PipeWire's combine stream.
{ name = libpipewire-module-combine-stream
args = {
combine.mode = sink
node.name = "Scarlett_2i4_Surround_4_0_Output"
node.description = "Scarlett 2i4 Surround 4.0 Output"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR RL RR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "Scarlett 2i4"
#device.icon-name = "audio-card-analog-usb"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
# To get better effect, treat headphones output as rear output.
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Speakers.
media.class = "Audio/Sink"
node.name = "Scarlett_2i4_Speakers"
}
]
actions = {
create-stream = {
audio.position = [ FL FR ]
combine.audio.position = [ FL FR ]
}
}
}
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Headphones.
media.class = "Audio/Sink"
node.name = "Scarlett_2i4_Headphones"
}
]
actions = {
create-stream = {
audio.position = [ FL FR ]
combine.audio.position = [ RL RR ]
}
}
}
]
}
}
# To make it easier, we also use PipeWire's combine stream to make a stereo
# input, so we don't need to wire manually.
{ name = libpipewire-module-combine-stream
args = {
combine.mode = source
node.name = "Scarlett_2i4_Stereo_Input"
node.description = "Scarlett 2i4 Stereo Input"
combine.latency-compensate = false
combine.props = {
audio.position = [ FL FR ]
# Those could be added here, but libgnome-volume-control only
# read those for ports from cards, not for virtual / network
# devices.
#device.description = "Scarlett 2i4"
#device.icon-name = "audio-card-analog-usb"
}
stream.props = {
# Link matching channels without remixing.
stream.dont-remix = true
}
stream.rules = [
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Left Mono Input.
media.class = "Audio/Source"
node.name = "Scarlett_2i4_Left_Mono_Input"
}
]
actions = {
create-stream = {
audio.position = [ MONO ]
combine.audio.position = [ FL ]
}
}
}
{
matches = [
# Any of the items in matches needs to match, if one
# does, actions are emited.
{
# All keys must match the value. `~` in value
# starts regex. Match Scarlett 2i4 Right Mono Input.
media.class = "Audio/Source"
node.name = "Scarlett_2i4_Right_Mono_Input"
}
]
actions = {
create-stream = {
audio.position = [ MONO ]
combine.audio.position = [ FR ]
}
}
}
]
}
}
]
</code></pre></figure>
<p>Theoretically creating new virtual devices wired to physical channels should also work, but my graph messed up after I tried it. Note that I swapped channels in surround 4.0 output, I use speakers sink for front and headphones sink for rear, which might leads into a better result, but it's opposite to what auto-profile generates.</p>
<p>So after owning this sound card for 7~8 years I finally tweaked it's channels as my will, and I find PipeWire is more flexible than PulseAudio on handling complex sound devices, and when compared with JACK which also uses graph and wire, PipeWire can control different sound cards, which is more convenient for users like me who have complex setup but simple demand.</p>
<p><img src="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/screenshot-1.png" alt="screenshot-1">
<img src="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/screenshot-2.png" alt="screenshot-2">
<img src="/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/screenshot-3.png" alt="screenshot-3"></p>
都不能算是 GNOME 的 Bug
https://sh.alynx.one/posts/Not-A-GNOME-Bug-at-All/
Alynx Zhou
alynx.zhou@gmail.com
2023-05-08T10:52:40.000Z
2023-05-08T10:52:40.000Z
Arch Linux 的官方仓库里终于有 GNOME 44 了,今天更新了一下系统,在思考出怎么解决 DaVinci Resolve 一定要去加载 onetbb 里面 intel 的 OpenCL 实现之前,我遇到了一个更奇怪的问题:所有的 XWayland 程序都显示不出来窗口…
<p>Arch Linux 的官方仓库里终于有 GNOME 44 了,今天更新了一下系统,在思考出怎么解决 DaVinci Resolve 一定要去加载 onetbb 里面 intel 的 OpenCL 实现之前,我遇到了一个更奇怪的问题:所有的 XWayland 程序都显示不出来窗口,程序启动了,没有报错,但是点不到。</p>
<p>忘了我当时在查什么反正看了一下 <code>journalctl -f</code> 发现一直刷一个 <code>mutter-x11-frames</code> core dump 的 log,我想起来 mutter 44 应该是把 X11 程序的 decoration 挪到单独的 client 里面实现了,所以也许是 mutter 的问题,不过我还是尝试用 gdb 看了一下 backtrace:</p>
<figure data-raw="Thread 1 "mutter-x11-fram" received signal SIGSEGV, Segmentation fault.
___pthread_mutex_lock (mutex=0x123) at pthread_mutex_lock.c:80
Downloading source file /usr/src/debug/glibc/glibc/nptl/pthread_mutex_lock.c
80 unsigned int type = PTHREAD_MUTEX_TYPE_ELISION (mutex);
(gdb) bt
#0 ___pthread_mutex_lock (mutex=0x123) at pthread_mutex_lock.c:80
#1 0x00007ffff685aaf6 in wl_proxy_create_wrapper (proxy=proxy@entry=0x55555558e510) at ../wayland-1.22.0/src/wayland-client.c:2446
#2 0x00007ffff2ad337c in getServerProtocolsInfo (protocols=0x7fffffffdc70, nativeDpy=0x55555558e510) at ../egl-wayland/src/wayland-egldisplay.c:464
#3 wlEglGetPlatformDisplayExport (data=0x5555555ae000, platform=<optimized out>, nativeDpy=0x55555558e510, attribs=<optimized out>) at ../egl-wayland/src/wayland-egldisplay.c:580
#4 0x00007ffff26acfa0 in () at /usr/lib/libEGL_nvidia.so.0
#5 0x00007ffff264c71c in () at /usr/lib/libEGL_nvidia.so.0
#6 0x00007ffff2ba4885 in GetPlatformDisplayCommon (platform=12760, native_display=0x55555558e510, attrib_list=0x0, funcName=0x7ffff2baad18 "eglGetDisplay") at ../libglvnd-v1.6.0/src/EGL/libegl.c:324
#7 0x00007ffff7a19357 in gdk_display_create_egl_display (native_display=0x55555558e510, platform=12757) at ../gtk/gdk/gdkdisplay.c:1484
#8 gdk_display_init_egl (self=0x5555555a1820, platform=12757, native_display=0x55555558e510, allow_any=0, error=0x5555555a17f8) at ../gtk/gdk/gdkdisplay.c:1667
#9 0x00007ffff79edb53 in gdk_x11_display_init_gl_backend (error=0x5555555a17f8, out_depth=0x5555555a18c4, out_visual=0x5555555a18c8, self=0x5555555a1820) at ../gtk/gdk/x11/gdkdisplay-x11.c:2975
#10 gdk_x11_display_init_gl (display=0x5555555a1820, error=0x5555555a17f8) at ../gtk/gdk/x11/gdkdisplay-x11.c:3013
#11 0x00007ffff7a198f0 in gdk_display_init_gl (self=0x5555555a1820) at ../gtk/gdk/gdkdisplay.c:1248
#12 gdk_display_prepare_gl (self=0x5555555a1820, error=0x0) at ../gtk/gdk/gdkdisplay.c:1320
#13 0x00007ffff79ec355 in gdk_x11_display_open (display_name=<optimized out>) at ../gtk/gdk/x11/gdkdisplay-x11.c:1479
#14 0x00007ffff7a15c62 in gdk_display_manager_open_display (manager=<optimized out>, name=0x0) at ../gtk/gdk/gdkdisplaymanager.c:431
#15 0x00007ffff777dda9 in gdk_display_open_default () at ../gtk/gdk/gdk.c:331
#16 gtk_init_check () at ../gtk/gtk/gtkmain.c:621
#17 gtk_init_check () at ../gtk/gtk/gtkmain.c:603
#18 0x00007ffff777dfee in gtk_init () at ../gtk/gtk/gtkmain.c:659
#19 0x0000555555557070 in main (argc=<optimized out>, argv=<optimized out>) at ../mutter/src/frames/main.c:56
" class="code-block"><pre class="code"><code>Thread 1 "mutter-x11-fram" received signal SIGSEGV, Segmentation fault.
___pthread_mutex_lock (mutex=0x123) at pthread_mutex_lock.c:80
Downloading source file /usr/src/debug/glibc/glibc/nptl/pthread_mutex_lock.c
80 unsigned int type = PTHREAD_MUTEX_TYPE_ELISION (mutex);
(gdb) bt
#0 ___pthread_mutex_lock (mutex=0x123) at pthread_mutex_lock.c:80
#1 0x00007ffff685aaf6 in wl_proxy_create_wrapper (proxy=proxy@entry=0x55555558e510) at ../wayland-1.22.0/src/wayland-client.c:2446
#2 0x00007ffff2ad337c in getServerProtocolsInfo (protocols=0x7fffffffdc70, nativeDpy=0x55555558e510) at ../egl-wayland/src/wayland-egldisplay.c:464
#3 wlEglGetPlatformDisplayExport (data=0x5555555ae000, platform=<optimized out>, nativeDpy=0x55555558e510, attribs=<optimized out>) at ../egl-wayland/src/wayland-egldisplay.c:580
#4 0x00007ffff26acfa0 in () at /usr/lib/libEGL_nvidia.so.0
#5 0x00007ffff264c71c in () at /usr/lib/libEGL_nvidia.so.0
#6 0x00007ffff2ba4885 in GetPlatformDisplayCommon (platform=12760, native_display=0x55555558e510, attrib_list=0x0, funcName=0x7ffff2baad18 "eglGetDisplay") at ../libglvnd-v1.6.0/src/EGL/libegl.c:324
#7 0x00007ffff7a19357 in gdk_display_create_egl_display (native_display=0x55555558e510, platform=12757) at ../gtk/gdk/gdkdisplay.c:1484
#8 gdk_display_init_egl (self=0x5555555a1820, platform=12757, native_display=0x55555558e510, allow_any=0, error=0x5555555a17f8) at ../gtk/gdk/gdkdisplay.c:1667
#9 0x00007ffff79edb53 in gdk_x11_display_init_gl_backend (error=0x5555555a17f8, out_depth=0x5555555a18c4, out_visual=0x5555555a18c8, self=0x5555555a1820) at ../gtk/gdk/x11/gdkdisplay-x11.c:2975
#10 gdk_x11_display_init_gl (display=0x5555555a1820, error=0x5555555a17f8) at ../gtk/gdk/x11/gdkdisplay-x11.c:3013
#11 0x00007ffff7a198f0 in gdk_display_init_gl (self=0x5555555a1820) at ../gtk/gdk/gdkdisplay.c:1248
#12 gdk_display_prepare_gl (self=0x5555555a1820, error=0x0) at ../gtk/gdk/gdkdisplay.c:1320
#13 0x00007ffff79ec355 in gdk_x11_display_open (display_name=<optimized out>) at ../gtk/gdk/x11/gdkdisplay-x11.c:1479
#14 0x00007ffff7a15c62 in gdk_display_manager_open_display (manager=<optimized out>, name=0x0) at ../gtk/gdk/gdkdisplaymanager.c:431
#15 0x00007ffff777dda9 in gdk_display_open_default () at ../gtk/gdk/gdk.c:331
#16 gtk_init_check () at ../gtk/gtk/gtkmain.c:621
#17 gtk_init_check () at ../gtk/gtk/gtkmain.c:603
#18 0x00007ffff777dfee in gtk_init () at ../gtk/gtk/gtkmain.c:659
#19 0x0000555555557070 in main (argc=<optimized out>, argv=<optimized out>) at ../mutter/src/frames/main.c:56
</code></pre></figure>
<p>然后我就在想看起来是 GTK4 的问题,我还去群里问了一下有没有 GNOME + NVIDIA 的用户,看看是我的问题还是 bug,不过没人理我,还差点把我恶心到了。然后我想了一下试了 <code>GDK_BACKEND=x11 nautilus</code> 发现也有一样的问题,就跑到 GTK 那边提了个 issue,结果那位有点出名的毒舌老哥跟我说看着不像是 GTK 的问题倒像是 nvidia 的问题,我也怀疑过,但我检查了一下和 nvidia 相关的都没什么变化,然后我去翻 glvnd 和 egl-wayland 的仓库也没翻出什么。换到 KDE 下面还是一样有问题。但我突然想到会不会和我设置的一些环境变量有关系,于是就去注销了一大片,结果就好了。最后我看了一下好像有 platform,发现是我设置过一个 <code>EGL_PLATFORM=wayland</code> 的环境变量,删掉这个就好了。</p>
<p>我想了一下这应该是我当初弄 Firefox 的硬件解码视频时候设置的,果不其然在 <a href="https://github.com/elFarto/nvidia-vaapi-driver#firefox" target="_blank" rel="external nofollow noreferrer noopener">https://github.com/elFarto/nvidia-vaapi-driver#firefox</a> 里面写了,看起来是因为这个变量导致 XWayland 程序加载 EGL 的时候把 platform 当成了 Wayland,不过我没想清楚为什么滚系统之前没有遇到这个问题。</p>
<p>总之这是个不能算 bug 的问题了,如果我在群里问的时候有人回我,我就能直接排除法发现是我自己配置的问题,结果提了 issue 以后发现不是上游的问题感觉很尴尬。想了一下还是决定把这个记在这里,因为我推测有很多人看了 <code>nvidia-vaapi-driver</code> 的文档,说不定也设置了这个变量然后遇到了同样的问题,记录下来方便搜到。</p>
Emacs 和 Lazy Loading 和 use-package
https://sh.alynx.one/posts/Emacs-Lazy-Loading-use-package/
Alynx Zhou
alynx.zhou@gmail.com
2023-01-20T09:55:00.000Z
2023-01-20T09:55:00.000Z
事先叠 buff:我不是说 use-package 一定要这么用,我也不是说所用不用 use-package 的人都不好,我只是说我觉得应该这样用 use-package 比较合适。 use-package 是个好东西,因为它解决了 Emacs 插件包从安装到配置的全过程,可以让…
<p>事先叠 buff:我不是说 <code>use-package</code> 一定要这么用,我也不是说所用不用 <code>use-package</code> 的人都不好,我只是说我觉得应该这样用 <code>use-package</code> 比较合适。</p>
<p><code>use-package</code> 是个好东西,因为它解决了 Emacs 插件包从安装到配置的全过程,可以让配置更结构化。不过也有人觉得 <code>use-package</code> 关键字过于复杂,总是没办法确定什么配置写到什么字段里面,也不知道展开之后悄悄发生了什么事情。从我再次决定自己打造一份 Emacs 配置以来看了很多不同人的配置,发现他们使用 <code>use-package</code> 的方式也是五花八门,有些人不爱用 <code>:bind</code> 和 <code>:hook</code>,干脆自己在 <code>:config</code> 里面调用 <code>define-key</code> 和 <code>add-hook</code>,有些人不清楚为什么自己的 <code>:config</code> 被延迟运行了,干脆全都用 <code>:init</code>。还有些人直接换成了其它号称更简单可控的替代品。我不是说上面这些方法都错了,实际上只要能得到想要的结果也无所谓怎么写,但我是一个比较注重逻辑的人,所以研究了一下到底这些关键字是怎么回事,并且试图写篇文章记录我推荐的写法。本来我打算写在注释里的,可是感觉写得太多,所以就放到博客里了。</p>
<p>问题的核心无非是:为什么我写的 <code>:config</code> 没有运行?到底什么情况下会有延迟加载?我打算在这里详细分析一下。</p>
<p>首先 Emacs 有一种叫做 autoloads 的东西,插件包的作者可以在某些函数前加上 autoloads 标记,然后创建 autoloads 文件。这个功能的作用很好理解,原本在启动时需要加载所有插件包的文件以便用户使用相应的功能,但不是所有的插件都是启动时就需要,只在启动时加载会让启动速度变得很慢。有了 autoloads 之后启动时只要加载 autoloads 文件,里面定义了如果运行某个函数,就去加载某个文件,这样等到对应的函数第一次运行的时候才被加载,从而提高启动速度。</p>
<p>而 <code>use-package</code> 做了什么呢?<code>use-package</code> 可以自动创建 autoloads,这样即使一个包本身没有 autoloads,也是可以延迟加载的。最简单的触发这个逻辑的关键字是 <code>:commands</code>,就是给后续的函数创建 autoloads 的意思。但如果你仔细阅读文档,就会发现还有几个创建 autoloads 的关键字,分别是 <code>:bind</code>、<code>:hook</code>、<code>:mode</code> 和 <code>:interpreter</code>。**如果有这几个关键字,<code>use-package</code> 不会立即加载一个包,而是依靠创建的 autoloads 实现延迟加载。**这也很好理解,这几个关键字的意思都是“在某种情况下启用”,所以自动延迟加载也很好理解。</p>
<p>然后为什么会遇到 <code>:config</code> 不会运行所以有些人统统把要调用的语句写到 <code>:init</code> 里面的问题呢?一般都是发生在类似下面的写法(既有快捷键 / 钩子,又要一启动就启用什么模式)里:</p>
<figure data-raw="(use-package marginalia
:ensure t
:bind (:map minibuffer-local-map
("M-A" . marginalia-cycle))
:config
(marginalia-mode 1))
" data-info="language-lisp" data-lang="lisp" class="code-block"><pre class="code"><code class="language-lisp">(use-package marginalia
:ensure t
:bind (:map minibuffer-local-map
("M-A" . marginalia-cycle))
:config
(marginalia-mode 1))
</code></pre></figure>
<p>我们得明确 <code>:init</code> 和 <code>:config</code> 的区别,<code>:init</code> 是“无论包有没有加载,都一定会先执行”的配置,<code>:config</code> 是“包加载之后才会被执行”的配置。然后按照上面关于延迟加载的分析,这就变成了一个“先有鸡还是先有蛋”的问题:</p>
<ol>
<li><code>use-package</code> 给这个包创建了快捷键对应的 autoloads,于是这个包不会立刻加载。</li>
<li><code>:config</code> 里面 <code>(marginalia-mode 1)</code> 要等到包加载之后才运行。</li>
<li>这个包本身有给 <code>marginalia-mode</code> 创建 autoloads,只要调用 <code>marginalia-mode</code> 就会加载这个包,但是根据 1 和 2,这句不会被调用,包也不会被加载,除非按了 1 里面的快捷键。</li>
</ol>
<p>打破这个循环的办法不止一种,比如把 <code>(marginalia-mode 1)</code> 写进 <code>:init</code>,这样无论如何都会调用它,于是 <code>use-package</code> 创建的 autoloads 被忽略了,包肯定会被加载。但我个人倾向于把这种启用模式的函数放在 <code>:config</code> 里面,而且作者也推荐不要在 <code>:init</code> 里面放过于复杂的函数,这时候打破循环的办法也很简单,只要使用 <code>:demand t</code>,告诉 <code>use-package</code> 立即加载这个包即可。</p>
<p>以上的内容其实都写在 <a href="https://github.com/jwiegley/use-package#notes-about-lazy-loading" target="_blank" rel="external nofollow noreferrer noopener">https://github.com/jwiegley/use-package#notes-about-lazy-loading</a>,只是可能很多人没有注意或者没看明白,于是我试图在这里更详细的解释一下。可能有人会问搞这么复杂真的有意义吗?我直接自己不要延迟加载直接都 <code>require</code> 不可以?不过我觉得这种激进的延迟加载方案确实让我的 Emacs 启动非常快,所以大概是有意义的吧。</p>
StackHarbor 的 2022 尾记
https://sh.alynx.one/posts/2022-Tail/
Alynx Zhou
alynx.zhou@gmail.com
2023-01-10T05:43:00.000Z
2023-01-10T05:43:00.000Z
<p>每年写年终总结我都会拖很晚,因为基本上我写博客是看心情,最近事情比较多,其实也打算再拖几天的,但是实在是不想回家之后写,所以不得不今天仓促动手。</p>
<p>然后实际上我不喜欢分类列提纲的写法,我本质上比较倾向于文学性的写法或者就是想到什么写什么,不过最近我在翻以前的年终总结的时候发现事情总是记的乱七八糟而且有些我都想不起来出现在哪篇文章里了,这当然可能也和我写完年终总结从来不看有关系,反正这次打算试一下分类的写法。</p>
<p>按照惯例还是要感慨一句时间过得真快,仅仅只是靠记忆的话,就会觉得自己什么也没做就过了一年。写年终总结的时候到处翻一下记录,才会意识到自己其实做了不少事情。</p>
<p>每年写年终总结我都会拖很晚,因为基本上我写博客是看心情,最近事情比较多,其实也打算再拖几天的,但是实在是不想回家之后写,所以不得不今天仓促动手。</p>
<p>然后实际上我不喜欢分类列提纲的写法,我本质上比较倾向于文学性的写法或者就是想到什么写什么,不过最近我在翻以前的年终总结的时候发现事情总是记的乱七八糟而且有些我都想不起来出现在哪篇文章里了,这当然可能也和我写完年终总结从来不看有关系,反正这次打算试一下分类的写法。</p>
<p>按照惯例还是要感慨一句时间过得真快,仅仅只是靠记忆的话,就会觉得自己什么也没做就过了一年。写年终总结的时候到处翻一下记录,才会意识到自己其实做了不少事情。</p>
<a id="more"></a>
<h1 id="%E7%BC%96%E7%A8%8B"><a class="heading-link header-link" href="/posts/2022-Tail/#%E7%BC%96%E7%A8%8B"></a>编程</h1>
<p>我经常会处于一种“我好菜啊怎么什么都做不了”和“我还能搞定这个其实还不错”的叠加态,实际上仔细翻一下感觉去年还是做了不少东西。比如说在 HackWeek 把 Show Me The Key 成功换成了 GTK4。然后还抽时间利用 Telegram 机器人做了个照片墙,虽然中间我把它关了很长时间,不过后来我又把它跑起来了。</p>
<p>这一年我印象最深的其实是搞我的 Emacs 配置。在有确定消息说 GitHub 打算放弃 Atom 之后,我不得不给我自己重新找一个编辑器,因为我得了一种看到 Visual Studio 就会死的病所以坚决不会用 VSCode,除非他们哪天改名部把 VS 从里面去掉。然后我一直是不喜欢模态编辑的所以也不会用 Vim,同时 Emacs 的 PGTK 分支已经被上游接受,所以我很高兴地重回 Emacs 拥抱我所知的第一个 pure Wayland 的 GUI 编辑器。说是重回,其实相当于重新学习了一遍 Emacs Lisp,毕竟我一开始尝试 Emacs 是被 Spacemacs 那句著名的口号吸引的(但是我又不用 Evil)。那时候其实我不太懂 Emacs Lisp,但是现在回头再看发现确实是更好掌握了。虽然有无数的人说应该从别人配好的 Emacs 配置开始,但我还是决定自己编写一套配置而不是使用最流行的 doom。一个是这些配置好像都以模态编辑为中心,另一个是我经常会自己定制自己的编辑器,使用这些别人配好的配置调起来总觉得很不自在。然后就是我逐渐理解了 <code>use-package</code> 的用法,解决了各种奇奇怪怪的问题,甚至还自己用 Emacs Lisp 写了很多自己需要的功能。虽然可能有人要问你搞这一通有什么意义之类的,但是我做事的一个原则就是看心情,我高兴就好,所以觉得还挺值的。</p>
<p>然后不论是工作还是个人爱好上这一年多少也做了点东西,毕竟我的工作就是我的爱好。比如很有意思的一个是我研究了一下 GNOME 的智能卡登录到底怎么搞,顺便也大致了解了一下 PAM 的配置,虽然可能这个还是没什么用,不过最后我修改了 openSUSE 的 gdm 包添加了一直缺失的指纹和智能卡的 PAM 配置,也算是帮助了其他人。我还抽出时间调查了一下 GTK3 和 GTK4 的亮色 / 暗色主题切换到底是怎么回事。然后还做了一些微小的贡献,比如我印象里一直有人吐槽说 GNOME Shell 的搜索只能从开头匹配而不能做子串匹配,还有人说难道他们只会用 <code>String.prototype.startsWith()</code> 不会用 <code>String.prototype.includes()</code>,我一开始只是想既然这么简单,有吐槽的时间为什么不自己改一个?于是我花时间看了一下还真不是这么简单,总之最后我阅读了 glib 里面的算法,并且添加了根据不同的匹配模式分组的功能,现在如果有单词开头匹配的会优先显示,然后再显示子串匹配,就可以通过搜索 <code>fox</code> 得到 <code>Firefox</code> 了(<a href="https://gitlab.gnome.org/GNOME/glib/-/merge_requests/3107" target="_blank" rel="external nofollow noreferrer noopener">https://gitlab.gnome.org/GNOME/glib/-/merge_requests/3107</a>)。</p>
<p>和这个类似的还有另一个,我看到有人说 <code>gdbus-codegen</code> 生成的代码没有加空指针检查导致程序崩溃,然后和开发者吵了起来,开发者说加空指针检查不是真的解决问题,这里不应该传空指针,那个人就丢出一堆各种代码规范说传了空指针应该继续运行不该崩溃,开发者说你给我们加的话我们愿意接受,他又说自己不擅长 python,总之我看了觉得很不可理喻,于是我自己改掉提交了然后嘲笑了那人一通。有些时候真不是开发者脾气不好,是有人态度太差……(<a href="https://gitlab.gnome.org/GNOME/glib/-/merge_requests/3175" target="_blank" rel="external nofollow noreferrer noopener">https://gitlab.gnome.org/GNOME/glib/-/merge_requests/3175</a>)</p>
<h1 id="%E9%9F%B3%E4%B9%90"><a class="heading-link header-link" href="/posts/2022-Tail/#%E9%9F%B3%E4%B9%90"></a>音乐</h1>
<p>去年一年我还是录了好几个曲子的,虽然我自己是觉得没怎么练琴而且还经常咕咕咕。不过我发出来的我自己还都觉得不错,虽然不是每个都有很多播放量吧。最近手上有几个想录的,比如 シリウスの心臓 和 暗恋是一个人的事,不过可能又要拖到年后了。</p>
<p>然后今年通过 <a href="https://space.bilibili.com/4947340" target="_blank" rel="external nofollow noreferrer noopener">澪音奏</a> 的翻唱听了好多伍佰的曲子,对于我这种几乎不会主动找歌曲来听的人,能扩充曲库还是好事。还在 B 站听了纵贯线的亡命之徒,没早点听到这个真是有点可惜。</p>
<h1 id="%E6%95%B0%E7%A0%81"><a class="heading-link header-link" href="/posts/2022-Tail/#%E6%95%B0%E7%A0%81"></a>数码</h1>
<p>其实我觉得我也没买什么东西,但是再看一下又不少,很多其实没什么可说的,比如买了个新镜头,那就是新镜头,也没什么好在博客里分析一番的。考虑到那块老移动硬盘用了很久,又买了一块三星的 T7 Shield,固态的移动硬盘还是可靠很多。犹豫了很久还是买了平板,不过不是 iPad,是 Galaxy Tab S8,除了 LCD 屏幕有点漏光,别的我都很满意,不管是看谱子还是看视频都不错,虽然有些 app 不支持横屏,但我还是觉得文件管理更重要。</p>
<p>开销比较大的是装了一台 NAS,实际也是挑来挑去才决定的,运行了小一年觉得还不错,极大的缓解了我的存储压力。</p>
<h1 id="%E6%91%84%E5%BD%B1"><a class="heading-link header-link" href="/posts/2022-Tail/#%E6%91%84%E5%BD%B1"></a>摄影</h1>
<p>自我评价的话我觉得还是有点进步,别的不说,今年我拍了很多自己觉得不错的照片。摸索了一年,我大概也知道怎么用 darktable 得到想要的效果了。之前看到有人总结了一下自己拍过的照片里觉得不错的,我觉得这个想法很好,于是挑了一些今年满意的照片放在这里:</p>
<p><img src="/posts/2022-Tail/DSC00674.jpg" alt="">
<img src="/posts/2022-Tail/DSC06074.jpg" alt="">
<img src="/posts/2022-Tail/DSC07236.jpg" alt="">
<img src="/posts/2022-Tail/R3103214_04.jpg" alt="">
<img src="/posts/2022-Tail/R3103766_04.jpg" alt="">
<img src="/posts/2022-Tail/DSC08433.jpg" alt="">
<img src="/posts/2022-Tail/DSC08556.jpg" alt="">
<img src="/posts/2022-Tail/DSC09543.jpg" alt="">
<img src="/posts/2022-Tail/DSC09817_01.jpg" alt="">
<img src="/posts/2022-Tail/R3104386.jpg" alt="">
<img src="/posts/2022-Tail/R3104387.jpg" alt="">
<img src="/posts/2022-Tail/000183680027.jpg" alt="">
<img src="/posts/2022-Tail/000183800005.jpg" alt="">
<img src="/posts/2022-Tail/000183800014.jpg" alt="">
<img src="/posts/2022-Tail/000183800015.jpg" alt="">
<img src="/posts/2022-Tail/DSC01159.jpg" alt="">
<img src="/posts/2022-Tail/DSC01265.jpg" alt="">
<img src="/posts/2022-Tail/DSC01274.jpg" alt=""></p>
<p>很想找机会把我喜欢的照片打印出来装上相框挂在家里,只是一直没去做这件事。</p>
<h1 id="%E5%8A%A8%E6%BC%AB"><a class="heading-link header-link" href="/posts/2022-Tail/#%E5%8A%A8%E6%BC%AB"></a>动漫</h1>
<p>今年应该只看了两部,一部是 DitF,具体的评价我在 <a href="/posts/Not-to-Be-EVA-but-to-Be-Pacific-Rim/">这篇文章</a> 写过了。另一部是 赛博朋克:边缘行者,我其实是不喜欢赛博朋克题材的,但是这一部做得太好了,我在这里向所有读者推荐。</p>
<h1 id="%E5%B0%8F%E8%AF%B4"><a class="heading-link header-link" href="/posts/2022-Tail/#%E5%B0%8F%E8%AF%B4"></a>小说</h1>
<p>今年应该是看了基地三部曲,还有格兰特船长的儿女和神秘岛,因为都是很好找到电子版的,所以没有买实体书,神秘岛以前看过简写版,其他的都是头一次看,总体上来说我觉得都不错,毕竟也是流传很久的书了。</p>
<h1 id="%E6%B8%B8%E6%88%8F"><a class="heading-link header-link" href="/posts/2022-Tail/#%E6%B8%B8%E6%88%8F"></a>游戏</h1>
<p>除了和开黑群的朋友打 Dota 2 就是和牛爷爷高先生吃鸡,今年更多的玩了游廊地图,感觉我还是想玩休闲一点不太需要团队配合的,吃鸡的话我以为我很久没玩了水平会很差,不过我好像逐渐掌握这个游戏怎么赢了,而且经常可以吃鸡。其实玩游戏本身倒不重要,重要的是有人一起玩。一个人玩的话,要不是因为看中单光一,我大概不会坚持玩 Dota 2。</p>
<h1 id="%E7%94%9F%E6%B4%BB"><a class="heading-link header-link" href="/posts/2022-Tail/#%E7%94%9F%E6%B4%BB"></a>生活</h1>
<p>在经历了翻大饼之后果然是不负众望的夺冠了,虽然已经好了。大饼现在是翻过来了,但是保不准哪天又要翻回去,反正我是受够了这种提心吊胆的日子。仔细想想今年我大概有好几次对着共享单车打开健康码扫码,还是挺可怕的。蓝猫今年去了日本,没办法再找她玩了,虽然我很羡慕,但是我又穷又懒。至于脱单这种事情,算了吧,我已经放弃了,一个人待着也挺好的,我一点都不羡慕别人(假的)。</p>
<p>最近几天突然又肋骨痛,只要有动作扯到就很难受,我也不知道是气胸还是肋间神经痛还是肌肉拉伤,本来打算去医院看看的,但最近事情安排满了,回家的时间取决于车票不取决于我,所以只能等回家之后再说。</p>
YubiKey 和 GNOME 和智能卡登录
https://sh.alynx.one/posts/YubiKey-GNOME-Smartcard-Login/
Alynx Zhou
alynx.zhou@gmail.com
2022-11-28T10:24:04.000Z
2022-11-30T13:08:11.000Z
最近我终于决定买了一个 YubiKey 5C,说出来不怕各位笑话,我买这玩意最初的动机只是觉得每次开机和解锁输密码太麻烦(但是为什么我不觉得 sudo 输密码麻烦呢?)。这还和我之前处理了一个 openSUSE 的 PAM 问题有关,我发现 GDM 有好几种不同的 PAM 配置,…
<p>最近我终于决定买了一个 YubiKey 5C,说出来不怕各位笑话,我买这玩意最初的动机只是觉得每次开机和解锁输密码太麻烦(但是为什么我不觉得 <code>sudo</code> 输密码麻烦呢?)。这还和我之前处理了一个 openSUSE 的 PAM 问题有关,我发现 GDM 有好几种不同的 PAM 配置,除了平时用的 <code>gdm-password</code> 密码登录,还有 <code>gdm-fingerprint</code> 指纹登录和 <code>gdm-smartcard</code> 智能卡登录。我一开始是打算买个指纹传感器的,查了一下 fprintd 的文档,支持的型号并不多,而且在淘宝上问客服 USB Product ID 和 Vendor ID 显然得不到回答,就退而求其次买智能卡了,而搜索智能卡得到最多的结果就是 YubiKey,进入一个不了解的领域之前和大部分人选一样的一般不会错太多,于是就下手了。</p>
<p>话说回来智能卡登录,如果你搜索 YubiKey 相关的文章,绝大多数都会告诉你把 <code>pam_u2f.so</code> 加到需要密码登录的 PAM 配置里,比如 <code>sudo</code> 或者 <code>gdm-password</code>,但这显然不是我想要的,我要的方案不是替换密码登录,而是和密码登录平行的配置文件,我知道 GNOME 有已经写好的智能卡配置,但是任何地方都搜不到如何启用,设置里没有相关选项,连 Arch Wiki 给的方案都只是用 <code>pam_u2f.so</code>。Red Hat 的支持文档里倒是提到了智能卡登录,然而用的却是他们自己的某个工具配置的。显然这是个起夜级 feature,最好的办法也许是找个起夜级 Linux 的桌面工程师来问问,哦什么我自己就是起夜级 Linux 桌面工程师,那没事了。还要说的一件事是怎么想智能卡这东西都和安全相关,而我自己不是专业的安全行业人士,所以我不会尝试解释清楚和安全相关的一些名词,以及如果我哪里真的写错了,希望专业人士多多指点,我肯定改。</p>
<p>总之相信你自己因为你才是职业选手,我还是自己看看这东西怎么弄吧,毕竟用 Arch 再用 GNOME 同时还打算搞 GNOME 的智能卡登录的人没几个,所以 Wiki 没有倒也正常。首先肯定是看 <code>/etc/pam.d/gdm-smartcard</code> 这个文件,里面别的看起来都比较正常,只有一行看起来和智能卡有关系:</p>
<figure data-raw="auth required pam_pkcs11.so wait_for_card card_only
" class="code-block"><pre class="code"><code>auth required pam_pkcs11.so wait_for_card card_only
</code></pre></figure>
<p>线索是有了,看来我需要这个 <code>pam_pkcs11.so</code>,虽然我也不知道这是什么,先搜一下哪个包有这个文件比较好。<code>pacman -F pam_pkcs11.so</code> 竟然没有返回任何结果,我确定不是我的 pacman 数据库没更新,那只能去浏览器里搜索了,最后我搜到了 <a href="https://github.com/OpenSC/pam_pkcs11" target="_blank" rel="external nofollow noreferrer noopener">https://github.com/OpenSC/pam_pkcs11</a>,虽然我也不知道 <code>PKCS#11</code> 是个什么玩意,但反正它是个 PAM 模块,既然不在官方仓库里,那大概率 AUR 里有人打包了,于是直接 <code>paru pam_pksc11</code> 就装了一个上来。</p>
<p>但装是装好了,也看不出来这玩意和 YubiKey 有什么联系,我大概是搜索了 PKCS YubiKey 然后搜到了 YubiKey 给的文档 <a href="https://developers.yubico.com/PIV/Guides/SSH_with_PIV_and_PKCS11.html" target="_blank" rel="external nofollow noreferrer noopener">Using PIV for SSH through PKCS #11</a>,好吧虽然我不是要用来 SSH 但是多半也有点用。看下来反正这个东西和 YubiKey 的 PIV 功能有关,我把 PIV 相关的文档都看了一遍,结果是云里雾里,相当没有头绪。一大堆文档告诉你各种各样的需求要做什么,但是几乎没怎么说这都是什么,于是恰好我的需求不在列表里我就不知道怎么办了。我又回头去看 <code>pam_pkcs11</code> 的文档,它写了一长串的东西,我反复看了几遍之后发现只要看 <a href="http://opensc.github.io/pam_pkcs11/doc/pam_pkcs11.html#HOWTO" target="_blank" rel="external nofollow noreferrer noopener">第 11 节的 HOWTO 部分</a> 就可以了。虽然我也不太清楚它都在说什么,但是至少这里告诉我说需要一个 root CA certificate,但我是个人使用哪来的这玩意,再回头看 YubiKey 的那篇文档里面恰好提到了什么 self-signed certificate,我拿这个试一试,结果成功了。为了方便参考,下面我就不讲我是怎么倒推这些奇怪的需求的了,而是顺序讲一下都需要配置什么。</p>
<p>首先如果你像我一样刚买了一个 YubiKey 打算利用它的 PIV 功能,那你得先初始化它,也就是改掉默认的 PIN,PUK 和管理密钥,这个可以通过官方的 YubiKey Manager 软件来操作,有 Qt 写的 GUI 版和命令行版本:</p>
<figure data-raw="# pacman -S yubikey-manager yubikey-manager-qt
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># pacman -S yubikey-manager yubikey-manager-qt
</code></pre></figure>
<p>我推荐使用命令行版本操作,因为那个 GUI 经常转圈转半天或者点了没反应:</p>
<figure data-raw="% ykman piv access change-pin
% ykman piv access change-puk
% ykman piv access change-management-key --generate --protect --touch
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">% ykman piv access change-pin
% ykman piv access change-puk
% ykman piv access change-management-key --generate --protect --touch
</code></pre></figure>
<p>默认 PIN 是 <code>123456</code>,默认 PUK 是 <code>12345678</code>,而管理密钥是个特别长的一串,用 <code>--generate</code> 可以让 <code>ykman</code> 给你生成一个,<code>--protect</code> 则是把这个直接存到 YubiKey 里面并用 PIN 保护,<code>--touch</code> 则是说每次要管理密钥的时候需要你摸一下。我也不是很懂,也许写进去以后需要的时候就不用自己背这玩意而是输 PIN 就行了吧,反正建议看官方文档 <a href="https://developers.yubico.com/PIV/Guides/Device_setup.html" target="_blank" rel="external nofollow noreferrer noopener">Device setup</a> 和 <code>ykman</code> 的 <code>--help</code>。</p>
<p>我的建议是不要看太多官方文档,因为它一会告诉你用 <code>yubico-piv-tool</code> 创建密钥,一会告诉你说可以用 <code>openssl</code> 创建密钥,一会又告诉你可以用 <code>pkcs11-tool</code> 搭配 <code>libykcs11.so</code> 创建密钥,算了吧,头都看晕了,我的测试是用 <code>yubico-piv-tool</code> 就可以了。</p>
<figure data-raw="% paru yubico-piv-tool
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">% paru yubico-piv-tool
</code></pre></figure>
<p>在 <code>9a</code> 这个槽创建一个 key 并把它的公钥写出来,为什么是 <code>9a</code> 好像因为这是第一个槽来着,自己去查官方文档吧,也可以写到别的槽里面:</p>
<figure data-raw="% yubico-piv-tool -s 9a -a verify-pin -a generate -o public.pem
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">% yubico-piv-tool -s 9a -a verify-pin -a generate -o public.pem
</code></pre></figure>
<p>需要先输入 PIN,然后灯闪的时候需要摸一下 YubiKey,它就开始生成了。</p>
<p>还要给这个密钥生成一个签名:</p>
<figure data-raw="% yubico-piv-tool -s 9a -a verify-pin -a selfsign-certificate -S "/CN=Alynx Zhou/" -i public.pem -o cert.pem
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">% yubico-piv-tool -s 9a -a verify-pin -a selfsign-certificate -S "/CN=Alynx Zhou/" -i public.pem -o cert.pem
</code></pre></figure>
<p>注意 <code>CN=</code> 后面的部分,这里会被 <code>pam_pkcs11.so</code> 用来验证这个智能卡属于系统里面哪个用户,所以简单的话直接写你的登录用户名,当然你像我一样不想写用户名也是有办法对应的,同样要输入 PIN。</p>
<p>再把证书导回到同一个槽,我也不知道为什么,文档说了我照做了:</p>
<figure data-raw="% yubico-piv-tool -s 9a -a verify-pin -a import-certificate -i cert.pem
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">% yubico-piv-tool -s 9a -a verify-pin -a import-certificate -i cert.pem
</code></pre></figure>
<p>还是要输入 PIN 然后灯闪的时候摸一下。</p>
<p>到这里 YubiKey 的配置就结束了。</p>
<p>要在系统上使用智能卡验证需要安装系统上和智能卡交互的软件包:</p>
<figure data-raw="# pacman -S ccid opensc pcsclite
% paru pam_pkcs11
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># pacman -S ccid opensc pcsclite
% paru pam_pkcs11
</code></pre></figure>
<p>启动一个相关的 daemon,或者启动 socket 也行,需要的时候它就自己起来了:</p>
<figure data-raw="# systemctl enable --now pcscd.socket
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># systemctl enable --now pcscd.socket
</code></pre></figure>
<p>如果我没漏掉什么乱七八糟的,就可以配置 PAM 模块了,它有一个配置目录叫 <code>/etc/pam_pksc11</code>,首先你要把上面生成的证书放到 <code>/etc/pam_pkcs11/cacerts</code>。</p>
<figure data-raw="# cd /pam/pkcs11/cacerts
# cp PATH_TO_YOUT_CERT/cert.pem ./
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># cd /pam/pkcs11/cacerts
# cp PATH_TO_YOUT_CERT/cert.pem ./
</code></pre></figure>
<p>你要在同一个目录下面运行一个什么什么 hash 命令生成一个 hash:</p>
<figure data-raw="# pkcs11_make_hash_link
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># pkcs11_make_hash_link
</code></pre></figure>
<p>接下来你要去搞它的配置文件,先复制一个样本过来:</p>
<figure data-raw="# cp /usr/share/doc/pam_pkcs11/pam_pkcs11.conf.example /etc/pam_pkcs11/pam_pkcs11.conf
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># cp /usr/share/doc/pam_pkcs11/pam_pkcs11.conf.example /etc/pam_pkcs11/pam_pkcs11.conf
</code></pre></figure>
<p>好像其实也没什么需要改的。文档说默认的配置用的是 OpenSC 的 PKCS#11 库,虽然 YubiKey 的文档一直跟你说什么 <code>libykcs11.so</code>,我的测试结果是不用理它,通用的接口就够了,以及这个 <code>libykcs11.so</code> 是属于 <code>yubico-piv-tool</code> 这个包的。</p>
<p>假如你刚才 <code>CN=</code> 后面写的不是你的用户名,那你需要一些配置告诉 <code>pam_pkcs11.so</code> 你这个证书对应的哪个用户,这一步在它的配置文件里叫 <code>mapper</code>。默认启用了一些 mapper 比如 <code>pwent</code>,这个就是把 <code>CN=</code> 后面的内容和 <code>/etc/passwd</code> 里面的用户名做匹配,但是如果你像我一样写的是全名,那就需要另一个默认启用的模块叫 <code>subject</code>。至于 subject 是什么需要运行下面这个命令:</p>
<figure data-raw="% pkcs11_inspect
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">% pkcs11_inspect
</code></pre></figure>
<p>它会输出各种 mapper 对应的 data,比如 <code>pwent</code> 输出的就是 <code>Alynx Zhou</code>,<code>subject</code> 输出的则是 <code>/CN=Alynx Zhou</code>。我们需要复制一个 <code>subject_mapping</code> 配置文件的样本过来:</p>
<figure data-raw="# cp /usr/share/doc/pam_pkcs11/subject_mapping.example /etc/pam_pkcs11/subject_mapping
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># cp /usr/share/doc/pam_pkcs11/subject_mapping.example /etc/pam_pkcs11/subject_mapping
</code></pre></figure>
<p>在这个文件后面加一行:</p>
<figure data-raw="/CN=Alynx Zhou -> alynx
" data-info="language-plain" data-lang="plain" class="code-block"><pre class="code"><code class="language-plain">/CN=Alynx Zhou -> alynx
</code></pre></figure>
<p>我的用户名是 <code>alynx</code>,你可以换成你自己的。</p>
<p>到这一步 <code>pam_pkcs11.so</code> 这个模块已经可以通过智能卡验证你的身份了,但是如果你火急火燎兴高采烈的重启了系统,GDM 还是会和你要密码。原因其实很简单,虽然现在 <code>/etc/pam.d/gdm-smartcard</code> 已经可用了,但 GDM 只有在检测到智能卡之后才会调用这个文件尝试智能卡登录,很显然它没检测到智能卡。</p>
<p>这里就比较难搞清楚了,我智能卡插的好好的,上面各种程序都能用,为什么你检测不到?我尝试用什么 GDM YubiKey 之类的关键词搜索了半天,也没人告诉我 GDM 到底怎么检测智能卡的。没有办法还是读代码吧,GNOME Shell <code>js/gdm/util.js</code> 里面的逻辑是通过 D-Bus 的 <code>org.gnome.SettingsDaemon.Smartcard</code> 获取智能卡信息,那我打开 D-Feet 从 Session Bus 里面找到这个,直接运行 <code>org.gnome.SettingsDaemon.Smartcard.Manager</code> 的 <code>GetInsertedTokens</code>,什么都没有。</p>
<p>根据 D-Bus 的信息,很显然这个接口是 <code>gnome-settings-daemon</code> 的 <code>smartcard</code> 插件提供的,我大概是搜索了什么 gsd-smartcard PKCS#11 的关键字之后找到了 <a href="https://gitlab.gnome.org/GNOME/gnome-settings-daemon/-/merge_requests/208" target="_blank" rel="external nofollow noreferrer noopener">https://gitlab.gnome.org/GNOME/gnome-settings-daemon/-/merge_requests/208</a>,其实我一开始也没太看懂这是什么意思,但得到一些有用的信息:</p>
<ul>
<li><code>gsd-smartcard</code> 用了什么 NSS API 获取智能卡设备。</li>
<li>这玩意要一个什么 system shared certificate NSS database。</li>
<li>除了 Red Hat 家那一套好像没什么别的发行版弄这个。</li>
</ul>
<p>这一路下来乱七八糟的名词已经够多的了现在又多了一个什么 NSS 而且只有 Red Hat 才配置了 system shared certificate NSS database,但不管怎么样我是职业选手我不能轻言放弃,还好 Arch Wiki 有这么一个页面 <a href="https://wiki.archlinux.org/title/Network_Security_Services" target="_blank" rel="external nofollow noreferrer noopener">Network Security Services</a>,但这不是管证书的吗,和智能卡设备有什么关系啊。这时候我又翻开了 Arch Wiki 关于智能卡的页面 <a href="https://wiki.archlinux.org/title/Smartcards#Chromium" target="_blank" rel="external nofollow noreferrer noopener">Smartcards</a>,里面讲了在 Chromium 里面加载智能卡需要在 NSS 数据库里面加一个模块(什么乱七八糟的),不过它操作的都是用户的家目录下面的数据库,这显然不是 system shared certificate NSS database。然后如果手工执行 <code>/usr/lib/gsd-smartcard -v</code>,会发现这玩意尝试读取 <code>/etc/pki/nssdb</code> 获取什么智能卡驱动列表,我系统里面根本没这个目录。算了,既然是 Red Hat 搞的东西,我看看他们怎么写的。正好我有个 Fedora 的虚拟机,打开虚拟机一看还真有这个目录,那就运行下面命令看看:</p>
<figure data-raw="% modutil -dbdir /etc/pki/nssdb -list
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">% modutil -dbdir /etc/pki/nssdb -list
</code></pre></figure>
<p>结果里面除了默认项还真有个叫 <code>p11-kit-proxy</code> 的玩意,我又回去看了一眼那个 Merge Request,现在我完全明白了,不知道为什么 NSS 这玩意会记录一个读取智能卡的驱动列表,然后 <code>gsd-smartcard</code> 是通过 NSS 获取到智能卡的驱动列表之后再尝试查询智能卡,实际上现在没什么人用 NSS 这个功能了,你这还得往系统的 NSS 数据库里面写东西,除了红帽子家都没人搞这个了,就算有用 NSS 读的(比如浏览器)也是读用户的 NSS 数据库。别的用智能卡的都直接用 <code>p11-kit</code> 去读智能卡,所以这个 Merge Request 也改成直接用 <code>p11-kit</code> 读了。不知道为什么这个 Merge Request 没能合并。再多说一句,就算是 Red Hat 的系统 NSS 数据库,现在也不直接写智能卡的驱动了,而也是通过 <code>p11-kit</code>,所以刚才在 Fedora 的数据库里只看到 <code>p11-kit-proxy</code> 这一个驱动……</p>
<p>既然这样我们也在这个数据库里写一个 <code>p11-kit-proxy</code>,根据 Arch Wiki 的智能卡页面,如果你要通过 <code>p11-kit</code> 操作 OpenSC 的驱动(这都什么乱七八糟的),那可能需要安装一个 AUR 包来保证它被加载(实际上就是个文件而已):</p>
<figure data-raw="% paru opensc-p11-kit-module
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell">% paru opensc-p11-kit-module
</code></pre></figure>
<p>创建数据库目录并往数据库里写 <code>p11-kit-proxy</code>:</p>
<figure data-raw="# mkdir /etc/pki/nssdb
# modutil -dbdir sql:/etc/pki/nssdb -add "p11-kit-proxy" -libfile p11-kit-proxy.so
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># mkdir /etc/pki/nssdb
# modutil -dbdir sql:/etc/pki/nssdb -add "p11-kit-proxy" -libfile p11-kit-proxy.so
</code></pre></figure>
<p>如果你和我一样又心急火燎的重启了,就会发现还是没用。这不科学啊,Fedora 的数据库里也是这么写的,看一眼 D-Bus 为什么还是没有智能卡。</p>
<p>实际上最后我发现只差一点点,Fedora 给这个目录下文件的权限是 <code>-rw-r--r--</code>,而我这边创建好的是 <code>-rw------</code>。<code>gsd-smartcard</code> 是以 session 用户运行的当然读不了。所以改一下权限就可以了。</p>
<figure data-raw="# chmod 0644 /etc/pki/nssdb/*
" data-info="language-shell" data-lang="shell" class="code-block"><pre class="code"><code class="language-shell"># chmod 0644 /etc/pki/nssdb/*
</code></pre></figure>
<p>接下来插着 YubiKey 重启,GDM 启动的 <code>gsd-smartcard</code> 就能读到系统的 NSS 数据库,检测到智能卡,于是调用 <code>/etc/pam.d/gdm-smartcard</code>,直接让你输入用户名,输入之后会提示你输入智能卡的 PIN,然后 <code>pam_pkcs11.so</code> 进行验证,就可以登录了。锁屏之后也只要输入智能卡的 PIN 就可以解锁。</p>
<p><del>按理说如果给 <code>pam_pkcs11.so</code> 发一个空白的用户名,它会根据智能卡返回用户名的,不知道为什么我在 GDM 用不了,一定要开机手动输入,有空我看看代码也许可以修改一下。</del> 我也不知道为什么一定要在 GDM 启动之前插入卡才可以,显示用户列表之后再插入卡我这里没反应。</p>
<p>更新(2022-11-30):花了我半天时间研究 GDM 和 PAM,问题不在 GDM,而是因为 Arch Linux 的 <code>gdm-smartcard</code> 首先调用了 <code>pam_shells</code> 检查用户是否有合法的 shell,遇到空用户名它第一个失败了,于是我提交了 <a href="https://gitlab.gnome.org/GNOME/gdm/-/merge_requests/193" target="_blank" rel="external nofollow noreferrer noopener">一个 MR</a>,把 <code>pam_shells</code> 挪到 <code>pam_pkcs11</code> 下面,这样它会检查自动返回的用户名。(虽然这些 PAM 配置文件是发行版自己写的但是大家都提交到 GNOME 那边了,我只改了 Arch 的因为我在用,别的发行版的用户先偷着乐吧。)</p>
<p>如果你想用智能卡解锁的话,一定得是用智能卡登录才可以,它会检查当前的卡是不是登录所用的那张卡,不是的话就只能密码解锁了。折腾这一套花了我一整天时间,因为资料实在是太少了,根本不知道它是怎么工作的。</p>
<p>以及最后我还发现一篇文章,里面的内容也是讲这个 NSS 数据库的解决方案的,也许我早看见这个就不会这么麻烦了: <a href="https://p11-glue.freedesktop.narkive.com/4z6daFWc/fixing-nss-and-p11-kit-in-fedora-and-beyond" target="_blank" rel="external nofollow noreferrer noopener">Fixing NSS and p11-kit in Fedora (and beyond)</a>。</p>
DaVinci Resolve 在 Linux 下的输入法支持
https://sh.alynx.one/posts/Input-Method-Support-for-DaVinci-Resolve-on-Linux/
Alynx Zhou
alynx.zhou@gmail.com
2022-11-25T09:50:13.000Z
2022-11-25T09:50:13.000Z
令人出乎意料,我竟然是 DaVinci Resolve(后面都简称达芬奇了)的付费用户。虽然它不是开源软件,但是有很好的 Linux 支持,使用体验和功能都是同类中的佼佼者,而且收费也相当合理。我选择付费一个原因是你支持我,我就支持你,这其实和我支持 Steam 和 Valve …
<p>令人出乎意料,我竟然是 DaVinci Resolve(后面都简称达芬奇了)的付费用户。虽然它不是开源软件,但是有很好的 Linux 支持,使用体验和功能都是同类中的佼佼者,而且收费也相当合理。我选择付费一个原因是你支持我,我就支持你,这其实和我支持 Steam 和 Valve 的理由差不多。另一个原因是众所周知的由于什么所谓系统专利许可证的原因达芬奇 Linux 版本不能解码 H264 和 H265 这两种常见的视频编码,只能使用 NVIDIA 显卡的 NVENC 和 NVDEC 来处理,而达芬奇将显卡加速功能作为收费的卖点。于是我就这样半自愿的上了贼船。</p>
<p>当然排除掉解码问题之后还有另一个比较难受的地方,就是达芬奇 Linux 版没有输入法支持,于是完全没办法输入中文。我猜测不像是某些故意恶心人的企业对 Linux 不友好,而单纯是因为英语开发者没有“输入法”这种概念。毕竟达芬奇的图形界面是基于 Qt 的,而 Qt 直接有现成的输入法支持,构建的时候打开开关就可以了嘛。为此我甚至专门跑到 BlackMagic Design 的用户论坛发了个帖子(<a href="https://forum.blackmagicdesign.com/viewtopic.php?f=33&t=150886" target="_blank" rel="external nofollow noreferrer noopener">https://forum.blackmagicdesign.com/viewtopic.php?f=33&t=150886</a>),作为付费用户,我给你钱,你就得给我办事,就是这么硬气。显然某位员工看到了我的帖子并把它移动到了 Feature Requests 版面,然后就没有然后了。闭源拖拉机总是这样,我看到了,但我懒得改,你给我等着吧。我倒不是说开源拖拉机的维护者都比较勤快,但是至少代码放在那里,说不定用户自己就给你改了送到你面前了,一般再懒的维护者都乐意接受。谁叫我没找到和达芬奇一样好用的开源视频剪辑软件呢。</p>
<p>不过从它用的 Qt 这一点上来看,应该是有什么办法可以 hack 一下让它支持输入法的。虽然我不是很熟悉 Qt,但是 Fcitx 的开发者 <a href="https://www.csslayer.info/" target="_blank" rel="external nofollow noreferrer noopener">@csslayer</a> 给了我一个方案,他之前写了一篇博客是关于给 Mathematica 添加输入法支持的(<a href="https://www.csslayer.info/wordpress/fcitx-dev/a-case-study-how-to-compile-a-fcitx-platforminputcontext-plugin-for-a-proprietary-software-that-uses-qt-5/" target="_blank" rel="external nofollow noreferrer noopener">https://www.csslayer.info/wordpress/fcitx-dev/a-case-study-how-to-compile-a-fcitx-platforminputcontext-plugin-for-a-proprietary-software-that-uses-qt-5/</a>),他觉得达芬奇也可以如法炮制,于是我阅读了一下,简单地概括就是首先查出来软件用了什么版本的 Qt,然后下载对应的源码,因为输入法支持属于 Qt 的某种插件,所以只要构建插件的时候链接到软件自带的 Qt,再把得到的插件复制到软件的 Qt 目录就可以了。一般来说软件就算修改了自带的 Qt,也不会修改有关插件的部分,所以我打算如法炮制一下。</p>
<p>首先是查看达芬奇自带的 Qt 的版本,这个非常简单:</p>
<figure data-raw="% strings /opt/resolve/libs/libQt5Core.so.5 | rg 'Qt 5'
Qt 5.15.2 (x86_64-little_endian-lp64 shared (dynamic) release build; by Clang 9.0.1 )
This is the QtCore library version Qt 5.15.2 (x86_64-little_endian-lp64 shared (dynamic) release build; by Clang 9.0.1 )
If that is not possible, in Qt 5 you must at least reimplement
" class="code-block"><pre class="code"><code>% strings /opt/resolve/libs/libQt5Core.so.5 | rg 'Qt 5'
Qt 5.15.2 (x86_64-little_endian-lp64 shared (dynamic) release build; by Clang 9.0.1 )
This is the QtCore library version Qt 5.15.2 (x86_64-little_endian-lp64 shared (dynamic) release build; by Clang 9.0.1 )
If that is not possible, in Qt 5 you must at least reimplement
</code></pre></figure>
<p>到这里应该就是去下载 Qt 5.15.2 版本的源码了,不过我突发奇想看了一眼系统安装的 Qt 版本:</p>
<figure data-raw="% pacman -Qi qt5-base | rg Version
Version : 5.15.7+kde+r176-1
" class="code-block"><pre class="code"><code>% pacman -Qi qt5-base | rg Version
Version : 5.15.7+kde+r176-1
</code></pre></figure>
<p>一般来说主次版本号不变的话不会有什么不兼容的改动,会不会我直接把系统的 <code>.so</code> 文件复制过去就可以用了呢?Qt 5 的 ibus 支持已经是 Qt 本身代码库的一部分了,安装到系统的路径是 <code>/usr/lib/qt/plugins/platforminputcontexts/libibusplatforminputcontextplugin.so</code>,我尝试直接把它链接过去:</p>
<figure data-raw="% sudo mkdir /opt/resolve/libs/plugins/platforminputcontexts
% sudo ln -s /usr/lib/qt/plugins/platforminputcontexts/libibusplatforminputcontextplugin.so /opt/resolve/libs/plugins/platforminputcontexts
" class="code-block"><pre class="code"><code>% sudo mkdir /opt/resolve/libs/plugins/platforminputcontexts
% sudo ln -s /usr/lib/qt/plugins/platforminputcontexts/libibusplatforminputcontextplugin.so /opt/resolve/libs/plugins/platforminputcontexts
</code></pre></figure>
<p>然后就没有然后了,我启动达芬奇之后 ibus 就直接工作了。没想到他们虽然不太了解 Linux 输入法,Qt 版本跟的倒是还挺新的。</p>
<p><img src="/posts/Input-Method-Support-for-DaVinci-Resolve-on-Linux/screenshot-1.png" alt="just works"></p>
<p>对于 Fcitx5 用户的话,首先要注意 Qt 5 的 Fcitx5 支持并不在 Qt 的代码库里,所以你需要安装 <code>fcitx5-qt</code>。不过文件路径的话都是一样的,只要把文件名里的 <code>ibus</code> 改成 <code>fcitx5</code> 就可以了。如果直接链接不能用,需要按照老 K 博客里的办法自己编译的话,需要下载单独的 <code>fcitx5-qt</code> 代码库。当然从根源上解决问题的话还是希望大家去论坛回复我的帖子,让 BlackMagic Design 开启构建开关,就不需要用奇怪的办法 hack 了。</p>
GTK 和 libhandy 和 Arc-Dark 主题
https://sh.alynx.one/posts/GTK-libhandy-Arc-Dark/
Alynx Zhou
alynx.zhou@gmail.com
2022-11-23T10:23:34.000Z
2022-11-23T10:23:34.000Z
黑夜让我选了黑色的主题,但是有些程序非要寻找光明? 我自认为不是个对应用程序外观有着病态一致性要求的人,我也从不介意一些个性化的程序选择自己的特殊样式。所以当 GTK 4 推荐的 libadwaita 不再支持传统的 GTK 主题的时候我也没什么反应:毕竟这玩意默认的样式看起来还…
<blockquote class="center-quote">黑夜让我选了黑色的主题,但是有些程序非要寻找光明?</blockquote>
<p>我自认为不是个对应用程序外观有着病态一致性要求的人,我也从不介意一些个性化的程序选择自己的特殊样式。所以当 GTK 4 推荐的 libadwaita 不再支持传统的 GTK 主题的时候我也没什么反应:毕竟这玩意默认的样式看起来还挺好看的。但即使是我这样宽容的人,对于 GTK 3 那个熟的不能再熟的 Adwaita 主题也审美疲劳了,那个银色和棕色会让所有手机厂笑话的,即使有些手机厂的审美还不如这玩意。</p>
<p>我个人最喜欢的配色其实是 Atom 的 One Dark 和 One Light,但我没那个精力利用调色盘自己维护一份主题,所以我退而求其次选择了在观感上比较接近的 <a href="https://github.com/jnsh/arc-theme" target="_blank" rel="external nofollow noreferrer noopener">Arc 主题</a>,这个主题其实是一个系列,我自己只在乎里面的两个变体:全亮色的 Arc 和全暗色的 Arc-Dark(似乎它自己 README 里面给的截图也有点问题)。</p>
<p>我自己是一个暗色模式爱好者,毕竟长时间面对屏幕,白底黑字实在是太刺眼了,相对而言,深蓝色做背景色浅灰色做前景色要好看很多。在很久很久以前混沌初开,Linux 桌面程序员还没有意识到需要有个全局的暗色/亮色开关的时候,设置主题非常简单粗暴,打开 GNOME Tweaks 把 GTK Theme 设置为 Arc-Dark,我就心满意足了。</p>
<p>可能是 libadwaita 不能更换主题导致很多反对的声音,并没有太多人谈论随之而来的全局暗色模式开关,但是某天我更新了系统之后发现设置里多了一个亮色/暗色选择,我觉得挺好,那我这里选暗色就行了嘛,果然所有用了 libadwaita 的程序都跟着变了亮暗,不过我用着用着就感觉不对劲了——怎么以前那些 GTK 3 的程序不用 Arc-Dark 而是用 Adwaita-dark 了,这和我想的不一样啊?然后我研究了一下,觉得更奇怪了,GTK 4 + libadwaita 的 GNOME Settings 用的是 libadwaita 的暗色版本(预期行为),GTK 3 的 GNOME Tweaks 用的是 GTK 3 的 Adwaita-dark(不对劲),但是同样是 GTK 3 的 GNOME Terminal 用的是我设置的 Arc-Dark(预期行为)。好家伙好家伙,我这一个桌面上三花聚顶了。</p>
<p><img src="/posts/GTK-libhandy-Arc-Dark/screenshot-1.png" alt="三花聚顶"></p>
<p>总这么待着我觉得怪怪的,于是我研究了一下,如果我要是选亮色模式呢?现在 GNOME Settings 是 libadwaita 的亮色版本了,然后 GNOME Tweaks 和 GNOME Terminal 都是 Arc-Dark,虽然好像一致了,又好像有点不一致,这回从三花聚顶变成黑白通吃。总之我忍受了很久 GTK 4 程序 <strong>大部分</strong> 是白的而 GTK 3 程序是黑的,直到我再也受不了决定翻开代码看看这些人是怎么写的。</p>
<p><img src="/posts/GTK-libhandy-Arc-Dark/screenshot-2.png" alt="黑白通吃"></p>
<p>为了能说明白,下面我就不从结果反推原因了,毕竟大家看到这里可能已经云里雾里,没必要和我再重复一遍破案过程了。</p>
<p>可能大部分人不是 GTK 开发者也不使用 GTK,对这玩意怎么调用主题存在一定的误区。实际上可以分为以下几类:</p>
<ol>
<li>不使用 libhandy 的 GTK 3 程序(比如 GNOME Terminal)和不使用 libadwaita 的 GTK 4 程序(比如 Show Me The Key),这一类程序不考虑 GNOME Settings 里面的亮色/暗色开关(指的是 GSettings 里面 <code>org.gnome.desktop.interface</code> 的 <code>color-scheme</code> 选项),而只考虑 <code>gtk-application-prefer-dark-theme</code>,这个值属于 <code>GtkSettings</code>,需要编辑 <code>~/.config/gtk-4.0/settings.ini</code> 和 <code>~/.config/gtk-3.0/settings.ini</code>。以及是的你没看错,GTK 4 不一定非要用 libadwaita,实际上虽然这个库叫 libadwaita,但它和 GTK 3 那个叫做 Adwaita 的默认主题几乎没有关系,它是 GTK 3 的组件库 libhandy 的进化版本。GNOME 推荐使用这个以便让整个桌面有统一的风格,但是 GTK 4 仍然是个完整的 UI 库,程序开发者完全可以不使用 libadwaita。</li>
<li>使用 libadwaita 的 GTK 4 程序(比如 GNOME Settings),这一类程序不考虑 GNOME Tweaks 里面的 GTK Theme 选项(实际上是 GSettings 里面 <code>org.gnome.desktop.interface</code> 的 <code>gtk-theme</code> 选项),只使用 libadwaita 内置的配色,所以我们也完全不需要关心它,它永远按照设置里的开关工作。</li>
<li>使用 libhandy 的 GTK 3 程序(比如 GNOME Tweaks),这个就相当复杂了,libhandy 考虑了桌面环境的亮色/暗色主题切换,但也考虑了用户自定义的 GTK Theme,于是在这里它华丽的乱套了。</li>
<li>还有最后一类程序,它们出于特定需要自己给自己套了自定义的 CSS,所以你拿它一点办法也没有,直接忽略(比如 Show Me The Key 的悬浮窗口)。</li>
</ol>
<p>看到这里一定有小黑子要怒吼了:“看看你们搞的乱七八糟的玩意!GNOME 真垃圾!老子就要刀耕火种就要当原始人,libadwaita 滚啊!”但是我建议用你那可怜的小脑袋瓜想一想,上面三条里面反而 libadwaita 是最符合预期的一个(亮色模式用亮色,暗色模式用暗色),所以我不会解决第二个,而是解决另外的两个。</p>
<p>首先从 libhandy 下手,相关的代码位于 <a href="https://gitlab.gnome.org/GNOME/libhandy/-/blob/main/src/hdy-style-manager.c#L286-L348" target="_blank" rel="external nofollow noreferrer noopener">https://gitlab.gnome.org/GNOME/libhandy/-/blob/main/src/hdy-style-manager.c#L286-L348</a>,如果你可怜的小脑袋瓜也没耐心看看代码的话,那么我大发慈悲替你读了一遍。相关的逻辑大概是说首先覆盖掉当前程序的 <code>gtk-application-prefer-dark-theme</code>(别忘了 libhandy 程序也是 GTK 3 程序),这个值会被设置成 <code>color-scheme</code> 的值。然后获取当前系统的 GTK Theme,因为我们考虑到浅色和深色主题切换,所以主题名被分成基础名和种类名两部分,如果系统的主题以 <code>-dark</code> 结尾,那就去掉这个后缀,得到基础名,并设置为当前程序的主题。那问题难道出在 Arc-Dark 的结尾是 <code>-Dark</code> 而不是 <code>-dark</code> 吗?也不是,GTK 主题对于暗色和亮色的区分不在主题名上,而是在主题目录下面的两个文件,一个叫做 <code>gtk.css</code>,另一个叫做 <code>gtk-dark.css</code>,如果 <code>color-scheme</code> 是 <code>prefer-dark</code>,libhandy 就会加载后者而非前者,这部分的代码在 <a href="https://gitlab.gnome.org/GNOME/libhandy/-/blob/main/src/hdy-style-manager.c#L106-L141" target="_blank" rel="external nofollow noreferrer noopener">https://gitlab.gnome.org/GNOME/libhandy/-/blob/main/src/hdy-style-manager.c#L106-L141</a>。于是在系统设置为暗色模式的时候,libhandy 会去加载 Arc-Dark 的 <code>gtk-dark.css</code>,但 Arc-Dark 作为一个暗色变体,只有 <code>gtk.css</code>,所以加载失败,libhandy 回退到 <code>Adwaita</code> 的 <code>gtk-dark.css</code>。而系统设置为亮色的时候,libhandy 会去加载 Arc-Dark 的 <code>gtk.css</code>,而作为一个暗色变体,这个文件实际写的是暗色配色,于是看起来正常了。(以及如果你 GTK Theme 设置为 <code>Adwaita-dark</code> 从这里你就会发现实际上加载的是 <code>Adwaita</code> 的 <code>gtk-dark.css</code>,而不是 <code>Adwaita-dark</code> 的 <code>gtk.css</code>,即使它们的配色是一样的。)</p>
<p>那么显然又有另一个问题,既然主题是靠内部的两个文件区分亮色和暗色的,为什么又会有 <code>Adwaita-dark</code> 和 <code>Arc-Dark</code> 这种名字里带暗色后缀的变体呢?并且还要在 <code>libhandy</code> 里面处理这个后缀,是不是多此一举?我们可以暂时先不考虑这个问题,而先简单解决第三条。从上面的分析可以得知为了能正常支持系统的亮色暗色切换,我们需要的是一个同时包含亮色暗色的主题,而不是一个只有暗色变体的主题,于是我们不能把 GTK Theme 设置为 Arc-Dark,而应该使用 Arc,但假如你在 GNOME Tweaks 里面设置好之后,你会发现仍然是黑白通吃:GNOME Settings 是 libadwaita 的暗色版本,GNOME Tweaks 是 Arc 的暗色版本,而 GNOME Terminal 和 Show Me The Key 却变成了亮的 Arc!</p>
<p><img src="/posts/GTK-libhandy-Arc-Dark/screenshot-3.png" alt="小黑子们不要笑得太早了"></p>
<p>我知道有的小黑子要迫不及待开始炮轰 GNOME 了:“什么玩意,整来整去不还是整不好吗,不如来当原始人。”但问题其实就是原始人留下的。现在我们回头看第一条:不使用 libhandy 的 GTK 3 程序和不使用 libadwaita 的 GTK 4 程序,这一类程序不考虑 GNOME Settings 里面的亮色/暗色开关,而只考虑 <code>gtk-application-prefer-dark-theme</code>。所以这个奇怪的表现恰好验证了这一条,同时也解释了“既然主题是靠内部的两个文件区分亮色和暗色的,为什么又会有 <code>Adwaita-dark</code> 和 <code>Arc-Dark</code> 这种名字里带暗色后缀的变体”这个问题:因为在一开始的设计里并没有什么全局亮色/暗色开关,也就没有要求主题同时提供 <code>gtk.css</code> 和 <code>gtk-dark.css</code>,那么为了让用户可以自选亮色暗色,只有提供两个不同的主题来解决问题。这也就是在 <code>Adwaita</code> 和 <code>Arc</code> 都提供了 <code>gtk-dark.css</code> 的情况下仍然存在 <code>Adwaita-dark</code> 和 <code>Arc-Dark</code> 的原因。然后在主题添加了 <code>gtk-dark.css</code> 之后,为了让 libhandy 的程序能够跟随系统开关切换亮色和暗色,就不能为了那些传统程序把 GTK Theme 设置为暗色变体的主题了,此时如果设置为同时包含两个文件的主题,默认这些程序会选择 <code>gtk.css</code>,也就会出现上面截图里的情况。解决这个的方案也不是很困难,<code>gtk-application-prefer-dark-theme</code> 就是为此添加的,支持它的 GTK 程序会按照这个选项来加载 <code>gtk.css</code> 或 <code>gtk-dark.css</code>。如果你像我一样平时主要用暗色模式,那就手动编辑 <code>~/.config/gtk-4.0/settings.ini</code> 写入以下内容(GTK 3 的话就是 <code>~/.config/gtk-3.0/settings.ini</code>):</p>
<figure data-raw="[Settings]
gtk-application-prefer-dark-theme=1
" data-info="language-ini" data-lang="ini" class="code-block"><pre class="code"><code class="language-ini">[Settings]
gtk-application-prefer-dark-theme=1
</code></pre></figure>
<p>你要是亮色爱好者,那就改成 <code>0</code>。这下倒是满足原始人的刀耕火种需求了哈,毕竟他们看起来也不想要系统的亮色/暗色开关的样子,不过说不定以后哪天系统的亮色/暗色开关也会同时修改这个选项呢?只是读取这个选项的 GTK 程序不会像 libhandy/libadwaita 的程序那样会动态切换,必须要关了重开才行。</p>
<p>还有一个奇怪的问题要注意,通常我们是在 GNOME Tweaks 里面设置 GTK Theme,不过根据 <a href="https://gitlab.gnome.org/GNOME/gnome-tweaks/-/blob/master/gtweak/tweaks/tweak_group_appearance.py#L75-L88" target="_blank" rel="external nofollow noreferrer noopener">https://gitlab.gnome.org/GNOME/gnome-tweaks/-/blob/master/gtweak/tweaks/tweak_group_appearance.py#L75-L88</a>,它会把上面那个 <code>gtk-application-prefer-dark-theme</code> 设置成 <code>0</code>,看注释里面的 BUG 描述,应该也是为了某种刀耕火种的情况解决的(甚至那时候还推荐搞个单独的暗色主题,并且删除了全局的倾向暗色的开关),大概那时候还没推荐用 libhandy,也没有 libadwaita,也没有设置里这种全局暗色/亮色的开关。总之我不建议经常修改 GTK 主题,并且每次修改之后记得手动修改这个选项。如果你觉得这种反复横跳又要保证兼容以前的决策的行为很蠢,那我只能说毕竟你不能要求以前的开发者预见到未来的人们怎么定义桌面的功能。</p>
<p>当然如果你毫不在乎亮色暗色切换(我就是要一直用暗色,所以你暗色模式给我选对了暗色主题就行了!),那还有个比较投机取巧的解决方案:把 Arc-Dark 的 <code>gtk.css</code> 复制并改名 <code>gtk-dark.css</code> 就可以了,原理不难理解。并且 Arc 主题已经做了这样的修改,只是还没有 Release(参见 <a href="https://github.com/jnsh/arc-theme/commit/73ada8563591fa48ae365686a358e874ca12edad" target="_blank" rel="external nofollow noreferrer noopener">https://github.com/jnsh/arc-theme/commit/73ada8563591fa48ae365686a358e874ca12edad</a>)。</p>
谁动了我的 DNS 解析?
https://sh.alynx.one/posts/Who-Moved-My-DNS-Resolving/
Alynx Zhou
alynx.zhou@gmail.com
2022-11-09T13:06:00.000Z
2024-02-01T10:15:00.000Z
<div class="alert-red">我发现这篇杂糅了关于设置 Zeroconf 的 mDNS 的需求和关于 Linux 下面 DNS 解析到底是怎么工作的描述,如果你只对后者感兴趣,请阅读最新的 <a href="/posts/Who-Moved-My-DNS-Resolving-Remastered/">谁动了我的 DNS 解析?(重制版)</a>。</div>
如果有人看到这个标题以为是什么科学上网相关然后高兴地点进来的话不要怪我,我其实想说的是 Linux 上有关 DNS 解析的流程,这个标题显然是化用自《谁动了我的奶酪?》,即使我并没有读过这本书。我计网真的没认真听课,写的内容都是我现学现卖的,有不对的希望读者指正。
<div class="alert-red">我发现这篇杂糅了关于设置 Zeroconf 的 mDNS 的需求和关于 Linux 下面 DNS 解析到底是怎么工作的描述,如果你只对后者感兴趣,请阅读最新的 <a href="/posts/Who-Moved-My-DNS-Resolving-Remastered/">谁动了我的 DNS 解析?(重制版)</a>。</div>
如果有人看到这个标题以为是什么科学上网相关然后高兴地点进来的话不要怪我,我其实想说的是 Linux 上有关 DNS 解析的流程,这个标题显然是化用自《谁动了我的奶酪?》,即使我并没有读过这本书。我计网真的没认真听课,写的内容都是我现学现卖的,有不对的希望读者指正。
<a id="more"></a>
<h1 id="%E9%9C%80%E6%B1%82"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving/#%E9%9C%80%E6%B1%82"></a>需求</h1>
<p>我有一台 NAS,一台 PC 和一台路由器,为了能上网也为了家里的无线设备可以连接 NAS,我给 PC 和 NAS 分别接上路由器,但是路由器只有千兆网口,而 PC 和 NAS 各自多一个 2500 Mbps 的网卡,为了实现最高的连接速度,我又买了一根网线把 PC 和 NAS 直接连接起来,于是现在三台设备两两相连。</p>
<p>直连两台设备其实非常简单,Network Manager 里面 IPv4 设置成手动,然后分别配置 IP 地址和子网掩码,再关掉 IPv6 就可以了,比如我分别设置 IP 为 <code>10.10.10.1</code> 和 <code>10.10.10.2</code>,然后子网掩码就是 <code>255.255.255.0</code>。然后在进行各种网络访问的时候只要使用这个 IP 就可以通过直连访问了。</p>
<p>但是我还是不太满意,我设置了帅气的主机名,为什么还得用 IP 访问呢?但如果我查询主机名对应的 IP,发现得到的并不是直连的 IP,而是比如 <code>192.168.1.80</code> 这样的通过路由器的 IP。于是我开始研究如何配置让 DNS 解析给我返回直连的 IP。</p>
<h1 id="long-long-ago"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving/#long-long-ago"></a>long long ago</h1>
<p>一般要讲故事,开头都是“很久很久以前……”,不过计算机领域也没什么太古老的故事可讲,毕竟公认的互联网前身 ARPANET 也就是二十世纪的事情。那个时候能互联的机器一共也就那么几个,所以解决的办法简单粗暴:我们每个机器都保存一个文件,里面记录所有人对应的域名和 IP 不就行了?这个优良传统一直留了下来,也就是现在所有系统里都有的 hosts 文件,不管你写的对不对,它的优先级都比 DNS 查询要高。对于我这个极其简单的网络环境,这肯定是不错的解决方案,但是程序员总会觉得这种非自动化的手段太 low 了,于是就被我 pass 掉了。</p>
<p>然后随着加入网络的机器越来越多,这个办法不好用了,毕竟每来一个新人就要所有人更新自己的文件,这复杂度也太高了。所以干脆我们搞一个集中的服务器专门放这个列表,其它机器都向它查询就好了。这就是 DNS 服务器的原理了,然后在局域网里,一般路由器和 DNS 服务器以及 DHCP 服务器都是同一台机器,因为很自然的所有设备都会连到路由器上,而 DHCP 服务器恰好知道它分配出去的 IP 地址,所以如果你输入主机名恰好能解析,那通常是你的路由器做了这些工作。但对于我这个子网来说,为了这两台电脑再配置 DHCP 和 DNS 显然太麻烦了,pass。</p>
<p>再后来各种子网越来越多,子网里的设备也越来越多,比如打印机这种,以至于现在各种智能家居,不可能再搞一个服务器用来注册“喂,我是茶壶”这种东西,于是苹果搞出了一个叫 Zeroconf 的协议,大概是在 DNS 的基础上可以让子网里支持这个协议的设备互相发现互相通知自己是什么。因为和 DNS 相关,所以有一个部分是 MulticastDNS (mDNS),简单来说就是不通过 DNS 服务器,而是通过这个协议发现的设备列表实现 DNS 解析。所以这是第三种方式。</p>
<p>以上三种方式其实都是我从 Arch Wiki 抄来的:<a href="https://wiki.archlinux.org/title/Network_configuration#Local_network_hostname_resolution" target="_blank" rel="external nofollow noreferrer noopener">https://wiki.archlinux.org/title/Network_configuration#Local_network_hostname_resolution</a></p>
<p>所以我决定搞一个第三种,这个好说,wiki 写了可以用 Avahi 做这个,不过怎么 systemd-resolved 也能做 mDNS?这玩意不是管 <code>/etc/resolv.conf</code> 的吗?Network Manager 不是也管这个吗?</p>
<h1 id="chattr-+i--etc-resolv-conf"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving/#chattr-+i--etc-resolv-conf"></a>chattr +i /etc/resolv.conf</h1>
<p>很多 Linux 用户都知道修改 DNS 服务器可以通过编辑 <code>/etc/resolv.conf</code> 实现,很多 Linux 用户也被 <code>/etc/resolv.conf</code> 困扰,一些人发现自己的这个文件是个软链接,而另一些人发现这个文件总被 Network Manager 覆盖,还有些人的发行版让他们用一个叫 <code>resolvconf</code> 的工具处理,然后现在 systemd 又搞了个叫 resolved 的东西来插一脚……我说的这些已经足够让一些不想学新东西同时又神经紧张的人开始大喊“fuck systemd, fuck network manager, fuck desktop environment and fuck the whole modern world”然后执行 <code>chattr +i /etc/resolv.conf</code> 了。不过别着急小炸药包们,也许这个世界上新出现的各种东西目的并不只是惹恼你们这群大笨蛋,哦是的,没错,我说,大笨蛋,恐龙勇士(停停停不要翻译腔了),而是真的有场景需要他们。也许对于某个 VPN 连接需要使用自己的 DNS 服务器,总之,不要觉得世界都围着你转,至少读一下这些东西的文档,会告诉你怎么阻止它们修改你的 <code>/etc/resolv.conf</code> 的。</p>
<p>但其实也不是一个 <code>/etc/resolv.conf</code> 搞定所有,有关这个的故事也是 long long ago,但毕竟是 UNIX 纪元之后的事情,没有太久,大概确实上古时代的程序都是直接读这个获取 DNS 服务器然后再做 DNS 解析的,但实际上这也不一定 OK,比如像之前说的打印机这种怎么解决?以及 hosts 呢?所以就有了更复杂的解决方案,大部分程序做 DNS 解析实际上是调用 glibc 里面 <code>getaddrinfo</code> 这个 API,所以在它后面我们就可以做一些工作。一个叫做 Name Service Switch 的东西发明出来就是干这个的,它可以理解为一个基于插件的结构,我们可以通过阅读 <code>/etc/nsswitch.conf</code> 里面的 <code>hosts</code> 这一行来理解,比如我这里默认是这样的:</p>
<figure data-raw="hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns
" class="code-block"><pre class="code"><code>hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns
</code></pre></figure>
<p>简单翻译一下的话意思就是查询一个域名的时候首先看看是不是 systemd-machined 的容器(<code>mymachines</code> 模块),不是的话再问问 systemd-resolved 能不能解析(<code>resolve</code> 模块),如果 systemd-resolved 可用,那到这也就完事了,后面的就不管了(<code>[!UNAVAIL=return]</code>),至于为什么我一会解释,然后 <code>files</code> 模块会读 hosts 文件,所以它优先级总是高于 DNS 服务器,然后看看是不是本机(<code>myhostname</code> 模块),然后再读 <code>/etc/resolv.conf</code> 里面的 DNS 服务器进行查询。</p>
<p>对于一个普通的桌面用户,应该使用的只是 Network Manager,默认 Network Manager 不会用 systemd-resolved,于是大部分情况一个外部域名最后还是查询 DNS 服务器,和以前没什么本质区别。那 Network Manager 你为什么要修改 <code>/etc/resolv.conf</code>?原因之一就是之前提到不同的 VPN 服务可能有不同的 DNS 服务器,因此建议这些用户不要手动编辑这个文件,可以直接在 Network Manager 的连接配置里设置某个连接的 DNS 服务器。</p>
<p>那 systemd-resolved 又是什么玩意?是不是 systemd 作者又要搞出什么花样替换我习惯的东西?但这东西好像还真是有些实际的需求,它不是一个简单的 <code>/etc/resolv.conf</code> 的管理工具,而可以理解为是一个自带缓存的 DNS 服务器。glibc 通过 <code>/etc/resolv.conf</code> 里面的 DNS 服务器查询 DNS 其实是不做缓存的,有些场景用户可能希望能自己缓存结果加快速度,这时候就需要搞这个东西了,它自己是一个 DNS 服务器,因此也就不再执行 nsswitch 里面后续的组件(用你的 <code>dns</code> 模块查询了我怎么缓存?)。除此之外它还声称自己提供了一个更好的 D-Bus 接口用来解析,而不是用 <code>getaddrinfo</code>,不过话又说回来,谁闲得没事去支持你这新搞的 D-Bus API,特别是你自己还搞了个 <code>getaddrinfo</code> 的模块。主观来说我其实不推荐一般的桌面用户配置这个,因为大概率你是在一个路由器后面,你的 DNS 服务器一般设置的都是路由器,而路由器上的 DNS 服务器一般会做缓存,所以其实完全没必要在自己电脑上启用这个……我也没遇到任何一定要使用它这个 D-Bus API 的程序。那 systemd-resolved 你为什么要修改 <code>/etc/resolv.conf</code>?原因是为了兼容那些直接读这个的上古程序,实际上人家就在这里写一行,就是让这些程序去查 systemd-resolved 内置的 DNS 服务器。</p>
<p>那至于 <code>resolvconf</code> 又是啥?这是一个叫 <code>openresolv</code> 的项目搞出来的东西,需求就是有各种程序都打算自己修改 <code>/etc/resolv.conf</code>,不单单是上面那两个,还有一些 VPN 服务之类的,那你们干脆都别管了,我来管,听我的(现在有 N + 1 种解决方案了)。但实际上我也不推荐你使用这个,因为桌面用户根本没有使用场景,你用 Network Manager 的话,就不要再单独使用什么 VPN 工具,因为 Network Manager 本身支持很多种 VPN 连接,你直接用它管理就好了。就算你需要用 systemd-resolved,其实这个也替你考虑好了,Network Manager 支持 systemd-resolved,检测到你用它的话,就不会去改 <code>/etc/resolv.conf</code>,而是直接去修改 systemd-resolved 的配置了。</p>
<p>更新(2023-09-07):感觉光靠嘴说还是不太清楚,我画了一张图……</p>
<p><img src="/posts/Who-Moved-My-DNS-Resolving/dns.png" alt="dns.png"></p>
<p>所以你可以看到 Network Manager 默认其实并不参与 DNS 解析,它只是方便到处跑的笔记本用户能用上各个局域网内的 DNS 服务器而已。</p>
<h1 id="%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A7%91%E5%AD%A6%E6%98%AF%E9%97%A8%E8%89%BA%E6%9C%AF%EF%BC%8C%E5%BD%93%E4%B8%94%E4%BB%85%E5%BD%93%E4%BD%A0%E4%B8%8D%E9%9C%80%E8%A6%81%E8%80%83%E8%99%91%E5%85%BC%E5%AE%B9%E6%80%A7%E7%9A%84%E6%97%B6%E5%80%99%E2%80%A6%E2%80%A6"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving/#%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A7%91%E5%AD%A6%E6%98%AF%E9%97%A8%E8%89%BA%E6%9C%AF%EF%BC%8C%E5%BD%93%E4%B8%94%E4%BB%85%E5%BD%93%E4%BD%A0%E4%B8%8D%E9%9C%80%E8%A6%81%E8%80%83%E8%99%91%E5%85%BC%E5%AE%B9%E6%80%A7%E7%9A%84%E6%97%B6%E5%80%99%E2%80%A6%E2%80%A6"></a>计算机科学是门艺术,当且仅当你不需要考虑兼容性的时候……</h1>
<p>那说回到 mDNS 这个问题,为什么我不直接用 systemd-resolved 解决呢?一个是上面提到的我不需要再做一次 DNS 缓存了,另一个是因为 CUPS 这个打印服务依赖 Avahi,它其实不只用到 mDNS,还用到了 Zeroconf 里面其它的功能比如发现设备去连接打印机,虽然我暂时也用不到 CUPS,但我确实是不想搞 systemd-resolved 的 DNS 服务器了,还是配置 Avahi 吧。当然假如你说我既想要 systemd-resolved 的 DNS 缓存和 D-Bus API 又想要 Avahi 的 Zeroconf 怎么办呢?额,其实也有办法,systemd-resolved 提供了选项让你关掉它的 mDNS 功能,具体我没有尝试,不过这样应该就不会冲突了。所以不要见到点新东西就生气,人家把各种兼容的东西都考虑到了,看两眼文档还不行吗……</p>
<p>然后搞清楚整个流程之后 Avahi 的配置其实不难,首先安装 <code>nss-mdns</code> 这个包,顾名思义是给 <code>nsswitch</code> 提供 <code>mdns</code> 模块,然后启动 <code>avahi-daemon.service</code>,然后编辑 <code>/etc/nsswitch.conf</code>,在 <code>resolve</code> 模块之前加入 <code>mdns4_minimal [NOTFOUND=return]</code>:</p>
<figure data-raw="hosts: mymachines mdns4 resolve [!UNAVAIL=return] files myhostname dns
" class="code-block"><pre class="code"><code>hosts: mymachines mdns4 resolve [!UNAVAIL=return] files myhostname dns
</code></pre></figure>
<p><code>mdns4</code> 模块会试图通过 mDNS 也就是找网络上其它的 Zeroconf 协议设备来解析 IPv4 地址,<code>4</code> 表示只尝试 IPv4,因为这种内网设备多半你不会给它分配 IPv6,当然也有 <code>6</code> 和没有数字同时支持两种的,不过由于现在的程序都优先查询 IPv6,而我只给直连配置了 IPv4,所以如果不用只支持 4 的,就会 fallback 到后面的模块,那就跑到路由器上查去了,我就是不想走路由器的。</p>
<h1 id="%E8%AF%BB%E8%80%85%E7%9C%8B%E7%88%BD%E4%BA%86%EF%BC%8C%E4%BD%86%E5%A5%BD%E5%83%8F%E7%BB%93%E6%9E%9C%E4%B8%8D%E6%98%AF%E6%88%91%E6%83%B3%E8%A6%81%E7%9A%84%E2%80%A6%E2%80%A6"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving/#%E8%AF%BB%E8%80%85%E7%9C%8B%E7%88%BD%E4%BA%86%EF%BC%8C%E4%BD%86%E5%A5%BD%E5%83%8F%E7%BB%93%E6%9E%9C%E4%B8%8D%E6%98%AF%E6%88%91%E6%83%B3%E8%A6%81%E7%9A%84%E2%80%A6%E2%80%A6"></a>读者看爽了,但好像结果不是我想要的……</h1>
<p>等到我把所有的东西都搞好以后我发现一个问题……mDNS 虽然说是子网上的设备互相发现,但是它没规定是哪个子网……于是喜闻乐见的每次 <code>getent ahosts timbersaw.local</code> 查询给我返回不一样的 IP,一会是 <code>10.10.10.2</code> 一会是 <code>192.168.1.80</code>,看起来还是写 hosts 比较靠谱……</p>
<p>最后我的配置是不用 <code>mdns4</code>,而是用 <code>mdns4_minimal</code>,这两个的区别是后者只考虑 <code>.local</code> 结尾的域名,并且如果查找不到的话直接返回 <code>NOTFOUND</code>,而不是继续 fallback:</p>
<figure data-raw="hosts: mymachines mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] files myhostname dns
" class="code-block"><pre class="code"><code>hosts: mymachines mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] files myhostname dns
</code></pre></figure>
<p>然后再修改 <code>/etc/hosts</code> 分别添加不带 <code>.local</code> 的主机名(因为 <code>.local</code> 会在 <code>files</code> 之前先被 mDNS 处理)。</p>
<h1 id="%E5%BD%93%E4%BD%A0%E8%A7%89%E5%BE%97%E9%80%90%E6%B8%90%E7%90%86%E8%A7%A3%E4%B8%80%E5%88%87%EF%BC%8C%E5%B9%B6%E8%AF%95%E5%9B%BE%E8%B5%B0%E5%87%BA%E6%96%B0%E6%89%8B%E6%9D%91%E2%80%A6%E2%80%A6"><a class="heading-link header-link" href="/posts/Who-Moved-My-DNS-Resolving/#%E5%BD%93%E4%BD%A0%E8%A7%89%E5%BE%97%E9%80%90%E6%B8%90%E7%90%86%E8%A7%A3%E4%B8%80%E5%88%87%EF%BC%8C%E5%B9%B6%E8%AF%95%E5%9B%BE%E8%B5%B0%E5%87%BA%E6%96%B0%E6%89%8B%E6%9D%91%E2%80%A6%E2%80%A6"></a>当你觉得逐渐理解一切,并试图走出新手村……</h1>
<div class="alert-blue">这一部分更新于 2024-02-01 18:15:00。因为我的网络配置终于突破了“只要全部交给 NetworkManager 就能解决”的范围。</div>
<p>首先我还是应该对之前的逻辑做一下总结,其实关键无非是一句话:需要添加新的 DNS 服务器的场景有很多,但管理 <code>/etc/resolv.conf</code> 的程序只能有一个。比如说你连接到家里的网络,那你首先会希望自己的 DNS 服务器是路由器。然后这时你需要连接到公司的 VPN,那你会多出一个 VPN 的 DNS 服务器用来查询内网域名,并且只应该对内网域名查询这个 DNS 服务器。如果你选择用 NetworkManager 管理 <code>/etc/resolv.conf</code>,那你也应该使用 NetworkManager 的 VPN 插件,通过 NetworkManager 去修改 <code>/etc/resolv.conf</code>。于是就不再需要额外的进程管理 DNS 查询。</p>
<p>而使我决定最后改用 systemd-resolved 管理 DNS 查询的原因是我开始使用 Tailscale/Headscale 构建一个我自己的 VPN 网络。Tailscale 包含一个叫做 MagicDNS 的组件,可以让你像使用路由器的 DNS 一样通过主机名访问这个虚拟专用网里的设备,此时它会直接覆盖掉 <code>/etc/resolv.conf</code> 让 DNS 查询走它自己的 DNS 服务器,这导致我的 OpenVPN 的 DNS 服务器被清掉,无法同时访问公司的内网。</p>
<p>如果你已经理解一切,解决方案应该也很清晰:要么把 Tailscale 也换成 NetworkManager 的插件版本(不存在),要么使用另一个专门管理 <code>/etc/resolv.conf</code> 的工具(systemd-resolved)让 Tailscale 和 NetworkManager 都交给它管理从而不要互相覆盖。考虑到 Avahi 的 mDNS 并没有像我想象的那样工作,我毫不犹豫的干掉了它换成了 systemd-resolved。</p>
<p>干掉 Avahi 的部分暂且不提,启用 systemd-resolved 的过程需要额外操作:</p>
<figure data-raw="# systemctl enable --now systemd-resolved
# ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
" class="code-block"><pre class="code"><code># systemctl enable --now systemd-resolved
# ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
</code></pre></figure>
<p>按照之前讲的,建立软链接是为了让那些老掉牙的程序也使用 systemd-resolved 内置的 DNS 服务器,于是大家现在都走 systemd-resolved 进行查询。然后 Tailscale 和 NetworkManager 都支持 systemd-resolved,检测到这个软链接就不会尝试直接覆盖 <code>/etc/resolv.conf</code>,而是通知 systemd-resolved 添加自己的 DNS 解析。</p>
<p>然后重启 NetworkManager 和 Tailscale:</p>
<figure data-raw="# systemctl restart NetworkManager
# systemctl restart tailscaled
" class="code-block"><pre class="code"><code># systemctl restart NetworkManager
# systemctl restart tailscaled
</code></pre></figure>
<p>但以上步骤只是让它们的 DNS 服务器设置可以共存,具体对于哪些域名通过哪个 DNS 服务器查询,是各个程序自己设置的,Tailscale 其实会正确的告诉 systemd-resolved 自己要处理的域名,但对于我的 OpenVPN 我发现需要我手动设置,由于我是使用 NetworkManager 管理我的 OpenVPN,所以需要执行 <code>nmcli connection edit VPN-CONNECTION</code>,然后 <code>set ipv4.dns-search a.internal,b.internal</code> 这样(看起来 NetworkManager 的 GUI 里没法修改这个),然后再重新开启 VPN 时候,你就可以通过 <code>resolvectl</code> 看到 OpenVPN 的 DNS 添加了正确的搜索范围。</p>
不应该做 EVA,而应该做环太平洋
https://sh.alynx.one/posts/Not-to-Be-EVA-but-to-Be-Pacific-Rim/
Alynx Zhou
alynx.zhou@gmail.com
2022-08-18T17:06:22.000Z
2022-08-18T17:06:22.000Z
<p>想必把 2018 年的 DARLING in the FRANXX (名字太长了,后面简称 DitF 吧)称作有争议的作品应该不会有人反对,不过我恰好是个不喜欢追新番的人,不然也许我在 2018 年写篇关于这个的博客应该会能获得不少点击量。总之我在 2022 年下载了全集并且几乎是不间断的在三天之内看完了,可能不是特别好评价,但是觉得还是得写点什么。如果读者觉得“怎么复读了很多已有的观点”或者“和我想看的完全不一样”,还麻烦多包涵或者自行关闭标签页。</p>
<p>想必把 2018 年的 DARLING in the FRANXX (名字太长了,后面简称 DitF 吧)称作有争议的作品应该不会有人反对,不过我恰好是个不喜欢追新番的人,不然也许我在 2018 年写篇关于这个的博客应该会能获得不少点击量。总之我在 2022 年下载了全集并且几乎是不间断的在三天之内看完了,可能不是特别好评价,但是觉得还是得写点什么。如果读者觉得“怎么复读了很多已有的观点”或者“和我想看的完全不一样”,还麻烦多包涵或者自行关闭标签页。</p>
<a id="more"></a>
<p>我认为这是一个优点和缺点同样突出的作品,倒不像很多人觉得是烂尾,结尾至少情理之中可以接受。主要问题是在于塑造人物形象和完善背景设定之间的冲突,也就是标题里写的“不应该做 EVA,而应该做环太平洋”。看完之后我半开玩笑地和 <a href="https://ry.huaji.store/" target="_blank" rel="external nofollow noreferrer noopener">@垚</a> 说:“都怪庵野秀明,非要在巨大机器人动画里面加上一堆反乌托邦末世玄学宗教的背景设定,导致后来的巨大机器人动画不这么做就好像缺了点什么一样。”我其实对巨大机器人动画不算是专家(比如我显然没看过高达),但我觉得对 EVA 还算是熟悉。</p>
<p>虽然我想说的问题是剧情方面的,不过还是要简单提一下作画。<a href="https://ry.huaji.store/" target="_blank" rel="external nofollow noreferrer noopener">@垚</a> 和我表示他一开始是奔着 TRIGGER 才去看的 DitF,结果看过之后对于动作场面大失所望。我其实也不是特别了解 TRIGGER,只是之前被他拉去电影院看了普罗米亚。一定要比较的话确实不管是美术风格还是动作场面都没有普罗米亚那么有特色,但我还是觉得至少在及格线以上了。我觉得特别出色的是机体的设计,不管是 EVA 还是环太平洋,机体设计都是偏向机械化的(虽然 EVA 内在是生物,但是外表仍然是机械),同时是男性化的设计。我还是头一次在动漫里看到女性形象和不是特别机械化设计的机体,而且甚至有丰富的表情,非常新鲜的同时也很符合设定(实际上动画里很多时候使用了 FRANXX 的形象代替女性寄驶员),属于是一个巨大的加分项。叫龙的设计也算新鲜,至少对我来说,看第一集的时候我明明期待的是出来一个传统的怪兽形象的,结果出来的是这么一种可以算是放飞自我的东西。虽然在逻辑上可能比较难以解释它的存在性,不过好在是动漫嘛,不需要在里面找现实。(话说回来了解我的朋友应该知道我最喜欢的敌人设计是 NieR: Automata 里面的机械生命体。)</p>
<p>然后说到我最关注的剧情了,我始终认为作品的核心是剧情。而且剧情是很难把控的东西,特别是对于原创剧情的作品来说,把剧情写好真的是一种不多见的能力。在我看来 DitF 在人物形象和感情戏上达到了一个极高的高度。主要人物有很多,但是每个人的个性都很清晰,并且我没有觉得哪个人的性格令我讨厌。同时故事本身不是简单直白一眼看到头的类型。比如第 13 集将故事推向了高潮,不仅仅因为这一集本身讲述的内容非常感人,而且将整个剧情前半部分埋下的伏笔全部都衔接上了(我甚至差点以为剧情要按绘本发展走向 bad end)。贯穿全篇出现过不止一次的“比翼鸟”的比喻,也非常的符合主题。但我在看一些二创视频的时候还是能看到一些 2018 年的评论在说第 14 集的剧情是喂屎,我确实可以理解追更的朋友当初等了一周之后看到这些阴差阳错然后还要提心吊胆等上一周才能看到下一集的焦躁心情,但这一集的矛盾激化成功的在一个高潮之后推进了剧情的节奏同时与下一集的高潮形成对比,而且这一集的内容非常的合理,虽然是各种巧合,但又很符合现实,符合人物的心理和动机。至于其它一些风评不好的部分比如搭档交换的剧情,只能说是见仁见智,有人不喜欢无可厚非,我还是觉得这部分也增加了故事的复杂性。</p>
<p>但是与塑造人物形象形成对比,完善背景设定方面我认为有比较严重的硬伤。24 集里面在前半部分简单介绍了一个可以说是反乌托邦的设定,然后大量的篇幅用来刻画人物之间的关系和人物细腻的心理活动,结果在感情戏达到高潮之后仿佛是编剧突然想起来“啊,我们挖了好大的坑还没填呢”一样,开始匆忙的填之前的坑。比如我到现在也没想通只在第 15 集里出现了一次的那个巨大的手到底是什么和有什么存在意义。比如叫龙公主在第 17 集开始有大篇幅的剧情之前几乎没有任何铺垫(你别告诉我第 15 集核心里掉出来的小人就算铺垫了),这和感情戏部分各种伏笔先放好然后再衔接完全不像是一部作品的风格,反正你让我看到前半段各种叫龙出现之后是想不到有这么一个个体的存在的。第 19 集通过介绍博士的角度介绍了人类向不死方向的发展,多少算是成功地填了一部分坑。然后整个作品似乎就陷入了“编剧发现还剩五集了填不完坑了于是开始放飞自我”的方向发展了,星实体是个什么东西?之前完全没铺垫过,现在强行在一集内塞给观众。鹤望兰·天燕座又是怎么来的?就算不在动漫里找现实,这也过于不符合逻辑了,看看别的叫龙是什么样子,它们怎么搞出这么个造型的啊。到底是博士发癫了还是编剧发癫了?然后可能是由于实在没办法了,机械降神一个最大反派叫做 VIRM,这已经不是硬伤了,这是直接一刀把脑袋砍掉从脖子往下截肢了。于是一个完全没有铺垫,思维及其简单,做事不讲逻辑的工具 boss 出现了……你可以告诉我打了半年的敌人并不是真正的敌人,但我不能容忍你用这么一种侮辱观众智商的方式告诉我这个事实。然后再一次放飞自我把科技水平拉到太空时代,说实在的,这个和反派登场比起来,已经到了我看见什么都不惊讶的程度了。然后再经过几集漫长的毫无必要的人类叫龙和 VIRM 的太空混战剧情终于结束了这种煎熬,我只能说这部分比起种田是完全的不讲道理了。</p>
<p>至于最后大结局的“生孩子”剧情,想必也有很多人不满意,虽然我自己讨厌小孩,但我觉得这部分不是什么问题。可能是考虑到当下的现实环境,年轻人确实对催生比较反感吧,但放在剧情里面,作为一个新世界开始的必要环节实际上是没什么问题的。最后黑色头发的少年和樱花色头发的少女在树下相遇的结尾也是我最满意的部分之一了,或者可能我恰好是一个容易被这种剧情打动的人吧。</p>
<p>另外 <a href="https://ry.huaji.store/" target="_blank" rel="external nofollow noreferrer noopener">@垚</a> 表示同样是 TRIGGER 的作品,普罗米亚的剧情要好很多,但我不是特别同意,我个人觉得普罗米亚最后 1/4 的剧情其实也向着强行收尾的方向走了,不过一部剧场版动画和季番还是不一样的,剧场版动画只有两个小时,不会有太复杂的背景设定,故事本身又是快节奏,就算强行推进一下剧情,观感也不会太差,而且独特的美术风格和配乐相当大程度的掩盖了剧情的问题。</p>
<p>总而言之,我其实是不太在意“符合逻辑但是大部分观众都不喜欢”的剧情的,比起这个,我觉得“不符合逻辑”的剧情问题要严重得多,这说明剧情走向已经变成无法把控的东西了,为了强行在剩下的集数里面结束故事,不得不强行引入一些东西。如果你问我怎么修改剧情能解决掉硬伤,我其实一开始也没什么思路。不过和 <a href="https://ry.huaji.store/" target="_blank" rel="external nofollow noreferrer noopener">@垚</a> 简单聊了一下之后我意识到了问题所在。从感情戏的篇幅和水平来看,很显然巨大机器人战斗只是个载体,这应该是个披着机器人战斗的皮的爱情故事,并且爱情故事部分相当的成功。在我看来 DitF 的思维和 EVA 其实是有相当大的差别的,虽然可能大家总是津津乐道碇真嗣、绫波零、明日香、渚薰之间的情感关系,但 EVA 没有对这部分的直接描写,更多是通过侧面细节描写,以及粉丝进行的分析推理得出的。所以 EVA 可以在背景设定上挖很大的坑然后有充足的时间填坑。因此我的想法是让这个作品向环太平洋的方向靠近,去掉反乌托邦的设定,比如什么种植园和 APE 都可以不要,直接快进到不知道为什么出现了名叫叫龙的怪兽进攻人类,于是人类设计了 FRANXX 并要少年少女操作来防御。然后人物的背景全部都不需要修改,最终的结局就像环太平洋一样直接摧毁掉叫龙来源就可以了。虽然可能会被 EVA 观众认为“没有达到 EVA 的高度”,但既然本来就不在一个赛道上(我这写的是爱情故事啊),这也没什么所谓了。考虑到第 13 集和 15 集的口碑,这样改应该不会折损它的优点。</p>
<p>最后还是要说,虽然有这么明显的硬伤,这部作品突出的优点还是让我受到了很大震撼并且在接下来的一周都沉浸在剧情里不能自拔。我个人也非常喜欢这部作品的 ED,无论是旋律还是歌词,以及演唱方面都可以说是一流的作品,特别是第 13 集高潮部分的《ひとり》,单从音乐和剧情结合的角度来说,确实达到了 EVA 的高度(让我想起来《翼をください》。《トリカゴ》也是绝妙的作品,特别是伴随 ED 出现的画面,“如果这些人物所在的是一个没有叫龙和 FRANXX 存在的世界会是什么样子呢?”(很遗憾我对 OP 没什么感觉)我同样对广、02、五郎和莓的人物形象非常的喜爱,复杂的情感关系使得这些人物变得颇为立体,而且他们完全没有动漫里一些经常出现的会让我讨厌的人物特质。</p>
PHP 故释
https://sh.alynx.one/posts/PHP-Story/
Alynx Zhou
alynx.zhou@gmail.com
2022-08-10T05:24:41.000Z
2022-08-10T05:24:41.000Z
<figure data-raw="// 新来的!如果你看到这段注释,说明上一个负责重构这个项目的程序员已经被气死了!
// 请你把下一行的数字加一,然后祝你好运!
// 63
" data-info="language-php" data-lang="php" class="code-block"><pre class="code"><code class="language-php">// 新来的!如果你看到这段注释,说明上一个负责重构这个项目的程序员已经被气死了!
// 请你把下一行的数字加一,然后祝你好运!
// 63
</code></pre></figure>
<p>起因是昨天晚上吃完饭回家路上和铁道迷闲聊说起他正在重写的 PHP 项目。于是我随口编了一个 <del>恐怖</del> 段子。</p>
<figure data-raw="// 新来的!如果你看到这段注释,说明上一个负责重构这个项目的程序员已经被气死了!
// 请你把下一行的数字加一,然后祝你好运!
// 63
" data-info="language-php" data-lang="php" class="code-block"><pre class="code"><code class="language-php">// 新来的!如果你看到这段注释,说明上一个负责重构这个项目的程序员已经被气死了!
// 请你把下一行的数字加一,然后祝你好运!
// 63
</code></pre></figure>
<p>起因是昨天晚上吃完饭回家路上和铁道迷闲聊说起他正在重写的 PHP 项目。于是我随口编了一个 <del>恐怖</del> 段子。</p>
<a id="more"></a>
<p>为什么是 63:</p>
<figure data-raw="% node
Welcome to Node.js v18.7.0.
Type ".help" for more information.
> Math.floor(Math.random() * 100)
63
>
" class="code-block"><pre class="code"><code>% node
Welcome to Node.js v18.7.0.
Type ".help" for more information.
> Math.floor(Math.random() * 100)
63
>
</code></pre></figure>
可能只适合我自己的 RIME 配置 2
https://sh.alynx.one/posts/My-RIME-2/
Alynx Zhou
alynx.zhou@gmail.com
2022-07-18T10:50:50.000Z
2022-07-18T10:50:50.000Z
<p>上一篇:<a href="/posts/My-RIME/">可能只适合我自己的 RIME 配置</a></p>
<p>这一篇的原因是我最近在偶然间刷博客刷到一篇 <a href="https://blog.coelacanthus.moe/posts/tech/a-new-rime-simp-pinyin-schema/" target="_blank" rel="external nofollow noreferrer noopener">讲 RIME 简体输入方案的文章</a>,里面提到说朙月拼音因为是繁体转简体所以会出现各种错误(其实我个人倒是没怎么遇到过),然后推荐了一个完全针对简体字的输入方案 <a href="https://github.com/hosxy/rime-aurora-pinyin/" target="_blank" rel="external nofollow noreferrer noopener">极光拼音</a>,我自己其实只会输入简体字,不怎么需要输入繁体字的功能,所以打算试试。</p>
<p>上一篇:<a href="/posts/My-RIME/">可能只适合我自己的 RIME 配置</a></p>
<p>这一篇的原因是我最近在偶然间刷博客刷到一篇 <a href="https://blog.coelacanthus.moe/posts/tech/a-new-rime-simp-pinyin-schema/" target="_blank" rel="external nofollow noreferrer noopener">讲 RIME 简体输入方案的文章</a>,里面提到说朙月拼音因为是繁体转简体所以会出现各种错误(其实我个人倒是没怎么遇到过),然后推荐了一个完全针对简体字的输入方案 <a href="https://github.com/hosxy/rime-aurora-pinyin/" target="_blank" rel="external nofollow noreferrer noopener">极光拼音</a>,我自己其实只会输入简体字,不怎么需要输入繁体字的功能,所以打算试试。</p>
<a id="more"></a>
<p>已经有人在 AUR 打包了 <code>rime-aurora-pinyin</code>,所以我直接拿来用了,然后类似于我上一篇文章处理朙月拼音的办法,给这个也做了一些自定义设置,主要是添加 emoji,修改默认的全角标点上屏行为,以及加载扩展过的字典,不过遇到了几个问题。</p>
<p>首先是我像上篇文章说的那样直接在 patch 下面添加 <code>__include: emoji_suggestion:/patch</code> 并不能输入 emoji,我研究了很长时间,甚至以为 emoji 功能依赖繁体转简体。结果其实并不是,打开 <code>emoji_suggestion.yaml</code> 可以看到下面几句:</p>
<figure data-raw="patch:
switches/@next:
name: emoji_suggestion
reset: 1
states: [ "🈚️️\uFE0E", "🈶️️\uFE0F" ]
'engine/filters/@before 0':
simplifier@emoji_suggestion
emoji_suggestion:
opencc_config: emoji.json
option_name: emoji_suggestion
tips: all
" data-info="language-yaml" data-lang="yaml" class="code-block"><pre class="code"><code class="language-yaml">patch:
switches/@next:
name: emoji_suggestion
reset: 1
states: [ "🈚️️\uFE0E", "🈶️️\uFE0F" ]
'engine/filters/@before 0':
simplifier@emoji_suggestion
emoji_suggestion:
opencc_config: emoji.json
option_name: emoji_suggestion
tips: all
</code></pre></figure>
<p><code>switches</code> 的部分可以先忽略,关键在于 <code>engine</code>,这个 emoji 输入的原理是添加一个 filter,它接收一个输入,然后去附带的 opencc 的词典里查找这个输入得到对应的结果,再把这个输出给下一个 filter,按照词典,输入应该是中文字或者词,并且我看了一下词典,简体和繁体是都有的,所以也不存在简繁转换的问题。其实问题在于这段配置会把它作为第一个 filter 加入列表,而极光拼音的默认 filter 列表是这样的:</p>
<figure data-raw=" filters:
- uniquifier
- charset_filter@gb2312
- charset_filter@gbk
" data-info="language-yaml" data-lang="yaml" class="code-block"><pre class="code"><code class="language-yaml"> filters:
- uniquifier
- charset_filter@gb2312
- charset_filter@gbk
</code></pre></figure>
<p>也就是说如果把 emoji 的 filter 加到第一个,它的输出就要继续经过 <code>uniquifier</code>,<code>charset_filter@gb2312</code>,<code>charset_filter@gbk</code>,后两个是极光拼音为了排除掉几乎用不到的生僻字而添加的。而 emoji 显然不属于 <code>gb2312</code> 也不属于 <code>gbk</code>,自然就被过滤掉了。</p>
<p>所以我的解决方案是把 emoji 的 filter 加到列表最后,其实加到哪里无所谓,只要你确定前一个 filter 的输出是中文,能触发 emoji 的 opencc 词典就好了,我单独写了一个 <code>emoji_suggestion.patch.yaml</code> 文件:</p>
<figure data-raw="switches/@next:
name: emoji_suggestion
reset: 1
states: [ "🈚️️\uFE0E", "🈶️️\uFE0F" ]
engine/filters/@next: simplifier@emoji_suggestion
emoji_suggestion:
opencc_config: emoji.json
option_name: emoji_suggestion
tips: all
" data-info="language-yaml" data-lang="yaml" class="code-block"><pre class="code"><code class="language-yaml">switches/@next:
name: emoji_suggestion
reset: 1
states: [ "🈚️️\uFE0E", "🈶️️\uFE0F" ]
engine/filters/@next: simplifier@emoji_suggestion
emoji_suggestion:
opencc_config: emoji.json
option_name: emoji_suggestion
tips: all
</code></pre></figure>
<p>导入的时候就写 <code>__include: emoji_suggestion.patch:/</code>。不过虽然我这个不再需要原来的那个 YAML 了,还是需要 <code>rime-emoji</code> 这个项目里其余的文件的。</p>
<p>顺便这也解释了为什么使用朙月拼音时候 emoji 后面的提示框显示的是繁体而非简体,因为朙月拼音从词库直接吐出来的是繁体,然后直接经过第一个 filter 就是 emoji,自然 emoji 查找时候用的就是繁体,然后才会经过简繁转换的 filter,所以如果把 emoji 的 filter 挪到简繁转换的 filter 后面,提示就会变成简体。</p>
<p>解决了 emoji 问题之后还有另一个问题,因为这个 emoji 的 filter 的输入是中文词组,也就意味着必须词库能吐出对应的中文词才能输入 emoji,比如说刚配置出来极光拼音的时候是吐不出来“笑哭”这个词的,所以就不会触发笑哭的 emoji。据说其它平台的输入法也有这个问题。其实没什么太好的解决方案,你可以说自己先手动打几次对应的词然后等 RIME 记住这个输入,不过我觉得也不太好。我想到的办法是既然需要词库里有,不如就让我用 emoji 的 opencc 词典生成一个 RIME 词库,然后扩展词库的时候加进去,这样无论如何都能吐出来了。其实也不是很麻烦,但是需要你把文字转成对应的拼音,那当然不能人工做这个操作了,我利用 Node 的 pinyin 库写了个脚本来做这件事:</p>
<figure data-raw="#!/usr/bin/env node
const fs = require("fs");
const OpenCC = require("opencc");
const {pinyin} = require("pinyin");
// 我的词库只需要简体中文,如果你需要繁体中文,把 `t2s` 改成 `s2t` 应该就好了。
const converter = new OpenCC("t2s.json");
const outputFileName = "emoji_suggestion.dict.yaml";
const inputFileNames = [];
if (process.argv.length <= 2) {
console.log(`Usage: ${process.argv[1]} file1 file2 ...`);
process.exit(0);
}
for (let i = 2; i < process.argv.length; ++i) {
inputFileNames.push(process.argv[i]);
}
const results = {};
for (const inputFileName of inputFileNames) {
const words = fs.readFileSync(
inputFileName, "utf8"
).split("\n").filter((line) => {
return line.length !== 0;
}).map((line) => {
return line.split("\t")[0];
});
for (const w of words) {
// rime-emoji 的 opencc 词典同时包含简体中文和繁体中文,但比如极光拼音
// 这种默认不包含简繁转换的方案多半只想要其中一种,所以使用 opencc 对候选词
// 进行一次转换。
const word = converter.convertSync(w);
if (results[word] != null) {
continue;
}
const py = pinyin(word, {
"heteronym": true,
"segment": true,
"style": "normal"
}).map((array) => {
// 有些时候就算利用结巴分词了,这个库仍然会没法判断多音字的读音然后丢出好
// 几个结果,只取第一个好了。
return array[0];
}).join(" ");
// 遇到处理不了的生僻字这个库会直接丢出原本的字……什么奇怪逻辑,只能判断是不
// 是字母或空格了。
if (/^[a-z ]*$/.test(py)) {
results[word] = py;
}
}
}
const outputLines = [
"# Rime dictionary for emoji",
"# encoding: utf-8",
"# Generated by `gen-emoji-dict.js` written by Alynx Zhou",
"",
"---",
"name: emoji_suggestion",
"version: \"0.1\"",
"sort: by_weight",
"...",
""
];
for (const k in results) {
outputLines.push(`${k}\t${results[k]}`);
}
// console.log(outputLines.join("\n"));
fs.writeFileSync(outputFileName, outputLines.join("\n"), "utf8");
" data-info="language-javascript" data-lang="javascript" class="code-block"><pre class="code"><code class="language-javascript">#!/usr/bin/env node
const fs = require("fs");
const OpenCC = require("opencc");
const {pinyin} = require("pinyin");
// 我的词库只需要简体中文,如果你需要繁体中文,把 `t2s` 改成 `s2t` 应该就好了。
const converter = new OpenCC("t2s.json");
const outputFileName = "emoji_suggestion.dict.yaml";
const inputFileNames = [];
if (process.argv.length <= 2) {
console.log(`Usage: ${process.argv[1]} file1 file2 ...`);
process.exit(0);
}
for (let i = 2; i < process.argv.length; ++i) {
inputFileNames.push(process.argv[i]);
}
const results = {};
for (const inputFileName of inputFileNames) {
const words = fs.readFileSync(
inputFileName, "utf8"
).split("\n").filter((line) => {
return line.length !== 0;
}).map((line) => {
return line.split("\t")[0];
});
for (const w of words) {
// rime-emoji 的 opencc 词典同时包含简体中文和繁体中文,但比如极光拼音
// 这种默认不包含简繁转换的方案多半只想要其中一种,所以使用 opencc 对候选词
// 进行一次转换。
const word = converter.convertSync(w);
if (results[word] != null) {
continue;
}
const py = pinyin(word, {
"heteronym": true,
"segment": true,
"style": "normal"
}).map((array) => {
// 有些时候就算利用结巴分词了,这个库仍然会没法判断多音字的读音然后丢出好
// 几个结果,只取第一个好了。
return array[0];
}).join(" ");
// 遇到处理不了的生僻字这个库会直接丢出原本的字……什么奇怪逻辑,只能判断是不
// 是字母或空格了。
if (/^[a-z ]*$/.test(py)) {
results[word] = py;
}
}
}
const outputLines = [
"# Rime dictionary for emoji",
"# encoding: utf-8",
"# Generated by `gen-emoji-dict.js` written by Alynx Zhou",
"",
"---",
"name: emoji_suggestion",
"version: \"0.1\"",
"sort: by_weight",
"...",
""
];
for (const k in results) {
outputLines.push(`${k}\t${results[k]}`);
}
// console.log(outputLines.join("\n"));
fs.writeFileSync(outputFileName, outputLines.join("\n"), "utf8");
</code></pre></figure>
<p>当然这个脚本不是很完美,比如 pinyin 识别不了的生僻字直接忽略了,不过我觉得它都识别不了,我多半也不会打出来的。然后虽然可以利用 jieba 分词提高多音字的准确性,还是有些不正确的,这些遇到了再手动纠错吧。</p>
<p>最后把这个词库添加进扩充词库:</p>
<figure data-raw="# 原来要结合默认词库和第三方词库,
# 需要自己编写一个词库让它 fallback 到极光拼音和第三方词库。
# 我说佛老师对不起对不起,我不懂规矩。
---
name: aurora_pinyin.extended
version: "0.1"
# `by_weight`(按词频高低排序)或 `original`(保持原码表中的顺序)。
sort: by_weight
# 听说默认简化字八股文效果不好,还是算了。
# https://blog.coelacanthus.moe/posts/tech/a-new-rime-simp-pinyin-schema/
# 因为导入的朙月拼音词库是繁转简,所以这里不能导入简化字八股文。
# 导入简化字八股文。
# vocabulary: essay-zh-hans
# 选择是否导入预设词汇表【八股文】。
# use_preset_vocabulary: true
import_tables:
# 主要是为了肥猫 wiki 词库。极光拼音好像是内置常用简化字表的。
- zhwiki
- aurora_pinyin
- emoji_suggestion
" data-info="language-yaml" data-lang="yaml" class="code-block"><pre class="code"><code class="language-yaml"># 原来要结合默认词库和第三方词库,
# 需要自己编写一个词库让它 fallback 到极光拼音和第三方词库。
# 我说佛老师对不起对不起,我不懂规矩。
---
name: aurora_pinyin.extended
version: "0.1"
# `by_weight`(按词频高低排序)或 `original`(保持原码表中的顺序)。
sort: by_weight
# 听说默认简化字八股文效果不好,还是算了。
# https://blog.coelacanthus.moe/posts/tech/a-new-rime-simp-pinyin-schema/
# 因为导入的朙月拼音词库是繁转简,所以这里不能导入简化字八股文。
# 导入简化字八股文。
# vocabulary: essay-zh-hans
# 选择是否导入预设词汇表【八股文】。
# use_preset_vocabulary: true
import_tables:
# 主要是为了肥猫 wiki 词库。极光拼音好像是内置常用简化字表的。
- zhwiki
- aurora_pinyin
- emoji_suggestion
</code></pre></figure>
<p>顺便说一下我其实也不太了解这个扩展词库的顺序怎么设置比较好,不过我尝试的结果是像这样把 emoji 放在最后面,就不会每次输入在前面提示很多并不常用的 emoji 词组的问题。</p>
<p>我这个脚本生成的词库只有简体,不过我发现朙月拼音的简繁转换还是可以正常处理简体词库的,也就是说会变成 词库出简体 -> 简繁转换 -> 繁体变 emoji,所以直接加给朙月拼音也没问题,如果我需要用繁体中文,可以直接切换方案到朙月拼音(虽然实际上我的配置是简化字版,不过看起来主要区别只是默认是否开启繁体转简体)。平时输入简体则直接用极光拼音。</p>
<p>完整配置在 <a href="https://github.com/AlynxZhou/alynx-rime-config/" target="_blank" rel="external nofollow noreferrer noopener">GitHub Repo</a> 更新。</p>
阻止 clangd 污染项目根目录的一些方法
https://sh.alynx.one/posts/Prevent-clangd-from-Making-Projects-Root-Dirty/
Alynx Zhou
alynx.zhou@gmail.com
2022-07-06T04:19:32.000Z
2022-08-17T08:28:58.000Z
<p>Emacs 的 lsp-mode 推荐使用 clangd 分析 C/C++ 代码,用起来体验还不错,但是让人非常恼火的是用户要主动或者被迫地在项目根目录下面添加一些文件,比如 <code>.clang_complete</code> 或者 <code>compile_commands.json</code> 来让 clangd 知道项目需要包含哪些库的头文件,以及 clangd 会直接把建立的索引丢到项目根目录下面的 <code>.cache</code> 目录里。虽然可以把这些加入 <code>.gitignore</code>,但保不齐哪个脾气古怪的上游维护者会和你纠缠半天让你解释为什么要加这些,实在是很麻烦。</p>
<p>Emacs 的 lsp-mode 推荐使用 clangd 分析 C/C++ 代码,用起来体验还不错,但是让人非常恼火的是用户要主动或者被迫地在项目根目录下面添加一些文件,比如 <code>.clang_complete</code> 或者 <code>compile_commands.json</code> 来让 clangd 知道项目需要包含哪些库的头文件,以及 clangd 会直接把建立的索引丢到项目根目录下面的 <code>.cache</code> 目录里。虽然可以把这些加入 <code>.gitignore</code>,但保不齐哪个脾气古怪的上游维护者会和你纠缠半天让你解释为什么要加这些,实在是很麻烦。</p>
<a id="more"></a>
<p><code>compile_commands.json</code> 比较好解决,clangd 提供了一个参数 <code>--compile-commands-dir=<string></code> 可以指定查找这个文件的目录,我直接把它设置为 <code>./build/</code>,因为大部分项目的 <code>.gitignore</code> 都会包含构建目录,也免得运行 <code>bear -- meson compile</code> 之后再把这个文件从 <code>build</code> 目录移出来。</p>
<figure data-raw="(use-package lsp-mode
:ensure t
:commands lsp
:hook ((c-mode . lsp-deferred)
(c++-mode . lsp-deferred)
(c-or-c++-mode . lsp-deferred)
(lsp-mode . lsp-enable-which-key-integration))
:bind (:map lsp-mode-map
("M-." . lsp-find-definition)
("M-," . lsp-find-references))
:custom
;; Move lsp files into local dir.
(lsp-server-install-dir (locate-user-emacs-file ".local/lsp/"))
(lsp-session-file (locate-user-emacs-file ".local/lsp-session"))
(lsp-keymap-prefix "C-c l")
;; Only enable log for debug.
;; This controls `*lsp-log*` buffer.
(lsp-log-io nil)
;; JavaScript (ts-ls) settings.
;; OMG, the FUCKING EVIL SHITTY VSCode TypeScript language server generates
;; log in project dir, can MicroSoft stop to let their software put shit in
;; front of users?
(lsp-clients-typescript-server-args '("--stdio" "--tsserver-log-file" "/tmp/tsserver-log.txt"))
(lsp-javascript-format-insert-space-after-opening-and-before-closing-nonempty-braces nil)
;; Always let clangd look for compile_commands.json under build dir so it will
;; not make project root dirty.
(lsp-clients-clangd-args ("--header-insertion-decorators=0" "--compile-commands-dir=./build/" "--enable-config")))
" data-info="language-elisp" data-lang="elisp" class="code-block"><pre class="code"><code class="language-elisp">(use-package lsp-mode
:ensure t
:commands lsp
:hook ((c-mode . lsp-deferred)
(c++-mode . lsp-deferred)
(c-or-c++-mode . lsp-deferred)
(lsp-mode . lsp-enable-which-key-integration))
:bind (:map lsp-mode-map
("M-." . lsp-find-definition)
("M-," . lsp-find-references))
:custom
;; Move lsp files into local dir.
(lsp-server-install-dir (locate-user-emacs-file ".local/lsp/"))
(lsp-session-file (locate-user-emacs-file ".local/lsp-session"))
(lsp-keymap-prefix "C-c l")
;; Only enable log for debug.
;; This controls `*lsp-log*` buffer.
(lsp-log-io nil)
;; JavaScript (ts-ls) settings.
;; OMG, the FUCKING EVIL SHITTY VSCode TypeScript language server generates
;; log in project dir, can MicroSoft stop to let their software put shit in
;; front of users?
(lsp-clients-typescript-server-args '("--stdio" "--tsserver-log-file" "/tmp/tsserver-log.txt"))
(lsp-javascript-format-insert-space-after-opening-and-before-closing-nonempty-braces nil)
;; Always let clangd look for compile_commands.json under build dir so it will
;; not make project root dirty.
(lsp-clients-clangd-args ("--header-insertion-decorators=0" "--compile-commands-dir=./build/" "--enable-config")))
</code></pre></figure>
<p>对于 <code>.cache/</code> 就不是那么好解决了,根据 <a href="https://github.com/clangd/clangd/issues/341#issuecomment-1003560792" target="_blank" rel="external nofollow noreferrer noopener">https://github.com/clangd/clangd/issues/341#issuecomment-1003560792</a>,似乎他们并没有关闭或者修改缓存目录的支持。不过我想到一个弯道超车的方案,<code>git</code> 本身应该是有从其它位置加载用户定义的 <code>gitignore</code> 文件的功能的,我利用这个写一个本地的 gitignore 不就行了吗,搜索之后得到 <a href="https://stackoverflow.com/questions/5724455/can-i-make-a-user-specific-gitignore-file" target="_blank" rel="external nofollow noreferrer noopener">https://stackoverflow.com/questions/5724455/can-i-make-a-user-specific-gitignore-file</a>,操作起来也很简单。首先我把这个文件放到 <code>~/.config/git/gitignore</code>,里面写上要忽略的 glob,然后运行 <code>git config --global core.excludesfile ~/.config/git/gitignore</code> 就大功告成。</p>
<p>不过就在我写这篇文章时,clangd 的 issue 上有人回复我,根据 <a href="https://github.com/clangd/clangd/issues/184#issuecomment-998244415" target="_blank" rel="external nofollow noreferrer noopener">https://github.com/clangd/clangd/issues/184#issuecomment-998244415</a>,现在 clangd 应该是会把索引放在 <code>compile_commands.json</code> 所在的目录,所以多少也算是解决了问题吧。虽然这样删掉构建目录之后索引缓存也没了,不过我觉得比起重建缓存,还是弄脏项目目录更恶心一点。</p>
<p>更新(2022-08-17):还有一个头疼的问题是 GLib 的 <code>g_clear_pointer</code> 宏里面使用到了对指针本体取 <code>sizeof</code> 的语法,而 clangd 默认会认为这是个错误,于是 lsp 就会标出一大堆问题。可以对项目进行设置关掉这一条,不过又会弄脏项目目录,查询文档得知 clangd 会读取 <code>~/.config/clangd/config.yaml</code> 这个用户级别的配置文件,于是在里面写入内容关掉这条检查:</p>
<figure data-raw="Diagnostics:
ClangTidy:
Remove: bugprone-sizeof-expression
" class="code-block"><pre class="code"><code>Diagnostics:
ClangTidy:
Remove: bugprone-sizeof-expression
</code></pre></figure>
<p>然后给 clangd 传递 <code>--enable-config</code> 这个参数即可。</p>