{"data":[{"title":"友情链接","url":"/blogroll/","content":"本来是打算把友情链接放到侧边栏里面的，但那边写太多的话用户体验非常差，而且后来看好多人的网站里都是给链接单独开了个页面写点介绍。我觉得这样挺不错的，所以自己也搞一个。侧边栏的友情链接现在只会放很重要并且还在更新的站点，太久没更新的话就只放在这个页面留档了。\n在这个页面学一下狼说话应该不会挨打吧？\n想和咱交换友链？\n\n\n如果你和一个烦人的叫做“程序猿DD”或“翟永超”的东西交换了友链的话，下面的部分你都不用看了，我不会和你交换友链的。\n很少有东西能让我厌恶到公开说出来，这家伙不幸就是其中之一。也不要问什么你怎么那么看不起人家之类的话，理念不一样，道不同不相为谋，我有权利控制我的网站不要和某些恶心的东西有联系。\n当然其他类似的卖书卖课卖公众号的待遇也是一样的，去 UC 震惊部当个小编或者老实运营自己的公众号不好吗？非要在程序员博客圈子制造这种垃圾？\n\n\n你添加咱\n想添加我友链比较简单，打个招呼然后加到你的网站就行了。\n网站的标题是“喵's StackHarbor”，而我的名字是“Alynx Zhou”，希望你不要弄混了，比如把名字写到了网站标题里或者大小写标点符号搞乱了。不过其实我一般懒得管所以问题不大。\n如果你的友链页面可以显示 logo 或者头像，请务必优先使用本人在侧边栏里的头像，直接复制图片链接即可。或者复制咱的 GitHub 头像链接也可以。如果你分别显示 logo 和头像那有点难办，不过我想 logo 可以放 这个高清的 favicon。\n网站介绍建议使用 subtitle \"Whisper to the World\"，不过你用侧边栏的 description 也是可以的，只不过这个可能经常跟我的心情一起变化。\n咱添加你\n要我添加你可能比较困难，首先我会优先考虑熟悉并且聊得来的人，陌生人的话建议不要贸然提出申请？社交还是比较困难的事情吧。\n如果聊得比较熟的话可能我自己就会和你提出交换友链的申请并且主动添加你了，但是也有可能我只是忘记了，那就发邮件或者消息告诉我你的博客链接吧！\n一般来说我会主动的按照上面要求别人的方式去获取博客相关信息，但你也可以直接把下面表格的内容发给我，朋友的意见总是优先考虑的。\n我会先阅读你的博客再考虑添不添加，所以假如你的博客文章质量很低或者和咱不是一路人，可能你要先从自己身上找找原因？\nLast but not least，侧边栏也有一个友情链接，但是因为空间限制，我不会把所有的友链都放上去，只会放一些比较重要或者干货很多的链接！希望你不要见怪。\n老相识\n这都是咱好久之前就认识并且一起写代码搞设备到现在的朋友。\n\n\n\n头像\n链接\n自我介绍\n我的介绍\n\n\n\n\n藍貓 八千代\n(formerly as 八雲) Amateur professional, professional amateur.\n仍在施工中……\n\n\n\nLGiki's Blog\n“喵喵喵”\n节奏王 Dogiki！每天咕咕咕，从不写博客！\n\n\n\nHackGhost\n\n在五道口睡觉的某头。博客从某一天起就不更新了。\n\n\n\nArt_Chen's Blog\n\n某著名 ROM 开发者，上大学了要努力写代码啊。\n\n\n大学认识的\n大学里面对面交流过而且关系很不错的朋友。\n\n\n\n头像\n链接\n自我介绍\n我的介绍\n\n\n\n\nMeow\n“一只有吱的喵~”\n刚入学的时候认识的学长，似乎也是一只猫，现在大概在美国呼吸自由的空气（大雾）。\n\n\n\nsqyon\n“试图让机器帮我学习的假 ACMer”\n优秀的学弟，一定是优秀的学弟。\n\n\n\n滑稽仓库\n\n董老师（咕老师）的奇怪网站。希望下次打 CSGO 前董老师能找到带麦克风的耳机。\n\n\n\ncserwen的站点\n\"Good Luck and Have Fun\"\n有女朋友的人生赢家……\n\n\n\nBo Lin's Blog\n“既来之 则安之”\n是学术界大佬了！是学术界大佬了！\n\n\nArch Linux CN 社区或者 Gentoo Linux CN 社区\n这里的人懂得好像都很多，咱经常向他们提问题，而且他们经常回复咱在群里的碎碎念，不加个友链好像说不过去。\n\n\n\n头像\n链接\n自我介绍\n我的介绍\n\n\n\n\n约伊兹的萌狼乡手札\n「虽然咱长久以来被尊为神，且被束缚在这块土地上，但咱根本不是什么伟大的神。咱就是咱。咱是赫萝。」\n我大概能理解这是个什么物种吧……反正我是猫。\n\n\n\nFarseerfc 的小窝\n「要是會能讓時間停下的魔法就好了… The World!」\n爱呼吸 fc 老师！似乎是日本某大学的教授，人生赢家，实名羡慕。\n\n\n\nHydroxide\n“oldherl 胡言乱语之一氧化二氢”\n我和老海尔挺熟的，我不能总是忘记添加他的友链……\n\n\n\njm33_ng\n\n似乎是个高产的大佬，博客里都是和安全相关的（大概）。\n\n\n\nNichi Yorozuya\n\"🐰🐰\"\n某世界一流大学年轻巨佬（当面确认，本人保证此条可信度）。\n\n\n\nBruce Z Blog\n「我不是兔子」\n经常迫害我的大白兔子，如果有人捉到了建议先放进锅里煮了再说。\n\n\n\nliolok\n\"I am not a cat, how disappointing :(\"\n纠结的皓奇老师，他好像也想当猫。\n\n\n\nEdward-P's Blog\n\n虽然咱经常叫他坏德华，但并不是什么坏人，保护各种德华从我做起。\n\n\n\n依云's Blog\n\n这也是一只狐狸，而且懂得很多很多很多于是我经常经常经常阅读博客。\n\n\n\n惠狐之书\n\"A fox called Megumi\"\n又是一只狐狸，私以为 Arch 社区可改名动物园。\n\n\n\nCS Slayer\n\n资深 休伯利安号甲板清洁工 崩坏三玩家，Fcitx 的作者。可是这和我冷酷无情的 iBus 用户有有什么关系呢。\n\n\n\nSukka's Blog\n“童话只美在真实却从不续写”\n另一个自称有大尾巴的狐狸。是 Hexo 的维护者之一。虽然我不用 Hexo 了但是觉得还是很厉害因为我之前看过 Hexo 的代码觉得迷迷糊糊。\n\n\n\nwgjak47's blog\n“运维开发工程师”\n是我看不懂的那种（指 k8s）\n\n\n\n初等記憶體\n「 一個你知道的地方，和一個沒有酒的故事 ｜ 言文 」\n听起来好像萌妹啊！\n\n\n\nOriginCode 札記\n「無人訪問之無趣博客 * 1」\n好像现在是 初中生 高中生来着……\n\n\n\nVifly 的博客\n“世上只有一种英雄主义——就是在认清生活的真相之后依然热爱生活。”\n好像是炼丹的，反正我又不懂炼丹……加速迫害不要停下来啊！\n\n\n\nLeo's Field\n\"Time to change drinks and mix... wait\"\n呃……之前忘记了……（逃\n\n\n\nBluempty\nhypengw 的博客\n\n\n\n其他地方认识的\n不符合其它几个模块的描述并不意味着不重要！\n\n\n\n头像\n链接\n自我介绍\n我的介绍\n\n\n\n\n鸡腿工坊\n\n新鲜 可爱的鸡腿！\n\n\n\n高渐离の屋\n一个不起眼的个人小站\n我真心觉得在他们公司干久了容易变杠精。\n\n\n\ntaoky's blog\n\n我读了一篇关于 GNOME 调试的博客觉得很有意思，推荐大家也看看。\n\n\n\nlugosi\n“我不做人啦jojo”\n看地偶认识的程序员……\n\n\n\n告白，花，狼 \n“👓 稻花香里说丰年，听取人生经验。”\n看地偶认识的程序员……\n\n\n"},{"title":"关于","url":"/about/","content":"我，可爱（划掉），打钱！\n生活所迫，经济不好，房租太贵……（省略一万字卖惨）\n我突然觉得很少有人打赏可能是因为我太克制了，打赏按钮并不怎么明显，而且还要点一下才能出现二维码。\nAnyway，点到这个页面说明你对我很感兴趣，我写的东西你觉得不错。否则吃了蛋干嘛去认识母鸡呢？所以我在这放一下打赏用的二维码不过分吧！\n打多少？多多益善！谁和钱过不去啊！\n\n  \n  \n\n\n个人简介\nAlynx Zhou\n Arch Linux user, Linux desktop dev at SUSE, self-taught cameraman, harmonica player.\n 邮箱：alynx.zhou@gmail.com\n Telegram: @AlynxZhou\n GitHub: @AlynxZhou\n PGP Fingerprint: 87F2 E316 E0AB C98B 9DE8  D4EF 042F D810 6009 54EF\n想了又想还是觉得以前写的介绍太造作了所以决定换个正常点的版本。\n\nAlynx Zhou / Alynx / AZ，能叫 Alynx 当然是最好的，我其实不是很介意你怎么称呼我，毕竟这是我自己造的名字，只要你尝试去读就很好了。但如果你实在担心自己读错或者是我懒得教别人怎么读的时候我一般会直接简称 AZ，其实这个我一开始写在博客里的时候只是觉得这个缩写也还行而且够简单，并没有真的打算让别人用这个，不过认识蓝猫之后她看了一下博客开始这么称呼，然后我觉得要不就让别人记个简单的吧，所以就接受了。虽然很多人会只读自己认识的部分但是最好还是别只叫 Zhou 毕竟前面才是我自己起的，不过我也不会生气。最后如果你一定要知道我想怎么读，其实这只是把 a lynx 写在一起而已，就这样读就可以了。\n最喜欢的二次元人物是 科学超电磁炮 里的 御坂美琴，与所有其它我喜欢的二次元人物都不同的是她是我希望成为但可能又永远不能成为的人。顺便一提我站黑琴讨厌上条当麻。其它喜欢的二次元人物有 Fate/stay night 里的 阿尔托莉雅·潘德拉贡 和 明日方舟 里的 浊心斯卡蒂。单推 中单光一，头像作者是 茶冷。\n操作系统使用 Arch Linux，桌面环境使用 GNOME，浏览器使用 Firefox，编辑器使用 Emacs，自己写的话首选的语言是 C 或者 JavaScript，移动设备选择 Android，Valve 对 Linux 友好所以用 Steam，Minecraft 只玩 Java 版因为基岩版不支持 Linux，Adobe 不支持 Linux 所以用 DaVinci Resolve Studio 剪视频。写这些是为了提醒有些喜欢到处安利或者打嘴仗的人别来烦我。\n半音阶口琴吹的并不算特别好，只求自己开心。摄影水平一般但自己觉得能给自己拍壁纸就算成功。\n最喜欢的歌手是 Kalafina，其它人的话听 陈奕迅 比较多。\n猫和狗之间更喜欢猫，可口可乐和百事可乐之间更喜欢可口可乐，肯德基和麦当劳之间更喜欢汉堡王，不过也不是非常讨厌另一种。\n不要盗用我的头像，因为这是专门画给我的。也不要直接复制或者简单替换我写的内容，因为我看到了复制品会觉得自己的很尴尬。\n被人需要是我存在的意义。\n别的什么想到了再写吧。\n对于一些人，在对着这个页面按 Control + c 之前，麻烦先看一下第 17 条。\n\n1. Alynx 可以接受的称呼包括 Alynx Zhou，Alynx，AZ。\n\n2. Alynx 这个词的来历、读音是 *Alynx* is just **a lynx**。\n\n3. Alynx 的本命是 **科学超电磁炮** 中的 “超电磁炮” *御坂美琴*，*Misaka Mikoto* 和 **Fate/stay night** 里面的 “Saber” *阿尔托莉雅·潘德拉贡* 还有 **明日方舟** 里面的 *浊心斯卡蒂*。有重要影响的 Dota 2 主播是 [*中单光一*](https://space.bilibili.com/434401868)。头像作者是 [*茶冷*](https://space.bilibili.com/741520)。\n\n4. Alynx 对 LGBTQIA 没有歧视但是 Alynx **只喜欢女孩子**，想要认识可爱的小姐姐，想脱单。\n\n5. Alynx 像猫一样懒。Alynx 是 Arch Linux CN 社区里稀有的 GNOME 用户之一，希望大家爱护稀有动物。\n\n6. Alynx 喜欢 Arch Linux、Atom、C、Coffee Script、Vala、GNOME、Firefox、Android，你也许喜欢与这些对立的软件，但是 **每个人选择自己喜欢的东西一定有她的理由**，所以 **请不要强行向 Alynx 安利你喜欢的东西**。\n\n7. Alynx 喜欢音乐，目前最喜欢的乐器是半音阶口琴，有不到 7 年的琴龄。学过 4 年二胡并通过了业余水平十级（虽然现在几乎忘光了），选了扬琴选修课所以大概了解一点点，其他了解一点的乐器还有长号，选到课的小提琴。\n\n8. Alynx 坚持用 Steam 并拒绝 Origin/Epic/Battle.net 等不打算支持 Linux 的平台（既然你忽略 Alynx 的需求，Alynx 也忽略你），*Counter-Strike: Global Offensive* 是目前为止唯一一个玩了超过 600 小时的游戏（现在 *Dota 2* ~~也快到这个时长了虽然都是打机器人~~ ~~已经 800 小时了~~ 已经两千小时了），唯一能够玩进去的 RPG 大概是 *NieR: Automata*，感谢室友特地带 PS4 到学校还和高中同学借了光盘让 Alynx 能和另一个室友一起以 60FPS 和高清画质通关主线（这话说的有点乱套）。另外表示室友四个人一起玩 *Overcooked!* 非常开心，所以预购了 *Overcooked!2*。Alynx 还买了 Switch。\n\n9. Alynx 的 Steam ID 是 AlynxZhou，如果你想和 Alynx 一起玩游戏的话。不定期在线，并且 **如果你是那种很吵很暴躁张口就喷队友的人请不要来打扰 Alynx**。\n\n10. Alynx 喜欢 Minecraft（Java版），**不接受 Win10 版**，PE/Java 版正版玩家（非网易版）。\n\n11. Alynx 已经玩不动崩坏 3rd 了，即使有小姐姐 + 数一数二的渲染技术。Alynx 也肝不动 Fate/Grand Order（非的肝不过欧的）（明明是碧蓝航线！）了，~~还没抽到蓝呆是 Alynx 没有卸载游戏的一个重要理由，她已经快绝望了~~**已经抽到蓝呆了**（已经满破了）（我永远喜欢FGO）（快说声多谢叶哥哥）（抽到蓝呆并升级到 100 之后感觉游戏失去了目标，已经半退坑了）。\n\n12. Alynx 想研究有关游戏渲染的东西，略微懂一点 OpenGL ES 和相关的矩阵运算。\n\n13. Alynx 不仅想学日语，还抓住机会选到了日语二外的任选课。\n\n14. Alynx 认为 *Kalafina* 和 *梶浦由纪* 才是 **真正的音乐**，*Hikaru* 是 Alynx 安装 Twitter App 的唯一理由。Alynx 也喜欢陈奕迅和林俊杰以及张杰这种唱功或者音乐水平确实到位的歌手，网易云有里《富士山下（live）》，《倾城》，《喜帖街》，《K歌之王》，《你给我听好》，《修炼爱情》。\n\n15. Alynx 经常懒癌发作什么也不想做，Alynx 是个不肥（182 cm / 72 KG）的宅。Alynx 喜欢猫，不太喜欢狗。\n\n16. Alynx 喜欢写一些乱七八糟的东西，即使这些没有人喜欢看，但无论如何 **这是我们在面对世界这一庞然大物时能留下的一点声音，无论是惊慌失措还是泰然面对**。\n\n17. Alynx 讨厌复制品。加上这一条是因为 Alynx 最近看到了好多部分复制这个页面的网站，Alynx 愤怒地希望这些人认清楚什么是拿来分享的什么是拿来看的。如果这些人不能理解，Alynx 表示你们 **尽管使用这个页面的样式**，因为代码就在 GitHub 上并使用 Apache-2.0 公开授权，Alynx **非常高兴你们喜欢这个主题，毕竟她用了两周来实现这些样式**。但是对于使用 CC BY-NC-ND 4.0 授权的网站内容，希望你们不要拿来充实自己的页面，这种行为有时候会让 Alynx 觉得自己写的很中二而删除对应的文章。**你有一千种介绍自己的方式，但没必要把 Alynx 的面具拿过来抠几个窟窿贴在自己脸上，因为 Alynx 会觉得痛**。你也可以给自己的网站起个独特的名字，而不是看到 StackHarbor 就觉得这个词天下第一，拿来自己用可不是夸奖 Alynx 审美的好办法。\n\n18. Alynx 喜欢的电影和动漫有《科学超电磁炮》，《Fate/Stay Night》系列，《Fate/Zero》，《你的名字。》，《爱乐之城》，《海上钢琴师》以及《新世纪福音战士》（包括 TV 和剧场版），《黑执事》。\n\n19. Alynx 最喜欢 C，曾经在 Bilibili 直播了一段时间的 C 语言教学。如果是动态类型那么一定是 Coffee Script，如果是面向对象又要静态类型，Alynx 选择 Vala。\n\n20. Alynx 喜欢可口可乐。\n\n21. Alynx 觉得能不能写出优美的句子完全看运气，即使有时候会因为自己写不出来而感到没用。\n\n22. Alynx 觉得谈了也没用所以 **莫谈国事**。\n\n23. Alynx 的梦想是环游世界（如果不去非洲南美洲也算的话？），一个人待在陌生的，没有人知道的地方仰望天空。\n\n24. Alynx 不喜欢说话，除非是对喜欢的人 / 说喜欢的东西，当然以前说的都是编程或者音乐，身边的人总是听不懂。\n\n25. Alynx 觉得 **比起依赖别人，我更喜欢被人依赖，如果我喜欢的人能让我默默付出我就很满足了**。所以不要和 Alynx 客套。\n\n26. Alynx 强调一下 **上一条很重要**。\n\n27. Alynx 讨厌需要鼠标不停点点点的程序，比如 MS Office，Visual Studio，手还要留着玩 FPS 游戏呢。\n\n28. Alynx 能有一个所爱的人这件事情和 root 密码一样重要，即使不知道她是否爱我。\n\n29. Alynx 讨厌刷题，讨厌为了刷题写出丑陋的代码，千金难买喵乐意。\n\n30. Alynx 更喜欢 Telegram 虽然不得不用 QQ 和微信。Alynx 也不能接受一个乱七八糟的播放器，Alynx 现在用网易云音乐下音乐然后用 Retro Music / Rhythmbox / MPV 听。Alynx 使用 Audacity 录制和编辑音频。Alynx 使用 DaVinci Resolve 剪辑视频，并且因为它支持 Linux 所以购买了 Studio 版本。\n\n31. Alynx 宁可不看不能在 Bilibili 上看而只能在其他国内视频网站上看的视频。Alynx 偶尔会在 Bilibili 直播 吹口琴 / 打游戏 / 写代码。\n\n32. Alynx 希望听到别人的声音，即使我常常讨厌交流。如果有什么问题，欢迎发邮件 / Telegram 甚至是去 [这里](https://github.com/AlynxZhou/AlynxZhou.github.io/issues) 发issue。\n\n33. Alynx 说你好，世界。\n-->\n\n有关域名和点击量\n由于选择困难和没事折腾的原因，建站以来换过三个域名了，然而我用的是不蒜子计数，它按照完整 URL 计算点击量，所以每更换一次域名就会丢失一次数据，并且作者一直咕咕咕所以没办法把数据转移过来，而我又想不到好的造轮子方案，所以丢了也就丢了，记在这里主要是怕我忘了，而且可以写一下换域名的理由，还挺好玩的：\n\nalynx.xyz：用了三年，点击量大概 10 万以上。2016 年我租这个域名的时候，xyz 后缀还没有打折，直到后来它开始搞活动变成垃圾站最爱……\nalynx.moe：用了不到一年，点击量大概 3 万以上。主要是有了收入发现自己能负担得起稍微贵一点的域名，然后觉得这个 moe 很可爱，直到后来我发现要给非动漫迷介绍这个实在是有点难……而且想了想以后年纪大了也不能总充可爱，会惹年轻人讨厌的（这都哪跟哪啊）……\nalynx.one：现在用的这个。主要原因是我有另一个 ismyonly.one 的域名，one 后缀年费很便宜也没套路，而且介绍起来也很简单。其实一开始我很想注册 ooo 后缀，后来发现是个印度公司搞的而且有人说容易和 000 混……我倒不是对印度公司有什么偏见（你就是有吧喂！），我只是想起 cat 后缀不是猫猫却被加泰罗尼亚人霸占的悲惨事实……\n\n看不见我 首页 test\n看不见我 首页 test\n\n\n\n\t\n\n-->\n"},{"title":"Undefined Script Works!","url":"/Undefined-Script-Works/","content":"Undefined Script Works!\n「I am the shell of my system.」\n「Command is my body, and argument is my blood.」\n「I have created over a thousand lines of logs.」\n「Unaware of less, nor aware of more.」\n「Withstood error to create many scripts, waiting for prompt's arrival.」\n「I have no mouse. This is the only bug.」\n「My whole life was `Undefined Script Works`!」\n\n一些个人项目\n大概都是些我觉得比较有意思的小玩意。\nHikaru\n生成这个网站的静态生成器。\n\n主页：https://hikaru.alynx.one/\n仓库：https://github.com/AlynxZhou/hikaru/\n\nReFrame\n又是一个 Linux 远程桌面，但是使用 DRM/KMS 的接口抓取显示内容，使用 EGL/OpenGL ES 解码像素，使用 uinput 处理输入，因此可以支持各种显卡驱动下的各种桌面环境，无论是 TTY 还是 Wayland。\n\n主页：https://reframe.alynx.one/\n仓库：https://github.com/AlynxZhou/reframe/\n\nShow Me The Key\n在屏幕上显示你按的键，Screenkey 的替代品，采用 libinput 作为后端因此可以同时支持 X11 和 Wayland。\n\n主页：https://showmethekey.alynx.one/\n仓库：https://github.com/AlynxZhou/showmethekey/\n\nFlipClock\nC 语言实现的一个开源 Fliqlo 替代品（macOS 用户视频里经常出现的翻页时钟屏保）。基于 SDL2。\n支持 Linux/Windows/Android 且 不依赖 Adobe Flash。可直接设置为 Windows 屏保，绿色免安装。\n\n仓库：https://github.com/AlynxZhou/flipclock/\nAndroid 仓库：https://github.com/AlynxZhou/flipclock-android/\nWindows 编译好的安装包下载：点击带有 win 的压缩包\nAndroid 编译好的安装包下载：点击 apk 文件 或者去 Google Play Store。\n\nKouichi 100\n“帮助光一天梯上分”小游戏，基本上是个平面版神庙逃亡，完全使用原生 JS 编写。\n\n主页：https://kouichi100.ismyonly.one/\n仓库：https://github.com/AlynxZhou/kouichi100/\n\nGNOME Terminal Middle Click Close Tab Patch\n一个添加了使用鼠标中键关闭 tab 功能的 GNOME Terminal（上游维护者脾气古怪，觉得鼠标中键关 tab 容易误触并且容易和中键粘贴所以不合 patch，我个人觉得没这么大问题，不过无所谓）。\nArch Linux 用户直接到 AUR 安装。\nGNOME Shell Extension Net Speed\n由于 Simple Netspeed 这个扩展一直没有修掉它字符乱闪的 bug（好像作者说是什么他不想让这个玩意总变化宽度所以设置了固定的 width 然后删了就好了但是他不想改，不过说实话我觉得这玩意最多也就变两个字符宽），于是我就自己写了一个，我不太需要它那么多模式也不需要调整字号，所以这个扩展只有一种模式和一种字号。\n\n仓库：https://github.com/AlynxZhou/gnome-shell-extension-net-speed/\nGNOME Shell 扩展安装页：https://extensions.gnome.org/extension/4478/net-speed/\nAUR: https://aur.archlinux.org/packages/gnome-shell-extension-net-speed/\n\nGNOME Shell Extension Fixed IME List\n我不知道是哪个脑子抽了的小天才在 GNOME Shell 里加了代码给输入法列表改成了最近优先排序，输入法列表顺序一直变化，于是当我有三个输入法的时候再也没办法不看列表盲切到我想要的那一个。这个扩展通过 hook 掉 GNOME Shell 里面一系列的函数去掉了这个添乱的“特性”，还你一个顺序固定的输入法列表。\n\n仓库：https://github.com/AlynxZhou/gnome-shell-extension-fixed-ime-list/\nGNOME Shell 扩展安装页：https://extensions.gnome.org/extension/3663/fixed-ime-list/\nAUR: https://aur.archlinux.org/packages/gnome-shell-extension-fixed-ime-list/\n\nGNOME Shell Extension Always Show Workspace Thumbnails\n更新到 GNOME 40 后如果你刚开机只有一个工作区的话，工作区缩略图现在是不显示的，开发者表示“这是为了方便那些不会用工作区的用户，给他们提供更大的空间”，但我觉得这位小天才显然忽视了我们这些高度依赖工作区的用户，另外我个人觉得既然你把工作区作为自己的主要特性之一，那对于那些不会用工作区的用户应当想办法提示并教会他们使用工作区，而不是迁就他们从而影响到已经在使用这个特性的用户。不过反正我看了一眼代码非常简单，这个扩展 hook 掉那个决定是否显示工作区缩略图的函数，不论什么时候都返回 true 就可以了。太过简单以至于我都没有给它打 AUR 的包。\n\n仓库：https://github.com/AlynxZhou/gnome-shell-extension-always-show-workspace-thumbnails/\nGNOME Shell 扩展安装页：https://extensions.gnome.org/extension/4156/always-show-workspace-thumbnails/\n\nAZBot-Telegram\n零依赖 Node.JS Telegram Bot API 框架。\n\n主页：https://tgbot.alynx.one/\n仓库：https://github.com/AlynxZhou/azbot-telegram/\n酒仙：点这里让酒仙帮你决定晚上吃什么\n\nAZPiano\n一个把你的按键映射成钢琴并且能记录下来的 React Web App。更新：已经不是 React 实现的了，因为我发现所有功能我都可以用 Vanilla JS 写，而且 create-react-app 引入了大把大把的依赖，不管升级到哪个版本都有可能有风险的依赖。\n\n主页：https://piano.alynx.one/\n仓库：https://github.com/AlynxZhou/azpiano-vanilla/\n\nAlynx Live Wallpaper\n让你选择视频作为壁纸的 Android 应用。\n\n主页：https://livewallpaper.alynx.one/\n仓库：https://github.com/AlynxZhou/alynx-live-wallpaper/\n下载：Google Play Store。\n\nBilibili Update Checker\n定期爬取列表里的 B 站 UP 主的最新视频，并显示已经有多久没有更新。\n\n仓库：https://github.com/AlynxZhou/bup/\n示例：https://c4boom.ismyonly.one/\n\n"},{"title":"Arch Linux ARM 在树莓派上的内核选择","url":"/posts/RPi-Arch-Linux-ARM-Kernel/","content":"对于 64 位的树莓派 3 和树莓派 4，安装 Arch Linux ARM 的话有两种内核可以选择，一种是来自博通的 rpi 分支，包含一些树莓派特定的补丁，另一种是来自主线的 mainline 分支，这个和其它系统的内核来源没什么区别。如果你选择安装 64 位的系统，那默认附带的是 mainline 内核，使用上没有什么问题，包括 GPU 驱动也是可用的，但是常见的那些操作树莓派 GPIO 的库和代码都假设你使用的 rpi 内核，会通过 rpi 内核特定的接口操作，因此如果你需要使用 GPIO 但又不想自己从头操作 GPIO 的话就需要使用 rpi 内核。但是默认只有 32 位的 Arch Linux ARM 使用的才是 rpi 内核，在 64 位机器上运行 32 位系统感觉怪怪的。\n在我刚拿到 64 位的树莓派的时候就是这么个情况，也许是历史遗留问题，那时候似乎没有运行 rpi 内核的 64 位系统可以选择。但是我最近把吃灰的树莓派 4 拿出来的时候搜索了一下包列表，发现其实已经有 rpi 内核可以用了，这里简单记录一下怎么切换成 rpi 内核。\n具体安装只需要安装下面两个包：\n# pacman -S linux-rpi raspberrypi-bootloader\n\nMainline 内核使用 u-boot 作为 bootloader，而 rpi 内核需要使用对应的 bootloader，安装的时候会提示你要移除原本的内核和 u-boot，确认就可以了。然后如果你改过 mainline 内核的启动参数，那需要也对 rpi 内核修改一下，因为两者修改启动参数的方式不一样，rpi 内核和其它的树莓派系统一样使用 cmdline.txt。\n然后在安装 mainline 内核的系统时你应该已经按照安装说明把 fstab 里面的 mmcblk0 换成了 mmcblk1，这时候需要换回 rpi 内核使用的 mmcblk0。\n"},{"title":"Emacs 在终端里大喊 OI","url":"/posts/Emacs-Says-OI-in-Terminal/","content":"Emacs 和大部分现代的代码编辑器不同，它除了是 GUI 编辑器还是一个 TUI 编辑器（或者说 GUI 的部分只是实现了一个特别的终端模拟器更合适）。所以当我出门使用 MacBook 但又要写 Linux 项目的时候，我发现直接 ssh 到 Linux 机器上调用 TUI 的 Emacs 比把代码同步过来再同步回去更加方便。但是很快我遇到一个奇怪的问题，只要我在 iTerm2 上移动鼠标，Emacs 里面就插入出现一串奇怪的编码一样的内容，然后只要切到别的窗口再切回 iTerm2，Emacs 就会插入一次 OI。如果是在 tmux 里面运行 Emacs 则不会有这个问题，让我百思不得其解。\n我甚至关掉了 Emacs 对于终端鼠标事件的支持，但是 OIOI 还是存在，我开始怀疑这是 iTerm2 的鼠标支持的问题，于是我去 Emacs 中文群里面问了一下。群友 @Kana 建议我使用 emacs -Q -nw 测试一下会不会是我配置文件的问题，因为他之前遇到过同样的问题，原因是绑定了 M-[ 的快捷键，结果我试了一下还真是！我把 M-[ 绑定到了一个减少 tab stop 的函数上，注释掉这一行就好了。\n既然知道问题出在哪里，原因也就很好分析了。终端里的复杂功能是通过使用 \\e[ 开头的转义控制序列实现的，比如最常见的颜色输出就是通过 \\e[ 开头后面跟上颜色编码实现，鼠标事件和窗口焦点切换也是。而 Emacs 有一个特性是如果你的键盘没有 Alt 键，那么你可以先按下 Esc 键再松开，然后再按下另一个键，就等价于按下 Alt 键和这个按键的组合键。恰好 \\e 是 Esc，于是转义序列就被 Emacs 识别为一个 M-[ 的组合键然后执行了我绑定的函数，后面的编码就会被认为是普通字符插入到 buffer 里面。所以对于 \\e[I 和 \\e[O，看起来就好像 Emacs 在不停大喊 OIOIOI 一样。以前我是完全不考虑在终端里运行 Emacs 的，所以就忽略了这个问题。\nPS 每年跨年之后我都会忘记要写新一年的年份，比如刚刚我就把这篇文章的日期写成了 2025 年 2 月 1 号然后思考为什么它没有出现在首页上。\n"},{"title":"我始终搞不懂多光标编辑和查找替换的区别在哪","url":"/posts/No-Idea-about-Multiple-Cursors/","content":"许多代码编辑器的官网都会展示自己的多光标编辑功能，乍一看这似乎是个效率很高很花哨的功能，但是实际上我从来没能真正的在实际写代码时利用它。\n最早推出多光标编辑的可能是 Sublime Text，但是我没有使用过。我在使用 Atom 的时候第一次接触到这个，Atom 激活多光标的方法之一是使用 Ctrl + 鼠标点击来产生多个光标，这可以让你快速的定位到你需要编辑的位置，唯一的缺点是你的手需要先离开键盘去拿鼠标。如果你不想让手离开键盘再回来（或者你的场景不适合用鼠标），那剩下的插入光标的方法无非是在上一行或者下一行的相同列插入光标，或者选中什么文本然后在所有相同的文本处插入光标。\n这就导致一个关键问题：每当我意识到“这个地方是否可以用多光标编辑一次解决”的时候，最后都会变成用查找替换解决。不信？当你在所有相同文本处插入光标的时候，不就是对这个文本进行了一次查找吗？然后你对所有这些光标进行的编辑也是相同的，那不就是替换吗？\n或者换一种说法，其实多光标编辑最大的问题是：除了用鼠标，没有什么简单的办法定位到许多不同的需要操作的文本。这个问题也不是多光标能解决的，多光标只能解决定位之后的事。我唯一能想到的可以绕开定位问题的场景是模版补全，编辑器自动给模版里多个需要用户起变量名的地方插入多光标，用户可以一次输入这些变量名。但是这又要求模版补全的插件考虑到是否有多光标功能。\n如果你能想到什么非常适合多光标编辑的场景和更加简单的用法，希望你能在评论区告诉我，这样也许我能意识到怎么用多光标更合适然后成功的把它加入我的工作流。\n与此类似的是，我经常意识到在编辑代码的时候自己的操作能力是有限的。如果有一些看起来效率很高的操作，但是由于各种原因，比如使用频率太低了记不住，或者操作起来需要思考很久，那它带来的思维负担超出了进行一系列下意识简单操作的难度，我会倾向于干脆放弃。一个不太恰当的例子可能是我在 Emacs 里面实现了类似 Vim 的按 C-o 在当前行下面添加一行空行，但实际上我根本没有用过它，因为我发现我按 C-e Enter 并不会慢多少，而且我根本不需要思考就可以做出这个操作。再比如虽然几乎所有 Emacs 用户都在用 magit，但我的习惯还是回到终端里面操作 git。总之我常常羡慕别人编辑代码时花哨的操作，但同时我又会意识到这也许真的不适合我。\n"},{"title":"通过 Intel 无线网卡远程唤醒台式机","url":"/posts/Wake-on-WLAN/","content":"有些时候需要开启我的台式机处理一些任务，特别是出门在外的时候，但是台式机里有两块 NVIDIA GPU，如果一直开机也太不节能环保了。所以就想研究一些能远程开机/唤醒的方案。\n最简单的方案肯定是 BIOS 里设置来电就开机，然后购买一个智能插座。但是在智能插座到货之前我就要出门，所以决定排列组合一下手头的方案。\nBIOS 里通常都会有 Wake on LAN 的支持，这个是网卡收到特定格式的数据包，检查和自己 MAC 地址一致之后就会通过 PCI-E 唤醒电脑。所以一开始我打算尝试这个，反正 Mac mini 待机功耗非常低，我可以一直开着 Mac mini，然后需要的时候 ssh 到 Mac mini 上再给台式机发包唤醒它。\n第一个障碍是虽然我的 Mac mini 和 NAS 和台式机之间是有网线连接的，但是 Intel X550 这个万兆网卡是不支持 Wake on LAN 的。所以我不得不再拿一根网线把主板的板载网卡和 Mac mini 连接起来。\n参考 Arch Wiki 的 Wake-on-LAN 页面，需要在 BIOS 的高级电源管理里打开由 PCI-E 设备唤醒，但是只打开这个不行，因为这样关机时候网卡还是没有启用的，所以需要打开 UEFI 网络堆栈，让网卡即使是在关机的状态下也可以启用。然后根据网上的说法还需要关闭 BIOS 的快速自检 经过验证并不需要关闭 BIOS 的快速自检，也许大家把这个和 Windows 的快速启动搞混了。然后在系统里用 ethtool -s eth0 wol g 开启网卡的 Wake on LAN 支持。但是即使以上都打开了，还是不能保证能用。至少我主板上的两个网卡都没法唤醒机器。开机状态下用 netcat 可以看到 Wake on LAN 的 magic packet，关机/睡眠状态下可以看到网卡灯亮着，但是就是没有办法唤醒机器。按道理说 Wake on LAN 只和连接层有关，所以并不需要配置 IP，我也不清楚中间哪里有问题导致不能用，也许是 macOS 或者是什么的限制导致必须指定 IP？但 BIOS 里又没有办法配置 UEFI IPXE 的 IP 地址。\n就在我觉得没有希望的时候，@lilydjwg 跟我说其实无线网卡也是可以唤醒电脑的，Intel AX200 确实支持这个功能。只要使用 iw phy phy0 wowlan enable magic-packet 启用就可以了。也许需要用 nmcli con modify CONNECTION 802-11-wireless.wake-on-wlan 0x8 给 Network Manager 也启用一下。\n唤醒的方式和 Wake on LAN 一样，Linux 下面可以使用 wol，macOS 下面可以使用 wakeonlan 这个脚本。需要注意的是无线和有线一样需要有连接才能收到包，有线只要插着网线就行了，但无线需要保持你的 WiFi 连接，而 UEFI 是没有无线网络支持的，所以 Wake on WLAN 只有睡眠状态能唤醒，不像 Wake on LAN （理论上）支持关机状态开机，但是对我的省电需求来说，休眠到内存也足够了。\n因为需要一直保持 WiFi 连接才能用，如果你的网络管理器/无线网卡开启了什么省电模式，它可能会在睡眠一定时间后断掉连接，然后就没办法唤醒了。这自然不是我们希望的，所以可以使用 nmcli con modify CONNECTION wifi.powersave 2 关掉 Network Manager 的省电模式，然后给 iwlwifi 内核模块设置 power_save=0 关掉驱动的省电模式。\n使用流程就是 systemctl suspend 让电脑休眠。然后用同一个网段的 Mac mini 执行 wakeonlan -i 目标 IP 地址 目标无线网卡 MAC 地址，就可以发出 magic packet，台式机收到后就会唤醒了。理论上来说这个和 IP 无关所以可以不指定 IP 地址以发送 magic packet 到广播地址，但是不知道为什么这样唤醒不了。\n需要注意的是以上各种依赖的都是 PCI-E 设备可以唤醒系统的功能，因此只有板载的无线网卡或者 PCI-E 的无线网卡可能支持唤醒，USB 无线网卡是不行的。\n\n更新（2025-10-11）：我似乎摸索出了有线网卡唤醒的正确方案，默认 wakeonlan 使用的 255.255.255.255 这个广播地址不起作用。似乎你应该使用的不是目标网卡的 IP 地址而是 要发包的网卡所在网段的广播地址：比如我把 Mac mini 和台式机用网线直接连接起来，然后给 Mac mini 的有线网卡设置 IP 地址为 10.10.11.9/24，那么只要我使用 wakeonlan -i 10.10.11.255 目标有线网卡 MAC 地址 就可以正确唤醒台式机了。即使台式机处在关机状态，只要打开了 BIOS 里的由 PCI-E 唤醒和 UEFI 网络堆栈也可以开机。对于 IP 是 192.168.1.9/24 的 Mac mini 无线网卡，也可以使用 wakeonlan -i 192.168.1.255 目标无线网卡 MAC 地址。我不知道为什么使用目标无线网卡的 IP 地址也可以唤醒，但也许是路由器替我做了什么工作，没有路由器的情况下就必须使用要发包的网卡所在网段的广播地址了。\n"},{"title":"ReFrame：又一个 Linux 远程桌面","url":"/posts/ReFrame-Yet-Another-Remote-Desktop-for-Linux/","content":"虽然大部分时间有 SSH 就足够方便高效的控制一台 Linux 设备，但是难免会遇到一些需要在某台机器的桌面环境下才能处理的需求。Linux 下面有各种各样的远程桌面实现方案，但是也各有各的问题。\n传统基于 X server 的远程桌面方案就不用说了，2025 年连 NVIDIA 都支持 Wayland 了，当然不能要求用户使用远程桌面之前再注销登录进 X11 会话。RustDesk 是个流行的远程桌面方案，但是它不支持 Wayland 下面的远程登录。如果不考虑其它桌面，只考虑我使用的 GNOME 的话，GNOME Remote Desktop 确实号称自己支持远程登录，但是当初我们讨论这个的实现方式的时候，由于 GNOME 的远程桌面接口是每个用户会话独立的，所以必然涉及不同的服务之间的切换，然后他们选择用 RDP 协议的 ServerRedirection 来实现，结果就是只能使用 RDP，而且不是所有的 RDP 客户端都支持 ServerRedirection，比如 macOS 下的 Windows App 就不支持，于是对我来说也几乎不能用。而由于我不用 wlroots，我也不确定 wayvnc 是否支持远程登录。\n解决问题的希望出现在 kmsvnc，这个项目使用 DRM 的 API 获取显卡输出的 frame buffer，因此和使用什么桌面完全无关，然后使用 VA-API 将 frame buffer 解码成像素数据交给 VNC 服务器，使用 uinput 处理 VNC 服务器的输入数据。而对于没有连接显示器的设备，可以使用 Linux 的功能强制开启一个显示输出。虽然这个项目还有一些问题，比如在 NVIDIA 显卡上 VA-API 没法解码，但是和作者 @isjerryxiao 聊了一下之后，他觉得应该使用 OpenGL 的 API 来解码 frame buffer，因为显卡的渲染部分总是要能处理自己的格式的。然后还有些其它的问题，比如 kmsvnc 只在启动时获取一次 buffer ID，导致从登录界面切换到用户会话时不会更新显示内容，以及由于 DRM API 需要权限，整个程序都以 root 权限运行，这对一个网络服务来说不是很安全，我把这些作为改进的目标。\n为了解决权限问题，我决定将程序分成两部分，高权限的进程仅仅负责从 DRM 抓取 frame buffer 和向 uinput 写入输入事件，低权限的进程负责启动一个 VNC 服务器，将 frame buffer 渲染成像素数据传给客户端和处理客户端的输入事件。DRM 导出和 OpenGL 导入交换的是内核的 DMA-BUF 文件描述符，而 UNIX socket 是可以传送文件描述符的，所以两个进程使用 UNIX socket 通信。为了避免重复造轮子，直接使用了 GLib 的各种工具和事件循环。\n抓取 frame buffer 的部分其实没有什么困难，而输入处理的部分值得注意的是正常的坐标输入设备分为相对和绝对两种类型，常见的鼠标和触摸板属于相对设备，而触摸屏属于绝对设备，当然像 VNC 传过来的坐标自然也是绝对坐标，所以这里需要模拟成绝对设备，不然很难处理客户端和服务端初始的光标位置不一致的情况。像 Qemu 这种虚拟机，默认虚拟的指针设备也是触摸屏而非鼠标。唯一一个需要注意的点是你需要给 uinput 的指针设备指定 X 轴和 Y 轴的长度，但是这里不要求你比例和屏幕尺寸一致。因为一般桌面环境会将指针设备的 X 轴和 Y 轴映射到整个虚拟桌面的大小，所以你需要的其实是鼠标指针在整个虚拟桌面中位置的比例，然后直接乘上你设置的长度值。\nOpenGL 的部分和普通的程序不一样的地方在于普通的程序一般是跑在窗口系统里的，使用 EGL 建立 OpenGL Context 的时候建立的是基于窗口系统的，但是这个程序是独立在窗口系统外面的，所以使用的是 EGL 的 surfaceless 平台，需要设置 EGL_PLATFORM=surfaceless 进行离屏渲染，然后对于大部分双显卡的系统，默认的 surfaceless 设备并不是实际用于输出的设备，但是只有显卡自己才能解码自己的输出格式，因此还要选择用哪个显卡渲染才行。至于将 DMA-BUF 的文件描述符导入成 OpenGL 纹理的 EGL_LINUX_DMA_BUF_EXT 虽然是个扩展，但是因为 0 拷贝的渲染需求实在是太常见了，一般的显卡应该都实现了，所以不算什么特别过分的要求。\n输入事件处理最难的地方在于把客户端的坐标映射成相对整个虚拟桌面的全局坐标，因为虚拟桌面其实是桌面环境的概念，我们没什么特别好的办法拿到整个虚拟桌面的尺寸，和当前选中的显示器在虚拟桌面中的相对位置，只能把这个问题留给用户，让用户手动设置在配置文件里面。\n整个进程完全是单线程的，不管是 socket 通信还是 VNC 服务器都跑在 GLib 的事件循环上，既然单线程的性能完全足够，就没有必要搞多线程增加额外的复杂度了。我觉得最妙的地方是我利用 systemd 的 socket 来启动高权限的进程，这样就做到只有当有 VNC 客户端连接进来时，高权限的部分才会运行，而所有 VNC 客户端都退出之后，高权限的部分也会退出。按需启动高权限进程增加了安全性，也减少了不必要的资源占用。\n项目地址：GitHub Repo\n"},{"title":"在 NAS 上部署更多服务","url":"/posts/Deploy-More-Services-on-NAS/","content":"由于篇幅限制就不写详细的配置教程了，只会记录一下文档里没有的需求和解决方案。\n\n基本上我在 NAS 上部署服务的时候相当克制，虽然我装了一个完整版的 Linux，但其实也就跑了 samba 共享、headscale 和 transmission 下载这几个。我也没有什么出门在外远程看家里的蓝光电影的需求，再说公网网速也很难满足这个需求，所以除非确实需要什么新功能，我会尽量少在 NAS 上运行服务。不过最近我还是发现了一些需求需要部署其它服务来解决。\n\n\nPhotoPrism\n一个我早就想解决的需求是按时间线快速查看照片，我发现翻看以前的照片非常有意思，有些已经忘掉的事情看到照片就会想起当时的心情，所以要是能快速的点开过去某个时间的照片就好了。\n为了不把自己绑死在某个后期软件上，我没有使用诸如 Lightroom 这类软件内置的照片库管理，而是直接将照片从存储卡里复制到磁盘阵列里。因为不想手动管理重复项，所以我就保持相机的文件夹逻辑。索尼相机虽然在拍照时可以直接按照日期建立文件夹，但是它是前四位序号加后四位月份日期，因此按名称排序并不是时间顺序。即使忽略掉这个问题，也没有办法快速的查看什么时候拍了什么照片，因为文件管理器通常并不能让你隔着文件夹看到里面都有什么照片。再考虑到拍了四年照片之后我已经积攒了相当大的照片库，直接打开一个个目录查看源文件对于磁盘 IO 速度是个相当大的考验。所以是时候部署一个能自动给照片分时分类创建缩略图的服务了。\n我选择的方案是 PhotoPrism，当然有好几个类似的项目可选，我就是随便选了一个，没什么特殊理由。PhotoPrism 的官网建议用户以 docker 的方式部署，但我不太喜欢容器化，而且我只有一台服务器也享受不到容器化带来的什么方便，所以我选择通过 AUR 的 photoprism 包安装。\n官方同时支持 sqlite 和 mysql 两个数据库，但是对于实际的项目使用 mysql 性能要好很多。如果你按照官方的文档使用 docker 应该会要你再部署一个 mariadb 的容器并配置它，但是却没有提如果不用 docker 要怎么办。其实也很简单，总之先装一个 mariadb 配置好并启动，这一部分直接参照 Arch Wiki 就好了。然后我创建了一个叫做 photoprism 的数据库和一个 photoprism 的数据库用户，再给这个用户设置好密码，给它操作这个数据库的权限，数据库准备工作就算完成了。\n使用 docker 的话通常是编辑 compose 文件里面的环境变量来配置 PhotoPrism，不使用 docker 的话则是通过配置文件。AUR 包里面的 PhotoPrism 会加载 /etc/photoprism/defaults.yml，里面只有几个路径要设置，当然其实也可以不改路径，毕竟我不打算把照片导入到 PhotoPrism 管理的目录，而是把我的照片目录链接过去，所以这几个目录只会存 PhotoPrism 的配置文件和缓存文件。然后按照官方文档的建议，不要在 defaults.yml 里面加更多内容，而是在你设置的 ConfigPath 下面创建一个 options.yml 在里面进行设置。\noptions.yml 里面的内容和 compose 文件里设置的环境变量的内容其实是等价的，只是写法不同，必须设置的大概也就如下几项，其它的当你启动 PhotoPrism 在设置里修改之后它会自动更新这个文件：\nAdminPassword: admin\nAdminUser: admin\nAuthMode: password\nDatabaseDriver: mysql\nDatabaseName: photoprism\nDatabasePassword: your_database_user_password\nDatabaseServer: /run/mysqld/mysqld.sock\nDatabaseUser: photoprism\nDisableTLS: true\nHttpPort: 2342\nSiteUrl: https://photos.example.com/\n\n如果你和我一样 mariadb 和 PhotoPrism 运行在同一台机器上，那可以通过 UNIX socket 指定数据库连接，否则可以使用 IP 和端口。如果你想给别人分享相册，就把 SiteUrl 设置成外网的网址。\n我不打算使用 PhotoPrism 的导入功能，这个功能会将你的照片复制到它管理的目录并按规则重命名。我打算只让它索引我的照片，所以要将我的照片目录挂载到\n OriginalsPath 而不是 ImportPath。使用 docker compose 的话会在 compose 文件里指定如何挂载，而我使用 fstab 进行 dir bind mount。以上这些基本上和使用 docker compose 的逻辑一样，只是配置的方式不同。\n然后如果修改路径到别处的话，记得保证 photoprism 用户对这几个路径是可读写的，因为 systemd 会以 photoprism 用户运行 PhotoPrism，systemctl start photoprism 之后，内网应该就可以通过 2342 端口访问它的前端了。点击资料库 -&gt; 索引，勾选完全重新扫描然后点击开始，应该就可以看到它开始索引目录里的照片并创建缩略图了。\n我大概有 24 万张照片，其中一半是 RAW 一半是对应的 JPG，PhotoPrism 运行了一天一夜给我所有的照片建立了索引和缩略图。如果你的 GPU 可以被它用来加速或者你的磁盘阵列速度比我快，应该不需要这么久。\nJellyfin\n我当然是没有出门在外看 NAS 上的视频的需求，但是在家里看的需求还是有的。之前我一直使用文件管理器通过 samba 直接播放，在电脑上当然不成问题，但是不知道是由于无线带宽有限还是 Android 上的 samba 客户端的问题，用手机或者平板看文件体积比较大的电影就加载不动了。Android 版 VLC 虽然有内置的 samba 支持，但是那个文件管理器超级难用啊，没有按日期排序，从一个目录返回上一个目录还不能保持之前的位置。所以我觉得部署一个 Jellyfin 还是有必要的。\nJellyfin 不使用系统的数据库，也不需要修改很多配置文件，大部分都在启动之后的设置向导里设置就可以了。所以安装就是直接在 Arch 官方源里安装 jellyfin-server 和 jellyfin-web，然后打开 /etc/jellyfin/jellyfin.env 看看你需要不需要修改默认的 data 和 cache 目录，然后把存放视频的目录挂载到 data 目录下面就行了。\nsystemctl start jellyfin 之后内网访问 8096 端口，按照设置向导设置，之后让它扫描你的视频目录。由于它要生成缩略图，所以也会比较吃资源。至于其它的使用和配置方法应该网上有大把资料，就不啰嗦了。\nCaddy\n我一般是不需要在外网访问 NAS 上的服务的，因为我可以通过 tailscale 的 VPN 进行访问。但偶尔我会有通过 web 服务给朋友发文件的需求和给被拍的朋友通过 PhotoPrism 返图的需求，这种情况下就有必要进行 HTTPS 反代了。\n要做 HTTPS 反代首先域名我早就有了，北京联通的 IP 虽然不是固定的，但是这个可以通过 DDNS 解决，我自己使用的是 CloudFlare 的 API 和 ddclient，证书的话可以通过 Let's Encrypt 解决，虽然现在流行的 HTTPS 反代服务器都有自动给配置文件里的域名申请证书的功能，但我还是不得不使用 certbot 提供的 DNS-01 验证。因为 HTTP-01 和 TLS-01 验证要求你的 80 或者 443 端口要能被外网访问，但是在国内运营商的网络里你暴露这种常见端口，怕不是觉得自己家水表还是查的不够勤。\n至于反代服务器，虽然 NGINX 自己就是个高性能反代服务器，但是它的配置文件还是太过复杂了，我不知道怎么写才能让它反向代理 websocket，PhotoPrism 的文档也说不建议使用 NGINX 反向代理，于是我决定挑一个现代又傻瓜的反代服务器。一开始我本来想尝试 Traefik，但是我看了很久它的文档，感觉全都在写它如何如何支持 docker 服务自动发现，但是我根本没用 docker，也不需要服务发现，我想找如何配置它让它代理我指定的端口，翻来覆去也没看明白。然后我去看 Caddy，发现 Caddy 的文档非常简单易懂，而且核心就是通过简单的手动配置实现各种功能，所以我就用 Caddy 了。\nCaddy 和 NGINX 一样既支持文件服务器又支持反向代理，所以我把之前用 NGINX 提供的文件服务也转由 Caddy 负责了。我的需求是在内网我会直接通过 IP 和端口访问对应的服务，不需要 Caddy 转发，然后外网则通过 Caddy 进行 HTTPS 反代。\n在官方源里安装 caddy 之后打开 /etc/caddy/Caddyfile，最下面应该有一行 import /etc/caddy/conf.d/*，这一行可以不动，我打算用它实现分离各个不同服务的配置的功能。然后首先实现端口 2345 上的文件服务器，非常简单：\n:2345 {\n\tfile_server browse {\n\t\troot /mnt/hdd0/http/fileshare\n\t}\n}\n\n只要这么几行就够了，比起写 NGINX 的配置文件还是简单太多了。想要添加 HTTP 的用户密码验证的话就去阅读 Caddy 文档里关于 basic_auth 的部分。\n然后就是添加外网反向代理的配置，我给 NAS 分配了一个域名，但我并不想让文件服务器作为这个域名的唯一功能，所以我通过 Caddy 给它重定向到一个子目录，这里可以创建一个 /etc/caddy/conf.d/fileshare：\nhttps://nas_domain.example.com {\n\ttls path_to_fullchain.pem path_to_privkey.pem\n\n\tredir /fileshare /fileshare/\n\thandle_path /fileshare/* {\n\t\treverse_proxy :2345\n\n\t}\n}\n\n相信这个配置已经简单到不需要我介绍也可以看懂了，至于 PhotoPrism 和 Jellyfin 这样的服务它们不推荐重定向到子目录（会影响 web 资源加载的路径），所以我不得不再分配一个域名给它并且设置 ddclient 和 certbot。然后同样创建一个 /etc/caddy/conf.d/photoprism：\nhttps://photo_domain.example.com {\n\ttls path_to_fullchain.pem path_to_privkey.pem\n\n\treverse_proxy :2342\n}\n\n这看起来比上一个还简单了。启动 Caddy 的话就 systemctl start caddy，改了配置文件就 systemctl reload caddy。\n最后由于我太懒了不想记住每一个 web 服务的端口号，我简单写了个网页列出所有这些服务，这样我在内网访问就可以先打开这个网页再点我想用的。但由于我的 NAS 有好几个网卡，我希望通过万兆直连访问时候就使用万兆网卡的 IP，通过路由器访问的时候就使用板载网卡的 IP，但是 HTML 的链接不能只写端口号，而普通的静态页面显然是不支持按需修改链接的。但是 Caddy 是使用 go 编写的，所以作为 web 服务器的时候支持 go 的模版语法（好吧我并不喜欢 go 的模版语法，但是有总比没有强）。所以可以在 /etc/caddy/Caddyfile 里面加入下面一段开启一个支持模版的 web 服务器：\nhttp:// {\n\troot * /mnt/ssd0/http/caddy\n\ttemplates\n\tfile_server browse\n}\n\n然后只要编辑 /mnt/ssd0/http/caddy/index.html，在里面用 {{ .Host }} 代替本机的 IP 就可以了。\n"},{"title":"升级万兆网络","url":"/posts/Move-to-10GbE/","content":"其实我一开始决定升级到万兆的原因不是觉得 2.5G 网络不够用，毕竟 btrfs 并没有内建 SSD 缓存的功能，甚至都没有 RAID1 的读取速度优化，所以机械硬盘顶天也就 300M/s 而已（何况我之前的 NAS 硬件也没有添加 SSD 的空间了，更何况建立 bcache 缓存需要清空当前的阵列从头开始，考虑到已有的数据量这显然不现实）。原因其实只是因为我以比较低的价格捡漏了一台 Mac mini 开始日常使用，而 Finder 在使用无线网络的时候访问 Samba 的性能实在不敢恭维（即使是有线网，Finder 在访问内容比较多的目录的时候加载速度也显著慢于 Nautilus，但至少比无线网好很多）。所以我考虑要不再从 NAS 拉一根网线到 Mac mini。但是我的 NAS 上已经没有多余的网口了，如果只是把原来接台式机的单口 2.5G PCIe 网卡换成双口 2.5G PCIe 网卡未免有些太过无聊，何况 Mac mini 本身也只有千兆网口，无论如何都得配个外接网卡，索性全部战未来换成万兆网卡吧。\n万兆网卡分成光口和电口两种。光口网卡温度比较低，但是较长距离需要使用光纤，布线的时候就得非常小心，较短距离可以使用某种封装好的铜线，但是为了不影响睡眠我把 NAS 放在卧室门外，又要绕过门缝又要贴墙，总距离有个小十米，所以感觉不是很适合我。电口网卡温度比较高，但可以使用普通的网线（虽然有人说需要什么六类七类线，但实际上家庭使用不超过 30 米的距离只要普通的五类线就完全可以跑满万兆了），而且考虑到如果以后升级新的 Mac mini 可以直接选配万兆网卡，那个也是电口的，所以我还是买了电口的万兆网卡。\nMac mini 这边其实没什么好选的，雷电口的万兆网卡一共也就那么几款，因为本身这个品类针对的就是各种 Mac，需要使用 Mac 能支持的芯片。我选择了一款 AQC113 芯片的，这款也是带万兆网卡的 Mac mini 内置的型号，因为市场比较小所以价格相比 PCIe 的服务器网卡要贵一些。如果你胆子大你也可以考虑自己购买雷电转 PCIe 的转接卡和 PCIe 万兆网卡拼一个，但可能需要专门处理散热。AQC113 已经是电口网卡里面据说“不那么热”的型号了，但我买的这款运行时候摸上去还是会感觉有点烫手，不过也可能是因为这款使用铝制外壳把热量传递出来的原因，反正雷电设备本身就很热，我的雷电 hub 平时摸上去也是会觉得很热的程度，还要考虑到 hub 使用的是塑料外壳，热量并不是完全传递出来。不过使用起来都一切正常，没有出现过那些发热比较严重的网卡因为不装风扇就过热卡死的问题。\n台式机和 NAS 这边可选的就很多了，因为是 PCIe 可以购买服务器网卡，大部分都是半高和双口的。我只有三台设备，主要的需求是把 Mac 和 PC 分别连接到 NAS，就省了买万兆交换机的钱。最便宜的电口万兆网卡是 Intel 的 X540，但是这款出来的比较早，是 PCIe 2.0 x8，制程比较老发热也比较严重，很多人建议在散热片上加一个小风扇，不然会死机。但是小风扇是高频噪音重灾区，而且考虑到以后升级主板的话大部分主板 PCIe x8 是和第一条 x16 共享的，反而一般都有独立的 x4 插槽，所以我觉得为了这两个加点成本是可以接受的。最后我买了两张 PCIe 3.0 x4 的 Intel X550，不加独立风扇使用起来也没什么问题。\n\n软件方面的话我在 NAS 上配了个桥接，倒不是因为我特别需要 Mac 访问 PC，只是我想让 NAS 的两个网口都用同一个 IP，而不是 Mac 和 PC 用不同的 IP 访问 NAS。因为我的 NAS 上面也用 Network Manager 所以建桥接也很简单。以及通过网络使用 rsync 复制文件的时候不要使用 --sparse 参数，尤其是在 macOS 上，这个参数会导致速度下降 100~200M/s。使用 cp 或者各种文件管理器的时候倒是没什么要注意的。\n后面我还是把万兆网络利用了起来，因为我忍受不了 ITX 的 NAS 机箱完全没有一点扩展空间的现状很久了，同时我买了一块 2T 的 NVMe SSD，本来是格式化成 exFAT 格式装进 USB 硬盘盒打算剪辑的时候随插随用，后面发现 macOS 的 exFAT 驱动性能比 Linux 下差很多，所以一不做二不休给 NAS 买了新的 MATX 主板和机箱，然后把这块 SSD 装在 NAS 的第二条 NVMe 槽用作剪辑盘，结果 macOS 通过万兆网和 Samba 访问比通过 USB 和 exFAT 快很多……\n"},{"title":"macOS 微调技巧合集","url":"/posts/Collection-of-macOS-Tweaks/","content":"以下记录了一些常见或者不常见的 macOS 微调技巧，其中有些是因为 macOS 的默认设置过于难用，然后又很难想到用什么关键词才能搜索到的选项，有些则是作为开发环境和 Linux 不一样的地方，可以进行调整。\n关闭拖拽到屏幕边缘并等待切换工作区\n作为一个桌面程序员，我认为最令用户恼火的使用体验就是按住并等待了，你不知道要等待多久才会触发，往往你以为已经触发了结果还没有，极大地降低了日常操作的效率。但是似乎苹果的设计师并没有意识到这种设计在电脑上有多难用，并且在多个地方使用了按住并等待触发的逻辑。其中甚至包含互相冲突的功能：比如 macOS 15 新增的拖动窗口到屏幕边缘分屏，这个和拖动窗口到屏幕边缘切换工作区完全冲突。你必须小心翼翼的拖动窗口到边缘，但是又不能完全碰到边缘，才可以完美的触发分屏而不是切换工作区。这在日常使用中几乎是不可能的，考虑到分屏更常用并且有多种方法切换工作区，关掉这个愚蠢的功能才是合理的。但是苹果并没有提供直接关掉这个功能的选项，只能通过把触发的等待时间加大到几乎不可能误触发来近似实现了。\n$ defaults write com.apple.dock workspaces-edge-delay -float 60 &amp;&amp; killall Dock\n\n这个指令会将等待时间加大到 60 秒，我相信没有人会在边缘按住鼠标 60 秒吧。\n减小右上角托盘图标间距\nmacOS 右上角托盘图标之间的间距简直大到离谱，特别是在有刘海的屏幕上，本来就不宽裕的长度根本容纳不下几个图标。很多用户被迫购买一些付费的软件来折叠显示托盘图标，实际上这个间距是可以调小的，只是一下子很难想到。\n$ defaults -currentHost write -globalDomain NSStatusItemSpacing -int 5\n\n这个会将状态栏图标的间隔缩小为 5。\n$ defaults -currentHost write -globalDomain NSStatusItemSelectionPadding -int 3\n\n这个会将状态栏图标点击和悬浮的显示特效宽度缩小为 3。\n也许需要注销并重新登录才会生效。在这个间距下只要再关掉一些不需要一直显示的图标，应该就可以显示所有日常需要的托盘图标了。\n兼容使用 GNU 版本命令行工具的脚本\n绝大多数 Linux 发行版附带的命令行工具都是 GNU 版本，而 macOS 附带的则是 BSD 版本，这两者各自包含一些不同的命令行选项。你可以通过 Homebrew 安装各种 GNU 版本的工具，但 Homebrew 默认会给这些命令加上 g 前缀以和系统自带的 BSD 版本区分开，并且会把不带 g 的文件安装到单独的目录里而不是加入到 PATH 里。\n如果只是顺应个人的使用习惯，那可以通过以下的方式设置 PATH 来默认使用 GNU 版本的工具：\n$ export PATH=\"/opt/homebrew/opt/coreutils/libexec/gnubin:${PATH}\"\n\n但是对于另一些命令行选项区别更大而又常用于脚本的工具（比如 sed，find，grep），直接设置 PATH 虽然可以兼容一些在 Linux 下开发的脚本，但是可能会不支持在 macOS 下开发的脚本，因为作者可能使用的是 BSD 版本的工具。我的解决方案是设置一个定义 PATH 的 alias：\n$ alias gnubin=\"PATH=\\\"\\\n/opt/homebrew/opt/coreutils/libexec/gnubin:\\\n/opt/homebrew/opt/findutils/libexec/gnubin:\\\n/opt/homebrew/opt/grep/libexec/gnubin:\\\n/opt/homebrew/opt/gnu-sed/libexec/gnubin:\\\n/opt/homebrew/opt/gnu-tar/libexec/gnubin:\\\n${PATH}\\\n\\\"\"\n\n这样当执行 GNU 版本的命令或者脚本时，只要在前面加上 gnubin 再执行即可。\n跨设备接收文件时使用较新的 rsync\nmacOS 自带了一个版本比较旧的 rsync，你可以使用 Homebrew 自行安装比较新的 rsync，但是当你从其他 Linux 机器通过 rsync 向 macOS 机器传文件时可能会报错，提示你使用的命令行选项不支持。这是因为 rsync 默认通过 SSH 进行跨设备文件传输，而 SSH 默认不会读取用户设置的 PATH 环境变量，直接去 /usr/bin 下面调用使用的是 macOS 自带的老版本 rsync，也就无法支持比较新的版本才有的选项了。\n解决方法是先编辑 /etc/ssh/sshd_config，设置 PermitUserEnvironment PATH 让它读取用户设置的 PATH 环境变量。\n然后编辑 ~/.ssh/environment 给 PATH 前面添加 Homebrew 的目录：\nPATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin\n\n重新启动 SSH 服务，应该就可以调用 Homebrew 安装的 rsync 了。\nOpen Build Service\n这一部分也许只有我自己工作会用到，或者你也使用 Open Build Service，需要在 macOS 上运行 osc。\nHomebrew 安装的 osc 在 macOS 里读不到系统的证书，所以即使将 SUSE 的证书导入到系统，访问 api.suse.de 时候也会报错。\n解决方法是在 ~/.config/osc/oscrc 里给 [https://api.suse.de] 设置 cafile=/Users/alynx/openvpn/SUSE/SUSE_Trust_Root.crt 让它能自己找到 SUSE 证书。\nosc vc 调用的是 obs-build 里面的 vc 脚本，但是由于 macOS 默认 APFS 设置了不区分大小写，而 obs-build 源码里包含 Build 和 PBuild 目录，以及 build 和 pbuild 脚本，macOS 会直接报文件名冲突，根本没办法解包 obs-build 的源码，也就没办法打包了。虽然重新格式化 APFS 可以设置区分大小写，但一些屎山软件比如 Adobe 家的垃圾只支持不区分大小写的文件系统，所以这么做得不偿失。2025 年还不支持区分大小写简直是犯罪啊。\n解决方法是直接去 https://github.com/openSUSE/obs-build/raw/refs/heads/master/vc 下载这个脚本到 ~/bin/osc-vc 之类的地方，然后在 ~/.config/osc/oscrc 给 [general] 设置 vc-cmd=/Users/alynx/bin/osc-vc 让它找到这个脚本就行了。\n默认 osc 会去 /usr/lib/obs/service 下面寻找各种 obs-service 插件，但自然你在 macOS 下面是不能安装到这个目录的，而且这些插件都没有打包成 Homebrew 的 Formula。我自己创建了一个 Tap 在 https://github.com/AlynxZhou/homebrew-openbuildservice/，里面的 osc 将插件目录修改成了 ${HOMEBREW_PREFIX}/lib/obs/service，并且将其他的插件的安装位置也修改了，所以建议你安装我的版本而不是 Homebrew core 里面的。不过这些插件大部分都依赖 obs-build 里面的各种脚本才能运行，所以直到 obs-build 同意修改目录名字来兼容不区分大小写的文件系统之前，都只能通过 osc commit --noservice 的方式凑合用了。\n"},{"title":"东京之旅","url":"/posts/Journey-to-Tokyo/","content":"题外话：本来应该去年九月从东京回来就写的，但由于一些不是很众所周知的原因我很久没更新了。昨天回家看到影视飓风发了去东京拍的样片，又想起来之前拍的照片，所以决定还是写一下。\n\n我其实是特别懒得出门的人，不是不想，只是嫌麻烦，而且其实我也没有特别想去的地方，除了日本。毕竟如果你也像我一样是个动漫爱好者的话你就很难不想去日本看看。所以董老师问我要不要一起去东京玩的时候，我做了足够久的心理建设。搞定签证机票住宿等等一系列麻烦的东西之后，我终于要进行人生第一次出国旅行了。在北京待了太久，早就想拍点不一样的东西，所以这篇主要是贴一些我出门玩拍的照片。\n买机票的时候没有买直飞，因为我也没有去过香港，所以买了中午到香港然后午夜飞东京的转机，到了香港凭护照和离开香港的机票就可以办进入香港的手续，并不需要港澳通行证。然后把在深圳的高中同学喊了过来一起吃了晚饭，我还是第一次见依山而建的城市。\n\n晚上两个人随便走走于是去拍了维多利亚港。\n\n由于一些众所周知的原因，在香港坐地铁的时候感觉非常熟悉好像回到了北京地铁一样（或许应该说北京某些地铁像香港地铁才对）。\n然后午夜飞东京第二天早上落地成田机场，经过一系列入境手续之后坐电车去酒店，虽然我的日语远没到能日常交流的水平，但靠着比划和英语大概也没什么问题。而且很大概率你能碰到会中文的工作人员。由于前一天很早出发然后午夜才睡觉所以这一天根本没拍什么照片，下午到酒店能入住之后直接就躺了。董老师来东京的目的是去看魔法未来，所以我们前几天住了一个很偏的酒店，与其说离东京近不如说是离千叶近，而且主要是房间真的小啊！\n落地东京的第一天除了吃饭睡觉什么也没干，第二天起来我坐电车去了御茶水的乐器街（说起来知道这边是乐器街还是因为听丸之内虐待狂），虽然距离很远但感觉并没有花很久，也许是人在北京住久了就会对通勤变得宽容？虽然这边大部分卖的都是我不会弹的吉他贝斯类的，但还是看得眼花缭乱。这边有一家很出名的 ESP 店，我走到二楼的时候应该是有个店员看出来我是中国游客所以用中文问我“你想找什么？”，但我一下子就听出来他应该是会说中文的日本人，因为母语者发音不会这么刻意。同学想要一个国内还没上市的效果器，问了一下价格，比闲鱼代购便宜太多了，于是我非常乐意的帮他代购了一个。但我拍的不是乐器而是那个好像很著名的电车机位，铃芽之旅里面出现过的。\n\n由于台风要来，第一天和第二天都在下雨，虽然我是很喜欢下雨，但是鞋子湿透了还是很痛苦。\n第三天白天去了魔法未来的展览，因为很喜欢赛车初音于是买了一把 24 款赛车初音的雨伞，结果临走的时候人在去机场的电车上想起来把伞落在酒店了，于是赶紧给酒店发邮件，他们对于处理外国游客遗落的物品有成熟的流程，所以后面我付了邮费提交了地址他们把伞给我寄到了家里。以及因为发现酒店楼下的罗森有赛车初音的一番赏所以每天晚上都去抽两发还和便利店打工的中东小哥聊了几次。晚上我意识到这个酒店离海边很近于是就跑去海边，然后拍到了海边散步的情侣 T_T 作为内陆长大而又很少去海边的人，晚上走到海边的时候感觉非常空旷，如果有幽闭恐惧症的反义词的话，应该可以更好的描述我当时的体验。\n\n第四天我决定把来拍照的目的贯彻到底，中午先去了川崎站进行了一个 Girls Band Cry 的打卡，结果在丸福咖啡店遇到了另外两个说中文的游客，全都是看了 GBC 来打卡的！可惜我在川崎站外面没有遇到什么弹吉他的大姐姐。买了下午天空树的门票，直接冲到最高进行一个俯瞰风景。天空树上面有个许愿台，可以买扭蛋然后把愿望写在里面的彩带上，于是我一边想着 “把愿望写在离天空更近的地方实现的会不会更快一点” 一边买了两个扭蛋，不过现在想想感受还是很复杂。我这个时候还没看过龙族，看完了觉得还好当时临时决定去天空树，下次一定还会再去。\n\n\n出来的时候又开始下小雨！这就是临海城市的感觉吗？但是我抬头向上看的时候发现云雾围绕的天空树于是拍下了绝佳的照片。\n\n\n由于这里是地标性建筑，所以下面有一个购物中心有很多专门的周边店，方便来玩的游客，我在里面转了很久，但由于一些原因不是很开心。但是接下来在商场里吃了一份寿喜锅，很好吃！\n第五天我们换到了市区里的酒店，住在浅草桥附近去哪里都很方便！而且房间面积竟然比第一家酒店要大。这一天我去了东京塔！我这次来其实没有制定什么旅游计划，单纯是给出国玩做一次可行性验证，所以其实落地东京就算是阶段性胜利了。但如果有什么一定要拍的，那就是东京塔了，因为我喜欢红色！\n\n转来转去找了好久附近哪里的楼上有东京塔的倒影，结果我低头发现东京塔的倒影就在东京塔的底座上。\n\n第六天我好像没有拍什么照片，因为酒店离秋叶原只有一站路所以我就早起去了秋叶原，二次元怎么能不去秋叶原电器街呢！但是这里甚至走路都能听到人说中文，“你买的这个东西 90% 是 Made in China！\"。\n没记错的话中午吃了一家叫做 468 的寿司，这家的老板人很好很有意思，见到我们是外国人于是拿出一张纸在上面画一盘寿司和一碗汤然后写上价格，还问我们是哪里来的。汤是一种粘稠的淀粉汤，尝起来大概是用胡椒调味，中间有一颗炸过的土豆泥丸子，是他家的招牌菜“芋吸”，感觉就是明明是普通的食材普通的调味，但能想到这样做确实是别出心裁，从来没有在别的地方尝过类似的，非常喜欢于是跟老板说每人再点一份。\n晚上我们根据排行榜去吃了和牛烧肉！来日本怎么能不吃和牛烧肉呢！店很小所以排队花了一些时间，但是吃到的时候感觉确实是非常值得，缺点的话……大概只有因为是炭火烤肉所以如果有顾客没搞好肉被点着了屋里就会很大浓烟吧。\n第七天我们去了涩谷附近，先去了明治神宫但这里没有人举行婚礼。\n\n然后在原宿买了一件我以前根本不会买的衣服，白色的衣服上面是各种蓝色的带子，当时莫名其妙的想到明日方舟的服装风格，然后店员小哥又很喜欢初音所以和我们聊了一会于是就买了（但是衣服很贵 T_T）。接着去了附近的稳田神社，好像设定上是平安名堇住在这里所以甚至有上面画着她的御守，所以我给列表里推平安名堇的 coser 带了一个。原宿这边有 Fender 的旗舰店，进去转了一圈，但我又不会弹这个。\n\n晚上去了涩谷，这里可能是这几天见过人最多的地方了。董老师跟我说有什么稀奇的感觉和西直门差不多，我说西直门哪有这么多人，董老师想了一会说那三里屯，三里屯倒是稍微有点这个意思。\n\n\n\n第八天去了之前在上野的时候去了一次的矢先稻荷神社，当时只是在地图上看到附近有神社就想去看看，结果一路上很想去厕所又找不到，走到神社门口发现有牌子说最近的厕所在哪里，感觉非常有缘分，但是第一次去身上没有多少硬币，所以又去了一次。\n\n中午本来想吃排行榜靠前的拉面店，结果到了门口发现老板给自己放了十天假，于是我们去了另一家高分的 らーめん 改，我以前吃过的日式拉面都是酱油或者猪骨汤的，这家的面是类似手擀面的粗面，汤是一种海鲜加盐的清汤，和我之前吃过的全都不一样，非常喜欢。\n下午又去了秋叶原，拍到一张很好看的逆光照片。\n\n傍晚的时候从酒店窗户看出去云很好看。\n\n这次其实没安排去富士山的计划，但想去东京的一个理由是因为富士山下这首歌，回程的飞机特地选了能看见富士山的一侧。\n\n最奇妙的感受其实是以前只在动漫里听过的地名突然有了实感，去过之后再看到这些地名，就会想起当时的感觉。它们不再是几个音节的排列组合，而是变成了无数个真实的细节。\n我其实很想多录一些视频，但是一个人暴走的结果就是没有什么体力拿出相机了……最后还是用拍到的一些片段剪了个 vlog 出来。\n\n\n还会再去的。\n"},{"title":"GNOME 和 IBus 和 Wayland 输入法","url":"/posts/GNOME-IBus-Wayland-IME/","content":"长久以来，用户对于 GNOME 的输入法是怎么实现的有着各种各样的误解，比如误以为 IBus 掌控从界面到引擎的全部逻辑。而我最近尝试给 GNOME 添加了 text-input-v1 支持，以便用户可以在运行于 Wayland 下的 Chromium/Electron 程序中使用输入法（Chromium 只支持 text-input-v1，而不是大部分其它程序都在用的 text-input-v3），这个过程中我阅读了大量和输入法相关的代码，打算把具体的结构写下来，这样如果有人想尝试修改，可以从我这里参考，而不是阅读错误的资料，找不到应该修改的位置，同时也防止自己某天想重新改的时候想不起来是怎么回事（实际上在我打算写这篇文章的时候发现自己已经开始忘记了，好恐怖）。\nWayland 下面标准的输入法结构分为三个部分，应用程序和混成器和输入法，其中应用程序通过 text-input 协议和混成器交换数据，混成器通过 input-method 协议和输入法交换数据。但假如你简单地把混成器对应成 GNOME Shell，输入法对应成 IBus，那就大错特错了。实际上 GNOME Shell 和 IBus 共同组成了这个结构里的“输入法”，而 Mutter 则实现了 text-input 和应用程序通过 Wayland 交互，也就是“混成器”，GNOME Shell 和 Mutter 则通过 ClutterInputMethod 这个接口交换数据，而不是 input-method 协议。下面我逐个解释具体的细节。\n首先是“输入法”部分，IBus 本身是可以当作完整的输入法使用，也就是既包含界面又包含引擎，但实际上的运行方式是 IBus 只负责引擎部分，和界面有关比如候选词提示框和切换输入法的逻辑全部是在 GNOME Shell 里面实现的。这样做的原因是在 Overview 模式下所有客户端的窗口都会被重定向，GNOME Shell 抢占了输入焦点，而 IBus 自己绘制界面时也被认为是客户端窗口之一，目前的设计不会对输入法客户端窗口做什么特殊处理，所以这里是 GNOME Shell 负责界面逻辑，然后通过 D-Bus 和 IBus 通信，获取到输入法的数据填充到输入法的界面里。题外话就是经常有人吐槽为什么 GNOME 要使用 IBus 提供的 D-Bus 协议而不是使用 Wayland 标准的 input-method 协议，但实际上 GNOME Shell 和 IBus 之间的 D-Bus 连接传输的是“输入法内部的数据”，而 input-method 协议传递的是“输入法和混成器之间的数据”，这两者显然不是一个东西。\n因此，如果你想修改 GNOME 下面输入法的界面，那需要修改的可能不是 IBus，而是 GNOME Shell 下面的 js/ui/ibusCandidatePopup.js，这个文件包含候选框界面，或者 js/ui/status/keyboard.js，这个文件包含输入源选择框。\n然后 GNOME Shell 需要和 Mutter 交换数据，这样才能通过 text-input 协议把数据从 Mutter 传送到应用程序里。如果打算实现 input-method 协议，你应该整个替换掉 GNOME Shell 和 IBus 组合起来实现的“输入法”，而非拿着输入法内部的 D-Bus 连接大做文章。实现起来并不困难，Mutter 本身使用 Clutter 作为图形库，Clutter 有 ClutterInputMethod 这个接口管理输入法，无论你是什么来头，最后都要回到这个接口，只有这样才能保证对 GNOME Shell 自己的 UI 也能实现文本输入（因为它们最后都是使用 Clutter），因此主要工作是把 input-method 的接口和 ClutterInputMethod 的接口连接起来。但这是理想情况，实际为什么没人这么做呢……因为 Wayland 实际上提供的是进程间通信，input-method 协议说到底只是给输入法和混成器两个不同进程提供一个通过 Wayland 进行进程间通信的方案而已……但 Mutter 是个库而不是单独的进程，GNOME Shell 和 Mutter 直接链接，属于同一个进程，因此完全没必要再绕一圈进程间通信啊……所以你可以看到 GNOME Shell 直接继承了 ClutterInputMethod 接口然后在里面对接 IBus 的 D-Bus 协议传过来的数据……\n如果你对这一部分有兴趣，可以直接查看 GNOME Shell 下面的 js/misc/inputMethod.js。如果你仍然坚持应当实现 input-method 协议的话，你可以尝试按我说的去改，但我仍然认为这样收益不大，因为只是解决了输入法从混成器获取数据的问题，也就是说是让 GNOME 支持 IBus 以外的输入法的方案之一，为什么说是之一呢，因为实际上目前 Linux 的输入法也只剩 IBus 和 Fcitx 两个，而 @CSSlayer 已经通过在 Fcitx 里实现 IBus 和 GNOME Shell 之间的 D-Bus 协议的方式解决了这个问题……因此我选择对他心怀感激然后接受现状。\n但除此之外你还需要解决输入法自己绘制界面在 Overview 下面会被 GNOME Shell 忽略的问题，如果你仔细阅读过 @CSSlayer 关于 Fcitx 开发的博客文章，你就会意识到最大的限制是在 Wayland 下面一个客户端程序没办法决定自己的全局坐标，而输入法也只是个普通的客户端窗口，解决这个需要混成器对输入法的窗口进行特殊处理，因此你需要在 Mutter 里面给 input-method 专门打洞来实现单独处理输入法窗口的 wl_surface 这部分。但对于 GNOME 来说，输入法界面已经是在混成器进程里实现的了，所以你可能需要从头写一大堆代码来处理这个并且和现有的输入法界面代码没有任何地方能复用，上游也自然没有很强烈的意愿合并这个，而对于 Fcitx 来说也可以通过 kimpanel 的 GNOME 扩展把 Fcitx 的界面嵌入到混成器进程里，这实际上和 GNOME Shell 对于输入法界面的处理方式是类似的……已经有可用的解决方案，实现 input-method 我觉得是有点吃力不讨好，至少你不能要求我去做这个……\n至于程序和混成器之间就没那么多事情了，这里就是通过标准的 text-input 协议进行，对于 Mutter 来说，仍然是把 ClutterInputMethod 的接口和 text-input 协议的接口对接起来，我做的主要是这一部分的工作。\n当然还有一种办法是 IM Module，因为说到底 text-input 和 input-method 只是为了想办法在程序和输入法之间交换数据，而你也可以绕过 Wayland，绕过混成器，IM Module 就是这样的方式，应用程序的图形库使用 IM Module 通过 D-Bus 直接和输入法交换数据，告诉输入法应该在哪里显示界面，有哪些按键事件，输入法再把候选词传回来。对于 GNOME，这个情况下只是用到了 GNOME Shell 里面关于输入法界面的部分，和 Mutter 以及 Wayland 就关系不大了。\n最后如果你还是看不懂，那我画了几个框图：\n你想象中的唯一的 Wayland 输入法架构：\n\n可能实际正在运行的架构：\n\nGNOME 只是在上面的基础上进行了一点点点点点变化：\n\n至于你问我 GNOME 为什么这么设计输入法结构？这不是我能回答你的问题，也许是历史原因，也许是特殊结构（毕竟 Clutter 这个图形库一开始是个客户端的图形库），我这篇文章只是讲述现状，希望能减少一些以讹传讹。\n"},{"title":"十年过去了，我买了台 MacBook","url":"/posts/10-Years-Passed-and-I-Bought-MacBook/","content":"我其实已经很久没有属于自己的笔记本了。毕业之后公司发了一台 Precision 5530 作为工作电脑，我就把我自己的 Precision 5510 留给家里人用了，再加上为了打游戏我分别在自己住处和家里装了台式机，用笔记本的场景就更少了。想来想去，需要笔记本的场景只有一个，就是出远门拍照的时候，可能需要把相机存储卡里的数据复制到移动硬盘里，然后可能顺便快速修图发朋友圈。虽然我之前尝试过平板电脑也能满足这些需求，但平板电脑供电的能力有限，连接移动硬盘还需要外接供电，而且 Android 版的 Lightroom 导入的速度也太慢了，只能说勉强能用。\n当然我可以直接带着公司的 Precision 5530 出门的时候用，但是实在是太重了，而且在 Linux 下面电池顶多能用两小时。有了台式机之后我对笔记本的需求产生了一些变化，比如笔记本可以不需要处理所有的任务，复杂的工作可以在台式机上解决，所以我对笔记本的需求变成了轻便和长续航。这样一看苹果的 M1 变成了最合适的选项：续航可以和平板电脑相比，但是又是实打实的电脑，同时对于剪视频修图这种媒体编辑非常合适。当然这都是马后炮，我一开始只是因为在二手频道刷到有人出售 M1 的 MacBook Air 只要 3000 块出头，感觉这个价格就算是买一个回来玩玩也很合适。不过这个价格是丐版，8G 内存对我来说等于不能用。但我看了一下 16G M1 的 MacBook Air，价格要贵 1500 左右，又有点下不去手。\n我恰好有一个朋友在苹果实习，她告诉我苹果有员工优惠计划，可以以 85 折给家人或者朋友购买 MacBook，不过仅限于官网有卖的产品，于是我开始思考要不要干脆加钱正经买一台笔记本给自己用，这中间反复的纠结过程就略过不提，总之我最后两千块钱卖掉了一千块钱购入的万世权杖（当时工作之后打算买一个玩玩，没想到涨了一倍），又两千块钱卖掉了很久没用过的尼康大 F，然后本着买新不买旧不行挂闲鱼的逻辑买了 M3 的 MacBook Air，毕竟有个在苹果工作的朋友可不容易！\n一开始我是想买 16G + 256G 的版本的，但是发现 16G + 512G 可以直接去 Apple Store 提货，思考再三还是买了 512G，甚至还加了 AppleCare+。然后看到午夜黑的配色虽然我知道肯定会粘指纹但我还是义无反顾的选了，然后直奔朝阳大悦城的 Apple Store 提了电脑本体和苹果购物袋。\n这个过程中当然有朋友质疑我一个 Linux 桌面程序员为什么会买 MacBook 自己用，除了上面那些比较理性的原因之外，还有个原因是就算是 Linux 桌面程序员也不是一出生就是 Linux 桌面程序员 的吧！大概十年以前我还不是 Linux 桌面程序员（甚至不是程序员）的时候，我对 Retina MacBook Pro 的宣传片印象深刻：\n\n\n我买电脑确实是看脸的，因此不管别的 Linux 爱好者有多喜欢 ThinkPad，我都对那玩意嗤之以鼻：您能先让自己看起来像是一整块东西吗？不过我看到这个宣传片的时候可能还在上初中，距离我能买得起五位数电脑看起来不能说是遥遥无期，也可以说是希望渺茫。至于上大学之前花了两万块买了 Precision 5510 则是后话了，原因也很简单，单纯是 XPS 15 那个时候看脸比 MacBook 更酷，而且我那个时候已经开始用 Linux 了。所以如果没有 XPS 15，我可能就会买 MacBook Pro，不过再回想一下 2016 年到 2020 年苹果在 MacBook 产品线上做了多少愚蠢的决策（我会把我这辈子见过最蠢的笔记本电脑的称号颁给 New MacBook……真是只有外观设计完全不考虑能不能用的顶峰啊）……可能没有买苹果反而是幸运。\n但把时间拨回到 2012 年，Retina MacBook Pro 确实看起来很棒，用起来也很棒。考虑到使用 M 系列芯片的 MacBook Air 确实在续航和性能之间找到了平衡点，并且苹果确实逐渐从当年制造愚蠢笔记本的顶峰退下来，所以我觉得是时候买一台了，就算是为了延迟满足十年前的自己。就像选择午夜黑的颜色也很简单，它很像十年前我用的蓝黑墨水，不像碳素墨水那么无聊，也不像蓝墨水那么肤浅。我也没打算考虑买 Windows 笔记本装 Linux 的选项，因为显然续航上难以和 MacBook 相比，而且按我看脸的标准能买的 Windows 笔记本也奔着五位数去了……\n拿到手之后确实也是比较满意吧，除了内存和硬盘卖金子价格还是让我很不爽，以及刘海……虽然一个写插件恶搞过这个刘海的人买了刘海笔记本这件事有点讽刺吧，但我想问既然因为刘海加高了顶栏，为什么要特意把顶栏做的比刘海高一点点！就算是为了告诉用户刘海两侧是联通的，这个设计也足够逼死强迫症了。\n我自己的博客生成器因为是 Node.js 写的所以直接就可以在 macOS 上运行，Emacs 则一直以来对 macOS 支持都好得过分，简单修改了 Ansel 的脚本之后我也成功在 macOS 上运行了，DaVinci Resolve 也是跨平台的，所以我期望在笔记本上做的任务都还比较方便，我甚至也跑了 Tailscale 方便我随时远程连接到 Linux 设备上。虽然别人可能觉得我打肿脸充胖子但我还是要说我觉得 GNOME 桌面的使用体验比 macOS 桌面要好，我在自己的博客里写自己的主观看法不是很过分吧！其它的当然可以适应（比如我在 GNOME 里把关闭按钮改到左边了，我不是很在乎这个，然后在 macOS 里面用 Loop 实现了窗口靠边贴放），但为什么 macOS 不能自己调整输入法顺序！我想要一个固定的自己设置的输入法列表是什么大逆不道的事情吗？\n当然少不了去星巴克当气氛组，比如这篇博客就是在星巴克写的：\n\n关于买这台笔记本还有个很有意思的插曲是一开始和在苹果的朋友对需求对了半小时结果她以为我要买的是 iPad Pro，特别是我还说了我要跑 Node.js 要写博客 iPad Pro 太蠢了之后，我不知道这个对话是怎么进行下去的，我们跨频道聊天一直聊到她以为我要花两千块钱买妙控键盘——我曾经无数次的批评过这个破玩意，毕竟 HHKB 这种顶级的静电容键盘我也才花了两千块……我真的有思考要是最后她给我买了 iPad Pro 和妙控键盘我是掏钱还是不掏钱……\n"},{"title":"Module 的意思不是 Module，Module 的意思是 Config","url":"/posts/Module-Does-Not-Mean-Module-Module-Means-Config/","content":"如果有得选，在开始一个新项目之前我是无论如何也不会选择 CMake 做构建系统的，我不信有任何人觉得 CMake 的语法很友好很容易读，Meson 相比之下更容易读写，而且按我的经验也更好用。但最大的问题是 Meson 出来的很晚，于是有大量的项目在 CMake 已经流行而 Meson 还没出现的时间里把基于 Autotools 的构建系统换成了 CMake（剩下没有更换的那些多数在 Meson 稳定之后更换到了 Meson）。显然把所有 CMake 项目重写成 Meson 是不现实的，最大的困难其实是 CMake 虽然难用，但在“跨平台的构建系统”这一点上已经足够用了，所以对于已有的 CMake 项目，我选择还是凑合用。\n实际需要我本人亲手调整 CMake 的项目很少，Ansel 是其中一个，因为当初从 Darktable fork 出来的时候我几乎是完全地整理了一遍它的构建系统，而我今天打开 Element 发现法国老哥在群里 @ 我说是 MSYS2 的 cURL 更新到 8.8.0 导致我们的 CI 构建不了了让我帮他看看，虽然我自己也不是很想写 CMake，但为了避免他一言不合就把依赖复制到项目源码里面我早就跟他说过这种事情我替他处理，所以还是说到做到。\n报错就是一堆 undefined reference，我直接去 Google 搜了一下没有搜到什么有用的资料。去 MSYS2 的 issue 搜索 curl 倒是搜到了 https://github.com/msys2/MINGW-packages/issues/21028，下面有人说把 CURL_LIBRARIES 这个变量换成 CURL::libcurl 这个 target 就可以了。我倒是理解这是什么意思，但我想知道这是怎么回事。这中间的过程可以说是曲折离奇。\n首先是在这里看到有维护者说这个可能是因为他们把 cURL 从 Autotools 构建换成 CMake 构建导致的，但我其实不是很理解，因为我印象里 CMake 查找 package 是调用 Find&lt;PackageName&gt;.cmake，我去看了我们项目本身没有提供这个文件，那应该调用的就是 /usr/share/cmake/Modules/FindCURL.cmake 这个由 CMake 本身提供的文件了，但抛开 CMake 这种 Find 模块谜一样的语法不谈，这个文件明确设置了 CURL_LIBRARIES 变量，那也就是说这个文件根本没有生效？这时候我去翻了 CMake find_package() 的文档，好家伙，这玩意除了有基础和扩展两种语法，还有 Module 和 Config 两种查找模式（内心一万匹草泥马飞奔而过）……如果是 Module 模式，就会用 FindCURL.cmake 这个模块，但如果是 Config 模式，就会去调用 CURLConfig.cmake 这个配置……而后面这个配置里面只写了 CMake 的 target，没有定义 CURL_LIBRARIES 这个变量。\n但这样又有两个疑问，首先是为什么会有后面这个 Config 文件呢？毕竟以前一直用的是 Module，我比较了 Arch 的 curl PKGBUILD 和 MSYS2 的 curl PKGBUILD 得出结论：这个文件是 CMake 构建项目的时候自动基于当前所有的 target 生成的，因为 Arch 使用 Autotools 构建的包没有这个文件。但为什么默认会使用这个 Config 呢？按照 CMake 的文档，Config 似乎是 Module 的 fallback，也就是说只有没有 Module 的时候才会使用 Config，我百思不得其解。但总之解决方案很简单，对于 Ansel 自己，我使用了下面的代码：\nfind_package(CURL REQUIRED)\nif(TARGET CURL::libcurl)\n  set(CURL_LIBRARIES CURL::libcurl)\nendif()\n\n这样可以保证对不同版本的兼容性，而对于我们自己构建的 exiv2 依赖，我一开始打算直接 find_package(CURL MODULE REQUIRED)，毕竟按照文档这样可以强制它忽略 Config 嘛，但结果我推到 CI 上仍然报错。我不是很理解，直到我看了一眼 log 发现它仍然是用的 Config，我当时真的骂人了，我不是写的很清楚要强制 Module 吗？这到底是怎么回事？\n当然实际上解决方法是有的，只要使用前一种方法就可以了。但我咽不下这口气，不按照文档说的顺序先尝试 Module 就算了，我强制 Module 了还是给我用 Config 是什么意思？你猜我最后在哪找到了解释，是在 FindCURL.cmake 的文档页面：\n\nIf CURL was built using the CMake buildsystem then it provides its own CURLConfig.cmake file for use with the find_package() command's config mode. This module looks for this file and, if found, returns its results with no further action.\n\n我觉得任何一个脑子正常的程序员都该骂做出这个改动的人，首先你改变了默认的加载顺序也就算了，我可以理解成这是你们 CMake 用户的某种隐性约定我新来的我不知道。你用 Config 的时候不遵守 Find&lt;PackageName&gt;.cmake Module 约定的导出变量我也忍了。但是请问为什么我在 显式指定 了 MODULE 关键字的情况下，仍然会被不知不觉的改变行为？这个说明藏在这么小的专题页面里，是生怕用户搞清楚为什么代码和实际行为完全相反吗？写这个逻辑的人有没有意识到自己是在好心办坏事？\n这个 FindCURL.cmake 是 CMake 官方提供的 module 之一，所以也没必要试图甩锅给其他人。总之我在使用 CMake 的过程中除了迷惑到难以阅读的拼写习惯，反人类且可能混杂不同时期或者不同风味（比如 find_package() 就有两种语法）的 API，写起来弯弯绕绕的约定习惯（比如 Find&lt;PackageName&gt;.cmake 约定导出 &lt;PackageName&gt;_LIBRARIES 然后经常会看到什么 set(&lt;PackageName&gt;_LIBRARIES &lt;PackageName&gt;_LIBRARY)）之外，现在又多了个就算你显式指定参数还是会偷偷做出相反行为的体验。我不反对 CMake 确实能用，但和 Meson 的使用体验比起来，写 CMake 确实不能算是什么令人享受的事情。\n"},{"title":"Pango 中的 Ink 和 Logical 矩形","url":"/posts/Ink-and-Logical-Rectangles-of-Pango/","content":"如果你尝试过自己在程序里绘制一段字符串，你就会发现你得到的 buffer 的高度并不总是你设置的字号大小，特别是英文。这个现象的原因是英文在书写的时候并不像汉字一样高矮都一样，而是有上有下，比如 g 这个字符的尾巴总是向下伸出来一点，这些字符通常是基线对齐的，也就是说主体部分会躺在一条基线上，尾巴和头发则可能会伸出来。而就算是方块造型的汉字，字体也可能会在上下留下一定的空间，导致实际排版的尺寸比设置的字号要大。（我不是专业的排版工作者或者字体设计师，如果说错了希望大家指正。）\n通常来说这种多占用一点空间的行为不会有什么问题，因为只有这样渲染一排字符的时候看起来才会比较自然。但假如你像我一样希望在字符串周围绘制边框的话，事情就变得麻烦起来了，如果你只是按照你设置的字号大小去绘制边框，你会发现这个字号大小被 Pango 这样的排版库当作基线以上的高度，所以字母的尾巴会出现在边框外面，这是绘制下划线的方式而不是绘制边框的方式。当然你可以从 Pango 获取一些有用的数据，有两个矩形尺寸可以获得，一个是 logical，这个是 Pango 用来排版的矩形，它是按照基线安排的，所以你使用这个绘制会保证字符主体都在一条直线上，多个字符串绘制出来的效果比较接近书写的习惯，也因此它的 x 和 y 坐标总是 0，是绘制的基准点。另一个是 ink，这个是“着墨”区域，也就是说把所有字符包进去的最小矩形，由于字符书写的时候是错落有致的，x 和 y 坐标并不总是 0，而是相对 logical 基准点的 offset，比如一个斜体的字符的 x 坐标很可能是负数，因为书写的时候它是倾斜到左边的字符下面的。\n对于这两个矩形，官方文档基本没有解释，而网上查到的很多解释是错的，实际上可以用下面的程序把它们绘制出来：\n\n\nstatic void draw(GtkDrawingArea *drawing_area, cairo_t *cr, int width, int height, gpointer user_data)\n{\n\tPangoLayout *layout = pango_cairo_create_layout(cr);\n\tpango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_NONE);\n\tPangoFontDescription *font = pango_font_description_new();\n\tpango_font_description_set_family(font, &quot;serif&quot;);\n\tpango_font_description_set_style(font, PANGO_STYLE_ITALIC);\n\tpango_font_description_set_absolute_size(font, 200 * PANGO_SCALE);\n\tpango_layout_set_font_description(layout, font);\n\n\tPangoRectangle string_ink;\n\tPangoRectangle string_logical;\n\tpango_layout_set_text(layout, &quot;g&quot;, -1);\n\tpango_layout_get_pixel_extents(layout, &amp;string_ink, &amp;string_logical);\n\tg_print(&quot;Logical: x is %d, y is %d, width is %d, height is %d.\\n&quot;, string_logical.x, string_logical.y, string_logical.width, string_logical.height);\n\tg_print(&quot;Ink: x is %d, y is %d, width is %d, height is %d.\\n&quot;, string_ink.x, string_ink.y, string_ink.width, string_ink.height);\n\n\tcairo_set_source_rgb(cr, 0.3, 0.3, 0.3);\n\tcairo_paint(cr);\n\n\tconst int x = 100;\n\tconst int y = 100;\n\tcairo_move_to(cr, x, y);\n\tcairo_set_source_rgba(cr, 1.0, 1.0, 1.0, 1.0);\n\tpango_cairo_show_layout(cr, layout);\n\n\tcairo_set_line_width(cr, 5);\n\tcairo_set_source_rgba(cr, 1.0, 0.0, 0.0, 1.0);\n\tcairo_rectangle(cr, x + string_logical.x, y + string_logical.y, string_logical.width, string_logical.height);\n\tcairo_stroke(cr);\n\n\tcairo_set_source_rgba(cr, 0.0, 0.0, 1.0, 1.0);\n\tcairo_rectangle(cr, x + string_ink.x, y + string_ink.y, string_ink.width, string_ink.height);\n\tcairo_stroke(cr);\n}\n\nstatic void on_activate(GtkApplication *app, gpointer user_data)\n{\n\tGtkWidget *window = gtk_application_window_new (app);\n\tgtk_window_set_title(GTK_WINDOW(window), &quot;Test Pango Extents&quot;);\n\tgtk_window_set_default_size(GTK_WINDOW(window), 500, 500);\n\tGtkWidget *area = gtk_drawing_area_new();\n\tgtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(area), draw, NULL, NULL);\n\tgtk_window_set_child(GTK_WINDOW(window), area);\n\tgtk_window_present(GTK_WINDOW(window));\n}\n\nint main(int argc, char *argv[])\n{\n\tGtkApplication *app = gtk_application_new(&quot;one.alynx.test-pango-extents&quot;, G_APPLICATION_DEFAULT_FLAGS);\n\tg_signal_connect(app, &quot;activate&quot;, G_CALLBACK(on_activate), NULL);\n\tint status = g_application_run(G_APPLICATION(app), argc, argv);\n\tg_object_unref(app);\n\treturn status;\n}\n\" data-info=\"language-c\" data-lang=\"c\" class=\"code-block\">#include &lt;gtk/gtk.h&gt;\n\nstatic void draw(GtkDrawingArea *drawing_area, cairo_t *cr, int width, int height, gpointer user_data)\n{\n\tPangoLayout *layout = pango_cairo_create_layout(cr);\n\tpango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_NONE);\n\tPangoFontDescription *font = pango_font_description_new();\n\tpango_font_description_set_family(font, \"serif\");\n\tpango_font_description_set_style(font, PANGO_STYLE_ITALIC);\n\tpango_font_description_set_absolute_size(font, 200 * PANGO_SCALE);\n\tpango_layout_set_font_description(layout, font);\n\n\tPangoRectangle string_ink;\n\tPangoRectangle string_logical;\n\tpango_layout_set_text(layout, \"g\", -1);\n\tpango_layout_get_pixel_extents(layout, &amp;string_ink, &amp;string_logical);\n\tg_print(\"Logical: x is %d, y is %d, width is %d, height is %d.\\n\", string_logical.x, string_logical.y, string_logical.width, string_logical.height);\n\tg_print(\"Ink: x is %d, y is %d, width is %d, height is %d.\\n\", string_ink.x, string_ink.y, string_ink.width, string_ink.height);\n\n\tcairo_set_source_rgb(cr, 0.3, 0.3, 0.3);\n\tcairo_paint(cr);\n\n\tconst int x = 100;\n\tconst int y = 100;\n\tcairo_move_to(cr, x, y);\n\tcairo_set_source_rgba(cr, 1.0, 1.0, 1.0, 1.0);\n\tpango_cairo_show_layout(cr, layout);\n\n\tcairo_set_line_width(cr, 5);\n\tcairo_set_source_rgba(cr, 1.0, 0.0, 0.0, 1.0);\n\tcairo_rectangle(cr, x + string_logical.x, y + string_logical.y, string_logical.width, string_logical.height);\n\tcairo_stroke(cr);\n\n\tcairo_set_source_rgba(cr, 0.0, 0.0, 1.0, 1.0);\n\tcairo_rectangle(cr, x + string_ink.x, y + string_ink.y, string_ink.width, string_ink.height);\n\tcairo_stroke(cr);\n}\n\nstatic void on_activate(GtkApplication *app, gpointer user_data)\n{\n\tGtkWidget *window = gtk_application_window_new (app);\n\tgtk_window_set_title(GTK_WINDOW(window), \"Test Pango Extents\");\n\tgtk_window_set_default_size(GTK_WINDOW(window), 500, 500);\n\tGtkWidget *area = gtk_drawing_area_new();\n\tgtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(area), draw, NULL, NULL);\n\tgtk_window_set_child(GTK_WINDOW(window), area);\n\tgtk_window_present(GTK_WINDOW(window));\n}\n\nint main(int argc, char *argv[])\n{\n\tGtkApplication *app = gtk_application_new(\"one.alynx.test-pango-extents\", G_APPLICATION_DEFAULT_FLAGS);\n\tg_signal_connect(app, \"activate\", G_CALLBACK(on_activate), NULL);\n\tint status = g_application_run(G_APPLICATION(app), argc, argv);\n\tg_object_unref(app);\n\treturn status;\n}\n\n下面是编译命令：\n$ gcc -o test-pango-extents `pkg-config --cflags --libs gtk4` test-pango-extents.c\n$ ./test-pango-extents\n\n输出的尺寸是这样：\nLogical: x is 0, y is 0, width is 113, height is 265.\nInk: x is -1, y is 102, width is 122, height is 151.\n\n得到的结果是这样的：\n\n但是这两个都不符合我的需求，因为我希望字符串按照边框对齐而不是按照基线对齐，同时边框看起来都一样高，直接绘制 logical 矩形实际仍然是基线对齐，比我想要的大了很多。而按照 ink 绘制不能保证边框一样高。最后的做法是使用 ink 的宽度绘制边框的宽度，然后利用 ink 的高度重新计算绘制位置将字符放在底边上，这里要注意的是因为默认绘制是以 logical 矩形左上角作为基准点，需要减去 ink 矩形的左上角 offset 才能从 ink 的左上角开始绘制。具体的修改在 https://github.com/AlynxZhou/showmethekey/commit/f1a2a5a995d6c224700b376b287c7aaff512d365#diff-295efe2fde895e5fedc8e4194f1be7ba064dc9787643139520f7913541aee1f6，这里就不赘述了。\n"},{"title":"构建和运行 Xen HVM 和 PV","url":"/posts/Xen-HVM-PV/","content":"请注意本文并不是推荐读者使用 Xen 作为虚拟化方案，相反，KVM 才是目前更合适大部分读者的方案。\n\n简介\n由于工作需要，最近我需要搞一个 Xen PV 来进行测试，在此之前我一直使用 qemu/KVM，只是听说 Xen 是 KVM 之前流行过的虚拟化方案。比起几乎什么都不需要做交给 libvirt 包办就可以的 KVM，Xen 的设置相对要复杂一点。\n\n\n首先 Xen 分为 HVM 和 PV 两种常见的虚拟方案（PVH 我也没用过），HVM 依赖于硬件虚拟化，和常见的虚拟机没什么区别，而 PV 并不依赖硬件虚拟化，是通过虚拟机 Linux 内核中特殊的驱动，将请求转给宿主机的内核代为操作，但配置起来也更加复杂。目前 PV 所需的代码已经并入 Linux 内核上游，你能安装的发行版大部分都可以直接作为 PV 的虚拟机运行。\n无论是 HVM 还是 PV，Xen 都用 domU（domain 的缩写，U 可以是 1、2、3……）代表虚拟机，然后用 dom0 代表宿主机，当然文档里也会叫宿主机 Hypervisor，这里我也可能直接将虚拟机叫做 VM。\n构建和运行 dom0\n温馨提示：根据我的经验，在 Xen Hypervsior 上运行的 GNOME 桌面会由于未知原因在未知操作时卡住，建议不要用你平时使用的开发机作为 Xen Hypervisor，而是另找一台机器作为服务器运行 Xen Hypervisor，然后远程连接上去操作。\n\n和 KVM 不需要什么操作就能用不一样，Xen 需要你构建一个单独的 boot loader，在加载 Linux 内核之前先加载它，从而实现 Xen 的支持。我这里用的 Arch Linux，需要构建 xen 这个 AUR 包，根据 wiki 所说推荐用下面的指令构建：\n$ build_stubdom=true efi_dir=\"/boot\" makepkg -si\n\n我这里直接把 ESP 挂载到 /boot，你可能需要按情况修改。\n你还需要 xen-qemu 这个 AUR 包提供 qemu 前端对 Xen 的支持，但和一般的 AUR 包不同的是如果你直接构建这个包，得到的包很可能和你已经安装的 qemu 文件冲突。这是我遇到的唯一一个必须在 clean chroot 才能构建的包。然后由于它依赖 xen 这个 AUR 包，你必须手动操作。\n首先安装 devtools：\n# pacman -S devtools\n\n然后创建一个用于构建 clean chroot 的目录：\n$ mkdir ~/chroot\n\n然后在里面安装基础依赖：\n$ mkarchroot ~/chroot/root base-devel\n\n一般这时候 chroot 里面应该已经是最新的了，但也可以用下面的命令更新：\n$ arch-nspawn ~/chroot/root pacman -Syu\n\n然后切换到你包含 xen-qemu 的 PKGBUILD 的目录，你可能需要的两个依赖是 xen 和 numactl，前者我们刚刚构建过，后者我们可以直接从官方仓库通过 pacman -S numactl 安装，然后在命令行参数里指定这两个文件的位置：\n$ 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\n\n然后用 pacman -U xen-qemu-*.tar.pkg.zst 安装你刚刚构建好的包，此时应该没有文件冲突了。\n你还需要安装下面的包提供虚拟机内的 BIOS 和 UEFI 引导支持：\n# pacman -S seabios edk2-ovmf\n\n然后你需要加载构建好的 Xen boot loader，让它在 Linux 内核之前启动，我使用的 systemd-boot，所以下面就简单写 systemd-boot 的配置方式，逻辑上是完全一致的，如果你使用 GRUB，建议参考 Arch Wiki。\n首先添加一个 systemd-boot 启动项文件，我这里使用 /boot/loader/entries/xen.conf：\ntitle Xen Hypervisor\nsort-key xen\nefi /xen.efi\n\n如果你也用 systemd-boot，这个文件对你来说应该非常简单，构建 xen 包的时候已经将支持 EFI 的 Xen boot loader 也就是 xen.efi 这个文件安装到了 ESP，只要引导它就可以。\n接下来我们编写 Xen 的配置文件让它可以正确找到你的 initramfs 和内核，并传递内核参数，配置文件 xen.cfg 需要和 xen.efi 位于同一个目录，这里就是 /boot/xen.cfg：\n[global]\ndefault=xen\n\n[xen]\noptions=console=vga loglvl=all noreboot\nkernel=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\nramdisk=initramfs-linux.img\nucode=amd-ucode.img\n\n以上的内核参数是我所用的，你可以从你当前的 boot loader 启动项文件里复制出你正在用的内核参数。如果你复制了 xen 包自带的示例文件而不是我的，需要注意里面包含限制宿主机可用内存的参数，这是为了避免在创建虚拟机时再限制宿主机内存，影响宿主机各种缓存的策略，默认的值给的很小，可能导致无法正常启动桌面，你可能需要改大一点。不过对于我们这种简单 debug 用，可以直接忽略此参数，影响不大。\n然后你需要让 Xen 所需的一些守护进程开机启动：\n# systemctl enable xenconsoled.service xen-init-dom0.service xen-qemu-dom0-disk-backend.service xendomains.service\n\n然后重启系统，选择名字是 Xen Hypervisor 的启动项，你应该就有一个可以运行 Xen 的环境了。\n配置和运行 domU\n然后按道理说既然 Xen 和 KVM 都是用 qemu 作为前端，那完全可以交给我常用的 virt-manager 操办一切，但我尝试构建了带 Xen 支持的 libvirt，结果运行起来发现由于某个 bug，它并不能真的支持 Xen，所以只能全手动操作了。\n构建虚拟机用的 NAT 网络\nvirt-manager 会帮我自动创建一个 NAT 网络使得虚拟机之间可以互相联系并且通过宿主机访问外网，我相信大部分人都需要让虚拟机联网，但只能我自己解决这件事了。\n具体的操作包含以下几步：添加一个桥接接口；然后给它分配一个 IP；再开启系统的 NAT 转发（包含 sysctl 和 iptables 两部分，我自己也不是 iptables 高手所以我也不能给你解释）；然后在这个接口上启动 DHCP 服务器，给虚拟机提供 IP 地址和 DNS 服务器。这样就完成了宿主机的部分，Xen 可以根据虚拟机的配置文件自动把虚拟机添加到你刚才创建的桥接接口上。\n为了简化这个操作我编写了一个脚本：\n.\nsysctl net.ipv4.ip_forward=1\niptables -t nat -A POSTROUTING -o &quot;${OUT_IF}&quot; -j MASQUERADE\n\n# Start DHCP server so we don't need to manually assign IP addresses for VMs.\n# `--no-daemon` starts dnsmasq in debug mode, so it won't overload SIGINT.\n# It is hard to prevent `dnsmasq` from listening `127.0.0.1:53` as a DNS server,\n# so just disable its DNS server, and send DNS server to VMs via DHCP.\n# It seems you need to set `--bind-interfaces` with `--interface` to make it\n# bind to a interface only, otherwise it will try to bind to other interfaces\n# and conflict with other dnsmasq instance (if exists).\ndnsmasq --no-daemon \\\n\t--port=0 \\\n\t--interface=&quot;${BR_IF}&quot; \\\n\t--bind-interfaces \\\n\t--dhcp-range=&quot;${BR_IP_RANGE}&quot; \\\n\t--dhcp-option=&quot;option:dns-server,${BR_DNS}&quot;\n\niptables -t nat -D POSTROUTING -o &quot;${OUT_IF}&quot; -j MASQUERADE\nsysctl &quot;net.ipv4.ip_forward=${FORWARD}&quot;\n\nip link set dev &quot;${BR_IF}&quot; down\nip link delete &quot;${BR_IF}&quot;\n\" data-info=\"language-bash\" data-lang=\"bash\" class=\"code-block\">#!/bin/bash\n\nset -x\n\nOUT_IF=\"${1}\"\nBR_IF=\"${2}\"\nBR_IP=\"192.168.123.1\"\nBR_IP_RANGE=\"192.168.123.100,192.168.123.200\"\nBR_DNS=\"1.1.1.1,8.8.8.8\"\n\n[[ -z \"${OUT_IF}\" ]] &amp;&amp; exit 1\n[[ -z \"${BR_IF}\" ]] &amp;&amp; BR_IF=\"vmbr0\"\n\nip link add name \"${BR_IF}\" type bridge\nip link set dev \"${BR_IF}\" up\n\nip address add dev \"${BR_IF}\" \"${BR_IP}/24\"\n\n# Enable NAT, so VMs can accept Internet.\nFORWARD=$(sysctl --values net.ipv4.ip_forward)\n# See &lt;https://www.karlrupp.net/en/computer/nat_tutorial&gt;.\nsysctl net.ipv4.ip_forward=1\niptables -t nat -A POSTROUTING -o \"${OUT_IF}\" -j MASQUERADE\n\n# Start DHCP server so we don't need to manually assign IP addresses for VMs.\n# `--no-daemon` starts dnsmasq in debug mode, so it won't overload SIGINT.\n# It is hard to prevent `dnsmasq` from listening `127.0.0.1:53` as a DNS server,\n# so just disable its DNS server, and send DNS server to VMs via DHCP.\n# It seems you need to set `--bind-interfaces` with `--interface` to make it\n# bind to a interface only, otherwise it will try to bind to other interfaces\n# and conflict with other dnsmasq instance (if exists).\ndnsmasq --no-daemon \\\n\t--port=0 \\\n\t--interface=\"${BR_IF}\" \\\n\t--bind-interfaces \\\n\t--dhcp-range=\"${BR_IP_RANGE}\" \\\n\t--dhcp-option=\"option:dns-server,${BR_DNS}\"\n\niptables -t nat -D POSTROUTING -o \"${OUT_IF}\" -j MASQUERADE\nsysctl \"net.ipv4.ip_forward=${FORWARD}\"\n\nip link set dev \"${BR_IF}\" down\nip link delete \"${BR_IF}\"\n\n你可以把这个脚本保存成 mkvmbr0.sh，然后 chmod +x mkvmbr0.sh，然后用 ip a 查看你当前联网所用的端口名，比如我的是 wlp5s0，就可以 sudo ./mkvmbr0.sh wlp5s0 创建一个运行在 vmbr0 端口上的 NAT 网络，脚本会启动 dnsmasq 作为 DHCP 服务器并通过 DHCP 服务器给虚拟机下发 DNS 服务器地址，如果你已经用完了虚拟机，Ctrl+C 打断 dnsmasq 它就会进行后续的清理工作并退出。\n编写 HVM 配置文件\nHVM 实在是没什么复杂的，这么说是相对于 PV 而言，比如说构建一个 openSUSE Tumbleweed 的 HVM 可以写一个叫做 hvm-tumbleweed.cfg 的文件：\nname = 'hvm-tumbleweed'\nbuilder = 'hvm'\nmemory = 2048\nvcpus = 4\ndisk = [ 'file:/home/alynx/xen/disk-tumbleweed.img,xvda,rw', 'file:/home/alynx/xen/openSUSE-Tumbleweed-DVD-x86_64-Current.iso,sdb:cdrom,r' ]\nvif = [ 'mac=00:16:3e:00:00:02,bridge=vmbr0' ]\nvnc = 1\nvnclisten = '0.0.0.0'\nvncdisplay = 1\n\n这里没什么要注意的，无非是 bridge= 后面接你 NAT 网络的桥接端口。以及你在配置文件里写 xvda 在虚拟机里会变成 sda，所以不要再写另一个叫做 sda 的设备了。如果你有多个虚拟机，记得修改 MAC 地址和 VNC 端口。\n创建磁盘文件可以用下面的命令：\n$ truncate -s 20G disk-tumbleweed.img\n\n然后用下面的命令就可以启动这个虚拟机：\n# xl create hvm-tumblweed.cfg\n\n其他的命令可以直接 man xl 查看手册。可以使用 vncviewer YOUR_HOST_IP:1 连接虚拟机。关于这个配置文件的具体语法可以参考 官方文档。\n编写 PV 配置文件\nPV 比起 HVM 可就复杂太多了，最痛苦的一个问题是由于它不是硬件虚拟化，所以你没有办法运行虚拟机磁盘上的 boot loader！解决方案有两个，要么是直接在配置文件里写好内核和 initramfs 的路径（这样你就得想办法把虚拟机的内核和 initramfs 搞到宿主机磁盘上），要么是在宿主机上构建一个 GRUB 镜像，然后让这个 GRUB 去找虚拟机磁盘里的 grub.cfg 并执行，然后引导虚拟机里面的内核（这都哪跟哪啊）。\n当然，如果你虚拟机的系统并不使用 GRUB 作为 boot loader，那你就只能使用第一种方案了。对于 openSUSE Tumbleweed，我摸索通了后面的方案，因此我在这里介绍这个方案如何操作。\n首先你需要克隆 GRUB 的源码，因为你得专门构建一个能在 Xen 虚拟机里运行的 GRUB：\n$ git clone git://git.savannah.gnu.org/grub.git\n\n然后构建（如果你的 Xen 是 32 位机器上的，把 amd64 换成 i386：\n$ ./autogen.sh\n$ ./configure --prefix=/opt/grub-xen --target=amd64 --with-platform=xen\n$ make\n# make install\n\n这会将 Xen 版本的 GRUB 安装到 /opt/grub-xen，接下来我们需要利用 GRUB 可以将一个 tar 作为 memdisk 的特性，在里面写一个 grub.cfg 让 GRUB 去按我们指定的路径搜索实际的 grub.cfg 并加载它（别问我为什么，这鬼东西简直太邪门了）。\n首先写一个 grub-bootstrap.cfg，这个文件的唯一作用就是让 GRUB 加载 memdisk 里面的 grub.cfg：\nnormal (memdisk)/grub.cfg\n\n然后写一个 grub.cfg，下面关键的问题来了，你怎么知道要搜索的真正的 grub.cfg 的路径呢，当然是想办法挂载出来自己看了，我这个文件里写了常见的安装好的系统的 grub.cfg 的位置和我自己看到的 openSUSE 安装 iso 里面的 grub.cfg 的位置，所以可以同时支持安装和启动系统：\nif search -s -f /boot/grub/grub.cfg ; then\n    echo \"Reading (${root})/boot/grub/grub.cfg\"\n    configfile /boot/grub/grub.cfg\nfi\n\nif search -s -f /boot/grub2/grub.cfg ; then\n    echo \"Reading (${root})/boot/grub2/grub.cfg\"\n    configfile /boot/grub2/grub.cfg\nfi\n\nif search -s -f /grub/grub.cfg ; then\n    echo \"Reading (${root})/grub/grub.cfg\"\n    configfile /grub/grub.cfg\nfi\n\nif search -s -f /grub2/grub.cfg ; then\n    echo \"Reading (${root})/grub2/grub.cfg\"\n    configfile /grub2/grub.cfg\nfi\n\nif search -s -f /EFI/BOOT/grub.cfg ; then\n    echo \"Reading (${root})/EFI/BOOT/grub.cfg\"\n    configfile /EFI/BOOT/grub.cfg\nfi\n\n然后把它打包成 tar：\n$ tar -cf memdisk.tar grub.cfg\n\n然后创建一个支持 Xen 的包含所有 GRUB 模块的 GRUB 镜像，我们将把它当作真正的虚拟机的 boot loader 运行，使用如下命令：\n$ /opt/grub-xen/bin/grub-mkimage -O x86_64-xen \\\n       -c grub-bootstrap.cfg \\ \n       -m memdisk.tar \\\n       -o grub-x86_64-xen.bin \\\n       /opt/grub-xen/lib/grub/x86_64-xen/*.mod\n\n然后你就可以编写一个 pv-tumbleweed.cfg 的配置文件：\nname = 'pv-tumbleweed'\nmemory = 2048\nvcpus = 4\nkernel = \"grub-x86_64-xen.bin\"\ndisk = [ 'file:/home/alynx/xen/disk-tumbleweed.img,sda,rw', 'file:/home/alynx/xen/openSUSE-Tumbleweed-DVD-x86_64-Current.iso,sdb:cdrom,r' ]\nvif = [ 'mac=00:16:3e:00:00:01,bridge=vmbr0' ]\nvnc = 1\nvnclisten = '0.0.0.0'\nvncdisplay = 1\n\n没错我们这里把我们刚刚生成的 boot loader 作为内核首先拉起来，然后不出意外你应该就能看到安装程序启动了，但是如果你火急火燎的一路下一步安装，你就会掉进下一个坑：openSUSE 默认使用 snapper 管理 btrfs 快照，默认的配置方案把 /boot 也放在 btrfs 子卷上。而上游的 GRUB 会从 btrfs 的根子卷而不是默认子卷开始访问，我是没能搞清楚该如何简单直接的访问 snapper 最新的快照所在的子卷，openSUSE 的 GRUB 则是打了一大堆 patch 让 GRUB 支持查找和加载默认的 btrfs 子卷。总之你直接安装之后我们的 grub.cfg 是查找不到真正的 grub.cfg 的，最简单的方案就是安装时干脆不要用 btrfs 从而不用 snapper，或者把 /boot 单独分区单独格式化单独挂载。\n然后你应该可以用下面的命令启动并链接到虚拟机的终端了：\n# xl create -c pv-tumblweed.cfg\n\n关于这个配置文件的具体语法可以参考 官方文档。\n这一切值得吗？\n在经历这一系列不知道是什么鬼东西的操作之后你终于有了一个可以用的 Xen PV，也许它唯一的优势就是可以在没有硬件虚拟化的机器上跑虚拟机，为此你付出的代价是一个没法用桌面的宿主机，一个说不定哪个新系统就没法引导的虚拟机 boot loader。但现在的设备有几个没有硬件虚拟化支持呢？这就意味着对于大多数人你可以简单地使用 qemu/KVM 几乎不需要任何额外的配置，并且还有 virt-manager 这样的程序全程帮你图形化配置虚拟机和 NAT 网络。\n而比如你想用上面的办法手动构建网络并启动一些 SLES Minimal OS 或者 openSUSE JeOS，这个过程也更简单，首先在 /etc/qemu/bridge.conf 里加入一行 allow vmbr0，然后用之前的 NAT 网络脚本 sudo ./mkvmbr0.sh wlp5s0，再用下面的 qemu 命令：\n$ qemu-system-x86_64 \\\n    -enable-kvm \\\n    -m 1G \\\n    -smp 1 \\\n    -drive if=virtio,format=qcow2,file=SLES15-SP5-Minimal-VM.x86_64-kvm-and-xen-GM.qcow2 \\\n    -nographic \\\n    -netdev bridge,id=eth0,br=vmbr0 \\\n    -device virtio-net,netdev=eth0\n\n它会调用 qemu-bridge-helper 自动将虚拟机加入你构建的桥接 NAT 网络，和 Xen 比起来简单很多，也不需要额外的配置。因此如果你只是需要一个自己的虚拟化平台，完全没有必要使用已经不再流行且可能存在更多问题的 Xen，使用 KVM 就足够了。这篇文章仅仅是为了在读者不得不需要构建一个 Xen 环境 debug 时作为参考。\n"},{"title":"kmsvnc 但是无头","url":"/posts/Headless-kmsvnc/","content":"由于 Wayland 并不存在像 X11 一样一个单独管理用于最终显示的 framebuffer 的进程，因此远程桌面需要各个桌面做自己的实现。而因为登录界面和用户会话一般是两个会话，远程登录过程中的会话切换就变得非常复杂，虽然 GNOME Remote Desktop 已经有了一个初步实现，但我对那个的逻辑不是很满意，而且他们目前依赖 RDP 的 server rediection，所以并不支持 VNC。\n按照当初我做 Show Me The Key 的逻辑，如果一样东西在混成器层面上不好实现，那应该考虑在下一层更统一的层面上实现，@Jerry Xiao 老师做了一个叫做 kmsvnc 的项目，通过 DRM/KMS 获取 framebuffer 并转给 VNC 服务器，虽然它也有一些自己的小问题，但我很喜欢这个方案。\n实际上我需要用到远程桌面的场景不多，毕竟 ssh 大部分时候更可靠也更高效，不过有时候需要修改 NAS 上一些桌面设置的时候会用到。但此时就有第一个问题，我的 NAS 是无头（就是没接显示器）的，没有显示器的情况下桌面没有输出，也就获取不到 framebuffer。请教了 @Jerry Xiao 老师之后得知可以通过内核参数实现强制让内核以为我们有个显示器，就可以解决这个问题。\n这个目的需要两个内核参数，分别是 video= 指定强制开启哪个显卡输出端口，和 drm.edid_firmware= 给这个端口指定一个 EDID 从而指定分辨率，正常的显示器会通过端口汇报自己的 EDID，而我们这里没有，所以需要手动指定。\n具体这两个参数都支持什么我就不细说了，因为很复杂，建议自己看文档。首先我们得选择一个显卡已经有的输出端口用来做这个，可以用下面的命令：\n$ for p in /sys/class/drm/*/status; do con=${p%/status}; echo -n \"${con#*/card?-}: \"; cat $p; done\n\n它会列出端口和它们的连接状态，比如我这里有三个：\nDP-1: disconnected\nHDMI-A-1: connected\nHDMI-A-2: disconnected\n\n很神奇的是我的主板上实际只有一个 DP 和一个 HDMI（我的是核芯显卡），经过我的测试主板的 HDMI 输出是 HDMI-A-2，至于 HDMI-A-1 到底在哪我不清楚，可能是 USB-C，但如果它并没有物理输出，那就更好了，所以我这里选择它。另外我建议不要选择 DP 输出，因为内核内置的 EDID 固件似乎并不支持 DP，导致无法指定分辨率。\n然后我们通过 video=\"HDMI-A-1:D\" 强制开启这个 HDMI 端口，一般你可能会查到用 e 表示强制启用一个端口，不过 D 表示强制启用一个数字输出端口，应该用哪个都无所谓。然后我们用 drm.edid_firmware=\"HDMI-A-1:edid/1920x1080.bin\" 给这个端口指定一个 EDID 固件，这里我们使用内核内置的 1920x1080 分辨率的固件，你也可以选择其它内置的，可以在内核源码 drivers/gpu/drm/drm_edid_load.c 文件中找到它们的名字和内容。\n然后重新启动系统，再用上面的命令，应该就像我一样有一个 connected 的输出了。然后我们启动 kmsvnc：\n# kmsvnc -p 5901 -b 0.0.0.0 -4 -d /dev/dri/card0\n\n再从另一台机器上访问：\n$ vncviewer YOUR_SERVER_IP:5901\n\n应该就能看到登录界面了。不过如果你也用 GDM，你可能会发现登录之后屏幕一直是 GDM 的背景色没有切换到用户会话，这个其实是因为 GDM 会选择另一个空闲的 tty 启动 GNOME，而 @Jerry Xiao 老师表示还没有找到一个普适的方案检测 tty 切换，所以这时候需要你手动 Ctrl+C 打断 kmsvnc，然后再重新运行上面的命令，应该就会选择 GNOME 桌面所在的 plane 了。\n如果你不是刚开机就启动 kmsvnc，可能会发现它报告找不到 plane 就退出了，这个目测是因为 GDM 检测不到用户输入就会自动停止显示器输出，只要 systemctl restart gdm 重新启动一下 GDM 就可以了。\n"},{"title":"谁动了我的 DNS 解析？（重制版）","url":"/posts/Who-Moved-My-DNS-Resolving-Remastered/","content":"这一篇是之前 谁动了我的 DNS 解析？ 的重制版，因为那一篇杂糅了关于设置 Zeroconf 的 mDNS 的需求和关于 Linux 下面 DNS 解析到底是怎么工作的描述，我怀疑大部分读者对前者不感兴趣（因为我自己后来也发现这玩意不是很可靠），而更想了解后者，所以打算拉出来单写一篇。\n标题显然是化用自《谁动了我的奶酪？》，即使我并没有读过这本书。\n\n\nlong long ago\n一般要讲故事，开头都是“很久很久以前……”，不过计算机领域也没什么太古老的故事可讲，毕竟公认的互联网前身 ARPANET 也就是二十世纪的事情。那个时候能互联的机器一共也就那么几个，所以解决的办法简单粗暴：我们每个机器都保存一个文件，里面记录所有人对应的域名和 IP 不就行了？这个优良传统一直留了下来，也就是现在所有系统里都有的 hosts 文件——不管你写的对不对，它的优先级都比后来出现的 DNS 查询要高。\n然后随着加入网络的机器越来越多，这个办法不好用了，毕竟每来一个新人就要所有人更新自己的文件，这复杂度也太高了。所以干脆我们搞一个集中的服务器专门放这个列表，其它机器都向它查询就好了，这就是 DNS 服务器的原理。然后在局域网里，一般路由器和 DNS 服务器以及 DHCP 服务器都是同一台机器，因为很自然的所有设备都会连到路由器上，而 DHCP 服务器恰好知道它分配出去的 IP 地址，所以如果你输入内网设备的主机名恰好能解析，那通常是你的路由器做了这些工作。\n但既然有了 DNS 服务器，那问题就变得复杂起来，比如我该将我的 DNS 服务器设置成哪一个？特别地，你可能会发现有很多不同的程序在试图修改你的 DNS 服务器设置，导致你打开某些网站本该秒开却不停地转圈圈，事情为什么会这么复杂？\nchattr +i /etc/resolv.conf\n很多 Linux 用户都知道修改 DNS 服务器可以通过编辑 /etc/resolv.conf 实现，很多 Linux 用户也被 /etc/resolv.conf 困扰，一些人发现自己的这个文件是个软链接，而另一些人发现这个文件总被 Network Manager 覆盖，还有些人的发行版让他们用一个叫 resolvconf 的工具处理，然后现在 systemd 又搞了个叫 systemd-resolved 的东西来插一脚……我说的这些已经足够让一些不想学新东西同时又神经紧张的人开始大喊“fuck systemd, fuck network manager, fuck desktop environment and fuck the whole modern world”然后执行 chattr +i /etc/resolv.conf 了。不过别着急小炸药包们，也许这个世界上新出现的各种东西目的并不只是惹恼你们这群大笨蛋，哦是的，没错，我说，大笨蛋，恐龙勇士（停停停不要翻译腔了），你不需要的功能并不意味着别人也不需要。总之，不要觉得世界都围着你转，至少读一下这些东西的文档，会告诉你怎么阻止它们修改你的 /etc/resolv.conf 的。\n在 DNS 服务器设置这件事上并不是一个 /etc/resolv.conf 搞定所有，有关这个的故事也是 long long ago，但毕竟是 UNIX 纪元之后的事情，没有太久，大概确实上古时代的程序都是直接读这个获取 DNS 服务器然后再做 DNS 解析的，但实际上这也不一定 OK，比如像之前说的 hosts 文件也需要考虑。所以就有了更复杂的解决方案，大部分程序做 DNS 解析实际上是调用 glibc 里面 getaddrinfo 这个 API，所以在它后面我们就可以做一些工作。一个叫做 Name Service Switch 的东西发明出来就是干这个的，它是基于插件的，我们可以通过阅读 /etc/nsswitch.conf 里面的 hosts 这一行来理解，比如我这里默认是这样的：\nhosts: mymachines resolve [!UNAVAIL=return] files myhostname dns\n\n简单翻译一下的话意思就是查询一个域名的时候首先看看是不是 systemd-machined 的容器（mymachines 模块），不是的话再问问 systemd-resolved 能不能解析（resolve 模块），如果 systemd-resolved 可用，那到这也就完事了，后面的就不管了（[!UNAVAIL=return]），至于为什么我一会解释，然后 files 模块会读 hosts 文件，所以它优先级总是高于 DNS 服务器，然后看看是不是本机（myhostname 模块），然后再读 /etc/resolv.conf 里面的 DNS 服务器进行查询。\n按照这个顺序，如果你处在一个极其简单的网络环境：只有一个网络连接（这里包含各种有线无线 VPN 隧道在内都只能有一个）并且完全不会移动到其它网络连接下使用，那确实只要在 /etc/resolv.conf 里面写死一个公开的 DNS 服务器就可以满足你的所有查询需求。但可惜并不是所有人的使用环境都这么简单，所以每个工具都有额外的策略并试图修改 /etc/resolv.conf。\n当你是个需要来回跑的笔记本用户……\n下面让我们考虑一个比那些觉得自己手搓一个 DHCP 客户端就能联网的大脑皮层极其光滑的只要 chattr +i /etc/resolv.conf 就能解决问题的小笨蛋们的场景稍微复杂一点的场景：你是一个背着笔记本来回跑的上班族，公司 WiFi 和家里 WiFi 的网段并不一样，而你需要在公司的时候将 DNS 服务器设置为公司的路由器，在家的时候将 DNS 服务器设置为家的路由器（什么水晶室女），以便在两地都可以通过内网设备的主机名访问对应的内网设备，显然你不可能靠 chattr +i /etc/resolv.conf 解决问题。\n这就是为什么 Network Manager 需要修改你的 /etc/resolv.conf（其它网络管理器我就不考虑了因为我没用过，而且对于所有这种可以帮你自动连接 WiFi 的网络管理器而言，设置 DNS 服务器的逻辑都应该是相同的），对于每个不同的网络连接，它都会记录或者自动获取该局域网的 DNS 服务器，然后根据你当前激活的连接把这个 DNS 服务器写入 /etc/resolv.conf，保证无论是使用 getaddrinfo 的程序还是自己读取 /etc/resolv.conf 的老古董程序都可以获取到正确的局域网 DNS 服务器从而访问内网里的设备。\n当你是个需要来回跑的笔记本用户，同时你还需要通过 VPN 远程办公……\n如果你完全理解了上面那一段，恭喜你已经脱离了草履虫进化成了脊椎动物了！但你又遇到了更复杂的场景：你的工作要求你连接公司的 VPN，从而通过内部的 DNS 服务器解析一些内网才能访问的网站，当你打开了 VPN 下载一些内网才能下载的工具时你想同时放一些音乐打发时间，但你最爱的音乐网站现在要转 3 秒的圈才能访问！Network Manager 可以帮你解决这个问题吗？答案是在某些情况下可以！\n尝试分析这个新的需求，你会发现问题在于 VPN 服务也要设置一个 DNS 服务器，如果你的 VPN 服务尝试自己覆盖 /etc/resolv.conf，那么 Network Manager 之前按照你的连接帮你设置的“正常的”的 DNS 服务器就会消失，你所有的 DNS 解析就会全部绕到公司内网的 DNS 服务器上跑一圈，导致所有网站的解析都变得很慢。这个时候你可能又打算大喊 chattr +i /etc/resolv.conf 退化成草履虫，但这样你就没办法解析公司内网的域名了，也许更好的办法是只让 Network Manager 管理 /etc/resolv.conf。因为大部分的 VPN 都已经有 Network Manager 的插件了，所以你只要在 Network Manager 里添加你的 VPN 连接，它就会像管理 WiFi 一样管理这个连接，此时你会发现你的 /etc/resolv.conf 里已经同时有了家里路由器的 DNS 服务器和 VPN 的 DNS 服务器。\n理想状态下，你的 VPN 应该会自动通知客户端对于哪些域名使用这个 VPN 的 DNS 服务器查询，如果你勾选了“仅对此网络上的资源使用此连接”的话。这样访问不在内网上的网站就不会到这个 DNS 服务器上转一圈，而是直接跑到“正常”的 DNS 服务器上查询。但如果不幸这个自动配置的过程出了问题，你可以通过 nmcli 修改这个 VPN 连接的 ipv4.dns-search 项，把需要在内网查询的域名列表手动设置好。\n对于绝大部分用户，这样的配置应该已经可以满足他们了！但实际情况永远只有更复杂，所以我们还要继续！\n当你是个需要来回跑的笔记本用户，同时你还需要通过 VPN 远程办公，但你的 VPN 并没有 Network Manager 的插件……\n欢迎刚刚进化完成的灵长类！坐稳了！我们要向现代人的方向冲刺了！现在我有一个更加复杂的需求：我给自己的各种内网设备搭建了一个 VPN，这样我即使身在外面也能访问我家里的服务器，但这个 VPN 使用 Tailscale，Network Manager 并没有相关的插件，于是 Tailscale 也来试图覆盖我的 /etc/resolv.conf，听个音乐又需要转 3 秒的圈，怎么办？\n最好在那边手握 chattr +i /etc/resolv.conf 的草履虫嘲笑之前堵上他的嘴，因为这同样会导致我失去解析 VPN 内网域名的能力，我们是现代人，我们要用 systemd-resolved 解决这个问题。\nsystemd-resolved 并不仅仅是一个管理 /etc/resolv.conf 的工具，实际上它本身是一个自带缓存的 DNS 服务器，然后向上接管各种不同的 DNS 查询逻辑，向下为各种需要设置 DNS 服务器的工具提供接口。因此如果你的各种网络连接工具都支持 systemd-resolved 的接口，那它们就不需要自己修改 /etc/resolv.conf，而是改为配合 systemd-resolved 工作，恰好 Network Manager 和 Tailscale 都支持 systemd-resolved。\n在接管 DNS 查询这个目的上 systemd-resolved 提供了三种不同的接口：首先是自己实现了一个 D-Bus 接口，其它程序可以通过这个接口来实现。然后是在 Name Service Switch 里添加了属于自己的模块以接管 getaddrinfo，如果检测到 systemd-resolved 已启用，那它的缓存 DNS 服务器就会接管所有的处理，包括 hosts 和 hostname，以及如果没有缓存到，就会主动向上级 DNS 服务器查询，因此在 /etc/nsswitch.conf 里面写了如果检测到 systemd-resolved 就直接返回，跳过后面的模块。最后对于那些自己读取 /etc/resolv.conf 的老古董，它也会修改这个文件接管这类程序，这时这个文件只是个软链接，里面只有一句就是把 DNS 服务器设置为 systemd-resolved 自己的 DNS 缓存服务器。\n如果你要启用 systemd-resolved，务必保证你的 /etc/resolv.conf 是指向 systemd-resolved 管理的文件的软链接：\n# ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf\n\n这样如果 Network Manager 和 Tailscale 启动时检测到 /etc/resolv.conf 是软链接，就会知道自己需要配合 systemd-resolved 工作（如果你正在配置这个，那就手动重启它们！）。此时执行 resolvectl status，应该能看到对于不同的网络接口，都配置了不同的 DNS 服务器，以及需要通过这个服务器查询的域名（如果你配置过的话！）。\n当然，你也可以通过 systemd-resolved 的配置添加一个全局的 DNS 服务器。systemd-resolved 支持 unicast，也就是说如果你查询的域名不符合任何一个网络接口设置的要通过这个接口的 DNS 服务器查询的域名的话，它就会通过所有网络接口的 DNS 服务器查询（也包含你设置的全局 DNS 服务器），然后取最快返回的结果。\n当你是个需要来回跑的笔记本用户，同时你还需要通过 VPN 远程办公，但你的 VPN 并没有 Network Manager 的插件，它也不支持 systemd-resolved……\n我们的口号是不做草履虫！实际上这种场景已经非常非常少见了，绝大多数的场景都能被 Network Manager + systemd-resolved 覆盖，这也就是为什么越来越多的通用发行版都使用这一套进行网络管理和 DNS 解析的原因。但如果你真的遇到了，也许你听说过一个叫做 resolvconf 的工具，在以前某些发行版会预装它。它的逻辑似乎就是有各种程序都打算自己修改 /etc/resolv.conf，那你们干脆都别管了，我来管，听我的（现在有 N + 1 种解决方案了）。至于它到底是怎么工作的，如何配置，这些都别问我！因为我也没有用过！我觉得你不如去建议不支持 systemd-resolved 的项目支持 systemd-resolved 比较实际……\n总之无论如何如果你搜到一篇老旧的教程告诉你设置这个 resolvconf，那你都该留个心眼查一下，万一你这个需求已经可以用 Network Manager 的插件或者它已经支持 systemd-resolved 了呢？\n太长不看！\n我准备了一张图，让你知道 DNS 解析在 Linux 上都发生了什么，以及 Network Manager 和 systemd-resolved 各自都扮演什么角色……\n\n"},{"title":"使用 Headscale 和 Tailscale 构建虚拟专用网","url":"/posts/Build-VPN-with-Headscale-and-Tailscale/","content":"需求\n很多在家里装了 NAS 的人都有一个相似的需求，那就是出门在外如何访问内网的 NAS 上运行的服务。很多人会选择公网 IP + 端口映射把需要的服务直接暴露到公网上，或者通过公网的 VPS 进行反向代理。但这些我都不放心，首先我的目的只是自己访问，而不是给别人访问，其次对于一些简易的 WebUI，暴露在公网上也容易被无聊的人扫端口并尝试入侵。实际上这个需求更倾向于 VPN（这里指的是它本来的意思也就是虚拟专用网，而不是佛跳墙），我曾经尝试过使用 WireGuard 和公网 VPS 构建一个简单的 VPN，但效果并不好，首先是我的 VPS 并不在国内，作为所有流量的中继实在是太不合适，实际使用起来几乎卡到不能自理，其次是 WireGuard 用作 VPN 服务器的话需要把其它所有 peers 都添加到服务器里，实在是太过麻烦。\n第一点我其实没想到什么好的解决办法，能想到最好的也就是利用家里有公网 IP 的特点把 VPN 服务器改到家里。而第二点我差点就想改成自建 OpenVPN 了，但这时我偶然找到一些资料，说不应该手动组建 WireGuard 网络，而是使用一些基于 WireGuard 的工具帮你自动组网。比较之后我决定使用 Tailscale。\nTailscale 能做的并不仅仅是帮你建立一个 VPN 服务器然后自动添加客户端，在此之上它有一些更妙的功能，比如 WireGuard 实际上并不是服务端/客户端架构，peers 之间是对等的，于是 Tailscale 可以尝试通过 NAT 穿透建立点对点的 WireGuard 连接，如果无法穿透才会通过服务端中继（Tailscale 官网有一篇关于如何实现较为可靠的 NAT 穿透的文章，至少我是没怎么看懂），这听起来很适合我的需求并且在实际使用中极大的提升了我的体验。但 Tailscale 本身只是客户端，它们通过销售自己的服务提供服务器供用户连接，客户端是开源的但服务端是闭源的。而我显然更希望自己搭建服务端，幸好有 Headscale 这个开源项目自己实现了一个 Tailscale 服务端，可以自己搭建。但 Headscale 自己的文档非常的简陋，所以我决定写篇博客记录一下具体配置的过程。\nHeadscale\n首先在公网能访问到的服务器上安装 Headscale，Arch Linux 的官方仓库里已经打包了：\n# pacman -S headscale\n\n然后需要修改配置文件 /etc/headscale/config.yaml，里面需要修改的只有几处，我这里简单介绍一下：\n首先第一个要修改的是 server_url，这个就是客户端连接服务器时使用的地址和端口，Headscale 使用的是 HTTP 协议，如果你不想明文在公网上裸奔，那可以在后面添加 HTTPS 证书和密钥使它支持 HTTPS。\n同样还需要修改 listen_addr，控制 Headscale 监听的网段和端口，这里端口要和上面的一致。\n下面其它的控制数据库和 gRPC 都保持默认即可，然后你可以修改想要给子网设备分配 IP 的网段，只要修改 ip_prefixes 就可以，要注意的是并不是所有网段都可以用，Tailscale 本身已经限制了一部分，你只能选择这个网段的子网段。我这里注释掉了 IPv6 因为我不需要。\n如果你想设置 HTTPS，Headscale 本身支持通过 ACME 帮你自动申请证书，这当然是最好的，但它并不支持通过 DNS 的方式验证域名所有权，也就意味着需要你能够监听 80 或者 443 端口，如果你是公网 IP 的家宽，这基本等于被 ISP 查水表，而如果是 VPS，你也大概率可能在这些端口上运行了其它的 HTTP 服务，所以我没有用这个功能。但它下面还提供了手动指定证书和密钥的选项，你可以使用 certbot 或者 acme.sh 之类的功能帮你处理好证书（和 certbot 搏斗实在是太痛苦了所以我省略了），然后将 tls_cert_path 设置为 fullchain.pem 所在的路径，tls_key_path 设置为 privkey.pem 所在的路径就可以。（需要注意 certbot 放置证书的路径只有 root 能读写，而 Headscale 并不是以 root 用户运行的，所以你还需要写 hooks 把文件复制出来并修改权限……）\n然后还有一个关于 DNS 的部分需要修改，Tailscale 提供了一个叫做 MagicDNS 的机制，当你连接上这个网络之后，就可以像在家用路由器后面一样通过主机名直接访问对应的设备，或者使用主机名 + 你定义的域名后缀，MagicDNS 会帮你解析到正确的 IP。但这里有一个问题，Headscale 默认的配置会让你运行 Tailscale 的设备将自己的 MagicDNS 服务器设置为 systemd-resolved 对所有域名使用的默认服务器（对没错 Tailscale 客户端上的 DNS 逻辑是被 Headscale 服务端控制的，什么奇怪的脑回路），这其实很不方便，特别是对于国内的一些网站比如 B 站会解析很慢并解析到离你比较远的 CDN 上，所以需要关闭这个功能，只优先对 Tailscale 的域名使用 MagicDNS 服务器。只要将 dns_config 下面 override_local_dns 设置为 false 即可。\n然后你还需要修改 dns_config 下面 base_domain 这一项，这个是 MagicDNS 里内部域名的后缀。\n解决了这些之后你就可以启动守护进程：\n# systemctl enable --now headscale\n\nHeadscale 的进程和相关配置都属于 headscale 用户和 headscale 组，因此如果你想直接修改相关配置，可以将自己加入 headscale 组：\n# gpasswd -a alynx headscale\n\n然后你需要创建一个 Headscale 的 user，说是 user 其实更像是 namespace：\n$ headscale users create azvpn\n\n上面提到的内部域名的逻辑就是 主机名.用户名.内部域名后缀，比如我设置的 base_domain 是 alynx.one，那 timbersaw 这台主机的内部域名就是 timbersaw.azvpn.alynx.one。\n后面我们会把设备添加到这个 namespaces，添加的时候自然需要验证权限，一般是 Tailscale 发起请求，Headscale 返回一个链接，打开链接之后是一条指令，你需要将里面的 USERNAME 换成你想要的，然后在 Headscale 所在的机器上运行这个指令。当然如果你不方便 ssh 连到 Headscale 所在的服务器，你也可以创建 preauthkey，直接在 Tailscale 连接时提供即可：\n$ headscale preauthkeys create --user azvpn --reusable --expiration 12h\n\nTailscale (Linux)\n这个同样也在 Arch Linux 的官方仓库，直接安装即可：\n# pacman -S tailscale\n\n稍微复杂的一个部分是 DNS，显然 MagicDNS 会修改你的 /etc/resolv.conf 设置为自己的 DNS\n服务器，但如果你和我的配置相同，那应该这个文件也是由 NetworkManager 管理的。如果你已经理解了 Linux 下面 DNS 解析的逻辑，你应该清楚无论何时都只应该有一个进程管理这个文件。解决方法要么是使用 NetworkManager 的插件来运行 Tailscale 从而只让 NetworkManager 管理 /etc/resolv.conf（并没有这样的插件），要么是两者全部放弃自己管理 DNS，交给第三者管理。\n无论是 Tailscale 还是 NetworkManager 都能自动检测 systemd-resolved 并配合它工作，所以我们启用这个代替 NetworkManager 管理 /etc/resolv.conf，过程很简单也很好理解。\n首先把 /etc/resolv.conf 链接到 systemd-resolved 的 stub 文件，这个文件的作用只有一个就是把 DNS 服务器设置成 systemd-resolved 运行的 DNS 服务器，这样所有的 DNS 查询就都被传给 systemd-resolved 进行处理：\n# ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf\n\n然后启动 systemd-resolved：\n# systemctl enable --now systemd-resolved\n\n接下来重启 NetworkManager，当它启动时检测到 /etc/resolv.conf 是指向 systemd-resolved 的 stub 文件的软链接，就不会尝试修改该文件而是自动配合 systemd-resolved 工作：\n# systemctl restart NetworkManager\n\n然后启动 Tailscale 的守护进程：\n# systemctl enable --now tailscaled\n\n接下来就可以尝试连接到 Headscale 服务器：\n# tailscale up --login-server https://YOURSERVER:YOURPORT\n\n如果你不想进行上面的手动验证流程，这一步可以直接附加上刚才创建的 preauthkey：\n# tailscale up --login-server https://YOURSERVER:YOURPORT --auth-key YOURPREAUTHKEY\n\n此时运行 ip a，应该可以看到多了一个叫 tailscale0 的网络接口。使用 resolvectl status 则可以看到这个接口有自己的 DNS 服务器，并且对 azvpn.alynx.one 的域名使用此服务器查询。此时已经可以使用 Tailscale 内网分配的 IP 或者 MagicDNS 提供的域名像在物理路由器后面一样访问内网的各种设备。\nTailscale (Android)\nTailscale 也有开源的 Android 客户端并且已经上架了 Google Play Store，但你安装之后可能会发现没有自定义服务器的选项，你需要点开并关闭右上角三个点菜单多次，然后菜单里就会多出一项 Change Server，设置成你自建的 Headscale 服务器，然后就可以使用主界面第二个登录选项进行交互登录了。目前似乎 Android 客户端还不支持使用 preauthkey 登录。\n"},{"title":"Node 的 http.request() 需要对 response 进行错误处理","url":"/posts/Node-HTTP-Request-Needs-to-Handle-Response-Error/","content":"我发现有些时候 Telegram bot 很适合用来 host 一些我自己要用的服务，因为只要通过手机上的聊天框就可以控制了，不需要我自己写一些什么后台页面。为了让构建和安装一个新 bot 的过程尽量简单，我自己用 Node 写了一个 没有外部依赖的 Telegram bot 框架。完全使用 Node 自带的模块比较麻烦的一点就是你需要自己基于 http.request() 进行封装，因为原版基于 EventEmitter 的接口写起来实在是太复杂了。\n\n\n把 http.request() 封装成 Promise 比一般的 API 要难一点，但也不是完全做不到，比如 官方文档上的示例代码 是这样写的（复制这么长一段不是我要占字数而是我真的被它坑了）：\n {\n  console.log(`STATUS: ${res.statusCode}`);\n  console.log(`HEADERS: ${JSON.stringify(res.headers)}`);\n  res.setEncoding('utf8');\n  res.on('data', (chunk) => {\n    console.log(`BODY: ${chunk}`);\n  });\n  res.on('end', () => {\n    console.log('No more data in response.');\n  });\n});\n\nreq.on('error', (e) => {\n  console.error(`problem with request: ${e.message}`);\n});\n\n// Write data to request body\nreq.write(postData);\nreq.end();\n\" data-info=\"language-JavaScript\" data-lang=\"JavaScript\" class=\"code-block\">import http from 'node:http';\nimport { Buffer } from 'node:buffer';\n\nconst postData = JSON.stringify({\n  'msg': 'Hello World!',\n});\n\nconst options = {\n  hostname: 'www.google.com',\n  port: 80,\n  path: '/upload',\n  method: 'POST',\n  headers: {\n    'Content-Type': 'application/json',\n    'Content-Length': Buffer.byteLength(postData),\n  },\n};\n\nconst req = http.request(options, (res) =&gt; {\n  console.log(`STATUS: ${res.statusCode}`);\n  console.log(`HEADERS: ${JSON.stringify(res.headers)}`);\n  res.setEncoding('utf8');\n  res.on('data', (chunk) =&gt; {\n    console.log(`BODY: ${chunk}`);\n  });\n  res.on('end', () =&gt; {\n    console.log('No more data in response.');\n  });\n});\n\nreq.on('error', (e) =&gt; {\n  console.error(`problem with request: ${e.message}`);\n});\n\n// Write data to request body\nreq.write(postData);\nreq.end();\n\n那对于一个 POST 请求，我就可以这样封装：\n {\n  const opts = {\n    &quot;method&quot;: &quot;POST&quot;,\n    &quot;timeout&quot;: 1500,\n    &quot;headers&quot;: {}\n  };\n  for (const [k, v] of Object.entries(headers)) {\n    opts[&quot;headers&quot;][k.toLowerCase()] = v;\n  }\n  if (!(isBuffer(body) || isString(body))) {\n    body = JSON.stringify(body);\n    opts[&quot;headers&quot;][&quot;content-type&quot;] = &quot;application/json&quot;;\n    opts[&quot;headers&quot;][&quot;content-length&quot;] = `${Buffer.byteLength(body)}`;\n  }\n  return new Promise((resolve, reject) => {\n    const req = http.request(url, opts, (res) => {\n      const chunks = [];\n      res.on(&quot;data&quot;, (chunk) => {\n\tchunks.push(chunk);\n      });\n      res.on(&quot;end&quot;, () => {\n\tresolve(Buffer.concat(chunks));\n      });\n    });\n    req.on(&quot;error&quot;, reject);\n    req.write(body);\n    req.end();\n  });\n};\n\" data-info=\"language-JavaScript\" data-lang=\"JavaScript\" class=\"code-block\">import * as http from \"node:http\";\nimport {Buffer} from \"node:buffer\";\nconst post = (url, body, headers = {}) =&gt; {\n  const opts = {\n    \"method\": \"POST\",\n    \"timeout\": 1500,\n    \"headers\": {}\n  };\n  for (const [k, v] of Object.entries(headers)) {\n    opts[\"headers\"][k.toLowerCase()] = v;\n  }\n  if (!(isBuffer(body) || isString(body))) {\n    body = JSON.stringify(body);\n    opts[\"headers\"][\"content-type\"] = \"application/json\";\n    opts[\"headers\"][\"content-length\"] = `${Buffer.byteLength(body)}`;\n  }\n  return new Promise((resolve, reject) =&gt; {\n    const req = http.request(url, opts, (res) =&gt; {\n      const chunks = [];\n      res.on(\"data\", (chunk) =&gt; {\n\tchunks.push(chunk);\n      });\n      res.on(\"end\", () =&gt; {\n\tresolve(Buffer.concat(chunks));\n      });\n    });\n    req.on(\"error\", reject);\n    req.write(body);\n    req.end();\n  });\n};\n\n反正流程无非是创建 request，然后在 response 里面收集 data 到 buffer，然后处理 request 的 error，再把 body 写到 request 里面。看起来很简单毕竟官方文档也这么写的对吧！然后就掉进坑里了。\n我的 Telegram bot 设置是要不停通过 HTTP 轮询获取更新，为了保证能一直轮询下去，就要在遇到错误的时候 catch 住简单处理，然后继续进行下次轮询。但明明我已经在可能出现错误的时候都处理了，bot 还是会在跑了几天以后遇到错误（通常是 read ETIMEOUT）然后完全停住，只能手动重启。我对此绞尽脑汁，但是想不出哪里有问题，同时因为这个要 bot 跑一段时间才能复现，也很难 debug，我甚至手动打了 log 来看是轮询停住了还是轮询没有停但却一直得到空的结果，实际证明是遇到错误停住了，但我不是已经进行错误处理了吗？\n这个问题实在是找不到什么参考，我尝试了一些没有意义的办法，最后差点去翻什么 axios 之类的代码看他们如何解决的了。不过我在此之前想了一下，会不会是因为不仅要写 req.on(\"error\", reject);，还要写 res.on(\"error\", reject); 来处理 response 的错误，否则 Node 就会直接把相关的错误抛出来停掉？其实我心里觉得不太可能，毕竟 示例代码里根本都没有写这句，但我还是本着没办法的办法写上去了：\n {\n   return new Promise((resolve, reject) => {\n     const req = https.request(url, opts, (res) => {\n       const chunks = [];\n+      res.on(&quot;error&quot;, reject);\n       res.on(&quot;data&quot;, (chunk) => {\n         chunks.push(chunk);\n       });\n       res.on(&quot;end&quot;, () => {\n         resolve(Buffer.concat(chunks));\n       });\n     });\n     req.on(&quot;error&quot;, reject);\n     req.write(body);\n\" data-info=\"language-patch\" data-lang=\"patch\" class=\"code-block\">diff --git a/azbot-telegram/bot-utils.js b/azbot-telegram/bot-utils.js\nindex 42e002e..f90a8eb 100644\n--- a/azbot-telegram/bot-utils.js\n+++ b/azbot-telegram/bot-utils.js\n@@ -360,13 +360,13 @@ const post = (url, body, headers = {}) =&gt; {\n   return new Promise((resolve, reject) =&gt; {\n     const req = https.request(url, opts, (res) =&gt; {\n       const chunks = [];\n+      res.on(\"error\", reject);\n       res.on(\"data\", (chunk) =&gt; {\n         chunks.push(chunk);\n       });\n       res.on(\"end\", () =&gt; {\n         resolve(Buffer.concat(chunks));\n       });\n     });\n     req.on(\"error\", reject);\n     req.write(body);\n\n然后问题就神奇的解！决！了！我的 bot 连续跑了十天半个月也没有挂，我心里这个气啊，为什么官方文档里的示例一点没提到要对 response 的 error 事件进行处理呢？甚至在网上也很难找到相关的信息，我推测是大部分人并不从头自己封装 HTTP 模块而是直接使用现成的库比如 axios，然后可能有人发现过这问题就简单地给 axios 提了这么一个 fix，就再也没人提起过这件事了。总之还是希望官方文档能更新一下示例代码吧。\n"},{"title":"2023 年的 Arch Linux 安装指南","url":"/posts/2023-Arch-Install/","content":"在安装 Arch Linux 之前，首先要准备 Arch Linux 的安装媒介。如果你打算安装在虚拟机里，那你并不需要一个实体的存储介质，因为虚拟机可以直接加载 iso 文件。但不管你在哪里安装，你都需要获取这个 iso 文件，引导进入其中的临时系统才能继续安装。\nArch Linux 的安装镜像每月更新一次，如果你点开官网的下载页面，你会发现没有直接的下载链接，而是推荐你使用种子下载或者镜像站下载。这是一个非常有必要的要求，因为官方的服务器不能承受世界各地所有的请求流量，以上两种方法通过将单一的下载来源转换为多个下载来源有效的减轻了官方服务器的压力。\n考虑到当今种子下载并不是一个流行的下载方式，对于部分读者而言可能难以掌握，我们这里就选择镜像站下载。所谓的镜像站就是将官方服务器上的文件原样下载到自己的服务器上，然后给别人提供下载服务的服务器。有了镜像站，世界各地的用户就不必连接相对较远较慢的官方服务器，而可以就近选择镜像站，获取到完全一样的文件。\n我们可以打开某个镜像站同步 archlinux 的目录，然后找到 iso/latest 目录，里面的 archlinux-x86_64.iso 就是我们需要的，以清华大学的镜像站为例，链接就是 https://mirrors.tuna.tsinghua.edu.cn/archlinux/iso/latest/archlinux-x86_64.iso。\n注意！Arch Linux 官方只对 x86-64 架构提供支持，如果你的设备不是该架构（可能性很低，如果你不是该架构，你应该已经有足够的经验自己解决问题了），可能需要使用其它分支项目并参阅相关的文档。\n当下载好 iso 文件之后，需要准备对应的安装媒介，这需要一个实体的存储介质，光盘是最传统的安装媒介，这也导致了各种系统的安装程序都以光盘镜像（iso）的格式打包。但想必当今的用户寻找光盘和光驱可能有些难度，所以 U 盘成为了更流行的安装媒介，找出一个你没有使用的 U 盘，备份好原本的数据，然后连接到你下载了安装镜像的电脑上。\n如果你的电脑上已经运行了 Linux，那你可以通过 dd 命令将 iso 文件写入到 U 盘里，Arch Linux 的安装镜像经过特殊处理，可以支持这样的 U 盘引导。首先通过 lsblk -f 查看你的 U 盘对应的设备文件是什么，然后使用 dd if=/PATH/TO/archlinux-x86_64.iso of=/dev/sdX，记得把 iso 和设备文件的路径改成你的实际路径，并且不要使用 U 盘分区的设备文件，而是使用代表整个 U 盘的设备文件。然后执行 sync，让内核把内存里缓冲的数据写回磁盘，保证安装镜像完全写进 U 盘里面。\n但你也可能会说如果我有 Linux，我为什么要装 Linux？这种情况下我们推荐 Windows 用户使用 Rufus 创建安装 U 盘，这个软件下载即可运行，不需要安装，然后在软件里分别选择 iso 文件的位置和 U 盘设备，点击写入即可获得一个安装 U 盘。\n无论你使用哪种方式，接下来弹出 U 盘，准备重启电脑。不过要保证重启的时候 U 盘仍然在你的电脑上。现在的电脑应该都支持 UEFI 引导，你需要搜索你的主板型号得知你的电脑应该按什么按键进入启动设备选单，反正无非是 F8、DEL、Enter 中的一个，在显示主板 logo 的时候狂按，直到出现一个让你选择的菜单，使用键盘上的方向键选择你刚刚做好的安装 U 盘，然后按下 Enter 选择。等屏幕上走完启动流程，你应该就会自动登录进一个 Arch Linux 的环境。如果你开启了安全启动，那你需要关掉，因为 Arch Linux 的安装镜像并没有进行安全启动需要的签名，这里就不介绍具体如何关闭了，因为各家主板的界面都不一样，建议搜索引擎搜索自己的主板型号+关闭安全启动。\nArch Linux 的安装环境是没有桌面的，你需要在命令行里自己调用各种命令完成一系列安装相关的操作，这样看起来比较难，但是也很灵活，可以根据自己的需要调整。首先你要做的是确定自己已经联网了，最简单的就是从路由器插一根网线到你的电脑上，这样应该就能上网了。如果你没有网线——那现在就该去买一根，比起现在给你讲清楚怎么在命令行下面连接无线网络，买网线更简单，真的。\n然后你应该使用 date 命令查看系统时间是否重要，许多加密方式依赖时间正确，比如 https，因此如果它不正确，你应该改正它，不过大部分情况都是正确的。\n接下来你应该准备安装系统的磁盘分区，首先你得通过 lsblk -f 找到要安装的硬盘，如果是 SATA 硬盘，它可能是 /dev/sda 或者 /dev/sdb 之类，如果是 NVMe 硬盘，那可能是 /dev/nvme0n1，一般来说根据容量判断是不会错的。你的目标磁盘上应当留有一定的未分配空间给新系统使用。注意如果分区和格式化时操作错误，可能会让你丢失已有的重要数据，因此在进行操作前务必仔细确认。\nUEFI 引导的机器大部分都使用 GPT 分区表，当然这其实主要是 Windows 的限制，因此我们使用 gdisk 进行分区，如果你使用的不是 GPT 分区表，那你可能需要自行了解一些相关知识。当你不知道该做什么的时候，输入 ? 可以显示帮助，输入 p 可以打印当前的分区表，输入 q 可以退出 gdisk，只有输入 w 才会真正修改硬盘上的分区表，所以如果你不确定就不要输入 w。\n输入 p 打出当前的分区表之后，你应该首先找到一个小的 FAT32 分区，一般会在磁盘的开头，容量不会超过 1G，这是你的 ESP 系统分区，UEFI 要求把引导文件放在这里。然后你应该按 n 新建一个分区，一般它会自动计算未分配空间的开头，不过你也可以手动输入来纠正，然后输入新分区的结尾位置，也可以用 +100G 的方式表示从开头位置创建一个 100G 的分区。一般只要分一个分区做 Linux 的根分区就好了，不过你有需要也可以创建更多的分区，比如你可能需要一个 swap 分区，那就用相同的办法创建一个。创建完你需要的分区之后，输入 p 确认一下新的分区表，然后按 w 写入分区表。\n接下来你需要在分好的分区上创建文件系统也就是格式化，因为分区表只是标记“从哪里到哪里属于哪个分区”，并没有在对应的位置创建实际的结构。比如你可以用 mkfs.ext4 /dev/sdXY 格式化你刚创建的根分区，然后用 mkswap /dev/sdXY 格式化你刚才创建的 swap 分区。记得在执行命令之前确认你使用的设备文件正确。然后你需要挂载你创建的分区到 /mnt，稍候会向里面写入系统文件。首先用 mount /dev/sdXY /mnt，把根分区挂载上，然后你需要创建其它分区的挂载点，比如 EFI 系统分区，对于这个如何挂载有很多种说法，不过我一般直接把它当作 boot 分区挂载，这样内核也会被安装到这个分区，有些预装 Windows 系统的电脑可能会分一个极小的 EFI 系统分区以至于放不下内核，那你可能需要查找更多资料，这不在这篇文章的讨论范围之内。总之先 mkdir /mnt/boot 然后 mount /dev/sdXY /mnt/boot。最后可以用 swapon /dev/sdXY 启用你刚才创建的 swap 分区，这样记录新系统挂载点的时候就会记录这个 swap 分区。\n然后需要修改镜像站列表，和之前下载 iso 一样，系统需要的各种软件包也依靠镜像站提高分发效率。你需要用一个编辑器编辑 /etc/pacman.d/mirrorlist，如果你没有熟悉的编辑器，那 nano 应该是个适合新手的选择，因为各种操作需要的快捷键都会显示在屏幕底部，^ 代表 Ctrl，M 代表 Alt，在列表里找到离你地理位置比较近的几个镜像站，然后删除对应的 Server =  前面的 # 来启用这个镜像站，一般启用两三个就足够了。\n然后就可以正式安装软件包到创建的分区了！使用 pacstrap -K /mnt base base-devel linux linux-firmware 安装软件包到 /mnt，你可以在后面附加更多你需要的软件包以便一并安装，甚至如果你不想第一次启动新系统还是命令行的话，也可以在这一步直接附加桌面环境进去。这里我贴一个基于我常用软件总结的列表作为参考：\nbase 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\n\n如果你和我有不同的偏好，你应该已经清楚如何安装你需要的软件，我在这里只以我自己使用的软件作为例子。为了方便使用，我在这一步直接安装了桌面环境，但桌面环境需要有可用的显卡驱动，对于 Intel 和 AMD，它们的开源驱动已经足够好用，应该会自动引入 mesa 所以没什么需要额外操作的，但是对于 NVIDIA，你还需要安装 nvidia 这个包来引入 NVIDIA 的闭源驱动。\n然后等待下载安装即可，现在大家的带宽都很高，如果确实选了离自己近的镜像站，这个步骤应该花不了多少时间。\n然后读取你对新系统的挂载信息并写入到新系统里，以便新系统基于这个数据挂载硬盘，使用 genfstab -U /mnt &gt;&gt; /mnt/etc/fstab 即可。\n现在你的新分区里应该有一个新系统需要的各种文件了，但是你还需要对它进行各种设置，首先需要 chroot 到新的系统，这是一个 Linux 内核的功能，可以让你以另一个文件系统作为根目录从而操作其中的各种文件，这里使用 arch-chroot /mnt 进入新系统的根目录。\n然后你要指定自己新系统的时区，比如你使用的时区是 Asia/Shanghai，那可以执行 ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime，你也可以将 Asia/Shanghai 修改为其它的时区，所有可用的时区都以目录和文件的形式列在 /usr/share/zoneinfo/ 下面。\n然后你需要执行 hwclock --systohc，这会假设你的 BIOS 时间是 UTC，这和 Windows 默认的假设不一致，Windows 认为你的 BIOS 时间就是本地时间。可以让 Linux 认为 BIOS 时间是本地时间，但是可能会导致各种问题，同样也有办法让 Windows 认为 BIOS 时间是 UTC 时间，只需要随便新建一个文本文档，写入如下内容：\nWindows Registry Editor Version 5.00\n\n[HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\TimeZoneInformation]\n\"RealTimeIsUniversal\"=dword:00000001\n\n然后保存并修改扩展名为 .reg，然后双击导入注册表项并重启。\n接下来修改本地化相关内容，首先是 glibc 需要对不同的语言生成不同的配置，需要用编辑器编辑 /etc/locale.gen 文件，必须要启用的是 en_US.UTF-8 UTF-8，别的可以按需求启用你需要的，只要删掉开头的 #，注意只要启用带 UTF-8 的就可以。不过这一步也可以略过其他的只启用英语，然后在桌面环境里修改语言的话桌面环境应该会自动处理相关的文件。然后运行 locale-gen，它会根据上述文件的内容具体生成对应文件。\n然后创建 /etc/locale.conf 文件，写入你当前使用的 LANG 变量，不过其实 tty 不能显示中文，所以这一步推荐设置成英文，等到桌面起来了再改也来得及，因此推荐写入 LANG=en_US.UTF-8。\n网络相关的配置首先需要设置 hostname，这一步只要打开 /etc/hostname 文件写入你想要的主机名就行了。我的习惯是使用 NetworkManager 管理网络连接，因此需要设置让系统下次启动时启用 NetworkManager，只要 systemctl enable NetworkManager 就可以。NetworkManager 会自动管理你的网卡，比如有线网卡就会自动尝试 DHCP，同时也提供和桌面环境的集成，方便使用无线网卡。\n一些无线网卡需要的固件可能被单独划分在别的包里，此时你可以安装这些包，比如高通的网卡就是 pacman -S linux-firmware-qcom。\n然后你需要进行启动相关的设置，首先你得生成 initramfs，这个东西解决的问题是“需要加载模块才能读取对应的文件系统，但模块就存在那个文件系统上”这种问题，为了打破鸡生蛋还是蛋生鸡的循环，解决方法就是创建一个非常小的包含必要模块的文件，和内核放在一起，保证启动时可以加载这个文件。生成这个文件很简单，因为我们没有什么特殊的配置，只要执行 mkinitcpio -P 就行了。\n同时现代的 CPU 都支持加载微码来热修复 CPU 的 bug，这也是通过在启动时加载相关的文件实现，首先需要安装对应的微码包，如果是 Intel 就 pacman -S intel-ucode，AMD 就 pacman -S amd-ucode。\n然后你还需要一个 bootloader 加载你的内核和 initramfs，最流行功能最全的是 GRUB，但我觉得 systemd-boot 也完全够用了，所以我选择 systemd-boot。因为已经安装了 systemd 所以就不需要额外安装什么了，只要 bootctl install，就可以安装引导需要的文件。\n但我们仍然需要告诉 loader 去哪里加载内核，加载哪个内核。systemd-boot 需要我们手动编写配置文件记录这些内容。首先需要 mkdir /boot/loader/entries 建立用于放置不同内核启动项的文件，然后再编辑 /boot/loader/entries/arch.conf 给默认的内核编写一个文件。\n一个配置文件推荐包含以下内容：\ntitle   Arch Linux\nlinux   /vmlinuz-linux\ninitrd\t/amd-ucode.img\ninitrd  /initramfs-linux.img\noptions root=\"UUID=xxxx-xxxx-xxxx\" rw add_efi_memmap\n\n基本上你需要改的有两处，一个是如果是 Intel 就把 amd-ucode 改成 intel-ucode，另一个是要把内核参数里 root= 的值设置为你的根分区，以便内核找到你真正的根分区。这个可以通过打开 /etc/fstab 找到里面挂载到 / 的设备得到需要的值。\n然后你需要编辑 /boot/loader/loader.conf，这是给 loader 的配置，其实只需要一行 default arch.conf，告诉 loader 默认加载 arch 内核的配置就可以了。\n最后需要进行密码配置，首先执行 passwd 设置 root 密码。由于 root 权限太高，平时不建议使用 root 操作，所以我们可以通过 useradd -m newuser 创建一个普通用户，-m 的意思是会自动给用户创建同名的 home 目录存储用户相关的文件，你也可以把 newuser 改成任何你想要的用户名。然后执行 passwd newuser 给这个新用户设置密码。同时为了方便进行高权限操作，我们需要允许新用户执行 sudo，首先执行 EDITOR=nano visudo 编辑 sudoers 文件，找到 root ALL=(ALL) ALL 一行，然后在下面插入 newuser ALL=(ALL) ALL（记得用你想要的用户名），保存即可。\n然后运行 systemctl enable gdm，这会告诉系统启动时启用 GNOME 显示管理器，下次启动时你就会得到图形界面并可以直接登录进桌面。此时关于新系统的设置都已基本完成，执行 exit 退出 chroot，然后执行 reboot 重启电脑，你应该可以在 UEFI 启动选单里找到 Linux Boot Manager，选择就会启动新安装的 Arch Linux。\n由于此时应该已经启动图形界面了，对于桌面的各种设置只要在图形界面的设置程序里设置即可，就不需要专门讲述怎么用了。\n"},{"title":"DaVinci Resolve 奇怪的素材位置计算逻辑","url":"/posts/Strange-Logic-of-DaVinci-Resolve-on-Calculating-Clip-Position/","content":"上一篇文章提到了 DaVinci Resolve 对于素材位置的计算逻辑非常奇怪并且不肯修改，这篇我试图概括一下具体的计算逻辑方便自己使用。如果你也遇到了同样的问题并且希望他们改进，可以去支持 我发的帖子（英语）。\n\n\n计算基准\n缩放/裁切/位置永远以原图大小作为基准，不会互相影响。\n项目设置里有“缩放原图至适配大小且不出现裁切”和“不调整原图大小并裁切超出部分”两个比较合理的选项，“缩放原图至适配大小且不出现裁切”可以理解为插入时间线之前就改变了原图大小。例如画布尺寸 1920x1080，素材尺寸 512x512，选择“缩放原图至适配大小且不出现裁切”，相当于用外部命令把素材缩放到 1080x1080 然后再插入时间线，后续缩放/裁切/位置均以 1080x1080 作为基准。\n缩放\n缩放变换只计算原图大小。\n缩放变换默认以素材中心作为基准。由于位置变换计算太复杂了，不考虑改变锚点参数的情况。\n裁切\n裁切只计算原图大小。无论素材的缩放变换参数是多少，都使用原图大小计算结果。\n例：画布 1920x1080，素材 512x512。\n\n选择“缩放原图至适配大小且不出现裁切”，此时原图大小是 1080x1080，左侧裁切 50% 应输入 1080 * 50% = 512。\n选择“不调整原图大小并裁切超出部分”，此时原图大小是 512x512，左侧裁切 50% 应输入 512 * 50% = 256。\n\n由于位置变换计算太复杂了，不考虑勾选“保留图片位置”的情况。\n位置\n位置变换只计算原图大小。无论素材的缩放变换参数和裁切参数是多少，都使用原图大小计算结果。\nDaVinci Resolve 对于素材位置输入框使用特殊的计算逻辑（怀疑是 bug），假设画布宽度为 canvas_width，画布高度为 canvas_height，原图宽度为 clip_width, 原图高度为 clip_height，如果想将素材水平方向移动 x 像素，垂直方向移动 y 像素，则需要填入的数据需要按照 f(x) = x / clip_width * canvas_width 和 f(y) = y / clip_height * canvas_height 进行计算。注意，按此计算逻辑，填入的两个数据的比例显然和实际移动的像素比例不同。\n例：画布 1920x1080，素材 512x512。\n\n选择“缩放原图至适配大小且不出现裁切”，此时原图大小是 1080x1080，向左移动 540 像素应输入 540 / 1080 * 1920 = 960。\n选择“不调整原图大小并裁切超出部分”，此时原图大小是 512x512，向左移动 540 像素应输入 540 / 512 * 1920 = 2025。\n\n"},{"title":"首先是犯下傲慢之罪的闭源拖拉机","url":"/posts/Firstly-The-Arrogant-Closed-Source-Tractor/","content":"很遗憾的，我们没有生活在可以完全只使用开源软件的世界里，所以有时候不得不忍受一些闭源拖拉机的傲慢。一个经典的情况就是明明用户花了钱，还是得当孙子——我指的是用户反馈完全没有开发者看这件事情。或者更进一步，我认为 GitHub 或者 GitLab 的 issue （或者 bugzilla）是比用户论坛更好的反馈工具。\n\n\n一个发生在我身上的例子是 PowerAMP，我曾经是它的付费用户（现在也是付费用户，但并不是活跃用户）。PowerAMP 有这样一个问题，当你搜索一首歌的时候，你必须输入两个或以上的字符搜索才会启动。这对于英语歌来说不是什么问题，因为大部分单词都是两个字符以上，几乎不会出现需要依靠一个字符查找歌曲的情况。但假如你是一个不会假名的日语歌爱好者，你想要依赖标题里某个你认识的汉字搜索到你想要的歌，这时候 PowerAMP 的搜索就是完完全全的废物。至少对于我自己来说这个需求曾经切实存在了很长一段时间。我不是没有尝试给开发者反馈，PowerAMP 有一个自己的论坛用作用户反馈工具，但过了两年也没有人回复 我的帖子。如果开发者完全不看用户反馈，或者只看热度高的用户反馈，那这论坛还有什么意义呢？\n所以非常搞笑的结论发生了，我作为一个付费用户，最终的解决方案是我去学了五十音，不过这也没有完全解决问题，我意识到即使这样，仍然存在无法解决的情况：比如标题只有一个汉字的歌，你永远也不可能在 PowerAMP 里通过标题搜索到它（那英点了个赞并评论“最烦装逼的播放器作者”）。我甚至已经脑补出了作者洋洋自得地写下 if (str.length &lt; 2) return; 以为自己对搜索做了天才般的优化，但在他可怜的脑袋里却找不到高德纳那句著名的“过早优化是万恶之源”，甚至用户的反馈也被他忽略掉了。最终我选择放弃这个自以为是的闭源拖拉机，换了一些功能没这么多，但却没有这个过早优化的替代品。\n更新（2022-07-28T19:26:00）：Arch Linux CN 社区里的 @weearc⚝ 跟我说他的 PowerAMP 输入单个字符是可以搜索的，这让我很疑惑，我又重新下载安装了一个 PowerAMP，但我的却仍然不行。我们俩研究一番后才发现问题所在：如果安装过后直接点导航栏的搜索，必须要输入两个或以上字符才能开始搜索。但如果在媒体库页面点击所有歌曲，然后点击列表标题下的搜索按钮，此时输入一个字符就会开始搜索。具体的原因可能是因为这时搜索添加了一个“所有歌曲”的过滤器，只搜索本地歌曲。大概是作者自以为是的决定限制包含在线源的搜索的启动条件来减少在线搜索的启动次数，不管怎么说这也属于一个过早优化，而且对我这种完全不使用在线功能的用户而言除了徒增迷惑之外没有任何的好处。虽然添加了“所有歌曲”的过滤器之后再从导航栏启动搜索仍然会带这个过滤器，可以作为一个 workaround 解决我的问题，我还是认为这对中文歌曲不是很友好。这次我发现 app 内的“联系我们”功能会给作者发邮件，于是我写了封邮件反馈这个不一致，希望他记得查看自己的邮箱。\n另一个我亲身经历的问题是 DaVinci Resolve，但是好话说在前头，比起大部分不友好的闭源软件开发商，BlackMagic\n Design 已经是班级里的三好学生了，我们就不提比如官方支持 Linux 这样大家都知道的优点，而是说一下同样的论坛反馈问题。Linux 版本的 DaVinci Resolve 不支持 Linux 上两种常见的输入法，而作为购买了 Studio 版的付费用户，我自然是积极的在官方论坛反馈了这个问题，结果嘛比 PowerAMP 好那么一点，一个 BlackMagic Design 的员工看到了我的帖子并把它移动到了 Feature Request 分区，然后就没有然后了。\n在某些人试图为拿走他们钱的闭源软件开发商找“也许是 Linux 输入法太多支持起来太麻烦他们真的没有足够人力做”的借口之前，我要先发制人说明一下，这其实也是个“一行代码”就能解决的问题，甚至并不需要 BlackMagic Design 写实际的代码。DaVinci Resolve 使用 Qt 作为界面库，Qt 本身就做好了 Linux 下面常见输入法的支持，只要在构建时候打开开关就可以，所以问题的关键在于他们的团队里没有人意识到 Linux 下面的 CJK 用户需要打开这个开关，也没有人愿意去做打开构建开关这个简单的工作，只是让反馈的帖子烂在论坛里。甚至更进一步，为什么我这么确定只需要做这么简单的工作呢？因为我自己发现了一个 workaround，只要把系统里 Qt 输入法插件的 .so 文件复制到 DaVinci Resolve 自带的 Qt 的对应目录，一切就完全工作了，所以可以充分说明并不是存在什么难以克服的障碍。（如果你也需要解决这个问题，具体的操作请阅读 DaVinci Resolve 在 Linux 下的输入法支持。）\n说到 DaVinci Resolve，他们还有一个令人迷惑的坐标计算问题。比如你在尺寸为 3840x2160 的画布里放进一个 200x200 的图片素材，然后想让这个图片向右移动这个图片的宽度，你应该输入多少呢，答案并不是 200，而是 3840。具体的计算逻辑大概是 f(x) = x / clip_width * canvas_width（计算纵坐标则需要换成高度，这样你横纵坐标看起来和实际位移完全不成比例），而这只是最简单的情况，如果你再对素材进行缩放，然后再进行裁切，那计算逻辑我也说不清楚是怎么回事了。如果你访问它的用户论坛，你会发现需要精确输入坐标的用户都对这个计算逻辑感到迷惑（相关的内容聚集在 这个帖子（英语）），并且这还导致了其它问题（用这个算法你可能需要输入极大的数值来挪动一个很小的素材，于是就会撞到输入框的数字上限）。但 BlackMagic Design 完全没有修改这个逻辑的想法。我可以理解为是怕影响兼容性，但完全可以添加一个设置项，如果勾选就保持以前的计算逻辑。这又是一个用户反馈了却被忽略的例子。\n但如果你读到这里觉得闭源软件的用户论坛就是最烂的反馈工具，那你还是太高估了闭源软件开发商的下限了。另一个我亲身经历的例子来自亲爱的 Micro$oft，作为 RDP 的标准制定者，微软的 Android RDP 客户端基本可以认为是实质上的官方实现，但 gnome-remote-desktop 的开发者遇到了 客户端内不能正确显示视频（英语） 的问题，导致这个问题的原因是 Microsoft 的 Android RDP 客户端写死了 image stride，导致读取错误，老实说这不是什么大不了的问题，改掉就好了嘛。于是我就积极主动的去该 app 的 Play Store 页面打算写评论，但是我看到应用简介里说“我们不会看 Play Store 评论，如果你要反馈问题，请发送至 rdandr@microsoft.com”，于是我又写了封邮件描述相关的问题，然后被气了个半死：一封自动回复的邮件告诉我你应该到这个链接反馈问题，点开那个链接我得到一个大大的 Error 404 This UserVoice instance is no longer available.。说不定这些傲慢的开发者还在沾沾自喜：我们的软件质量真好，竟然没有用户反馈问题！（不过我刚才又查看了一下 Play Store 页面，现在他们换成了一个反馈链接，我暂时还没有测试这个链接是否可用。）\n所以这其实是我推崇开源软件的原因之一：通常来说开源软件的开发者都很重视用户反馈，并且会建立良好的反馈渠道。即使你遇到了一个“知道错了，但我不改”的开发者，你也可以尝试自己动手修改代码解决问题，然后提交给他或者自己维护 patch。但对于闭源软件这显然不现实，你只能希望开发者大发慈悲常来看用户论坛并注意到你的反馈。\n另一方面，issue 大概是比用户论坛更好的反馈工具。虽然对于闭源软件开发商，很难找到相关的例子，不过 Valve 就是这么一个独特的例子。以前他们使用 GitHub Issue 作为 DOTA 2 和 CSGO 的 Linux / macOS 版本的反馈渠道，后来更进一步鼓励玩家在 GitHub Issue 上反馈 DOTA 2 的 bug。虽然 Valve 也存在“知道错了，但我不改”这种情况（比如 Steam Linux 版的输入法支持，但这个和他们使用了自己编写的 UI 框架有关系，大概解决起来有难度），但他们的开发者确实会看 GitHub Issue，并处理玩家的反馈（比如在官网 最新的一篇文章（英语） 里提到很多用户在 GitHub 上反馈了炸弹人的新 bug）。\n\n  \n\n\n但，是什么导致 issue 在用户反馈上比用户论坛效果更好呢？毕竟本质上二者都是“发帖”“回复”的流程，我尝试分析一下其中的不同。\n一个我认为很重要的区别是社区文化，或者说是谁在使用相关的工具：2023 年，完全没有接触过开源项目的程序员应该是不存在的，也就是说如果你是开发者，你大概率早就使用过 GitHub 或者 GitLab 这样的工具，issue 对你来说是一个你会去重视或至少会去看的东西。而论坛更大概率不是开发者直接运营，也许是什么专门的论坛客服在处理，他们可能并不懂技术，或者就算开发者会参与进用户论坛，论坛帖子对程序员来说也不是什么一定要看的东西。当用户遇到问题的时候，向开发者直接反馈应当是解决问题最直接的途径，如果在用户和开发者之间插入一层不懂技术的客服，那大概率是场灾难。（我真的没有在针对什么“微软社区支持专员”哦，真的没有。）\n另一个可能的原因也许是排序方式：issue 默认是按发布时间而不是回复时间排列的，因此开发者大概率会逐个查看新出现的 issue 并处理。但论坛通常默认以回复时间排列，更加重视“热度”，于是很容易出现一个问题不太热门就完全被忽略掉的情况，但对于软件开发而言，不应该因为一个问题热度不高就完全置之不理，因为热度可能受很多其它因素影响，比如用户主要使用的语言和开发者不同，反馈时存在语言或者网络障碍，热度不能直观地反映出程序本身的问题。\n但也许上述区别只决定了开发者能不能看到用户反馈，另一个决定性因素是开发者想不想看到用户反馈。我个人是觉得对于软件质量的追求应该是程序员自发的而不是被迫的，所以对用户反馈更应当积极处理。不得不承认，相似体量的项目，开源项目的开发者比起闭源项目的开发者好像确实是更有责任感一点。\n"},{"title":"PipeWire 和 HDMI 音频和虚拟设备和复合/分离通道","url":"/posts/PipeWire-HDMI-Audio-Virtual-Device-Combine-Split-Channel/","content":"这篇文章同时有 中文版本 和 英文版本。\nThis post is both available in Chinese version and English version.\n\n\n中文版本\n认识的朋友里很少有人有像我这么复杂的音频系统。长话短说，为了能让 PS4、Switch 和电脑分享一个扬声器，我把它接在了显示器上而不是电脑的内置声卡上，这样所有设备都通过 HDMI/DP 输出音频到扬声器。一开始这也没什么，后来我又添置了一块显示器，我发现在 Linux 下面经常搞不清楚究竟哪一个音频设备才是连接着扬声器的显示器，可能上周还是 HDMI 1，这周就变成 HDMI 2，而且也不是每次都会变，导致我经常需要试试才知道哪一个是我需要的。直到前天我忍不了了，决定发挥动手能力解决这个问题。\n一开始我以为是 PipeWire 给设备排序的时候是随机排序的，那简单，只要我找到每个设备对应的 ID，然后关掉没有扬声器的那个 HDMI 输出就可以了。但是我发现似乎 PipeWire 只是按照 ALSA 给出的设备编号来排序，并没有自己编号，于是就算关闭一个设备，下次被关闭的也可能是另一个。然后我就在想难道 ALSA 没有固定 HDMI 音频设备的功能吗？毕竟就算是显示器也是有 EDID 这种东西的，于是我查了各种 ALSA 的资料，确实是可以通过 udev 指定不同声卡的顺序，但对于 HDMI 这种属于同一个声卡的不同端口的设备没什么办法。我甚至查到了 NVIDIA 关于显卡音频的文档，里面说每个端口会有一个叫做 ELD 的数据，描述了连接的显示器信息，不过通过 cat /proc/asound/cardX/eld* 查看之后我发现这个标准最多只给到显示器的型号，而我为了不在多显示器缩放上浪费精力，买了两台同样型号的显示器，没有序列号字段就还是没办法分辨不同的显示器！当然如果你的 HDMI 设备的型号不同，那其实就简单了，ALSA 现在会读取 ELD 里面的显示器型号，然后 PipeWire 会把这个作为 node.nick 属性，你可以直接通过这个属性分辨设备，也可以利用这个属性写 WirePlumber 重命名规则修改你的桌面环境会用到的属性，就可以固定名称了。不过我还得继续寻求帮助。\n于是我就在公司的 research 邮件列表发了封邮件讲述了我的设备连接方式和需求，结果 Takashi Iwai（内核音频子系统的维护者之一）回复我说确实没有什么办法，音频驱动只是按照显卡给的顺序分配编号，所以大概率是随机的。特别是我还发现这玩意好像也不一定按照显示器输出的顺序来排号，于是 Plan A 是彻底行不通了。那我还有 Plan B 和 Plan C。\n和其它同事给的建议一样，其中一个想法是购买一个硬件的混合器，把两台显示器的音频输出硬件连接到同一个扬声器的输入，甚至一个同事还给我画了电路图说你只要这样就能自己做一个了。不过这个方案既有优点也优缺点，优点是电脑和游戏机可以同时发声，缺点是我要在电脑上修改音量就得始终记得把两个音量都改成一样的。我对前者需求不大，所以打算最后再尝试这个。\n当然有硬件的解法就有软件的解法，PipeWire 和 JACK 一样可以进行基于图的连接，那我只要搞一个虚拟的输出设备然后把两个 HDMI 设备跟它连一起不就行了？Arch Wiki 上恰好有一段 同时向一块声卡上的不同端口输出音频 的文档，我本来以为照做即可，但发现还是不对，并没有出现我想象中的一个新音频设备。不过后来我仔细研究，搞懂了里面各种术语，才知道是怎么回事。\n首先我发现这一段文档其实只是描述如何创建一个“能同时显示两个 mapping 的 profile”，那到底什么是 mapping 什么是 profile？Mapping 可以理解成声卡上的某一种输入/输出组合，然后 profile 决定当前可以在哪几种组合中选择。举例来说就是假如你有一个 2 进 4 出的音频设备，那它可以是只有双声道输出，只有四声道输出，或者双声道输入四声道输出等等组合，这就是不同的 profile。为什么要同时输出不同端口需要创建一个 profile 呢，因为默认 ALSA 采用的是 auto-profile，会给每一个 mapping 创建一个 profile，而默认的一个 mapping 就是一个 HDMI 端口，因此假如你打开 pavucontrol 或者 Helvum，会发现如果不切换 profile，两个 HDMI 设备只能显示一个，也就没法给它们同时连接。当然你可能又会问为什么 GNOME Shell 里面又能显示两个 HDMI 设备？因为 libgnome-volume-control 是先枚举设备然后枚举端口，并不是直接枚举端口（受 profile 影响），选择端口的时候再自动切换 profile。\n所以第一步是创建一个新的 profile sets，比如我创建的是 /usr/share/alsa-card-profile/mixer/profile-sets/hdmi-multiple.conf：\n[General]\nauto-profiles = no\n\n[Mapping hdmi-stereo]\ndescription = Digital Stereo (HDMI)\ndevice-strings = hdmi:%f\npaths-output = hdmi-output-0\nchannel-map = left,right\npriority = 9\ndirection = output\n\n[Mapping hdmi-stereo-extra1]\ndescription = Digital Stereo (HDMI 2)\ndevice-strings = hdmi:%f,1\npaths-output = hdmi-output-1\nchannel-map = left,right\npriority = 7\ndirection = output\n\n# If you have more HDMI devices, add them here.\n\n# Show multiple HDMI mappings so I could connect to them all.\n[Profile hdmi-multiple]\ndescription = Multiple Digital Stereo (HDMI)\noutput-mappings = hdmi-stereo hdmi-stereo-extra1\n\n上面的 mapping 是直接从 default.conf 里面抄的，下面那个 profile 就是包含上面的两个 mapping，然后需要写 WirePlumber 规则来给显卡上的声卡套用这个 profile。我把它写到 /etc/wireplumber/main.lua.d/51-hdmi-multiple.lua：\nrule = {\n  matches = {\n    {\n      -- Sometimes PCI sound card name has `.1` or other suffix, so it's better\n      -- to use description to match it.\n      { \"device.description\", \"matches\", \"TU104 HD Audio Controller\" },\n    },\n  },\n  apply_properties = {\n    [\"api.alsa.use-acp\"] = true,\n    -- By default, it creates profiles for each mappings, so one profile has one\n    -- mapping, but I want to combine 2 mappings, so I have to manually create\n    -- a profile to show 2 mappings.\n    [\"api.acp.auto-profile\"] = false,\n    [\"api.acp.auto-port\"] = false,\n    [\"device.profile-set\"] = \"hdmi-multiple.conf\",\n    [\"device.profile\"] = \"hdmi-multiple\",\n  },\n}\n\ntable.insert(alsa_monitor.rules, rule)\n\n然后执行 systemctl --user restart wireplumber，Helvum 里面应该就能同时看到两个显示器的 HDMI 音频设备了。\n接下来是 Arch Wiki 里面没有提到的部分，如何同时向两个设备输出音频？最简单的就是像 JACK 一样直接把输出的程序同时连接到两个音频设备上就行了，但这样既不能持久化，也不能在桌面环境里调节音量。阅读了 PipeWire 的文档之后发现这部分可以通过虚拟设备来解决，有一个叫做 combine-stream 的模块就可以创建这样的复合设备，于是参考 combine-stream 的文档，我创建 /etc/pipewire/pipewire.conf.d/10-hdmi-combined-sink.conf 并写入如下内容：\ncontext.modules = [\n    {   name = libpipewire-module-combine-stream\n        args = {\n            combine.mode = sink\n            node.name = \"combined-hdmi-stereo\"\n            node.description = \"Combined HDMI / DisplayPort\"\n            combine.latency-compensate = false\n            combine.props = {\n                audio.position = [ FL FR ]\n                # Those could be added here, but libgnome-volume-control only\n                # read those for ports from cards, not for virtual / network\n                # devices.\n                #device.description = \"TU104 HD Audio Controller\"\n                #device.icon-name = \"audio-card-analog-pci\"\n            }\n            stream.props = {\n                # Link matching channels without remixing.\n                stream.dont-remix = true\n            }\n            stream.rules = [\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts glob. Match all HDMI devices on TU104.\n                            media.class = \"Audio/Sink\"\n                            # Sometimes PCI sound card name has `.1` or other\n                            # suffix, so it's better to use description to\n                            # match it.\n                            node.description = \"~TU104 HD Audio Controller Digital Stereo *\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            combine.audio.position = [ FL FR ]\n                            audio.position = [ FL FR ]\n                        }\n                    }\n                }\n            ]\n        }\n    }\n]\n\n逻辑很简单，就是创建一个复合设备，输入到该设备的音频会输出给给定显卡上的所有 HDMI 输出，然后 systemctl --user restart pipewire wireplumber 就可以在 GNOME 里选择这个输出设备并调节音量了，不管扬声器插在哪个 HDMI/DP 显示器上，都能工作。\n解决了这个问题之后我发现利用 PipeWire 的虚拟设备还可以解决我 USB 声卡的通道问题。我现在用的是我上高三时候买的 Scarlett 2i4，有两个输入和四个输出，而 auto-profile 就会自动把它设置成一个四声道环绕声输出和一个立体声输入，但实际上这个四个输出是双声道的耳机和双声道的扬声器，两个输入通常会分别用来输入话筒和乐器，而不是作为双声道输入。于是长久以来我只能在各种软件里手动设置单声道音频解决这个问题。而这次读文档我发现 PipeWire 早就给出了例子，虽然是针对另一款声卡（UMC404HD 的扬声器/耳机分离 和 UMC404HD 的话筒/乐器分离），不过总而言之是大同小异，我也参照着弄了一下我的声卡。\n首先你想要手动分离声卡的各个通道，仍然需要换掉默认的 profile，不过这次不需要手动编写了，PipeWire 给所有音频设备都提供了一个叫做 pro-audio 的 profile，这个会直接暴露声卡的所有通道而不做额外的假设（显然桌面环境对于这种裸配置的支持并不好），而后我们就可以为所欲为了，所以先创建 /etc/wireplumber/main.lua.d/51-scarlett-2i4.lua 写入规则让它默认使用 pro-audio：\nrule = {\n  matches = {\n    {\n      { \"device.name\", \"matches\", \"alsa_card.usb-Focusrite_Scarlett_2i4_USB-00\" },\n    },\n  },\n  apply_properties = {\n    [\"audio.rate\"] = 48000,\n    [\"audio.allowed-rates\"] = \"44100,48000,88200,96000\",\n    --[\"api.alsa.period-size\"] = 2048,\n    --[\"api.alsa.headroom\"] = 1024,\n    [\"api.alsa.use-acp\"] = true,\n    -- By default, it creates profiles for stereo input and surround 4.0 output,\n    -- but actually the card is 2 inputs, stereo headphones output and stereo\n    -- speakers output, so we disable auto profile here, and use the Pro Audio\n    -- profile to expose all ports, and combine them manually.\n    [\"api.acp.auto-profile\"] = false,\n    [\"api.acp.auto-port\"] = false,\n    [\"device.profile\"] = \"pro-audio\",\n  },\n},\n\ntable.insert(alsa_monitor.rules, rule)\n\n然后 systemctl --user restart wireplumber，再打开 Helvum 应该能看到声卡不再被瞎推测为什么 LR RR 之类的声道，而是直接显示 AUX0~3，接下来就可以创建虚拟设备分别映射不同的通道了。\n首先对于输出，我分离出耳机/扬声器两个不同的双声道虚拟输出设备，平时我只用耳机。这里和官方文档示例里声卡不同的地方是那款声卡后两个通道是耳机，而 Scarlett 2i4 前两个通道就是耳机，这也是为什么就算默认被当成四通道环绕声也能用的原因。总之在 /etc/pipewire/pipewire.conf.d/10-scarlett-2i4-sinks.conf 里面写入如下的配置就可以了：\n.\n    #\n    # Differs from UMC404HD, Scarlett 2i4 uses the first two outputs for headphones.\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = &quot;Scarlett 2i4 Headphones&quot;\n            capture.props = {\n                node.name = &quot;Scarlett_2i4_Headphones&quot;\n                media.class = &quot;Audio/Sink&quot;\n                audio.position = [ FL FR ]\n            }\n            playback.props = {\n                node.name = &quot;playback.Scarlett_2i4_Headphones&quot;\n                audio.position = [ AUX0 AUX1 ]\n                target.object = &quot;alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0&quot;\n                stream.dont-remix = true\n                node.passive = true\n            }\n        }\n    }\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = &quot;Scarlett 2i4 Speakers&quot;\n            capture.props = {\n                node.name = &quot;Scarlett_2i4_Speakers&quot;\n                media.class = &quot;Audio/Sink&quot;\n                audio.position = [ FL FR ]\n            }\n            playback.props = {\n                node.name = &quot;playback.Scarlett_2i4_Speakers&quot;\n                audio.position = [ AUX2 AUX3 ]\n                target.object = &quot;alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0&quot;\n                stream.dont-remix = true\n                node.passive = true\n            }\n        }\n    }\n]\n\" class=\"code-block\">context.modules = [\n    # See &lt;https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-speakersheadphones-virtual-sinks&gt;.\n    #\n    # Differs from UMC404HD, Scarlett 2i4 uses the first two outputs for headphones.\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = \"Scarlett 2i4 Headphones\"\n            capture.props = {\n                node.name = \"Scarlett_2i4_Headphones\"\n                media.class = \"Audio/Sink\"\n                audio.position = [ FL FR ]\n            }\n            playback.props = {\n                node.name = \"playback.Scarlett_2i4_Headphones\"\n                audio.position = [ AUX0 AUX1 ]\n                target.object = \"alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0\"\n                stream.dont-remix = true\n                node.passive = true\n            }\n        }\n    }\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = \"Scarlett 2i4 Speakers\"\n            capture.props = {\n                node.name = \"Scarlett_2i4_Speakers\"\n                media.class = \"Audio/Sink\"\n                audio.position = [ FL FR ]\n            }\n            playback.props = {\n                node.name = \"playback.Scarlett_2i4_Speakers\"\n                audio.position = [ AUX2 AUX3 ]\n                target.object = \"alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0\"\n                stream.dont-remix = true\n                node.passive = true\n            }\n        }\n    }\n]\n\n执行 systemctl --user restart pipewire wireplumber 应该可以看到多了两个分别是 Scarlett 2i4 Headphones 和 Scarlett 2i4 Speakers 的音频输出设备。对于输入通道，我们也同理将它映射成两个单独的单声道虚拟输入设备，写到 /etc/pipewire/pipewire.conf.d/10-scarlett-2i4-sources.conf：\n.\n    #\n    # Differs from UMC404HD, Scarlett 2i4 can be two mono inputs or one stereo\n    # input, depends on how we wire it in software.\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = &quot;Scarlett 2i4 Left Mono Input&quot;\n            capture.props = {\n                node.name = &quot;capture.Scarlett_2i4_Left_Mono_Input&quot;\n                audio.position = [ AUX0 ]\n                stream.dont-remix = true\n                target.object = &quot;alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0&quot;\n                node.passive = true\n            }\n            playback.props = {\n                node.name = &quot;Scarlett_2i4_Left_Mono_Input&quot;\n                media.class = &quot;Audio/Source&quot;\n                audio.position = [ MONO ]\n            }\n        }\n    }\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = &quot;Scarlett 2i4 Right Mono Inputt&quot;\n            capture.props = {\n                node.name = &quot;capture.Scarlett_2i4_Right_Mono_Input&quot;\n                audio.position = [ AUX1 ]\n                stream.dont-remix = true\n                target.object = &quot;alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0&quot;\n                node.passive = true\n            }\n            playback.props = {\n                node.name = &quot;Scarlett_2i4_Right_Mono_Input&quot;\n                media.class = &quot;Audio/Source&quot;\n                audio.position = [ MONO ]\n            }\n        }\n    }\n]\n\" class=\"code-block\">context.modules = [\n    # See &lt;https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-microphoneguitar-virtual-sources&gt;.\n    #\n    # Differs from UMC404HD, Scarlett 2i4 can be two mono inputs or one stereo\n    # input, depends on how we wire it in software.\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = \"Scarlett 2i4 Left Mono Input\"\n            capture.props = {\n                node.name = \"capture.Scarlett_2i4_Left_Mono_Input\"\n                audio.position = [ AUX0 ]\n                stream.dont-remix = true\n                target.object = \"alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0\"\n                node.passive = true\n            }\n            playback.props = {\n                node.name = \"Scarlett_2i4_Left_Mono_Input\"\n                media.class = \"Audio/Source\"\n                audio.position = [ MONO ]\n            }\n        }\n    }\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = \"Scarlett 2i4 Right Mono Inputt\"\n            capture.props = {\n                node.name = \"capture.Scarlett_2i4_Right_Mono_Input\"\n                audio.position = [ AUX1 ]\n                stream.dont-remix = true\n                target.object = \"alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0\"\n                node.passive = true\n            }\n            playback.props = {\n                node.name = \"Scarlett_2i4_Right_Mono_Input\"\n                media.class = \"Audio/Source\"\n                audio.position = [ MONO ]\n            }\n        }\n    }\n]\n\n按理说到这里就结束了，但以防万一真的有人想用这款声卡做四声道环绕声输出，或者立体声输入，同理可以使用之前的 combine-stream 再把这些虚拟设备复合起来，可以在 /etc/pipewire/pipewire.conf.d/10-scarlett-2i4-combined.conf 写入如下配置：\ncontext.modules = [\n    # Is there anyone who really uses Scarlett 2i4 for surround 4.0 output?\n    # Anyway, we could achieve this with PipeWire's combine stream.\n    {   name = libpipewire-module-combine-stream\n        args = {\n            combine.mode = sink\n            node.name = \"Scarlett_2i4_Surround_4_0_Output\"\n            node.description = \"Scarlett 2i4 Surround 4.0 Output\"\n            combine.latency-compensate = false\n            combine.props = {\n                audio.position = [ FL FR RL RR ]\n                # Those could be added here, but libgnome-volume-control only\n                # read those for ports from cards, not for virtual / network\n                # devices.\n                #device.description = \"Scarlett 2i4\"\n                #device.icon-name = \"audio-card-analog-usb\"\n            }\n            stream.props = {\n                # Link matching channels without remixing.\n                stream.dont-remix = true\n            }\n            # To get better effect, treat headphones output as rear output.\n            stream.rules = [\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts regex. Match Scarlett 2i4 Speakers.\n                            media.class = \"Audio/Sink\"\n                            node.name = \"Scarlett_2i4_Speakers\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            audio.position = [ FL FR ]\n                            combine.audio.position = [ FL FR ]\n                        }\n                    }\n                }\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts regex. Match Scarlett 2i4 Headphones.\n                            media.class = \"Audio/Sink\"\n                            node.name = \"Scarlett_2i4_Headphones\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            audio.position = [ FL FR ]\n                            combine.audio.position = [ RL RR ]\n                        }\n                    }\n                }\n            ]\n        }\n    }\n    # To make it easier, we also use PipeWire's combine stream to make a stereo\n    # input, so we don't need to wire manually.\n    {   name = libpipewire-module-combine-stream\n        args = {\n            combine.mode = source\n            node.name = \"Scarlett_2i4_Stereo_Input\"\n            node.description = \"Scarlett 2i4 Stereo Input\"\n            combine.latency-compensate = false\n            combine.props = {\n                audio.position = [ FL FR ]\n                # Those could be added here, but libgnome-volume-control only\n                # read those for ports from cards, not for virtual / network\n                # devices.\n                #device.description = \"Scarlett 2i4\"\n                #device.icon-name = \"audio-card-analog-usb\"\n            }\n            stream.props = {\n                # Link matching channels without remixing.\n                stream.dont-remix = true\n            }\n            stream.rules = [\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts regex. Match Scarlett 2i4 Left Mono Input.\n                            media.class = \"Audio/Source\"\n                            node.name = \"Scarlett_2i4_Left_Mono_Input\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            audio.position = [ MONO ]\n                            combine.audio.position = [ FL ]\n                        }\n                    }\n                }\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts regex. Match Scarlett 2i4 Right Mono Input.\n                            media.class = \"Audio/Source\"\n                            node.name = \"Scarlett_2i4_Right_Mono_Input\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            audio.position = [ MONO ]\n                            combine.audio.position = [ FR ]\n                        }\n                    }\n                }\n            ] \n        }\n    }\n]\n\n理论上来说，再创建虚拟设备直接连到物理通道应该也是可行的，但我尝试过之后连接图乱掉了，所以我换成 combine-stream 实现了。有一个要注意的点是我在环绕声里交换了一下通道，扬声器输出被我当作前面的音源，而耳机输出被我当作后面的音源，这样应该效果会更好，不过是和 auto-profile 假设的相反。\n于是在购买这款声卡七八年之后我终于在 Linux 下面把它按我想要的用法划分了通道，同时发现 PipeWire 对复杂音频设备的处理确实比 PulseAudio 更加灵活，而和同样基于图和连接的 JACK 相比，又能同时控制不同的声卡，对于我这种设备复杂需求却不复杂的用户而言显然更加方便。\n\n\n\nEnglish Version\nI 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.\nAt 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 cat /proc/asound/cardX/eld*, 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 node.nick 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.\nThen 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.\nAnother 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.\nIf 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 Simultaneous output to multiple sinks on the same sound card 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.\nFirst 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 libgnome-volume-control 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.\nSo the first step to do is create a new profile sets, I use /usr/share/alsa-card-profile/mixer/profile-sets/hdmi-multiple.conf:\n[General]\nauto-profiles = no\n\n[Mapping hdmi-stereo]\ndescription = Digital Stereo (HDMI)\ndevice-strings = hdmi:%f\npaths-output = hdmi-output-0\nchannel-map = left,right\npriority = 9\ndirection = output\n\n[Mapping hdmi-stereo-extra1]\ndescription = Digital Stereo (HDMI 2)\ndevice-strings = hdmi:%f,1\npaths-output = hdmi-output-1\nchannel-map = left,right\npriority = 7\ndirection = output\n\n# If you have more HDMI devices, add them here.\n\n# Show multiple HDMI mappings so I could connect to them all.\n[Profile hdmi-multiple]\ndescription = Multiple Digital Stereo (HDMI)\noutput-mappings = hdmi-stereo hdmi-stereo-extra1\n\nI just copy mapping from default.conf, 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 /etc/wireplumber/main.lua.d/51-hdmi-multiple.lua:\nrule = {\n  matches = {\n    {\n      -- Sometimes PCI sound card name has `.1` or other suffix, so it's better\n      -- to use description to match it.\n      { \"device.description\", \"matches\", \"TU104 HD Audio Controller\" },\n    },\n  },\n  apply_properties = {\n    [\"api.alsa.use-acp\"] = true,\n    -- By default, it creates profiles for each mappings, so one profile has one\n    -- mapping, but I want to combine 2 mappings, so I have to manually create\n    -- a profile to show 2 mappings.\n    [\"api.acp.auto-profile\"] = false,\n    [\"api.acp.auto-port\"] = false,\n    [\"device.profile-set\"] = \"hdmi-multiple.conf\",\n    [\"device.profile\"] = \"hdmi-multiple\",\n  },\n}\n\ntable.insert(alsa_monitor.rules, rule)\n\nAnd then run systemctl --user restart wireplumber, you should see both 2 HDMI sinks in Helvum now.\nThen 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 combine-stream's document, write following content into /etc/pipewire/pipewire.conf.d/10-hdmi-combined-sink.conf:\ncontext.modules = [\n    {   name = libpipewire-module-combine-stream\n        args = {\n            combine.mode = sink\n            node.name = \"combined-hdmi-stereo\"\n            node.description = \"Combined HDMI / DisplayPort\"\n            combine.latency-compensate = false\n            combine.props = {\n                audio.position = [ FL FR ]\n                # Those could be added here, but libgnome-volume-control only\n                # read those for ports from cards, not for virtual / network\n                # devices.\n                #device.description = \"TU104 HD Audio Controller\"\n                #device.icon-name = \"audio-card-analog-pci\"\n            }\n            stream.props = {\n                # Link matching channels without remixing.\n                stream.dont-remix = true\n            }\n            stream.rules = [\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts glob. Match all HDMI devices on TU104.\n                            media.class = \"Audio/Sink\"\n                            # Sometimes PCI sound card name has `.1` or other\n                            # suffix, so it's better to use description to\n                            # match it.\n                            node.description = \"~TU104 HD Audio Controller Digital Stereo *\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            combine.audio.position = [ FL FR ]\n                            audio.position = [ FL FR ]\n                        }\n                    }\n                }\n            ]\n        }\n    }\n]\n\nIt'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 systemctl --user restart pipewire wireplumber 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.\nThen 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 (Split speakers/headphones of UMC404HD 和 Split speakers/headphones of UMC404HD), but mostly they are the same, so I also tweak my sound card.\nThe 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 pro-audio 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 pro-audio by default in /etc/wireplumber/main.lua.d/51-scarlett-2i4.lua:\nrule = {\n  matches = {\n    {\n      { \"device.name\", \"matches\", \"alsa_card.usb-Focusrite_Scarlett_2i4_USB-00\" },\n    },\n  },\n  apply_properties = {\n    [\"audio.rate\"] = 48000,\n    [\"audio.allowed-rates\"] = \"44100,48000,88200,96000\",\n    --[\"api.alsa.period-size\"] = 2048,\n    --[\"api.alsa.headroom\"] = 1024,\n    [\"api.alsa.use-acp\"] = true,\n    -- By default, it creates profiles for stereo input and surround 4.0 output,\n    -- but actually the card is 2 inputs, stereo headphones output and stereo\n    -- speakers output, so we disable auto profile here, and use the Pro Audio\n    -- profile to expose all ports, and combine them manually.\n    [\"api.acp.auto-profile\"] = false,\n    [\"api.acp.auto-port\"] = false,\n    [\"device.profile\"] = \"pro-audio\",\n  },\n},\n\ntable.insert(alsa_monitor.rules, rule)\n\nThen run systemctl --user restart wireplumber, 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.\nFor 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 /etc/pipewire/pipewire.conf.d/10-scarlett-2i4-sinks.conf:\n.\n    #\n    # Differs from UMC404HD, Scarlett 2i4 uses the first two outputs for headphones.\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = &quot;Scarlett 2i4 Headphones&quot;\n            capture.props = {\n                node.name = &quot;Scarlett_2i4_Headphones&quot;\n                media.class = &quot;Audio/Sink&quot;\n                audio.position = [ FL FR ]\n            }\n            playback.props = {\n                node.name = &quot;playback.Scarlett_2i4_Headphones&quot;\n                audio.position = [ AUX0 AUX1 ]\n                target.object = &quot;alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0&quot;\n                stream.dont-remix = true\n                node.passive = true\n            }\n        }\n    }\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = &quot;Scarlett 2i4 Speakers&quot;\n            capture.props = {\n                node.name = &quot;Scarlett_2i4_Speakers&quot;\n                media.class = &quot;Audio/Sink&quot;\n                audio.position = [ FL FR ]\n            }\n            playback.props = {\n                node.name = &quot;playback.Scarlett_2i4_Speakers&quot;\n                audio.position = [ AUX2 AUX3 ]\n                target.object = &quot;alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0&quot;\n                stream.dont-remix = true\n                node.passive = true\n            }\n        }\n    }\n]\n\" class=\"code-block\">context.modules = [\n    # See &lt;https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-speakersheadphones-virtual-sinks&gt;.\n    #\n    # Differs from UMC404HD, Scarlett 2i4 uses the first two outputs for headphones.\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = \"Scarlett 2i4 Headphones\"\n            capture.props = {\n                node.name = \"Scarlett_2i4_Headphones\"\n                media.class = \"Audio/Sink\"\n                audio.position = [ FL FR ]\n            }\n            playback.props = {\n                node.name = \"playback.Scarlett_2i4_Headphones\"\n                audio.position = [ AUX0 AUX1 ]\n                target.object = \"alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0\"\n                stream.dont-remix = true\n                node.passive = true\n            }\n        }\n    }\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = \"Scarlett 2i4 Speakers\"\n            capture.props = {\n                node.name = \"Scarlett_2i4_Speakers\"\n                media.class = \"Audio/Sink\"\n                audio.position = [ FL FR ]\n            }\n            playback.props = {\n                node.name = \"playback.Scarlett_2i4_Speakers\"\n                audio.position = [ AUX2 AUX3 ]\n                target.object = \"alsa_output.usb-Focusrite_Scarlett_2i4_USB-00.pro-output-0\"\n                stream.dont-remix = true\n                node.passive = true\n            }\n        }\n    }\n]\n\nThen run systemctl --user restart pipewire wireplumber 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 /etc/pipewire/pipewire.conf.d/10-scarlett-2i4-sources.conf:\n.\n    #\n    # Differs from UMC404HD, Scarlett 2i4 can be two mono inputs or one stereo\n    # input, depends on how we wire it in software.\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = &quot;Scarlett 2i4 Left Mono Input&quot;\n            capture.props = {\n                node.name = &quot;capture.Scarlett_2i4_Left_Mono_Input&quot;\n                audio.position = [ AUX0 ]\n                stream.dont-remix = true\n                target.object = &quot;alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0&quot;\n                node.passive = true\n            }\n            playback.props = {\n                node.name = &quot;Scarlett_2i4_Left_Mono_Input&quot;\n                media.class = &quot;Audio/Source&quot;\n                audio.position = [ MONO ]\n            }\n        }\n    }\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = &quot;Scarlett 2i4 Right Mono Inputt&quot;\n            capture.props = {\n                node.name = &quot;capture.Scarlett_2i4_Right_Mono_Input&quot;\n                audio.position = [ AUX1 ]\n                stream.dont-remix = true\n                target.object = &quot;alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0&quot;\n                node.passive = true\n            }\n            playback.props = {\n                node.name = &quot;Scarlett_2i4_Right_Mono_Input&quot;\n                media.class = &quot;Audio/Source&quot;\n                audio.position = [ MONO ]\n            }\n        }\n    }\n]\n\" class=\"code-block\">context.modules = [\n    # See &lt;https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Virtual-Devices#behringer-umc404hd-microphoneguitar-virtual-sources&gt;.\n    #\n    # Differs from UMC404HD, Scarlett 2i4 can be two mono inputs or one stereo\n    # input, depends on how we wire it in software.\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = \"Scarlett 2i4 Left Mono Input\"\n            capture.props = {\n                node.name = \"capture.Scarlett_2i4_Left_Mono_Input\"\n                audio.position = [ AUX0 ]\n                stream.dont-remix = true\n                target.object = \"alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0\"\n                node.passive = true\n            }\n            playback.props = {\n                node.name = \"Scarlett_2i4_Left_Mono_Input\"\n                media.class = \"Audio/Source\"\n                audio.position = [ MONO ]\n            }\n        }\n    }\n    {   name = libpipewire-module-loopback\n        args = {\n            node.description = \"Scarlett 2i4 Right Mono Inputt\"\n            capture.props = {\n                node.name = \"capture.Scarlett_2i4_Right_Mono_Input\"\n                audio.position = [ AUX1 ]\n                stream.dont-remix = true\n                target.object = \"alsa_input.usb-Focusrite_Scarlett_2i4_USB-00.pro-input-0\"\n                node.passive = true\n            }\n            playback.props = {\n                node.name = \"Scarlett_2i4_Right_Mono_Input\"\n                media.class = \"Audio/Source\"\n                audio.position = [ MONO ]\n            }\n        }\n    }\n]\n\nEvery 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 /etc/pipewire/pipewire.conf.d/10-scarlett-2i4-combined.conf:\ncontext.modules = [\n    # Is there anyone who really uses Scarlett 2i4 for surround 4.0 output?\n    # Anyway, we could achieve this with PipeWire's combine stream.\n    {   name = libpipewire-module-combine-stream\n        args = {\n            combine.mode = sink\n            node.name = \"Scarlett_2i4_Surround_4_0_Output\"\n            node.description = \"Scarlett 2i4 Surround 4.0 Output\"\n            combine.latency-compensate = false\n            combine.props = {\n                audio.position = [ FL FR RL RR ]\n                # Those could be added here, but libgnome-volume-control only\n                # read those for ports from cards, not for virtual / network\n                # devices.\n                #device.description = \"Scarlett 2i4\"\n                #device.icon-name = \"audio-card-analog-usb\"\n            }\n            stream.props = {\n                # Link matching channels without remixing.\n                stream.dont-remix = true\n            }\n            # To get better effect, treat headphones output as rear output.\n            stream.rules = [\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts regex. Match Scarlett 2i4 Speakers.\n                            media.class = \"Audio/Sink\"\n                            node.name = \"Scarlett_2i4_Speakers\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            audio.position = [ FL FR ]\n                            combine.audio.position = [ FL FR ]\n                        }\n                    }\n                }\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts regex. Match Scarlett 2i4 Headphones.\n                            media.class = \"Audio/Sink\"\n                            node.name = \"Scarlett_2i4_Headphones\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            audio.position = [ FL FR ]\n                            combine.audio.position = [ RL RR ]\n                        }\n                    }\n                }\n            ]\n        }\n    }\n    # To make it easier, we also use PipeWire's combine stream to make a stereo\n    # input, so we don't need to wire manually.\n    {   name = libpipewire-module-combine-stream\n        args = {\n            combine.mode = source\n            node.name = \"Scarlett_2i4_Stereo_Input\"\n            node.description = \"Scarlett 2i4 Stereo Input\"\n            combine.latency-compensate = false\n            combine.props = {\n                audio.position = [ FL FR ]\n                # Those could be added here, but libgnome-volume-control only\n                # read those for ports from cards, not for virtual / network\n                # devices.\n                #device.description = \"Scarlett 2i4\"\n                #device.icon-name = \"audio-card-analog-usb\"\n            }\n            stream.props = {\n                # Link matching channels without remixing.\n                stream.dont-remix = true\n            }\n            stream.rules = [\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts regex. Match Scarlett 2i4 Left Mono Input.\n                            media.class = \"Audio/Source\"\n                            node.name = \"Scarlett_2i4_Left_Mono_Input\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            audio.position = [ MONO ]\n                            combine.audio.position = [ FL ]\n                        }\n                    }\n                }\n                {\n                    matches = [\n                        # Any of the items in matches needs to match, if one\n                        # does, actions are emited.\n                        {\n                            # All keys must match the value. `~` in value\n                            # starts regex. Match Scarlett 2i4 Right Mono Input.\n                            media.class = \"Audio/Source\"\n                            node.name = \"Scarlett_2i4_Right_Mono_Input\"\n                        }\n                    ]\n                    actions = {\n                        create-stream = {\n                            audio.position = [ MONO ]\n                            combine.audio.position = [ FR ]\n                        }\n                    }\n                }\n            ] \n        }\n    }\n]\n\nTheoretically 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.\nSo 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.\n\n\n\n"},{"title":"都不能算是 GNOME 的 Bug","url":"/posts/Not-A-GNOME-Bug-at-All/","content":"Arch Linux 的官方仓库里终于有 GNOME 44 了，今天更新了一下系统，在思考出怎么解决 DaVinci Resolve 一定要去加载 onetbb 里面 intel 的 OpenCL 实现之前，我遇到了一个更奇怪的问题：所有的 XWayland 程序都显示不出来窗口，程序启动了，没有报错，但是点不到。\n忘了我当时在查什么反正看了一下 journalctl -f 发现一直刷一个 mutter-x11-frames core dump 的 log，我想起来 mutter 44 应该是把 X11 程序的 decoration 挪到单独的 client 里面实现了，所以也许是 mutter 的问题，不过我还是尝试用 gdb 看了一下 backtrace：\n, nativeDpy=0x55555558e510, attribs=) at ../egl-wayland/src/wayland-egldisplay.c:580\n#4  0x00007ffff26acfa0 in  () at /usr/lib/libEGL_nvidia.so.0\n#5  0x00007ffff264c71c in  () at /usr/lib/libEGL_nvidia.so.0\n#6  0x00007ffff2ba4885 in GetPlatformDisplayCommon (platform=12760, native_display=0x55555558e510, attrib_list=0x0, funcName=0x7ffff2baad18 &quot;eglGetDisplay&quot;) at ../libglvnd-v1.6.0/src/EGL/libegl.c:324\n#7  0x00007ffff7a19357 in gdk_display_create_egl_display (native_display=0x55555558e510, platform=12757) at ../gtk/gdk/gdkdisplay.c:1484\n#8  gdk_display_init_egl (self=0x5555555a1820, platform=12757, native_display=0x55555558e510, allow_any=0, error=0x5555555a17f8) at ../gtk/gdk/gdkdisplay.c:1667\n#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\n#10 gdk_x11_display_init_gl (display=0x5555555a1820, error=0x5555555a17f8) at ../gtk/gdk/x11/gdkdisplay-x11.c:3013\n#11 0x00007ffff7a198f0 in gdk_display_init_gl (self=0x5555555a1820) at ../gtk/gdk/gdkdisplay.c:1248\n#12 gdk_display_prepare_gl (self=0x5555555a1820, error=0x0) at ../gtk/gdk/gdkdisplay.c:1320\n#13 0x00007ffff79ec355 in gdk_x11_display_open (display_name=) at ../gtk/gdk/x11/gdkdisplay-x11.c:1479\n#14 0x00007ffff7a15c62 in gdk_display_manager_open_display (manager=, name=0x0) at ../gtk/gdk/gdkdisplaymanager.c:431\n#15 0x00007ffff777dda9 in gdk_display_open_default () at ../gtk/gdk/gdk.c:331\n#16 gtk_init_check () at ../gtk/gtk/gtkmain.c:621\n#17 gtk_init_check () at ../gtk/gtk/gtkmain.c:603\n#18 0x00007ffff777dfee in gtk_init () at ../gtk/gtk/gtkmain.c:659\n#19 0x0000555555557070 in main (argc=, argv=) at ../mutter/src/frames/main.c:56\n\" class=\"code-block\">Thread 1 \"mutter-x11-fram\" received signal SIGSEGV, Segmentation fault.\n___pthread_mutex_lock (mutex=0x123) at pthread_mutex_lock.c:80\nDownloading source file /usr/src/debug/glibc/glibc/nptl/pthread_mutex_lock.c\n80        unsigned int type = PTHREAD_MUTEX_TYPE_ELISION (mutex);                                                                                                                                                                                             \n(gdb) bt\n#0  ___pthread_mutex_lock (mutex=0x123) at pthread_mutex_lock.c:80\n#1  0x00007ffff685aaf6 in wl_proxy_create_wrapper (proxy=proxy@entry=0x55555558e510) at ../wayland-1.22.0/src/wayland-client.c:2446\n#2  0x00007ffff2ad337c in getServerProtocolsInfo (protocols=0x7fffffffdc70, nativeDpy=0x55555558e510) at ../egl-wayland/src/wayland-egldisplay.c:464\n#3  wlEglGetPlatformDisplayExport (data=0x5555555ae000, platform=&lt;optimized out&gt;, nativeDpy=0x55555558e510, attribs=&lt;optimized out&gt;) at ../egl-wayland/src/wayland-egldisplay.c:580\n#4  0x00007ffff26acfa0 in  () at /usr/lib/libEGL_nvidia.so.0\n#5  0x00007ffff264c71c in  () at /usr/lib/libEGL_nvidia.so.0\n#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\n#7  0x00007ffff7a19357 in gdk_display_create_egl_display (native_display=0x55555558e510, platform=12757) at ../gtk/gdk/gdkdisplay.c:1484\n#8  gdk_display_init_egl (self=0x5555555a1820, platform=12757, native_display=0x55555558e510, allow_any=0, error=0x5555555a17f8) at ../gtk/gdk/gdkdisplay.c:1667\n#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\n#10 gdk_x11_display_init_gl (display=0x5555555a1820, error=0x5555555a17f8) at ../gtk/gdk/x11/gdkdisplay-x11.c:3013\n#11 0x00007ffff7a198f0 in gdk_display_init_gl (self=0x5555555a1820) at ../gtk/gdk/gdkdisplay.c:1248\n#12 gdk_display_prepare_gl (self=0x5555555a1820, error=0x0) at ../gtk/gdk/gdkdisplay.c:1320\n#13 0x00007ffff79ec355 in gdk_x11_display_open (display_name=&lt;optimized out&gt;) at ../gtk/gdk/x11/gdkdisplay-x11.c:1479\n#14 0x00007ffff7a15c62 in gdk_display_manager_open_display (manager=&lt;optimized out&gt;, name=0x0) at ../gtk/gdk/gdkdisplaymanager.c:431\n#15 0x00007ffff777dda9 in gdk_display_open_default () at ../gtk/gdk/gdk.c:331\n#16 gtk_init_check () at ../gtk/gtk/gtkmain.c:621\n#17 gtk_init_check () at ../gtk/gtk/gtkmain.c:603\n#18 0x00007ffff777dfee in gtk_init () at ../gtk/gtk/gtkmain.c:659\n#19 0x0000555555557070 in main (argc=&lt;optimized out&gt;, argv=&lt;optimized out&gt;) at ../mutter/src/frames/main.c:56\n\n然后我就在想看起来是 GTK4 的问题，我还去群里问了一下有没有 GNOME + NVIDIA 的用户，看看是我的问题还是 bug，不过没人理我，还差点把我恶心到了。然后我想了一下试了 GDK_BACKEND=x11 nautilus 发现也有一样的问题，就跑到 GTK 那边提了个 issue，结果那位有点出名的毒舌老哥跟我说看着不像是 GTK 的问题倒像是 nvidia 的问题，我也怀疑过，但我检查了一下和 nvidia 相关的都没什么变化，然后我去翻 glvnd 和 egl-wayland 的仓库也没翻出什么。换到 KDE 下面还是一样有问题。但我突然想到会不会和我设置的一些环境变量有关系，于是就去注销了一大片，结果就好了。最后我看了一下好像有 platform，发现是我设置过一个 EGL_PLATFORM=wayland 的环境变量，删掉这个就好了。\n我想了一下这应该是我当初弄 Firefox 的硬件解码视频时候设置的，果不其然在 https://github.com/elFarto/nvidia-vaapi-driver#firefox 里面写了，看起来是因为这个变量导致 XWayland 程序加载 EGL 的时候把 platform 当成了 Wayland，不过我没想清楚为什么滚系统之前没有遇到这个问题。\n总之这是个不能算 bug 的问题了，如果我在群里问的时候有人回我，我就能直接排除法发现是我自己配置的问题，结果提了 issue 以后发现不是上游的问题感觉很尴尬。想了一下还是决定把这个记在这里，因为我推测有很多人看了 nvidia-vaapi-driver 的文档，说不定也设置了这个变量然后遇到了同样的问题，记录下来方便搜到。\n"},{"title":"Emacs 和 Lazy Loading 和 use-package","url":"/posts/Emacs-Lazy-Loading-use-package/","content":"事先叠 buff：我不是说 use-package 一定要这么用，我也不是说所用不用 use-package 的人都不好，我只是说我觉得应该这样用 use-package 比较合适。\nuse-package 是个好东西，因为它解决了 Emacs 插件包从安装到配置的全过程，可以让配置更结构化。不过也有人觉得 use-package 关键字过于复杂，总是没办法确定什么配置写到什么字段里面，也不知道展开之后悄悄发生了什么事情。从我再次决定自己打造一份 Emacs 配置以来看了很多不同人的配置，发现他们使用 use-package 的方式也是五花八门，有些人不爱用 :bind 和 :hook，干脆自己在 :config 里面调用 define-key 和 add-hook，有些人不清楚为什么自己的 :config 被延迟运行了，干脆全都用 :init。还有些人直接换成了其它号称更简单可控的替代品。我不是说上面这些方法都错了，实际上只要能得到想要的结果也无所谓怎么写，但我是一个比较注重逻辑的人，所以研究了一下到底这些关键字是怎么回事，并且试图写篇文章记录我推荐的写法。本来我打算写在注释里的，可是感觉写得太多，所以就放到博客里了。\n问题的核心无非是：为什么我写的 :config 没有运行？到底什么情况下会有延迟加载？我打算在这里详细分析一下。\n首先 Emacs 有一种叫做 autoloads 的东西，插件包的作者可以在某些函数前加上 autoloads 标记，然后创建 autoloads 文件。这个功能的作用很好理解，原本在启动时需要加载所有插件包的文件以便用户使用相应的功能，但不是所有的插件都是启动时就需要，只在启动时加载会让启动速度变得很慢。有了 autoloads 之后启动时只要加载 autoloads 文件，里面定义了如果运行某个函数，就去加载某个文件，这样等到对应的函数第一次运行的时候才被加载，从而提高启动速度。\n而 use-package 做了什么呢？use-package 可以自动创建 autoloads，这样即使一个包本身没有 autoloads，也是可以延迟加载的。最简单的触发这个逻辑的关键字是 :commands，就是给后续的函数创建 autoloads 的意思。但如果你仔细阅读文档，就会发现还有几个创建 autoloads 的关键字，分别是 :bind、:hook、:mode 和 :interpreter。**如果有这几个关键字，use-package 不会立即加载一个包，而是依靠创建的 autoloads 实现延迟加载。**这也很好理解，这几个关键字的意思都是“在某种情况下启用”，所以自动延迟加载也很好理解。\n然后为什么会遇到 :config 不会运行所以有些人统统把要调用的语句写到 :init 里面的问题呢？一般都是发生在类似下面的写法（既有快捷键 / 钩子，又要一启动就启用什么模式）里：\n(use-package marginalia\n  :ensure t\n  :bind (:map minibuffer-local-map\n              (\"M-A\" . marginalia-cycle))\n  :config\n  (marginalia-mode 1))\n\n我们得明确 :init 和 :config 的区别，:init 是“无论包有没有加载，都一定会先执行”的配置，:config 是“包加载之后才会被执行”的配置。然后按照上面关于延迟加载的分析，这就变成了一个“先有鸡还是先有蛋”的问题：\n\nuse-package 给这个包创建了快捷键对应的 autoloads，于是这个包不会立刻加载。\n:config 里面 (marginalia-mode 1) 要等到包加载之后才运行。\n这个包本身有给 marginalia-mode 创建 autoloads，只要调用 marginalia-mode 就会加载这个包，但是根据 1 和 2，这句不会被调用，包也不会被加载，除非按了 1 里面的快捷键。\n\n打破这个循环的办法不止一种，比如把 (marginalia-mode 1) 写进 :init，这样无论如何都会调用它，于是 use-package 创建的 autoloads 被忽略了，包肯定会被加载。但我个人倾向于把这种启用模式的函数放在 :config 里面，而且作者也推荐不要在 :init 里面放过于复杂的函数，这时候打破循环的办法也很简单，只要使用 :demand t，告诉 use-package 立即加载这个包即可。\n以上的内容其实都写在 https://github.com/jwiegley/use-package#notes-about-lazy-loading，只是可能很多人没有注意或者没看明白，于是我试图在这里更详细的解释一下。可能有人会问搞这么复杂真的有意义吗？我直接自己不要延迟加载直接都 require 不可以？不过我觉得这种激进的延迟加载方案确实让我的 Emacs 启动非常快，所以大概是有意义的吧。\n"},{"title":"StackHarbor 的 2022 尾记","url":"/posts/2022-Tail/","content":"每年写年终总结我都会拖很晚，因为基本上我写博客是看心情，最近事情比较多，其实也打算再拖几天的，但是实在是不想回家之后写，所以不得不今天仓促动手。\n然后实际上我不喜欢分类列提纲的写法，我本质上比较倾向于文学性的写法或者就是想到什么写什么，不过最近我在翻以前的年终总结的时候发现事情总是记的乱七八糟而且有些我都想不起来出现在哪篇文章里了，这当然可能也和我写完年终总结从来不看有关系，反正这次打算试一下分类的写法。\n按照惯例还是要感慨一句时间过得真快，仅仅只是靠记忆的话，就会觉得自己什么也没做就过了一年。写年终总结的时候到处翻一下记录，才会意识到自己其实做了不少事情。\n\n\n编程\n我经常会处于一种“我好菜啊怎么什么都做不了”和“我还能搞定这个其实还不错”的叠加态，实际上仔细翻一下感觉去年还是做了不少东西。比如说在 HackWeek 把 Show Me The Key 成功换成了 GTK4。然后还抽时间利用 Telegram 机器人做了个照片墙，虽然中间我把它关了很长时间，不过后来我又把它跑起来了。\n这一年我印象最深的其实是搞我的 Emacs 配置。在有确定消息说 GitHub 打算放弃 Atom 之后，我不得不给我自己重新找一个编辑器，因为我得了一种看到 Visual Studio 就会死的病所以坚决不会用 VSCode，除非他们哪天改名部把 VS 从里面去掉。然后我一直是不喜欢模态编辑的所以也不会用 Vim，同时 Emacs 的 PGTK 分支已经被上游接受，所以我很高兴地重回 Emacs 拥抱我所知的第一个 pure Wayland 的 GUI 编辑器。说是重回，其实相当于重新学习了一遍 Emacs Lisp，毕竟我一开始尝试 Emacs 是被 Spacemacs 那句著名的口号吸引的（但是我又不用 Evil）。那时候其实我不太懂 Emacs Lisp，但是现在回头再看发现确实是更好掌握了。虽然有无数的人说应该从别人配好的 Emacs 配置开始，但我还是决定自己编写一套配置而不是使用最流行的 doom。一个是这些配置好像都以模态编辑为中心，另一个是我经常会自己定制自己的编辑器，使用这些别人配好的配置调起来总觉得很不自在。然后就是我逐渐理解了 use-package 的用法，解决了各种奇奇怪怪的问题，甚至还自己用 Emacs Lisp 写了很多自己需要的功能。虽然可能有人要问你搞这一通有什么意义之类的，但是我做事的一个原则就是看心情，我高兴就好，所以觉得还挺值的。\n然后不论是工作还是个人爱好上这一年多少也做了点东西，毕竟我的工作就是我的爱好。比如很有意思的一个是我研究了一下 GNOME 的智能卡登录到底怎么搞，顺便也大致了解了一下 PAM 的配置，虽然可能这个还是没什么用，不过最后我修改了 openSUSE 的 gdm 包添加了一直缺失的指纹和智能卡的 PAM 配置，也算是帮助了其他人。我还抽出时间调查了一下 GTK3 和 GTK4 的亮色 / 暗色主题切换到底是怎么回事。然后还做了一些微小的贡献，比如我印象里一直有人吐槽说 GNOME Shell 的搜索只能从开头匹配而不能做子串匹配，还有人说难道他们只会用 String.prototype.startsWith() 不会用 String.prototype.includes()，我一开始只是想既然这么简单，有吐槽的时间为什么不自己改一个？于是我花时间看了一下还真不是这么简单，总之最后我阅读了 glib 里面的算法，并且添加了根据不同的匹配模式分组的功能，现在如果有单词开头匹配的会优先显示，然后再显示子串匹配，就可以通过搜索 fox 得到 Firefox 了（https://gitlab.gnome.org/GNOME/glib/-/merge_requests/3107）。\n和这个类似的还有另一个，我看到有人说 gdbus-codegen 生成的代码没有加空指针检查导致程序崩溃，然后和开发者吵了起来，开发者说加空指针检查不是真的解决问题，这里不应该传空指针，那个人就丢出一堆各种代码规范说传了空指针应该继续运行不该崩溃，开发者说你给我们加的话我们愿意接受，他又说自己不擅长 python，总之我看了觉得很不可理喻，于是我自己改掉提交了然后嘲笑了那人一通。有些时候真不是开发者脾气不好，是有人态度太差……（https://gitlab.gnome.org/GNOME/glib/-/merge_requests/3175）\n音乐\n去年一年我还是录了好几个曲子的，虽然我自己是觉得没怎么练琴而且还经常咕咕咕。不过我发出来的我自己还都觉得不错，虽然不是每个都有很多播放量吧。最近手上有几个想录的，比如 シリウスの心臓 和 暗恋是一个人的事，不过可能又要拖到年后了。\n然后今年通过 澪音奏 的翻唱听了好多伍佰的曲子，对于我这种几乎不会主动找歌曲来听的人，能扩充曲库还是好事。还在 B 站听了纵贯线的亡命之徒，没早点听到这个真是有点可惜。\n数码\n其实我觉得我也没买什么东西，但是再看一下又不少，很多其实没什么可说的，比如买了个新镜头，那就是新镜头，也没什么好在博客里分析一番的。考虑到那块老移动硬盘用了很久，又买了一块三星的 T7 Shield，固态的移动硬盘还是可靠很多。犹豫了很久还是买了平板，不过不是 iPad，是 Galaxy Tab S8，除了 LCD 屏幕有点漏光，别的我都很满意，不管是看谱子还是看视频都不错，虽然有些 app 不支持横屏，但我还是觉得文件管理更重要。\n开销比较大的是装了一台 NAS，实际也是挑来挑去才决定的，运行了小一年觉得还不错，极大的缓解了我的存储压力。\n摄影\n自我评价的话我觉得还是有点进步，别的不说，今年我拍了很多自己觉得不错的照片。摸索了一年，我大概也知道怎么用 darktable 得到想要的效果了。之前看到有人总结了一下自己拍过的照片里觉得不错的，我觉得这个想法很好，于是挑了一些今年满意的照片放在这里：\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n很想找机会把我喜欢的照片打印出来装上相框挂在家里，只是一直没去做这件事。\n动漫\n今年应该只看了两部，一部是 DitF，具体的评价我在 这篇文章 写过了。另一部是 赛博朋克：边缘行者，我其实是不喜欢赛博朋克题材的，但是这一部做得太好了，我在这里向所有读者推荐。\n小说\n今年应该是看了基地三部曲，还有格兰特船长的儿女和神秘岛，因为都是很好找到电子版的，所以没有买实体书，神秘岛以前看过简写版，其他的都是头一次看，总体上来说我觉得都不错，毕竟也是流传很久的书了。\n游戏\n除了和开黑群的朋友打 Dota 2 就是和牛爷爷高先生吃鸡，今年更多的玩了游廊地图，感觉我还是想玩休闲一点不太需要团队配合的，吃鸡的话我以为我很久没玩了水平会很差，不过我好像逐渐掌握这个游戏怎么赢了，而且经常可以吃鸡。其实玩游戏本身倒不重要，重要的是有人一起玩。一个人玩的话，要不是因为看中单光一，我大概不会坚持玩 Dota 2。\n生活\n在经历了翻大饼之后果然是不负众望的夺冠了，虽然已经好了。大饼现在是翻过来了，但是保不准哪天又要翻回去，反正我是受够了这种提心吊胆的日子。仔细想想今年我大概有好几次对着共享单车打开健康码扫码，还是挺可怕的。蓝猫今年去了日本，没办法再找她玩了，虽然我很羡慕，但是我又穷又懒。至于脱单这种事情，算了吧，我已经放弃了，一个人待着也挺好的，我一点都不羡慕别人（假的）。\n最近几天突然又肋骨痛，只要有动作扯到就很难受，我也不知道是气胸还是肋间神经痛还是肌肉拉伤，本来打算去医院看看的，但最近事情安排满了，回家的时间取决于车票不取决于我，所以只能等回家之后再说。\n"},{"title":"YubiKey 和 GNOME 和智能卡登录","url":"/posts/YubiKey-GNOME-Smartcard-Login/","content":"最近我终于决定买了一个 YubiKey 5C，说出来不怕各位笑话，我买这玩意最初的动机只是觉得每次开机和解锁输密码太麻烦（但是为什么我不觉得 sudo 输密码麻烦呢？）。这还和我之前处理了一个 openSUSE 的 PAM 问题有关，我发现 GDM 有好几种不同的 PAM 配置，除了平时用的 gdm-password 密码登录，还有 gdm-fingerprint 指纹登录和 gdm-smartcard 智能卡登录。我一开始是打算买个指纹传感器的，查了一下 fprintd 的文档，支持的型号并不多，而且在淘宝上问客服 USB Product ID 和 Vendor ID 显然得不到回答，就退而求其次买智能卡了，而搜索智能卡得到最多的结果就是 YubiKey，进入一个不了解的领域之前和大部分人选一样的一般不会错太多，于是就下手了。\n话说回来智能卡登录，如果你搜索 YubiKey 相关的文章，绝大多数都会告诉你把 pam_u2f.so 加到需要密码登录的 PAM 配置里，比如 sudo 或者 gdm-password，但这显然不是我想要的，我要的方案不是替换密码登录，而是和密码登录平行的配置文件，我知道 GNOME 有已经写好的智能卡配置，但是任何地方都搜不到如何启用，设置里没有相关选项，连 Arch Wiki 给的方案都只是用 pam_u2f.so。Red Hat 的支持文档里倒是提到了智能卡登录，然而用的却是他们自己的某个工具配置的。显然这是个起夜级 feature，最好的办法也许是找个起夜级 Linux 的桌面工程师来问问，哦什么我自己就是起夜级 Linux 桌面工程师，那没事了。还要说的一件事是怎么想智能卡这东西都和安全相关，而我自己不是专业的安全行业人士，所以我不会尝试解释清楚和安全相关的一些名词，以及如果我哪里真的写错了，希望专业人士多多指点，我肯定改。\n总之相信你自己因为你才是职业选手，我还是自己看看这东西怎么弄吧，毕竟用 Arch 再用 GNOME 同时还打算搞 GNOME 的智能卡登录的人没几个，所以 Wiki 没有倒也正常。首先肯定是看 /etc/pam.d/gdm-smartcard 这个文件，里面别的看起来都比较正常，只有一行看起来和智能卡有关系：\nauth       required                    pam_pkcs11.so        wait_for_card card_only\n\n线索是有了，看来我需要这个 pam_pkcs11.so，虽然我也不知道这是什么，先搜一下哪个包有这个文件比较好。pacman -F pam_pkcs11.so 竟然没有返回任何结果，我确定不是我的 pacman 数据库没更新，那只能去浏览器里搜索了，最后我搜到了 https://github.com/OpenSC/pam_pkcs11，虽然我也不知道 PKCS#11 是个什么玩意，但反正它是个 PAM 模块，既然不在官方仓库里，那大概率 AUR 里有人打包了，于是直接 paru pam_pksc11 就装了一个上来。\n但装是装好了，也看不出来这玩意和 YubiKey 有什么联系，我大概是搜索了 PKCS YubiKey 然后搜到了 YubiKey 给的文档 Using PIV for SSH through PKCS #11，好吧虽然我不是要用来 SSH 但是多半也有点用。看下来反正这个东西和 YubiKey 的 PIV 功能有关，我把 PIV 相关的文档都看了一遍，结果是云里雾里，相当没有头绪。一大堆文档告诉你各种各样的需求要做什么，但是几乎没怎么说这都是什么，于是恰好我的需求不在列表里我就不知道怎么办了。我又回头去看 pam_pkcs11 的文档，它写了一长串的东西，我反复看了几遍之后发现只要看 第 11 节的 HOWTO 部分 就可以了。虽然我也不太清楚它都在说什么，但是至少这里告诉我说需要一个 root CA certificate，但我是个人使用哪来的这玩意，再回头看 YubiKey 的那篇文档里面恰好提到了什么 self-signed certificate，我拿这个试一试，结果成功了。为了方便参考，下面我就不讲我是怎么倒推这些奇怪的需求的了，而是顺序讲一下都需要配置什么。\n首先如果你像我一样刚买了一个 YubiKey 打算利用它的 PIV 功能，那你得先初始化它，也就是改掉默认的 PIN，PUK 和管理密钥，这个可以通过官方的 YubiKey Manager 软件来操作：\n# pacman -S yubikey-manager\n\n之前这个软件还有个 Qt 写的 GUI 版本，但是已经被官方放弃了，不过本来 GUI 也不是很好用，所以建议还是用命令行：\n% ykman piv access change-pin\n% ykman piv access change-puk\n% ykman piv access change-management-key --generate --protect --touch\n\n默认 PIN 是 123456，默认 PUK 是 12345678，而管理密钥是个特别长的一串，用 --generate 可以让 ykman 给你生成一个，--protect 则是把这个直接存到 YubiKey 里面并用 PIN 保护，--touch 则是说每次要管理密钥的时候需要你摸一下。我也不是很懂，也许写进去以后需要的时候就不用自己背这玩意而是输 PIN 就行了吧，反正建议看官方文档 Device setup 和 ykman 的 --help。\n我的建议是不要看太多官方文档，因为它一会告诉你用 yubico-piv-tool 创建密钥，一会告诉你说可以用 openssl 创建密钥，一会又告诉你可以用 pkcs11-tool 搭配 libykcs11.so 创建密钥，算了吧，头都看晕了，我的测试是用 yubico-piv-tool 就可以了。\n% paru yubico-piv-tool\n\n在 9a 这个槽创建一个 key 并把它的公钥写出来，为什么是 9a 好像因为这是第一个槽来着，自己去查官方文档吧，也可以写到别的槽里面：\n% yubico-piv-tool -s 9a -a verify-pin -a generate -o public.pem\n\n需要先输入 PIN，然后灯闪的时候需要摸一下 YubiKey，它就开始生成了。\n还要给这个密钥生成一个签名：\n% yubico-piv-tool -s 9a -a verify-pin -a selfsign-certificate -S \"/CN=Alynx Zhou/\" -i public.pem -o cert.pem\n\n注意 CN= 后面的部分，这里会被 pam_pkcs11.so 用来验证这个智能卡属于系统里面哪个用户，所以简单的话直接写你的登录用户名，当然你像我一样不想写用户名也是有办法对应的，同样要输入 PIN。\n再把证书导回到同一个槽，我也不知道为什么，文档说了我照做了（大概是为了方便携带，需要证书的时候可以直接从 YubiKey 里面导出）：\n% yubico-piv-tool -s 9a -a verify-pin -a import-certificate -i cert.pem\n\n还是要输入 PIN 然后灯闪的时候摸一下。\n\n更新（2025-04-24）：我发现生成的证书就像 HTTPS 的证书一样其实是会过期的，默认的有效期是一年，到期了需要重新生成一个证书。我大概理解了这玩意是怎么回事并且搞清楚如何用 ykman 配置了所以我这里再记录一下怎么用 ykman 更新证书。\n整个的流程其实是你有一对私钥公钥用来签发证书，私钥在你的 YubiKey 里，公钥则是一个文件，使用公钥签发证书，然后把证书丢给 pam_pkcs11，pam_pkcs11 请求卡片用私钥验证证书是否符合。为了方便，证书可以导入 YubiKey，也可以从 YubiKey 里导出。\n首先可以用 ykman piv info 查看一下目前证书的信息。\n如果已经有一个过期的就用 ykman piv certificates delete 9a 删掉旧的证书。\n然后用 ykman piv certificates generate -s \"CN=Alynx Zhou\" 9a public.pem 签发一个新证书，注意这里字符串的格式和 yubico-piv-tool 不一样，这里用逗号而不是斜杠做分隔符。\n和 yubico-piv-tool 不一样，此时证书是直接生成在卡片里的，要交给 pam_pkcs11 的话得用 ykman piv certificates export 9a cert.pem 导出成文件。\n\n到这里 YubiKey 的配置就结束了。\n要在系统上使用智能卡验证需要安装系统上和智能卡交互的软件包：\n# pacman -S ccid opensc pcsclite\n% paru pam_pkcs11\n\n启动一个相关的 daemon，或者启动 socket 也行，需要的时候它就自己起来了：\n# systemctl enable --now pcscd.socket\n\n如果我没漏掉什么乱七八糟的，就可以配置 PAM 模块了，它有一个配置目录叫 /etc/pam_pksc11，首先你要把上面生成的证书放到 /etc/pam_pkcs11/cacerts。\n# cd /etc/pam_pkcs11/cacerts\n# cp PATH_TO_YOUT_CERT/cert.pem ./\n\n你要在同一个目录下面运行一个什么什么 hash 命令生成一个 hash：\n# pkcs11_make_hash_link\n\n接下来你要去搞它的配置文件，先复制一个样本过来：\n# cp /usr/share/doc/pam_pkcs11/pam_pkcs11.conf.example /etc/pam_pkcs11/pam_pkcs11.conf\n\n好像其实也没什么需要改的。文档说默认的配置用的是 OpenSC 的 PKCS#11 库，虽然 YubiKey 的文档一直跟你说什么 libykcs11.so，我的测试结果是不用理它，通用的接口就够了，以及这个 libykcs11.so 是属于 yubico-piv-tool 这个包的。\n假如你刚才 CN= 后面写的不是你的用户名，那你需要一些配置告诉 pam_pkcs11.so 你这个证书对应的哪个用户，这一步在它的配置文件里叫 mapper。默认启用了一些 mapper 比如 pwent，这个就是把 CN= 后面的内容和 /etc/passwd 里面的用户名做匹配，但是如果你像我一样写的是全名，那就需要另一个默认启用的模块叫 subject。至于 subject 是什么需要运行下面这个命令：\n% pkcs11_inspect\n\n它会输出各种 mapper 对应的 data，比如 pwent 输出的就是 Alynx Zhou，subject 输出的则是 /CN=Alynx Zhou。我们需要复制一个 subject_mapping 配置文件的样本过来：\n# cp /usr/share/doc/pam_pkcs11/subject_mapping.example /etc/pam_pkcs11/subject_mapping\n\n在这个文件后面加一行：\n alynx\n\" data-info=\"language-plain\" data-lang=\"plain\" class=\"code-block\">/CN=Alynx Zhou -&gt; alynx\n\n我的用户名是 alynx，你可以换成你自己的。\n到这一步 pam_pkcs11.so 这个模块已经可以通过智能卡验证你的身份了。\n\n更新（2025-04-24）：直接使用 p11-kit 的 MR 已经合并了，所以下面讲配置 NSS 数据库的部分都不需要做了。\n\n但是如果你火急火燎兴高采烈的重启了系统，GDM 还是会和你要密码。原因其实很简单，虽然现在 /etc/pam.d/gdm-smartcard 已经可用了，但 GDM 只有在检测到智能卡之后才会调用这个文件尝试智能卡登录，很显然它没检测到智能卡。\n这里就比较难搞清楚了，我智能卡插的好好的，上面各种程序都能用，为什么你检测不到？我尝试用什么 GDM YubiKey 之类的关键词搜索了半天，也没人告诉我 GDM 到底怎么检测智能卡的。没有办法还是读代码吧，GNOME Shell js/gdm/util.js 里面的逻辑是通过 D-Bus 的 org.gnome.SettingsDaemon.Smartcard 获取智能卡信息，那我打开 D-Feet 从 Session Bus 里面找到这个，直接运行 org.gnome.SettingsDaemon.Smartcard.Manager 的 GetInsertedTokens，什么都没有。\n根据 D-Bus 的信息，很显然这个接口是 gnome-settings-daemon 的 smartcard 插件提供的，我大概是搜索了什么 gsd-smartcard PKCS#11 的关键字之后找到了 https://gitlab.gnome.org/GNOME/gnome-settings-daemon/-/merge_requests/208，其实我一开始也没太看懂这是什么意思，但得到一些有用的信息：\n\ngsd-smartcard 用了什么 NSS API 获取智能卡设备。\n这玩意要一个什么 system shared certificate NSS database。\n除了 Red Hat 家那一套好像没什么别的发行版弄这个。\n\n这一路下来乱七八糟的名词已经够多的了现在又多了一个什么 NSS 而且只有 Red Hat 才配置了 system shared certificate NSS database，但不管怎么样我是职业选手我不能轻言放弃，还好 Arch Wiki 有这么一个页面 Network Security Services，但这不是管证书的吗，和智能卡设备有什么关系啊。这时候我又翻开了 Arch Wiki 关于智能卡的页面 Smartcards，里面讲了在 Chromium 里面加载智能卡需要在 NSS 数据库里面加一个模块（什么乱七八糟的），不过它操作的都是用户的家目录下面的数据库，这显然不是 system shared certificate NSS database。然后如果手工执行 /usr/lib/gsd-smartcard -v，会发现这玩意尝试读取 /etc/pki/nssdb 获取什么智能卡驱动列表，我系统里面根本没这个目录。算了，既然是 Red Hat 搞的东西，我看看他们怎么写的。正好我有个 Fedora 的虚拟机，打开虚拟机一看还真有这个目录，那就运行下面命令看看：\n% modutil -dbdir /etc/pki/nssdb -list\n\n结果里面除了默认项还真有个叫 p11-kit-proxy 的玩意，我又回去看了一眼那个 Merge Request，现在我完全明白了，不知道为什么 NSS 这玩意会记录一个读取智能卡的驱动列表，然后 gsd-smartcard 是通过 NSS 获取到智能卡的驱动列表之后再尝试查询智能卡，实际上现在没什么人用 NSS 这个功能了，你这还得往系统的 NSS 数据库里面写东西，除了红帽子家都没人搞这个了，就算有用 NSS 读的（比如浏览器）也是读用户的 NSS 数据库。别的用智能卡的都直接用 p11-kit 去读智能卡，所以这个 Merge Request 也改成直接用 p11-kit 读了。不知道为什么这个 Merge Request 没能合并。再多说一句，就算是 Red Hat 的系统 NSS 数据库，现在也不直接写智能卡的驱动了，而也是通过 p11-kit，所以刚才在 Fedora 的数据库里只看到 p11-kit-proxy 这一个驱动……\n既然这样我们也在这个数据库里写一个 p11-kit-proxy，根据 Arch Wiki 的智能卡页面，如果你要通过 p11-kit 操作 OpenSC 的驱动（这都什么乱七八糟的），那可能需要安装一个 AUR 包来保证它被加载（实际上就是个文件而已）：\n% paru opensc-p11-kit-module\n\n创建数据库目录并往数据库里写 p11-kit-proxy：\n# mkdir /etc/pki/nssdb\n# modutil -dbdir sql:/etc/pki/nssdb -add \"p11-kit-proxy\" -libfile p11-kit-proxy.so\n\n如果你和我一样又心急火燎的重启了，就会发现还是没用。这不科学啊，Fedora 的数据库里也是这么写的，看一眼 D-Bus 为什么还是没有智能卡。\n实际上最后我发现只差一点点，Fedora 给这个目录下文件的权限是 -rw-r--r--，而我这边创建好的是 -rw------。gsd-smartcard 是以 session 用户运行的当然读不了。所以改一下权限就可以了。\n# chmod 0644 /etc/pki/nssdb/*\n\n\n接下来插着 YubiKey 重启，GDM 启动的 gsd-smartcard 就能检测到智能卡，于是调用 /etc/pam.d/gdm-smartcard，直接让你输入用户名，输入之后会提示你输入智能卡的 PIN，然后 pam_pkcs11.so 进行验证，就可以登录了。锁屏之后也只要输入智能卡的 PIN 就可以解锁。\n按理说如果给 pam_pkcs11.so 发一个空白的用户名，它会根据智能卡返回用户名的，不知道为什么我在 GDM 用不了，一定要开机手动输入，有空我看看代码也许可以修改一下。 我也不知道为什么一定要在 GDM 启动之前插入卡才可以，显示用户列表之后再插入卡我这里没反应。\n更新（2022-11-30）：花了我半天时间研究 GDM 和 PAM，问题不在 GDM，而是因为 Arch Linux 的 gdm-smartcard 首先调用了 pam_shells 检查用户是否有合法的 shell，遇到空用户名它第一个失败了，于是我提交了 一个 MR，把 pam_shells 挪到 pam_pkcs11 下面，这样它会检查自动返回的用户名。（虽然这些 PAM 配置文件是发行版自己写的但是大家都提交到 GNOME 那边了，我只改了 Arch 的因为我在用，别的发行版的用户先偷着乐吧。）\n如果你想用智能卡解锁的话，一定得是用智能卡登录才可以，它会检查当前的卡是不是登录所用的那张卡，不是的话就只能密码解锁了。折腾这一套花了我一整天时间，因为资料实在是太少了，根本不知道它是怎么工作的。\n以及最后我还发现一篇文章，里面的内容也是讲这个 NSS 数据库的解决方案的，也许我早看见这个就不会这么麻烦了： Fixing NSS and p11-kit in Fedora (and beyond)。\n"},{"title":"DaVinci Resolve 在 Linux 下的输入法支持","url":"/posts/Input-Method-Support-for-DaVinci-Resolve-on-Linux/","content":"令人出乎意料，我竟然是 DaVinci Resolve（后面都简称达芬奇了）的付费用户。虽然它不是开源软件，但是有很好的 Linux 支持，使用体验和功能都是同类中的佼佼者，而且收费也相当合理。我选择付费一个原因是你支持我，我就支持你，这其实和我支持 Steam 和 Valve 的理由差不多。另一个原因是众所周知的由于什么所谓系统专利许可证的原因达芬奇 Linux 版本不能解码 H264 和 H265 这两种常见的视频编码，只能使用 NVIDIA 显卡的 NVENC 和 NVDEC 来处理，而达芬奇将显卡加速功能作为收费的卖点。于是我就这样半自愿的上了贼船。\n当然排除掉解码问题之后还有另一个比较难受的地方，就是达芬奇 Linux 版没有输入法支持，于是完全没办法输入中文。我猜测不像是某些故意恶心人的企业对 Linux 不友好，而单纯是因为英语开发者没有“输入法”这种概念。毕竟达芬奇的图形界面是基于 Qt 的，而 Qt 直接有现成的输入法支持，构建的时候打开开关就可以了嘛。为此我甚至专门跑到 BlackMagic Design 的用户论坛发了个帖子（https://forum.blackmagicdesign.com/viewtopic.php?f=33&amp;t=150886），作为付费用户，我给你钱，你就得给我办事，就是这么硬气。显然某位员工看到了我的帖子并把它移动到了 Feature Requests 版面，然后就没有然后了。闭源拖拉机总是这样，我看到了，但我懒得改，你给我等着吧。我倒不是说开源拖拉机的维护者都比较勤快，但是至少代码放在那里，说不定用户自己就给你改了送到你面前了，一般再懒的维护者都乐意接受。谁叫我没找到和达芬奇一样好用的开源视频剪辑软件呢。\n不过从它用的 Qt 这一点上来看，应该是有什么办法可以 hack 一下让它支持输入法的。虽然我不是很熟悉 Qt，但是 Fcitx 的开发者 @csslayer 给了我一个方案，他之前写了一篇博客是关于给 Mathematica 添加输入法支持的（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/），他觉得达芬奇也可以如法炮制，于是我阅读了一下，简单地概括就是首先查出来软件用了什么版本的 Qt，然后下载对应的源码，因为输入法支持属于 Qt 的某种插件，所以只要构建插件的时候链接到软件自带的 Qt，再把得到的插件复制到软件的 Qt 目录就可以了。一般来说软件就算修改了自带的 Qt，也不会修改有关插件的部分，所以我打算如法炮制一下。\n首先是查看达芬奇自带的 Qt 的版本，这个非常简单：\n% strings /opt/resolve/libs/libQt5Core.so.5 | rg 'Qt 5'\nQt 5.15.2 (x86_64-little_endian-lp64 shared (dynamic) release build; by Clang 9.0.1 )\nThis is the QtCore library version Qt 5.15.2 (x86_64-little_endian-lp64 shared (dynamic) release build; by Clang 9.0.1 )\nIf that is not possible, in Qt 5 you must at least reimplement\n\n到这里应该就是去下载 Qt 5.15.2 版本的源码了，不过我突发奇想看了一眼系统安装的 Qt 版本：\n% pacman -Qi qt5-base | rg Version\nVersion         : 5.15.7+kde+r176-1\n\n一般来说主次版本号不变的话不会有什么不兼容的改动，会不会我直接把系统的 .so 文件复制过去就可以用了呢？Qt 5 的 ibus 支持已经是 Qt 本身代码库的一部分了，安装到系统的路径是 /usr/lib/qt/plugins/platforminputcontexts/libibusplatforminputcontextplugin.so，我尝试直接把它链接过去：\n% sudo mkdir /opt/resolve/libs/plugins/platforminputcontexts\n% sudo ln -s /usr/lib/qt/plugins/platforminputcontexts/libibusplatforminputcontextplugin.so /opt/resolve/libs/plugins/platforminputcontexts\n\n然后就没有然后了，我启动达芬奇之后 ibus 就直接工作了。没想到他们虽然不太了解 Linux 输入法，Qt 版本跟的倒是还挺新的。\n\n对于 Fcitx5 用户的话，首先要注意 Qt 5 的 Fcitx5 支持并不在 Qt 的代码库里，所以你需要安装 fcitx5-qt。不过文件路径的话都是一样的，只要把文件名里的 ibus 改成 fcitx5 就可以了。如果直接链接不能用，需要按照老 K 博客里的办法自己编译的话，需要下载单独的 fcitx5-qt 代码库。当然从根源上解决问题的话还是希望大家去论坛回复我的帖子，让 BlackMagic Design 开启构建开关，就不需要用奇怪的办法 hack 了。\n"},{"title":"GTK 和 libhandy 和 Arc-Dark 主题","url":"/posts/GTK-libhandy-Arc-Dark/","content":"黑夜让我选了黑色的主题，但是有些程序非要寻找光明？\n\n我自认为不是个对应用程序外观有着病态一致性要求的人，我也从不介意一些个性化的程序选择自己的特殊样式。所以当 GTK 4 推荐的 libadwaita 不再支持传统的 GTK 主题的时候我也没什么反应：毕竟这玩意默认的样式看起来还挺好看的。但即使是我这样宽容的人，对于 GTK 3 那个熟的不能再熟的 Adwaita 主题也审美疲劳了，那个银色和棕色会让所有手机厂笑话的，即使有些手机厂的审美还不如这玩意。\n我个人最喜欢的配色其实是 Atom 的 One Dark 和 One Light，但我没那个精力利用调色盘自己维护一份主题，所以我退而求其次选择了在观感上比较接近的 Arc 主题，这个主题其实是一个系列，我自己只在乎里面的两个变体：全亮色的 Arc 和全暗色的 Arc-Dark（似乎它自己 README 里面给的截图也有点问题）。\n我自己是一个暗色模式爱好者，毕竟长时间面对屏幕，白底黑字实在是太刺眼了，相对而言，深蓝色做背景色浅灰色做前景色要好看很多。在很久很久以前混沌初开，Linux 桌面程序员还没有意识到需要有个全局的暗色/亮色开关的时候，设置主题非常简单粗暴，打开 GNOME Tweaks 把 GTK Theme 设置为 Arc-Dark，我就心满意足了。\n可能是 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（预期行为）。好家伙好家伙，我这一个桌面上三花聚顶了。\n\n总这么待着我觉得怪怪的，于是我研究了一下，如果我要是选亮色模式呢？现在 GNOME Settings 是 libadwaita 的亮色版本了，然后 GNOME Tweaks 和 GNOME Terminal 都是 Arc-Dark，虽然好像一致了，又好像有点不一致，这回从三花聚顶变成黑白通吃。总之我忍受了很久 GTK 4 程序 大部分 是白的而 GTK 3 程序是黑的，直到我再也受不了决定翻开代码看看这些人是怎么写的。\n\n为了能说明白，下面我就不从结果反推原因了，毕竟大家看到这里可能已经云里雾里，没必要和我再重复一遍破案过程了。\n可能大部分人不是 GTK 开发者也不使用 GTK，对这玩意怎么调用主题存在一定的误区。实际上可以分为以下几类：\n\n不使用 libhandy 的 GTK 3 程序（比如 GNOME Terminal）和不使用 libadwaita 的 GTK 4 程序（比如 Show Me The Key），这一类程序不考虑 GNOME Settings 里面的亮色/暗色开关（指的是 GSettings 里面 org.gnome.desktop.interface 的 color-scheme 选项），而只考虑 gtk-application-prefer-dark-theme，这个值属于 GtkSettings，需要编辑 ~/.config/gtk-4.0/settings.ini 和 ~/.config/gtk-3.0/settings.ini。以及是的你没看错，GTK 4 不一定非要用 libadwaita，实际上虽然这个库叫 libadwaita，但它和 GTK 3 那个叫做 Adwaita 的默认主题几乎没有关系，它是 GTK 3 的组件库 libhandy 的进化版本。GNOME 推荐使用这个以便让整个桌面有统一的风格，但是 GTK 4 仍然是个完整的 UI 库，程序开发者完全可以不使用 libadwaita。\n使用 libadwaita 的 GTK 4 程序（比如 GNOME Settings），这一类程序不考虑 GNOME Tweaks 里面的 GTK Theme 选项（实际上是 GSettings 里面 org.gnome.desktop.interface 的 gtk-theme 选项），只使用 libadwaita 内置的配色，所以我们也完全不需要关心它，它永远按照设置里的开关工作。\n使用 libhandy 的 GTK 3 程序（比如 GNOME Tweaks），这个就相当复杂了，libhandy 考虑了桌面环境的亮色/暗色主题切换，但也考虑了用户自定义的 GTK Theme，于是在这里它华丽的乱套了。\n还有最后一类程序，它们出于特定需要自己给自己套了自定义的 CSS，所以你拿它一点办法也没有，直接忽略（比如 Show Me The Key 的悬浮窗口）。\n\n看到这里一定有小黑子要怒吼了：“看看你们搞的乱七八糟的玩意！GNOME 真垃圾！老子就要刀耕火种就要当原始人，libadwaita 滚啊！”但是我建议用你那可怜的小脑袋瓜想一想，上面三条里面反而 libadwaita 是最符合预期的一个（亮色模式用亮色，暗色模式用暗色），所以我不会解决第二个，而是解决另外的两个。\n首先从 libhandy 下手，相关的代码位于 https://gitlab.gnome.org/GNOME/libhandy/-/blob/main/src/hdy-style-manager.c#L286-L348，如果你可怜的小脑袋瓜也没耐心看看代码的话，那么我大发慈悲替你读了一遍。相关的逻辑大概是说首先覆盖掉当前程序的 gtk-application-prefer-dark-theme（别忘了 libhandy 程序也是 GTK 3 程序），这个值会被设置成 color-scheme 的值。然后获取当前系统的 GTK Theme，因为我们考虑到浅色和深色主题切换，所以主题名被分成基础名和种类名两部分，如果系统的主题以 -dark 结尾，那就去掉这个后缀，得到基础名，并设置为当前程序的主题。那问题难道出在 Arc-Dark 的结尾是 -Dark 而不是 -dark 吗？也不是，GTK 主题对于暗色和亮色的区分不在主题名上，而是在主题目录下面的两个文件，一个叫做 gtk.css，另一个叫做 gtk-dark.css，如果 color-scheme 是 prefer-dark，libhandy 就会加载后者而非前者，这部分的代码在 https://gitlab.gnome.org/GNOME/libhandy/-/blob/main/src/hdy-style-manager.c#L106-L141。于是在系统设置为暗色模式的时候，libhandy 会去加载 Arc-Dark 的 gtk-dark.css，但 Arc-Dark 作为一个暗色变体，只有 gtk.css，所以加载失败，libhandy 回退到 Adwaita 的 gtk-dark.css。而系统设置为亮色的时候，libhandy 会去加载 Arc-Dark 的 gtk.css，而作为一个暗色变体，这个文件实际写的是暗色配色，于是看起来正常了。（以及如果你 GTK Theme 设置为 Adwaita-dark 从这里你就会发现实际上加载的是 Adwaita 的 gtk-dark.css，而不是 Adwaita-dark 的 gtk.css，即使它们的配色是一样的。）\n那么显然又有另一个问题，既然主题是靠内部的两个文件区分亮色和暗色的，为什么又会有 Adwaita-dark 和 Arc-Dark 这种名字里带暗色后缀的变体呢？并且还要在 libhandy 里面处理这个后缀，是不是多此一举？我们可以暂时先不考虑这个问题，而先简单解决第三条。从上面的分析可以得知为了能正常支持系统的亮色暗色切换，我们需要的是一个同时包含亮色暗色的主题，而不是一个只有暗色变体的主题，于是我们不能把 GTK Theme 设置为 Arc-Dark，而应该使用 Arc，但假如你在 GNOME Tweaks 里面设置好之后，你会发现仍然是黑白通吃：GNOME Settings 是 libadwaita 的暗色版本，GNOME Tweaks 是 Arc 的暗色版本，而 GNOME Terminal 和 Show Me The Key 却变成了亮的 Arc！\n\n我知道有的小黑子要迫不及待开始炮轰 GNOME 了：“什么玩意，整来整去不还是整不好吗，不如来当原始人。”但问题其实就是原始人留下的。现在我们回头看第一条：不使用 libhandy 的 GTK 3 程序和不使用 libadwaita 的 GTK 4 程序，这一类程序不考虑 GNOME Settings 里面的亮色/暗色开关，而只考虑 gtk-application-prefer-dark-theme。所以这个奇怪的表现恰好验证了这一条，同时也解释了“既然主题是靠内部的两个文件区分亮色和暗色的，为什么又会有 Adwaita-dark 和 Arc-Dark 这种名字里带暗色后缀的变体”这个问题：因为在一开始的设计里并没有什么全局亮色/暗色开关，也就没有要求主题同时提供 gtk.css 和 gtk-dark.css，那么为了让用户可以自选亮色暗色，只有提供两个不同的主题来解决问题。这也就是在 Adwaita 和 Arc 都提供了 gtk-dark.css 的情况下仍然存在 Adwaita-dark 和 Arc-Dark 的原因。然后在主题添加了 gtk-dark.css 之后，为了让 libhandy 的程序能够跟随系统开关切换亮色和暗色，就不能为了那些传统程序把 GTK Theme 设置为暗色变体的主题了，此时如果设置为同时包含两个文件的主题，默认这些程序会选择 gtk.css，也就会出现上面截图里的情况。解决这个的方案也不是很困难，gtk-application-prefer-dark-theme 就是为此添加的，支持它的 GTK 程序会按照这个选项来加载 gtk.css 或 gtk-dark.css。如果你像我一样平时主要用暗色模式，那就手动编辑 ~/.config/gtk-4.0/settings.ini 写入以下内容（GTK 3 的话就是 ~/.config/gtk-3.0/settings.ini）：\n[Settings]\ngtk-application-prefer-dark-theme=1\n\n你要是亮色爱好者，那就改成 0。这下倒是满足原始人的刀耕火种需求了哈，毕竟他们看起来也不想要系统的亮色/暗色开关的样子，不过说不定以后哪天系统的亮色/暗色开关也会同时修改这个选项呢？只是读取这个选项的 GTK 程序不会像 libhandy/libadwaita 的程序那样会动态切换，必须要关了重开才行。\n还有一个奇怪的问题要注意，通常我们是在 GNOME Tweaks 里面设置 GTK Theme，不过根据 https://gitlab.gnome.org/GNOME/gnome-tweaks/-/blob/master/gtweak/tweaks/tweak_group_appearance.py#L75-L88，它会把上面那个 gtk-application-prefer-dark-theme 设置成 0，看注释里面的 BUG 描述，应该也是为了某种刀耕火种的情况解决的（甚至那时候还推荐搞个单独的暗色主题，并且删除了全局的倾向暗色的开关），大概那时候还没推荐用 libhandy，也没有 libadwaita，也没有设置里这种全局暗色/亮色的开关。总之我不建议经常修改 GTK 主题，并且每次修改之后记得手动修改这个选项。如果你觉得这种反复横跳又要保证兼容以前的决策的行为很蠢，那我只能说毕竟你不能要求以前的开发者预见到未来的人们怎么定义桌面的功能。\n当然如果你毫不在乎亮色暗色切换（我就是要一直用暗色，所以你暗色模式给我选对了暗色主题就行了！），那还有个比较投机取巧的解决方案：把 Arc-Dark 的 gtk.css 复制并改名 gtk-dark.css 就可以了，原理不难理解。并且 Arc 主题已经做了这样的修改，只是还没有 Release（参见 https://github.com/jnsh/arc-theme/commit/73ada8563591fa48ae365686a358e874ca12edad）。\n"},{"title":"谁动了我的 DNS 解析？","url":"/posts/Who-Moved-My-DNS-Resolving/","content":"我发现这篇杂糅了关于设置 Zeroconf 的 mDNS 的需求和关于 Linux 下面 DNS 解析到底是怎么工作的描述，如果你只对后者感兴趣，请阅读最新的 谁动了我的 DNS 解析？（重制版）。\n如果有人看到这个标题以为是什么科学上网相关然后高兴地点进来的话不要怪我，我其实想说的是 Linux 上有关 DNS 解析的流程，这个标题显然是化用自《谁动了我的奶酪？》，即使我并没有读过这本书。我计网真的没认真听课，写的内容都是我现学现卖的，有不对的希望读者指正。\n\n\n\n需求\n我有一台 NAS，一台 PC 和一台路由器，为了能上网也为了家里的无线设备可以连接 NAS，我给 PC 和 NAS 分别接上路由器，但是路由器只有千兆网口，而 PC 和 NAS 各自多一个 2500 Mbps 的网卡，为了实现最高的连接速度，我又买了一根网线把 PC 和 NAS 直接连接起来，于是现在三台设备两两相连。\n直连两台设备其实非常简单，Network Manager 里面 IPv4 设置成手动，然后分别配置 IP 地址和子网掩码，再关掉 IPv6 就可以了，比如我分别设置 IP 为 10.10.10.1 和 10.10.10.2，然后子网掩码就是 255.255.255.0。然后在进行各种网络访问的时候只要使用这个 IP 就可以通过直连访问了。\n但是我还是不太满意，我设置了帅气的主机名，为什么还得用 IP 访问呢？但如果我查询主机名对应的 IP，发现得到的并不是直连的 IP，而是比如 192.168.1.80 这样的通过路由器的 IP。于是我开始研究如何配置让 DNS 解析给我返回直连的 IP。\nlong long ago\n一般要讲故事，开头都是“很久很久以前……”，不过计算机领域也没什么太古老的故事可讲，毕竟公认的互联网前身 ARPANET 也就是二十世纪的事情。那个时候能互联的机器一共也就那么几个，所以解决的办法简单粗暴：我们每个机器都保存一个文件，里面记录所有人对应的域名和 IP 不就行了？这个优良传统一直留了下来，也就是现在所有系统里都有的 hosts 文件，不管你写的对不对，它的优先级都比 DNS 查询要高。对于我这个极其简单的网络环境，这肯定是不错的解决方案，但是程序员总会觉得这种非自动化的手段太 low 了，于是就被我 pass 掉了。\n然后随着加入网络的机器越来越多，这个办法不好用了，毕竟每来一个新人就要所有人更新自己的文件，这复杂度也太高了。所以干脆我们搞一个集中的服务器专门放这个列表，其它机器都向它查询就好了。这就是 DNS 服务器的原理了，然后在局域网里，一般路由器和 DNS 服务器以及 DHCP 服务器都是同一台机器，因为很自然的所有设备都会连到路由器上，而 DHCP 服务器恰好知道它分配出去的 IP 地址，所以如果你输入主机名恰好能解析，那通常是你的路由器做了这些工作。但对于我这个子网来说，为了这两台电脑再配置 DHCP 和 DNS 显然太麻烦了，pass。\n再后来各种子网越来越多，子网里的设备也越来越多，比如打印机这种，以至于现在各种智能家居，不可能再搞一个服务器用来注册“喂，我是茶壶”这种东西，于是苹果搞出了一个叫 Zeroconf 的协议，大概是在 DNS 的基础上可以让子网里支持这个协议的设备互相发现互相通知自己是什么。因为和 DNS 相关，所以有一个部分是 MulticastDNS (mDNS)，简单来说就是不通过 DNS 服务器，而是通过这个协议发现的设备列表实现 DNS 解析。所以这是第三种方式。\n以上三种方式其实都是我从 Arch Wiki 抄来的：https://wiki.archlinux.org/title/Network_configuration#Local_network_hostname_resolution\n所以我决定搞一个第三种，这个好说，wiki 写了可以用 Avahi 做这个，不过怎么 systemd-resolved 也能做 mDNS？这玩意不是管 /etc/resolv.conf 的吗？Network Manager 不是也管这个吗？\nchattr +i /etc/resolv.conf\n很多 Linux 用户都知道修改 DNS 服务器可以通过编辑 /etc/resolv.conf 实现，很多 Linux 用户也被 /etc/resolv.conf 困扰，一些人发现自己的这个文件是个软链接，而另一些人发现这个文件总被 Network Manager 覆盖，还有些人的发行版让他们用一个叫 resolvconf 的工具处理，然后现在 systemd 又搞了个叫 resolved 的东西来插一脚……我说的这些已经足够让一些不想学新东西同时又神经紧张的人开始大喊“fuck systemd, fuck network manager, fuck desktop environment and fuck the whole modern world”然后执行 chattr +i /etc/resolv.conf 了。不过别着急小炸药包们，也许这个世界上新出现的各种东西目的并不只是惹恼你们这群大笨蛋，哦是的，没错，我说，大笨蛋，恐龙勇士（停停停不要翻译腔了），而是真的有场景需要他们。也许对于某个 VPN 连接需要使用自己的 DNS 服务器，总之，不要觉得世界都围着你转，至少读一下这些东西的文档，会告诉你怎么阻止它们修改你的 /etc/resolv.conf 的。\n但其实也不是一个 /etc/resolv.conf 搞定所有，有关这个的故事也是 long long ago，但毕竟是 UNIX 纪元之后的事情，没有太久，大概确实上古时代的程序都是直接读这个获取 DNS 服务器然后再做 DNS 解析的，但实际上这也不一定 OK，比如像之前说的打印机这种怎么解决？以及 hosts 呢？所以就有了更复杂的解决方案，大部分程序做 DNS 解析实际上是调用 glibc 里面 getaddrinfo 这个 API，所以在它后面我们就可以做一些工作。一个叫做 Name Service Switch 的东西发明出来就是干这个的，它可以理解为一个基于插件的结构，我们可以通过阅读 /etc/nsswitch.conf 里面的 hosts 这一行来理解，比如我这里默认是这样的：\nhosts: mymachines resolve [!UNAVAIL=return] files myhostname dns\n\n简单翻译一下的话意思就是查询一个域名的时候首先看看是不是 systemd-machined 的容器（mymachines 模块），不是的话再问问 systemd-resolved 能不能解析（resolve 模块），如果 systemd-resolved 可用，那到这也就完事了，后面的就不管了（[!UNAVAIL=return]），至于为什么我一会解释，然后 files 模块会读 hosts 文件，所以它优先级总是高于 DNS 服务器，然后看看是不是本机（myhostname 模块），然后再读 /etc/resolv.conf 里面的 DNS 服务器进行查询。\n对于一个普通的桌面用户，应该使用的只是 Network Manager，默认 Network Manager 不会用 systemd-resolved，于是大部分情况一个外部域名最后还是查询 DNS 服务器，和以前没什么本质区别。那 Network Manager 你为什么要修改 /etc/resolv.conf？原因之一就是之前提到不同的 VPN 服务可能有不同的 DNS 服务器，因此建议这些用户不要手动编辑这个文件，可以直接在 Network Manager 的连接配置里设置某个连接的 DNS 服务器。\n那 systemd-resolved 又是什么玩意？是不是 systemd 作者又要搞出什么花样替换我习惯的东西？但这东西好像还真是有些实际的需求，它不是一个简单的 /etc/resolv.conf 的管理工具，而可以理解为是一个自带缓存的 DNS 服务器。glibc 通过 /etc/resolv.conf 里面的 DNS 服务器查询 DNS 其实是不做缓存的，有些场景用户可能希望能自己缓存结果加快速度，这时候就需要搞这个东西了，它自己是一个 DNS 服务器，因此也就不再执行 nsswitch 里面后续的组件（用你的 dns 模块查询了我怎么缓存？）。除此之外它还声称自己提供了一个更好的 D-Bus 接口用来解析，而不是用 getaddrinfo，不过话又说回来，谁闲得没事去支持你这新搞的 D-Bus API，特别是你自己还搞了个 getaddrinfo 的模块。主观来说我其实不推荐一般的桌面用户配置这个，因为大概率你是在一个路由器后面，你的 DNS 服务器一般设置的都是路由器，而路由器上的 DNS 服务器一般会做缓存，所以其实完全没必要在自己电脑上启用这个……我也没遇到任何一定要使用它这个 D-Bus API 的程序。那 systemd-resolved 你为什么要修改 /etc/resolv.conf？原因是为了兼容那些直接读这个的上古程序，实际上人家就在这里写一行，就是让这些程序去查 systemd-resolved 内置的 DNS 服务器。\n那至于 resolvconf 又是啥？这是一个叫 openresolv 的项目搞出来的东西，需求就是有各种程序都打算自己修改 /etc/resolv.conf，不单单是上面那两个，还有一些 VPN 服务之类的，那你们干脆都别管了，我来管，听我的（现在有 N + 1 种解决方案了）。但实际上我也不推荐你使用这个，因为桌面用户根本没有使用场景，你用 Network Manager 的话，就不要再单独使用什么 VPN 工具，因为 Network Manager 本身支持很多种 VPN 连接，你直接用它管理就好了。就算你需要用 systemd-resolved，其实这个也替你考虑好了，Network Manager 支持 systemd-resolved，检测到你用它的话，就不会去改 /etc/resolv.conf，而是直接去修改 systemd-resolved 的配置了。\n更新（2023-09-07）：感觉光靠嘴说还是不太清楚，我画了一张图……\n\n所以你可以看到 Network Manager 默认其实并不参与 DNS 解析，它只是方便到处跑的笔记本用户能用上各个局域网内的 DNS 服务器而已。\n计算机科学是门艺术，当且仅当你不需要考虑兼容性的时候……\n那说回到 mDNS 这个问题，为什么我不直接用 systemd-resolved 解决呢？一个是上面提到的我不需要再做一次 DNS 缓存了，另一个是因为 CUPS 这个打印服务依赖 Avahi，它其实不只用到 mDNS，还用到了 Zeroconf 里面其它的功能比如发现设备去连接打印机，虽然我暂时也用不到 CUPS，但我确实是不想搞 systemd-resolved 的 DNS 服务器了，还是配置 Avahi 吧。当然假如你说我既想要 systemd-resolved 的 DNS 缓存和 D-Bus API 又想要 Avahi 的 Zeroconf 怎么办呢？额，其实也有办法，systemd-resolved 提供了选项让你关掉它的 mDNS 功能，具体我没有尝试，不过这样应该就不会冲突了。所以不要见到点新东西就生气，人家把各种兼容的东西都考虑到了，看两眼文档还不行吗……\n然后搞清楚整个流程之后 Avahi 的配置其实不难，首先安装 nss-mdns 这个包，顾名思义是给 nsswitch 提供 mdns 模块，然后启动 avahi-daemon.service，然后编辑 /etc/nsswitch.conf，在 resolve 模块之前加入 mdns4_minimal [NOTFOUND=return]：\nhosts: mymachines mdns4 resolve [!UNAVAIL=return] files myhostname dns\n\nmdns4 模块会试图通过 mDNS 也就是找网络上其它的 Zeroconf 协议设备来解析 IPv4 地址，4 表示只尝试 IPv4，因为这种内网设备多半你不会给它分配 IPv6，当然也有 6 和没有数字同时支持两种的，不过由于现在的程序都优先查询 IPv6，而我只给直连配置了 IPv4，所以如果不用只支持 4 的，就会 fallback 到后面的模块，那就跑到路由器上查去了，我就是不想走路由器的。\n读者看爽了，但好像结果不是我想要的……\n等到我把所有的东西都搞好以后我发现一个问题……mDNS 虽然说是子网上的设备互相发现，但是它没规定是哪个子网……于是喜闻乐见的每次 getent ahosts timbersaw.local 查询给我返回不一样的 IP，一会是 10.10.10.2 一会是 192.168.1.80，看起来还是写 hosts 比较靠谱……\n最后我的配置是不用 mdns4，而是用 mdns4_minimal，这两个的区别是后者只考虑 .local 结尾的域名，并且如果查找不到的话直接返回 NOTFOUND，而不是继续 fallback：\nhosts: mymachines mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] files myhostname dns\n\n然后再修改 /etc/hosts 分别添加不带 .local 的主机名（因为 .local 会在 files 之前先被 mDNS 处理）。\n当你觉得逐渐理解一切，并试图走出新手村……\n这一部分更新于 2024-02-01 18:15:00。因为我的网络配置终于突破了“只要全部交给 NetworkManager 就能解决”的范围。\n\n首先我还是应该对之前的逻辑做一下总结，其实关键无非是一句话：需要添加新的 DNS 服务器的场景有很多，但管理 /etc/resolv.conf 的程序只能有一个。比如说你连接到家里的网络，那你首先会希望自己的 DNS 服务器是路由器。然后这时你需要连接到公司的 VPN，那你会多出一个 VPN 的 DNS 服务器用来查询内网域名，并且只应该对内网域名查询这个 DNS 服务器。如果你选择用 NetworkManager 管理 /etc/resolv.conf，那你也应该使用 NetworkManager 的 VPN 插件，通过 NetworkManager 去修改 /etc/resolv.conf。于是就不再需要额外的进程管理 DNS 查询。\n而使我决定最后改用 systemd-resolved 管理 DNS 查询的原因是我开始使用 Tailscale/Headscale 构建一个我自己的 VPN 网络。Tailscale 包含一个叫做 MagicDNS 的组件，可以让你像使用路由器的 DNS 一样通过主机名访问这个虚拟专用网里的设备，此时它会直接覆盖掉 /etc/resolv.conf 让 DNS 查询走它自己的 DNS 服务器，这导致我的 OpenVPN 的 DNS 服务器被清掉，无法同时访问公司的内网。\n如果你已经理解一切，解决方案应该也很清晰：要么把 Tailscale 也换成 NetworkManager 的插件版本（不存在），要么使用另一个专门管理 /etc/resolv.conf 的工具（systemd-resolved）让 Tailscale 和 NetworkManager 都交给它管理从而不要互相覆盖。考虑到 Avahi 的 mDNS 并没有像我想象的那样工作，我毫不犹豫的干掉了它换成了 systemd-resolved。\n干掉 Avahi 的部分暂且不提，启用 systemd-resolved 的过程需要额外操作：\n# systemctl enable --now systemd-resolved\n# ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf\n\n按照之前讲的，建立软链接是为了让那些老掉牙的程序也使用 systemd-resolved 内置的 DNS 服务器，于是大家现在都走 systemd-resolved 进行查询。然后 Tailscale 和 NetworkManager 都支持 systemd-resolved，检测到这个软链接就不会尝试直接覆盖 /etc/resolv.conf，而是通知 systemd-resolved 添加自己的 DNS 解析。\n然后重启 NetworkManager 和 Tailscale：\n# systemctl restart NetworkManager\n# systemctl restart tailscaled\n\n但以上步骤只是让它们的 DNS 服务器设置可以共存，具体对于哪些域名通过哪个 DNS 服务器查询，是各个程序自己设置的，Tailscale 其实会正确的告诉 systemd-resolved 自己要处理的域名，但对于我的 OpenVPN 我发现需要我手动设置，由于我是使用 NetworkManager 管理我的 OpenVPN，所以需要执行 nmcli connection edit VPN-CONNECTION，然后 set ipv4.dns-search a.internal,b.internal 这样（看起来 NetworkManager 的 GUI 里没法修改这个），然后再重新开启 VPN 时候，你就可以通过 resolvectl 看到 OpenVPN 的 DNS 添加了正确的搜索范围。\n"},{"title":"不应该做 EVA，而应该做环太平洋","url":"/posts/Not-to-Be-EVA-but-to-Be-Pacific-Rim/","content":"想必把 2018 年的 DARLING in the FRANXX （名字太长了，后面简称 DitF 吧）称作有争议的作品应该不会有人反对，不过我恰好是个不喜欢追新番的人，不然也许我在 2018 年写篇关于这个的博客应该会能获得不少点击量。总之我在 2022 年下载了全集并且几乎是不间断的在三天之内看完了，可能不是特别好评价，但是觉得还是得写点什么。如果读者觉得“怎么复读了很多已有的观点”或者“和我想看的完全不一样”，还麻烦多包涵或者自行关闭标签页。\n\n\n我认为这是一个优点和缺点同样突出的作品，倒不像很多人觉得是烂尾，结尾至少情理之中可以接受。主要问题是在于塑造人物形象和完善背景设定之间的冲突，也就是标题里写的“不应该做 EVA，而应该做环太平洋”。看完之后我半开玩笑地和 @垚 说：“都怪庵野秀明，非要在巨大机器人动画里面加上一堆反乌托邦末世玄学宗教的背景设定，导致后来的巨大机器人动画不这么做就好像缺了点什么一样。”我其实对巨大机器人动画不算是专家（比如我显然没看过高达），但我觉得对 EVA 还算是熟悉。\n虽然我想说的问题是剧情方面的，不过还是要简单提一下作画。@垚 和我表示他一开始是奔着 TRIGGER 才去看的 DitF，结果看过之后对于动作场面大失所望。我其实也不是特别了解 TRIGGER，只是之前被他拉去电影院看了普罗米亚。一定要比较的话确实不管是美术风格还是动作场面都没有普罗米亚那么有特色，但我还是觉得至少在及格线以上了。我觉得特别出色的是机体的设计，不管是 EVA 还是环太平洋，机体设计都是偏向机械化的（虽然 EVA 内在是生物，但是外表仍然是机械），同时是男性化的设计。我还是头一次在动漫里看到女性形象和不是特别机械化设计的机体，而且甚至有丰富的表情，非常新鲜的同时也很符合设定（实际上动画里很多时候使用了 FRANXX 的形象代替女性寄驶员），属于是一个巨大的加分项。叫龙的设计也算新鲜，至少对我来说，看第一集的时候我明明期待的是出来一个传统的怪兽形象的，结果出来的是这么一种可以算是放飞自我的东西。虽然在逻辑上可能比较难以解释它的存在性，不过好在是动漫嘛，不需要在里面找现实。（话说回来了解我的朋友应该知道我最喜欢的敌人设计是 NieR: Automata 里面的机械生命体。）\n然后说到我最关注的剧情了，我始终认为作品的核心是剧情。而且剧情是很难把控的东西，特别是对于原创剧情的作品来说，把剧情写好真的是一种不多见的能力。在我看来 DitF 在人物形象和感情戏上达到了一个极高的高度。主要人物有很多，但是每个人的个性都很清晰，并且我没有觉得哪个人的性格令我讨厌。同时故事本身不是简单直白一眼看到头的类型。比如第 13 集将故事推向了高潮，不仅仅因为这一集本身讲述的内容非常感人，而且将整个剧情前半部分埋下的伏笔全部都衔接上了（我甚至差点以为剧情要按绘本发展走向 bad end）。贯穿全篇出现过不止一次的“比翼鸟”的比喻，也非常的符合主题。但我在看一些二创视频的时候还是能看到一些 2018 年的评论在说第 14 集的剧情是喂屎，我确实可以理解追更的朋友当初等了一周之后看到这些阴差阳错然后还要提心吊胆等上一周才能看到下一集的焦躁心情，但这一集的矛盾激化成功的在一个高潮之后推进了剧情的节奏同时与下一集的高潮形成对比，而且这一集的内容非常的合理，虽然是各种巧合，但又很符合现实，符合人物的心理和动机。至于其它一些风评不好的部分比如搭档交换的剧情，只能说是见仁见智，有人不喜欢无可厚非，我还是觉得这部分也增加了故事的复杂性。\n但是与塑造人物形象形成对比，完善背景设定方面我认为有比较严重的硬伤。24 集里面在前半部分简单介绍了一个可以说是反乌托邦的设定，然后大量的篇幅用来刻画人物之间的关系和人物细腻的心理活动，结果在感情戏达到高潮之后仿佛是编剧突然想起来“啊，我们挖了好大的坑还没填呢”一样，开始匆忙的填之前的坑。比如我到现在也没想通只在第 15 集里出现了一次的那个巨大的手到底是什么和有什么存在意义。比如叫龙公主在第 17 集开始有大篇幅的剧情之前几乎没有任何铺垫（你别告诉我第 15 集核心里掉出来的小人就算铺垫了），这和感情戏部分各种伏笔先放好然后再衔接完全不像是一部作品的风格，反正你让我看到前半段各种叫龙出现之后是想不到有这么一个个体的存在的。第 19 集通过介绍博士的角度介绍了人类向不死方向的发展，多少算是成功地填了一部分坑。然后整个作品似乎就陷入了“编剧发现还剩五集了填不完坑了于是开始放飞自我”的方向发展了，星实体是个什么东西？之前完全没铺垫过，现在强行在一集内塞给观众。鹤望兰·天燕座又是怎么来的？就算不在动漫里找现实，这也过于不符合逻辑了，看看别的叫龙是什么样子，它们怎么搞出这么个造型的啊。到底是博士发癫了还是编剧发癫了？然后可能是由于实在没办法了，机械降神一个最大反派叫做 VIRM，这已经不是硬伤了，这是直接一刀把脑袋砍掉从脖子往下截肢了。于是一个完全没有铺垫，思维及其简单，做事不讲逻辑的工具 boss 出现了……你可以告诉我打了半年的敌人并不是真正的敌人，但我不能容忍你用这么一种侮辱观众智商的方式告诉我这个事实。然后再一次放飞自我把科技水平拉到太空时代，说实在的，这个和反派登场比起来，已经到了我看见什么都不惊讶的程度了。然后再经过几集漫长的毫无必要的人类叫龙和 VIRM 的太空混战剧情终于结束了这种煎熬，我只能说这部分比起种田是完全的不讲道理了。\n至于最后大结局的“生孩子”剧情，想必也有很多人不满意，虽然我自己讨厌小孩，但我觉得这部分不是什么问题。可能是考虑到当下的现实环境，年轻人确实对催生比较反感吧，但放在剧情里面，作为一个新世界开始的必要环节实际上是没什么问题的。最后黑色头发的少年和樱花色头发的少女在树下相遇的结尾也是我最满意的部分之一了，或者可能我恰好是一个容易被这种剧情打动的人吧。\n另外 @垚 表示同样是 TRIGGER 的作品，普罗米亚的剧情要好很多，但我不是特别同意，我个人觉得普罗米亚最后 1/4 的剧情其实也向着强行收尾的方向走了，不过一部剧场版动画和季番还是不一样的，剧场版动画只有两个小时，不会有太复杂的背景设定，故事本身又是快节奏，就算强行推进一下剧情，观感也不会太差，而且独特的美术风格和配乐相当大程度的掩盖了剧情的问题。\n总而言之，我其实是不太在意“符合逻辑但是大部分观众都不喜欢”的剧情的，比起这个，我觉得“不符合逻辑”的剧情问题要严重得多，这说明剧情走向已经变成无法把控的东西了，为了强行在剩下的集数里面结束故事，不得不强行引入一些东西。如果你问我怎么修改剧情能解决掉硬伤，我其实一开始也没什么思路。不过和 @垚 简单聊了一下之后我意识到了问题所在。从感情戏的篇幅和水平来看，很显然巨大机器人战斗只是个载体，这应该是个披着机器人战斗的皮的爱情故事，并且爱情故事部分相当的成功。在我看来 DitF 的思维和 EVA 其实是有相当大的差别的，虽然可能大家总是津津乐道碇真嗣、绫波零、明日香、渚薰之间的情感关系，但 EVA 没有对这部分的直接描写，更多是通过侧面细节描写，以及粉丝进行的分析推理得出的。所以 EVA 可以在背景设定上挖很大的坑然后有充足的时间填坑。因此我的想法是让这个作品向环太平洋的方向靠近，去掉反乌托邦的设定，比如什么种植园和 APE 都可以不要，直接快进到不知道为什么出现了名叫叫龙的怪兽进攻人类，于是人类设计了 FRANXX 并要少年少女操作来防御。然后人物的背景全部都不需要修改，最终的结局就像环太平洋一样直接摧毁掉叫龙来源就可以了。虽然可能会被 EVA 观众认为“没有达到 EVA 的高度”，但既然本来就不在一个赛道上（我这写的是爱情故事啊），这也没什么所谓了。考虑到第 13 集和 15 集的口碑，这样改应该不会折损它的优点。\n最后还是要说，虽然有这么明显的硬伤，这部作品突出的优点还是让我受到了很大震撼并且在接下来的一周都沉浸在剧情里不能自拔。我个人也非常喜欢这部作品的 ED，无论是旋律还是歌词，以及演唱方面都可以说是一流的作品，特别是第 13 集高潮部分的《ひとり》，单从音乐和剧情结合的角度来说，确实达到了 EVA 的高度（让我想起来《翼をください》。《トリカゴ》也是绝妙的作品，特别是伴随 ED 出现的画面，“如果这些人物所在的是一个没有叫龙和 FRANXX 存在的世界会是什么样子呢？”（很遗憾我对 OP 没什么感觉）我同样对广、02、五郎和莓的人物形象非常的喜爱，复杂的情感关系使得这些人物变得颇为立体，而且他们完全没有动漫里一些经常出现的会让我讨厌的人物特质。\n"},{"title":"PHP 故释","url":"/posts/PHP-Story/","content":"// 新来的！如果你看到这段注释，说明上一个负责重构这个项目的程序员已经被气死了！\n// 请你把下一行的数字加一，然后祝你好运！\n// 63\n\n起因是昨天晚上吃完饭回家路上和铁道迷闲聊说起他正在重写的 PHP 项目。于是我随口编了一个 恐怖 段子。\n\n\n为什么是 63：\n Math.floor(Math.random() * 100)\n63\n> \n\" class=\"code-block\">% node\nWelcome to Node.js v18.7.0.\nType \".help\" for more information.\n&gt; Math.floor(Math.random() * 100)\n63\n&gt; \n\n"},{"title":"可能只适合我自己的 RIME 配置 2","url":"/posts/My-RIME-2/","content":"上一篇：可能只适合我自己的 RIME 配置\n这一篇的原因是我最近在偶然间刷博客刷到一篇 讲 RIME 简体输入方案的文章，里面提到说朙月拼音因为是繁体转简体所以会出现各种错误（其实我个人倒是没怎么遇到过），然后推荐了一个完全针对简体字的输入方案 极光拼音，我自己其实只会输入简体字，不怎么需要输入繁体字的功能，所以打算试试。\n\n\n已经有人在 AUR 打包了 rime-aurora-pinyin，所以我直接拿来用了，然后类似于我上一篇文章处理朙月拼音的办法，给这个也做了一些自定义设置，主要是添加 emoji，修改默认的全角标点上屏行为，以及加载扩展过的字典，不过遇到了几个问题。\n首先是我像上篇文章说的那样直接在 patch 下面添加 __include: emoji_suggestion:/patch 并不能输入 emoji，我研究了很长时间，甚至以为 emoji 功能依赖繁体转简体。结果其实并不是，打开 emoji_suggestion.yaml 可以看到下面几句：\npatch:\n  switches/@next:\n    name: emoji_suggestion\n    reset: 1\n    states: [ \"🈚️️\\uFE0E\", \"🈶️️\\uFE0F\" ]\n  'engine/filters/@before 0':\n    simplifier@emoji_suggestion\n  emoji_suggestion:\n    opencc_config: emoji.json\n    option_name: emoji_suggestion\n    tips: all\n\nswitches 的部分可以先忽略，关键在于 engine，这个 emoji 输入的原理是添加一个 filter，它接收一个输入，然后去附带的 opencc 的词典里查找这个输入得到对应的结果，再把这个输出给下一个 filter，按照词典，输入应该是中文字或者词，并且我看了一下词典，简体和繁体是都有的，所以也不存在简繁转换的问题。其实问题在于这段配置会把它作为第一个 filter 加入列表，而极光拼音的默认 filter 列表是这样的：\n  filters:\n    - uniquifier\n    - charset_filter@gb2312\n    - charset_filter@gbk\n\n也就是说如果把 emoji 的 filter 加到第一个，它的输出就要继续经过 uniquifier，charset_filter@gb2312，charset_filter@gbk，后两个是极光拼音为了排除掉几乎用不到的生僻字而添加的。而 emoji 显然不属于 gb2312 也不属于 gbk，自然就被过滤掉了。\n所以我的解决方案是把 emoji 的 filter 加到列表最后，其实加到哪里无所谓，只要你确定前一个 filter 的输出是中文，能触发 emoji 的 opencc 词典就好了，我单独写了一个 emoji_suggestion.patch.yaml 文件：\nswitches/@next:\n  name: emoji_suggestion\n  reset: 1\n  states: [ \"🈚️️\\uFE0E\", \"🈶️️\\uFE0F\" ]\nengine/filters/@next: simplifier@emoji_suggestion\nemoji_suggestion:\n  opencc_config: emoji.json\n  option_name: emoji_suggestion\n  tips: all\n\n导入的时候就写 __include: emoji_suggestion.patch:/。不过虽然我这个不再需要原来的那个 YAML 了，还是需要 rime-emoji 这个项目里其余的文件的。\n顺便这也解释了为什么使用朙月拼音时候 emoji 后面的提示框显示的是繁体而非简体，因为朙月拼音从词库直接吐出来的是繁体，然后直接经过第一个 filter 就是 emoji，自然 emoji 查找时候用的就是繁体，然后才会经过简繁转换的 filter，所以如果把 emoji 的 filter 挪到简繁转换的 filter 后面，提示就会变成简体。\n解决了 emoji 问题之后还有另一个问题，因为这个 emoji 的 filter 的输入是中文词组，也就意味着必须词库能吐出对应的中文词才能输入 emoji，比如说刚配置出来极光拼音的时候是吐不出来“笑哭”这个词的，所以就不会触发笑哭的 emoji。据说其它平台的输入法也有这个问题。其实没什么太好的解决方案，你可以说自己先手动打几次对应的词然后等 RIME 记住这个输入，不过我觉得也不太好。我想到的办法是既然需要词库里有，不如就让我用 emoji 的 opencc 词典生成一个 RIME 词库，然后扩展词库的时候加进去，这样无论如何都能吐出来了。其实也不是很麻烦，但是需要你把文字转成对应的拼音，那当然不能人工做这个操作了，我利用 Node 的 pinyin 库写了个脚本来做这件事：\n {\n    return line.length !== 0;\n  }).map((line) => {\n    return line.split(&quot;\\t&quot;)[0];\n  });\n  for (const w of words) {\n    // rime-emoji 的 opencc 词典同时包含简体中文和繁体中文，但比如极光拼音\n    // 这种默认不包含简繁转换的方案多半只想要其中一种，所以使用 opencc 对候选词\n    // 进行一次转换。\n    const word = converter.convertSync(w);\n    if (results[word] != null) {\n      continue;\n    }\n    const py = pinyin(word, {\n      &quot;heteronym&quot;: true,\n      &quot;segment&quot;: true,\n      &quot;style&quot;: &quot;normal&quot;\n    }).map((array) => {\n      // 有些时候就算利用结巴分词了，这个库仍然会没法判断多音字的读音然后丢出好\n      // 几个结果，只取第一个好了。\n      return array[0];\n    }).join(&quot; &quot;);\n    // 遇到处理不了的生僻字这个库会直接丢出原本的字……什么奇怪逻辑，只能判断是不\n    // 是字母或空格了。\n    if (/^[a-z ]*$/.test(py)) {\n      results[word] = py;\n    }\n  }\n}\n\nconst outputLines = [\n  &quot;# Rime dictionary for emoji&quot;,\n  &quot;# encoding: utf-8&quot;,\n  &quot;# Generated by `gen-emoji-dict.js` written by Alynx Zhou&quot;,\n  &quot;&quot;,\n  &quot;---&quot;,\n  &quot;name: emoji_suggestion&quot;,\n  &quot;version: \\&quot;0.1\\&quot;&quot;,\n  &quot;sort: by_weight&quot;,\n  &quot;...&quot;,\n  &quot;&quot;\n];\nfor (const k in results) {\n  outputLines.push(`${k}\\t${results[k]}`);\n}\n// console.log(outputLines.join(&quot;\\n&quot;));\nfs.writeFileSync(outputFileName, outputLines.join(&quot;\\n&quot;), &quot;utf8&quot;);\n\" data-info=\"language-javascript\" data-lang=\"javascript\" class=\"code-block\">#!/usr/bin/env node\n\nconst fs = require(\"fs\");\nconst OpenCC = require(\"opencc\");\nconst {pinyin} = require(\"pinyin\");\n\n// 我的词库只需要简体中文，如果你需要繁体中文，把 `t2s` 改成 `s2t` 应该就好了。\nconst converter = new OpenCC(\"t2s.json\");\n\nconst outputFileName = \"emoji_suggestion.dict.yaml\";\n\nconst inputFileNames = [];\n\nif (process.argv.length &lt;= 2) {\n  console.log(`Usage: ${process.argv[1]} file1 file2 ...`);\n  process.exit(0);\n}\n\nfor (let i = 2; i &lt; process.argv.length; ++i) {\n  inputFileNames.push(process.argv[i]);\n}\n\nconst results = {};\n\nfor (const inputFileName of inputFileNames) {\n  const words = fs.readFileSync(\n    inputFileName, \"utf8\"\n  ).split(\"\\n\").filter((line) =&gt; {\n    return line.length !== 0;\n  }).map((line) =&gt; {\n    return line.split(\"\\t\")[0];\n  });\n  for (const w of words) {\n    // rime-emoji 的 opencc 词典同时包含简体中文和繁体中文，但比如极光拼音\n    // 这种默认不包含简繁转换的方案多半只想要其中一种，所以使用 opencc 对候选词\n    // 进行一次转换。\n    const word = converter.convertSync(w);\n    if (results[word] != null) {\n      continue;\n    }\n    const py = pinyin(word, {\n      \"heteronym\": true,\n      \"segment\": true,\n      \"style\": \"normal\"\n    }).map((array) =&gt; {\n      // 有些时候就算利用结巴分词了，这个库仍然会没法判断多音字的读音然后丢出好\n      // 几个结果，只取第一个好了。\n      return array[0];\n    }).join(\" \");\n    // 遇到处理不了的生僻字这个库会直接丢出原本的字……什么奇怪逻辑，只能判断是不\n    // 是字母或空格了。\n    if (/^[a-z ]*$/.test(py)) {\n      results[word] = py;\n    }\n  }\n}\n\nconst outputLines = [\n  \"# Rime dictionary for emoji\",\n  \"# encoding: utf-8\",\n  \"# Generated by `gen-emoji-dict.js` written by Alynx Zhou\",\n  \"\",\n  \"---\",\n  \"name: emoji_suggestion\",\n  \"version: \\\"0.1\\\"\",\n  \"sort: by_weight\",\n  \"...\",\n  \"\"\n];\nfor (const k in results) {\n  outputLines.push(`${k}\\t${results[k]}`);\n}\n// console.log(outputLines.join(\"\\n\"));\nfs.writeFileSync(outputFileName, outputLines.join(\"\\n\"), \"utf8\");\n\n当然这个脚本不是很完美，比如 pinyin 识别不了的生僻字直接忽略了，不过我觉得它都识别不了，我多半也不会打出来的。然后虽然可以利用 jieba 分词提高多音字的准确性，还是有些不正确的，这些遇到了再手动纠错吧。\n最后把这个词库添加进扩充词库：\n# 原来要结合默认词库和第三方词库，\n# 需要自己编写一个词库让它 fallback 到极光拼音和第三方词库。\n# 我说佛老师对不起对不起，我不懂规矩。\n---\nname: aurora_pinyin.extended\nversion: \"0.1\"\n# `by_weight`（按词频高低排序）或 `original`（保持原码表中的顺序）。\nsort: by_weight\n# 听说默认简化字八股文效果不好，还是算了。\n# https://blog.coelacanthus.moe/posts/tech/a-new-rime-simp-pinyin-schema/\n# 因为导入的朙月拼音词库是繁转简，所以这里不能导入简化字八股文。\n# 导入简化字八股文。\n# vocabulary: essay-zh-hans\n# 选择是否导入预设词汇表【八股文】。\n# use_preset_vocabulary: true\n\nimport_tables:\n  # 主要是为了肥猫 wiki 词库。极光拼音好像是内置常用简化字表的。\n  - zhwiki\n  - aurora_pinyin\n  - emoji_suggestion\n\n顺便说一下我其实也不太了解这个扩展词库的顺序怎么设置比较好，不过我尝试的结果是像这样把 emoji 放在最后面，就不会每次输入在前面提示很多并不常用的 emoji 词组的问题。\n我这个脚本生成的词库只有简体，不过我发现朙月拼音的简繁转换还是可以正常处理简体词库的，也就是说会变成 词库出简体 -&gt; 简繁转换 -&gt; 繁体变 emoji，所以直接加给朙月拼音也没问题，如果我需要用繁体中文，可以直接切换方案到朙月拼音（虽然实际上我的配置是简化字版，不过看起来主要区别只是默认是否开启繁体转简体）。平时输入简体则直接用极光拼音。\n完整配置在 GitHub Repo 更新。\n"},{"title":"阻止 clangd 污染项目根目录的一些方法","url":"/posts/Prevent-clangd-from-Making-Projects-Root-Dirty/","content":"Emacs 的 lsp-mode 推荐使用 clangd 分析 C/C++ 代码，用起来体验还不错，但是让人非常恼火的是用户要主动或者被迫地在项目根目录下面添加一些文件，比如 .clang_complete 或者 compile_commands.json 来让 clangd 知道项目需要包含哪些库的头文件，以及 clangd 会直接把建立的索引丢到项目根目录下面的 .cache 目录里。虽然可以把这些加入 .gitignore，但保不齐哪个脾气古怪的上游维护者会和你纠缠半天让你解释为什么要加这些，实在是很麻烦。\n\n\ncompile_commands.json 比较好解决，clangd 提供了一个参数 --compile-commands-dir=&lt;string&gt; 可以指定查找这个文件的目录，我直接把它设置为 ./build/，因为大部分项目的 .gitignore 都会包含构建目录，也免得运行 bear -- meson compile 之后再把这个文件从 build 目录移出来。\n(use-package lsp-mode\n  :ensure t\n  :commands lsp\n  :hook ((c-mode . lsp-deferred)\n         (c++-mode . lsp-deferred)\n         (c-or-c++-mode . lsp-deferred)\n         (lsp-mode . lsp-enable-which-key-integration))\n  :bind (:map lsp-mode-map\n              (\"M-.\" . lsp-find-definition)\n              (\"M-,\" . lsp-find-references))\n  :custom\n  ;; Move lsp files into local dir.\n  (lsp-server-install-dir (locate-user-emacs-file \".local/lsp/\"))\n  (lsp-session-file (locate-user-emacs-file \".local/lsp-session\"))\n  (lsp-keymap-prefix \"C-c l\")\n  ;; Only enable log for debug.\n  ;; This controls `*lsp-log*` buffer.\n  (lsp-log-io nil)\n  ;; JavaScript (ts-ls) settings.\n  ;; OMG, the FUCKING EVIL SHITTY VSCode TypeScript language server generates\n  ;; log in project dir, can MicroSoft stop to let their software put shit in\n  ;; front of users?\n  (lsp-clients-typescript-server-args '(\"--stdio\" \"--tsserver-log-file\" \"/tmp/tsserver-log.txt\"))\n  (lsp-javascript-format-insert-space-after-opening-and-before-closing-nonempty-braces nil)\n  ;; Always let clangd look for compile_commands.json under build dir so it will\n  ;; not make project root dirty.\n  (lsp-clients-clangd-args (\"--header-insertion-decorators=0\" \"--compile-commands-dir=./build/\" \"--enable-config\")))\n\n对于 .cache/ 就不是那么好解决了，根据 https://github.com/clangd/clangd/issues/341#issuecomment-1003560792，似乎他们并没有关闭或者修改缓存目录的支持。不过我想到一个弯道超车的方案，git 本身应该是有从其它位置加载用户定义的 gitignore 文件的功能的，我利用这个写一个本地的 gitignore 不就行了吗，搜索之后得到 https://stackoverflow.com/questions/5724455/can-i-make-a-user-specific-gitignore-file，操作起来也很简单。首先我把这个文件放到 ~/.config/git/gitignore，里面写上要忽略的 glob，然后运行 git config --global core.excludesfile ~/.config/git/gitignore 就大功告成。\n不过就在我写这篇文章时，clangd 的 issue 上有人回复我，根据 https://github.com/clangd/clangd/issues/184#issuecomment-998244415，现在 clangd 应该是会把索引放在 compile_commands.json 所在的目录，所以多少也算是解决了问题吧。虽然这样删掉构建目录之后索引缓存也没了，不过我觉得比起重建缓存，还是弄脏项目目录更恶心一点。\n更新（2022-08-17）：还有一个头疼的问题是 GLib 的 g_clear_pointer 宏里面使用到了对指针本体取 sizeof 的语法，而 clangd 默认会认为这是个错误，于是 lsp 就会标出一大堆问题。可以对项目进行设置关掉这一条，不过又会弄脏项目目录，查询文档得知 clangd 会读取 ~/.config/clangd/config.yaml 这个用户级别的配置文件，于是在里面写入内容关掉这条检查：\nDiagnostics:\n  ClangTidy:\n    Remove: bugprone-sizeof-expression\n\n然后给 clangd 传递 --enable-config 这个参数即可。\n"},{"title":"从 PulseAudio 到 PipeWire","url":"/posts/From-PulseAudio-to-PipeWire/","content":"这篇的操作是在之前 运行在 JACK 上层的 PulseAudio 基础上进行的。\n我自己的音频配置比较复杂，虽然 PipeWire 号称能兼容 PulseAudio 和 JACK 的 client 并且在某些发行版成为了默认选项，我还是没很快换掉。因为我想像之前用 PulseAudio 那样把 PipeWire 做成 JACK 的 client，虽然它的文档一直说支持这样，但是看起来两个月前代码才写好。于是我最近尝试了一下。\n\n\n首先需要安装 pipewire，pipewire-alsa，pipewire-pulse 和一个 PipeWire Media Session Manager，我用的是新的 WirePlumber 但是不要装 pipewire-jack 因为这个是模拟 JACK server 的。然后配置你的 session manager 开启 alsa.jack-device = true，然后理论上就能在设置里看到 JACK Sink/Source 了……但是……\n首先装 pipewire-pulse 会替代 pulseaudio，但是按照上篇文章应该是安装了 pulseaudio-jack 这个依赖 pulseaudio 的包，解决方法是先卸载掉 pulseaudio-jack。然后继续安装重新登录应该 PipeWire 已经起来了，按理说这时候启动 JACK 就可以，但是不管我怎么搞都看不到 JACK Sink/Source，所以就备用方案，直接用 PipeWire 替代 JACK 看看，虽然早就可以这么做了，但是之前之前尝试 PipeWire 感觉不是那么稳定，所以就没一直用。\n使用 PipeWire 当 JACK 的话要安装 pipewire-jack，和前面差不多的问题是 pipewire-jack 替代 jack2 但是 jack2-dbus 依赖 jack2，那就先删掉 jack2-dbus 再装就好了，然后重新登录，一切正常，Qjackctl 的 Graph 也能正常操作。而且比较有趣的是这样原本使用 PulseAudio 的程序也会在 JACK Graph 里面显示成节点（因为最后都通过 PipeWire），使用 Ardour 录音也没什么问题。其实我的需求还是比较简单的，也不需要什么太低的延迟，只是很多录音的程序都用 JACK 所以才要用。\n用了一段时间之后感觉没什么问题，设备之间来回切换也没有卡顿了，驱动我的 2i4 也是完全正常，以后应该就先这样用了。\n更新（2022-07-21）：我发现在 Ardour 里面录音还是有问题，具体表现是录超过 1 分钟就会报 xrun，怀疑是这个 bug：https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/2257。不过其它 DAW 比如 REAPER 或者 Zrythm 都没问题，我倒是挺想换成 Zrythm 的，但是它还在 Beta 阶段。以及前面板插入耳机似乎设置里检测不到，但是开启一下 pauvcontrol 又能检测到了，总之是一些奇怪的小问题，也许我应该换回去。\n更新（2022-07-21）：我把我的 Sony Playstation Eye 拔掉之后似乎 Ardour 就正常了……我记得这个摄像头的麦克风阵列以前可以用的，不知道为什么现在 PulseAudio 都用不了了，所以看起来不是 PipeWire 的问题。至于为什么只有 Ardour + PipeWire 会出现这个问题，我猜是因为 Ardour 会连接所有可用的设备给自己用，于是就被这个不工作的设备影响出现延迟，而原版的 JACK 只会请求一个设备，Ardour 根本就看不到 PS Eye。也许我还是得买个正经的摄像头……\n"},{"title":"装了台 NAS","url":"/posts/Build-My-NAS/","content":"本来我是不打算装 NAS 的，甚至都把我的星际蜗牛关了，因为我觉得我又没有网络多人协作的需求，而且我醒着的时候我的台式机也醒着，我睡着了又不会用到网络存储。不过自从我买了相机开始拍照片录视频，存储空间就越来越紧张了，先是把我机箱里的硬盘从 2T 直接升级到 8T，又觉得没有冗余始终心慌慌。偏偏我现在这个机箱哪里都不错，就是机械硬盘位不太充裕。为了扩展存储空间，也只能装一个 NAS 了。\n\n\n决定好装 NAS 之后比较难的就是选硬件了，我肯定不会买那些闭源拖拉机的，我要装 Linux。肯定排除掉星际蜗牛，因为我实在不放心用那个背板带四块硬盘。然后对这种低功耗的设备用那种主板和 CPU 集成的赛扬应该不错，还免了主动散热，问题是我不知道在哪里能买到。所以还是考虑普通零售的硬件，不过大部分零售的硬件都有点性能过剩了，挑来挑去挑出下面的一套配置，比较个人倾向，不建议大家直接拿过来用。\n\nCPU：i3-10105T 645\n主板：七彩虹 B460I 579\n机箱：乔思伯 N1 669\n电源：银欣 SX500-LG 549\n散热：利民 AXP90-X47 139\n内存：光威 8G DDR4 2666 169x2\n网卡：EDUP PCI-E 2.5Gbps 网卡 89\nSSD：闪迪 至尊高速 NVMe 256G 259\nHDD：西数 HC550 16T 1498x4\nHDD：希捷 酷鹰 2T 375\n扩展：乐扩 M.2 A+E Key 转 SATA 75\n\n下面简单介绍一下为什么选这套。\n首先装 NAS 第一个要决定的不是别的而是硬盘，毕竟你这设备就是拿来放硬盘的，因为台式机里面已经有 8T 的硬盘了，我也不想再经历换硬盘时候拷贝数据的痛苦，所以还是直接上了 16T 的。这种容量建议直接购买企业盘，不过也就和静音说拜拜了。没选希捷的银河系列不是因为别的，只是因为西数 HC 系列便宜点而已。选 4 块盘而不是 3 块的原因也很简单，我不会用 ZFS，肯定要用 btrfs，但是 btrfs 的 RAID5 有 bug，于是性能容量冗余的平衡点就是 RAID10 了，那就需要 4 块硬盘起步。\n决定好硬盘之后就得选合适的机箱，毕竟你得能把硬盘装进去，不过我对大部分的 NAS 机箱都不满意，有些类似个人产品或者众筹的机箱比如什么宝藏盒TANK之类的看起来很能装，但是造型不伦不类，做工也一般般，然后可能迎广或者万由有一些成品 NAS 机箱看起来不错，但是基本上是高配蜗牛，而且我实在不想用 flex 电源。其实某种意义上我是先看中乔思伯 N1 这个机箱才决定装的 NAS，我对这个牌子其实没什么好感，但这个机箱实在是过于好看，而且可以装 SFX 或者 SFX-L 电源，还是有很多零售产品可以选的。\n选好机箱以后就是机箱里能塞进什么硬件就塞什么硬件了，乔思伯 N1 和大部分 NAS 机箱一样都只能放 ITX 主板，但 ITX 主板可选的实在不多。首先我想要一个内置核显的 CPU，因为最简单的调试办法肯定是给机箱接上键盘鼠标显示器，也比较方便调整 BIOS，而 ITX 只有一个宝贵的 PCIE 插槽，再说 NAS 多半也没必要上个亮机卡耗电。而 AMD 那边 200GE 太弱了，Ryzen Pro 系列又太强了，实在不知道选什么比较好，而且也不容易买到最低配的 Ryzen Pro CPU，所以就选 Intel i3 了，正好还有带 T 的低功耗版本。同时 10 代的主板还比较好买，也就是说能买到新的。虽然这台机箱前面有 USB-C，不过实际上是和 USB 3.0 共享一个插座，所以也不需要主板有 USB-C 的插座。七彩虹这块 B460 的优点在于后面还是有一个标准的 USB-C 接口，以及能在京东自营买到。\nSFX 电源虽然不如 ATX 电源好选，但是仍然比 flex 电源好选太多了，而且这台机器还可以放下 SFX-L 的电源，12cm 的风扇肯定比 4cm 要安静很多。于是我就在京东随便挑了一个观感不坏的牌子的全模组 SFX-L 电源里功率最低的。不过装这个机箱的时候最好注意一点，买的电源的 24pin 线最好不要是那种捆成圆柱型的，因为需要从主板边上极小的缝隙里拉过来，排线肯定比圆柱线要容易过，银欣这款是排线。当然你是定制模组线的有钱人当我没说。\n这款机箱最高可以支持 70mm 的下压散热，所以其实完全可以买利民那款 12cm 的散热器，不过我觉得也无所谓了，我比较担心 12cm 的下压散热会导致在主板上装内存和 SSD 比较困难，9cm 的也够用了。\n内存其实是随便选的，因为我不用 ZFS，其它文件系统对于 ECC 也没那么严格的依赖。然后 i3 和 B460 也不会支持更高的频率了。\n考虑到我的台式机上有两个网口，分别是 1Gbps 和 2.5Gbps 的，我就在想可不可以把 NAS 和台式机通过 2.5Gbps 直接连起来（路由器什么时候普及 2.5Gbps 啊），这样速度基本上和访问本机的机械硬盘没有太大差距。但是主板厂家实在是太抠门了，本来 ITX 主板型号就不多，内置 2.5Gbps 网卡的就更少了。虽然让一个 2.5Gbps 的网卡占着 PCI-E x16 插槽实在是有点浪费，但也没别的选择了。至于万兆，考虑到要使用专门的线和专门的网卡，还得给台式机也装一个，算了吧，反正机械硬盘的阵列也跑不满万兆。\n然后系统盘只要随便搞一个 NVMe 就可以了，反正安装一个 Arch Linux 不会占用多少空间。实际上比较头痛的是怎么接上所有的硬盘。这款机箱有 5 个在背板上的 3.5 寸硬盘位和一个单独的 2.5 寸硬盘位。大部分 ITX 主板都只有 4 个 SATA 接口，本来我觉得接 4 盘的阵列够了，但是后来我发现北邮人 pt 可以使用我的交大学校邮箱注册……要是挂 PT，我觉得还是单独放一块硬盘比较好，那接口就不够用了。比较靠谱的办法是买 HBA 卡把 PCI-E 转成 SATA，但是已经被 2.5 Gbps 网卡用掉了。我还没有发现同时有网卡和 SATA 的 PCI-E 扩展卡。还有一个方案是 M.2 转 5 个 SATA 的转接卡，其实倒也可以用一块 2.5 寸的 SSD 当作系统盘，然后解放出这个 M.2 接口，不过我实在不放心把 4 盘阵列放在这种转接卡上……而且我估计系统盘也是不能接在转接卡上的。当然你可以买一块有 2.5 Gbps 网卡的 ITX 主板，不过那基本只有 Z590 可选了，最便宜的华擎 Z590 ITX 也要 1400。所以我最后采用的方案是放弃掉无线网卡，反正 NAS 摆在路由器附近可以拉网线，然后把无线网卡下面那个 A+E Key 的 M.2 接口利用上。有这样的转接卡，可以转接出两个 SATA 口，虽然我只要一个就够了。\n不过当我拿到转接卡的快递之后发现安装还是有点麻烦。最好的办法是拧下无线网卡之后发现那个保护罩可以拆掉一侧的面板，然后可以把无线网卡从保护罩上拿下来再换成转接卡。但是那个固定无线网卡的螺丝实在太紧了我拧不下来，只能放弃这个保护罩，反正转接卡应该也不会受到什么外力，就在我把转接卡插好打算开机之前，我发现还是有点问题……虽然 SATA 接口已经够矮了，但是里面那个用不上的 SATA 接口还是顶住了前面的音频接口，导致转接板是歪的。其实可以淘宝再买一个 M.2 A+E Key 的延长线，不过那样我纠结的就是把延长出来的转接卡固定在哪里了。不管了！反正我就要一个 SATA 口，用刀把里面的 SATA 口的顶端削掉吧！虽然削的很粗糙但是还是达到了效果，然后用一块胶带把那个口粘住，防止 SATA 接口里面的金属触点接触到音频口保护壳而短路，再把一块泡沫放到转接板后面顶住转接板，我觉得就差不多了。\n直接装上是歪的：\n\n“高个的 SATA 接口被我给锯了，比矮个的还矮！”：\n\n“磨”改之后的效果，应该没问题吧：\n\n最后就是绞尽脑汁把各种线塞到 ITX 机箱里面，实在是太痛苦了。对了，这个机箱自带的风扇接线不是很长，而它那个硬盘背板上的插针又是满速的，如果你要是想插到主板上进行调速，最好自备一根 4pin 风扇延长线。\n剩下就没什么好说的了，毕竟我不打算在这里复述一遍 Arch Wiki。我对 btrfs 使用的参数是 -m raid1c3 -d raid10，然后在系统里设置了 samba 和 transmission 的daemon。最后附上一些装完的照片。\n\n\n\n"},{"title":"用 Telegram 机器人做后端的照片墙","url":"/posts/Telegram-Bot-as-Gallery-Backend/","content":"杜洛夫叔叔我啊，最喜欢 TON 了。\n\n其实倒也不一定是 Telegram 机器人，只是我比较熟悉这个的 API，不过看杜洛夫叔叔自己打自己脸的架势甚至比起陈叔叔有过之而无不及，说不定哪天我真的可能把这个服务转移到别的上去，反正原理都是类似的。这篇文章只是记录一下这个点子的来源和形成过程，其实实现起来没什么技术难度。\n\n\n我平时总是强迫自己多拍点照片，因为拍照这种东西如果不强迫自己，那就越来越懒最后干脆就不拍了。虽然我没什么审美也没什么技术，不过数量加运气还是能得到一些不错的照片。我试图把这些照片分享到公共平台，不过考虑到我自己的口味问题，有点难办。平时我一般发微信朋友圈然后点击同步到 QQ 空间，这样熟人就可以看到了，不过这不能算是公共平台吧。而微博这种垃圾平台，如果不是猫日在上面更新，我大概也早就不用了，所以是断然不可能发的。很多国内摄影会发小红书，但是我也比较讨厌这个平台，而且它似乎只有手机 APP 没有网页版。抖音我也没什么好感。B 站动态我倒是挺愿意发一发的，不过叔叔的技术水平实在是拉中拉，动态连个指定页码翻页功能都没有。然后考虑国外的社交平台的话，其实也没什么意思，这里我要特别提一下好多名人明星和摄影师喜欢的 Instagram，我真的不能理解这东西是怎么火的，手机客户端一点也不 Native，既不是 Android 风也不是 iOS 风，而且感觉功能比 Twitter 差多了，至于网页版，点开图片显示的窗口比手机屏幕还小，而且 2022 年了，抖音网页版都能不登录就访问，Instagram 网页版还一定要我登录。然后我想退而求其次我能不能利用现有的 API，写个 Telegram 机器人替我定期发图，我只要把图发给 Telegram 机器人就好了。我见过很多这样的 Twitter 机器人。不过问题还是一样，国内平台只想从真人用户身上薅钱，根本不在乎什么技术相关的东西，也不会给你做公开 API。国外平台的话，我还是更想给中文环境的用户看吧，所以也不合适。\n其实我不是没想过直接发在我自己的博客上，但是遇到了一些问题，首先博客是以文章为中心的，一篇文章要有标题，但是我想的就是发图然后配几句话或者根本不配（您 pay 吗？），要是单独作为一个 page，那这个 page 可能会很长。我也考虑过新建一个站点然后做一个特化的主题用生成器生成，不过这有点杀鸡用牛刀的意思了。再之后的问题是写博客我会写很长的文章，所以我肯定是在电脑上编写，但我发照片可能是心情到了就发，显然用手机更方便，要是为了这个再写一个网页的发图后台有点麻烦了。所以干脆综合一下想法，跑一个 Telegram 机器人作为后台，每次收到我发的图就自动建立目录然后下载进去，再生成静态网页就可以了。正好我之前从蓝猫那买了一个树莓派 4，但是不知道有什么用，就让它来跑这个。\n机器人的部分其实不难，需要注意的也就是限制能交互的用户名，以及只处理私聊的文本和图片消息。其实理论上可以做到每次发带字的图片组就发一条，但是考虑到扩展性我没这样做，而是使用命令处理了（而且从 API 上图片组也不是一条消息，而是多条消息带有同样的组标识）。我的做法是搞了一个状态机，进入 create 状态，收到的图片消息就会被加入图片数组，文本消息则会被连接起来，然后通过状态转移可以进入不同的状态，比如进入 authors 状态就会把收到的每条消息作为一个作者名字放进作者数组，然后最后执行 commit 命令让机器人以时间戳作为名字创建目录，然后把图片下载下来，把图片数组作者数组文本都作为键值对写入这个目录的 index.json，这样我们就既有图片又有元数据了。然后让机器人调用生成用的脚本。同样还有 cancel 命令可以取消未 commit 的操作，以及超时之后自动取消。同时为了方便还有 delete 状态，每条消息都会被作为要删除的目录的名字，commit 之后一起删除。当然时间戳有一个需要注意的地方，就是 UNIX 纪元之前的时间是负数，如果直接转成字符串创建目录，就是以 - 开头的，在 shell 里面会比较难处理（因为命令行程序会把 - 作为参数开始的标记，这不是 shell 负责的，所以就算你反斜杠转义或者加引号也没用，一个通俗约定是在这样开头的字符串前面加一个 -- 的参数，意思是让程序不要把后面的字符串当作参数列表处理），简单地办法是替换掉这个符号就好了，比如我换成 n（但是真的会有人穿越到 1970 年之前用这玩意吗？）。对于 Telegram Bot API 还有需要注意的地方，就是图片下面带的文字并不是在 text 键下面，而是在 caption 下面，不过处理的代码倒是可以复用。调用生成的脚本的时候也要注意工作目录，这个过程完全是处理磁盘文件，目录很重要。\n压缩图片的解决方案其实非常简单：用 Telegram 发图片的时候不就自动压缩图片了吗！虽然作为访问者，我常常希望图片网站提供原图下载，不过当我自己 host 一个图片网站的时候，我觉得还是不要传原图好了，毕竟我是放在 GitHub 上，有容量限制，而且就算没有容量限制，原图访问起来太慢了，体验也不会特别好，反正实在不行如果有访问者真心喜欢，让他发邮件联系我就行了。\n一开始其实我觉得这个网页应该会比较简单，所以干脆把构建网页的代码也写进机器人好了，不过群友建议还是分开，只是把 Telegram 机器人作为数据来源，这样以后也好添加别的数据来源，我觉得合理。构建网页其实很简单了，读取所有的图片目录的数据，排序，渲染进网页里，你可以用各种方式解决这个问题，比如现代的双向绑定框架 React 或者 Vue，比如传统的模板系统 EJS 或者 Nunjucks，但是我不想引入别的依赖了，所以做了一个投机取巧的办法，构建的时候读取各个目录的 json，然后得到一个大的数组，给它分页，写进不同的 json 里面，前端的网页通过 JavaScrip fetch 去获取这个作为索引的 json，把实际的内容渲染进元素里面（其实就是不用 React 的单页应用了）。然后页码其实就是处理 QueryString 里面的 p 参数，这个也可以用 JS 解决。于是我只要构建好之后把这个页面 push 到 GitHub 开启 Pages 就行了。（其实如果实在讨厌前端跑 JS 的话，后端代码里直接拼 HTML 字符串也行嘛，不过我是懒得改了。）\n\n前端的部分没什么好说的，怎么排版照片是个问题，我参考了 https://css-tricks.com/adaptive-photo-layout-with-flexbox/，这个效果很不错，不过评论里指出可以用 CSS 伪元素占据最后的空白空间，我也这么实现的。\n后端（机器人）的代码在 https://github.com/AlynxZhou/image-collector-bot/, 前端的代码在 https://github.com/AlynxZhou/azgallery/，感兴趣的朋友也可以自己搞一个玩玩，如果你想看最后的成果，这个博客的菜单栏里面就有照片墙的链接了。\n"},{"title":"我如何在 Emacs 里面处理缩进宽度和 Tab 宽度","url":"/posts/How-I-Handle-Indent-Offset-and-Tab-Width-in-Emacs/","content":"\nTabs are 8 characters, and thus indentations are also 8 characters. There are heretic movements that try to make indentations 4 (or even 2!) characters deep, and that is akin to trying to define the value of PI to be 3.\n-- Linux Kernel Coding Style\n\n当然，我不是在任何情况下都同意上面那句话，虽然在写 C 的时候它是绝对的真理。我个人倾向于取其中一部分——任何试图把 Tab 宽度定义为 8 以外的行为都无异于把 PI 定义为 3。\n\n\n写这篇文章是因为我意识到一个问题：Tab 宽度和缩进宽度是两个无关的变量。很多人简单地把缩进宽度理解为 Tab 宽度，不过这不能怪他们，因为大部分的编辑器都把这两者当作相同的东西。而当我重新开始用 Emacs，才发现这两个本来应该是不一样的。\n为了防止迷惑，首先解释几个我说的名词：\n\nTab 宽度：字面意义上的一个 Tab 字符应该相当于几个空格的宽度，在 Emacs 里面是 tab-width 这个变量，我个人建议固定为 8。\n缩进宽度：当你的代码应该增加一个缩进级别的时候，应该向右缩进几个空格的宽度，在 Emacs 里面每个模式都有不同的变量控制，比如 c-indent-offset。\n缩进级别：代码逻辑层次（或许可以简单理解为作用域）每增加一层，一般就应该增加一个缩进层级。\n使用 Tab 缩进：不是说只使用 Tab 缩进的意思。事实上如果你的代码可以只使用 Tab 缩进，那么 Tab 宽度为几都无所谓，因为一个缩进宽度就是一个 Tab，但实际上你不一定只使用 Tab 缩进，因为你可能还包含对齐的情况，比方说函数头参数太多，而你希望换行后的参数能够和左括号的下一个字符对齐，那么这个距离大概率无法被 Tab 宽度整除。因此使用 Tab 缩进的含义是“总缩进宽度能够用 Tab 宽度整除的部分用 Tab，余数的部分用空格”，也就是 Emacs 里面 (indent-tabs-mode 1) 的效果。\n使用空格缩进：和上面相反，意味着缩进部分完全不使用 Tab 字符，那么 Tab 宽度为几也无所谓。也就是 Emacs 里面 (indent-tabs-mode -1) 的效果。\n\n我个人的建议和倾向主要是两个，第一个是 Tab 宽度固定为 8，第二个则是如果你认为缩进宽度为 8 也就是一个 Tab 对你来说太宽，那么你就应该完全不用 Tab 缩进，也就是使用空格缩进，而不是修改 Tab 宽度。\n这样做的理由很简单，主要是我最近修改 GTK 代码时候发现的，GTK 代码是典型的 GNU 风格，我一开始以为它是完全不使用 Tab 缩进，并且缩进宽度是 2，并且我在 Atom 里面也是把这个项目的 Tab 宽度设为 2，但我前段时间发现 GTK 实际上是使用 Tab 的，比如一个很有意思的情况：某段代码的总缩进级别是 5，而它实际上使用的是 1 个 Tab 和 2 个空格，如果你把 Tab 宽度设置成 2，那这段代码在你的编辑器里就会错误的显示成 2 个缩进级别，只有你的 Tab 宽度是 8 的时候才能正确的显示。\n这些奇怪缩进的项目给了我两个教训：Tab 宽度和缩进宽度并不是一个东西，以及最好假设 Tab 宽度为 8。当然，以上的建议都是以遵照现有代码的风格为前提，应该不会有人任性到把别人的项目都改成自己风格再提交贡献吧。\n我倒不是完全的 Tab 缩进党，比如在 Python 里我设置缩进宽度是 4，而在 JavaScript 里我设置缩进宽度是 2，主要是因为对于 C 这种不允许嵌套函数并且没有类似 class 这种层次的语言来说，缩进级别完全就是函数内部逻辑，当你总缩进宽度达到 24 的时候，你已经在函数里有三层逻辑了。而对 Python 或者 JS 这类函数经常是类的方法的语言，本身函数就已经带着一个缩进级别了，三个缩进级别仅仅只代表两层函数内部逻辑，更别提比如回调函数是匿名函数的情况了。\n至于当缩进宽度不是 8 时使用空格缩进而不是用 Tab 然后修改 Tab 宽度的理由也很简单，和上面说的一样，完全使用 Tab 缩进时虽然 Tab 宽度不会影响总缩进级别，但是一旦遇到对齐的情况，Tab 宽度不一致，对齐的部分就不再一样了。\n说了这么多理论，该到具体的我怎么在 Emacs 里面处理了，首先是默认值，我个人是定义 Tab 宽度为 8 并且设置允许使用 Tab 缩进。\n(setq-default tab-width 8)\n(setq-default indent-tabs-mode t)\n\n默认的 C 编码风格是 GNU，但是 GNU 的编码风格实在是太恐怖了，特别是大部分人的入门书上应该都是 K&amp;R 或者类似的风格。我换成了我喜欢的 linux 内核风格。有的变量你可以直接使用 setq，但另一些使用 setq 会说你声明了一个新的自由变量，这个时候还是遵照建议使用 Emacs 的 customize 系统吧。\n(customize-set-variable 'c-default-style '((java-mode . \"java\")\n                                           (awk-mode . \"awk\")\n                                           (other . \"linux\")))\n\n前面说过 Emacs 对于不同的模式使用不同的变量作为缩进宽度，这样对于编写配置其实很困难，因为让你时刻记住哪个模式用哪个变量显然不太现实，这里我用了一个比较投机取巧的办法——Emacs 有所谓 buffer-local 变量的设定，也就是说一个变量会有一个默认值，然后每个 buffer 都可以有一个该变量的副本，可以设置成不同的值，如果没有则使用默认值。利用这个功能我创建了一个单独的 buffer-local 变量 indent-offset，然后把以上所有这些都设置为该变量的别名，于是我对每个 buffer 只要修改 indent-offset 的副本就可以了。我这里只写了我使用到的模式的变量，如果有其他的就加到列表里，或者我看 doom-modeline 的代码里几乎包含了所有常见模式的变量，或许可以拿来用。\n(defconst mode-indent-offsets '(c-basic-offset\n                                js-indent-level\n                                css-indent-offset\n                                sgml-basic-offset\n                                python-indent-offset\n                                lua-indent-level\n                                web-mode-code-indent-offset\n                                web-mode-css-indent-offset\n                                web-mode-markup-indent-offset\n                                markdown-list-indent-width)\n  \"Different modes' indent variables to make alias to indent-offset.\")\n\n(dolist (mode-indent-offset mode-indent-offsets)\n  (defvaralias mode-indent-offset 'indent-offset))\n\n(defvar-local indent-offset tab-width)\n\n那么接下来就是我们如何针对不同的 buffer 进行不同的设定了，这里分为两部分，一部分是这个 buffer 是否使用 Tab 缩进，另一部分则是缩进宽度设置为多少。Atom 里面有一个状态栏插件，点击就可以快速设置，Emacs 里面我写了两个简单的函数方便调用，执行对应的函数设置是否使用 Tab，并且它们都会询问你想把缩进宽度设置为多少。我分别把这两个函数绑定到 C-c i TAB 和 C-c i SPC。对于 GTK，操作起来可以是 M-x indent-tabs RET 2 RET。\n(defun indent-tabs (num)\n  \"Mark this buffer to indent with tabs and set indent offset to NUM chars.\"\n  (interactive `(,(read-number \"Indent offset (chars): \" indent-offset)))\n  (indent-tabs-mode 1)\n  (when (/= indent-offset num)\n    (setq indent-offset num)))\n(global-set-key (kbd \"C-c i TAB\") 'indent-tabs)\n\n(defun indent-spaces (num)\n  \"Mark this buffer to indent with spaces and set indent offset to NUM chars.\"\n  (interactive `(,(read-number \"Indent offset (chars): \" indent-offset)))\n  (indent-tabs-mode -1)\n  (when (/= indent-offset num)\n    (setq indent-offset num)))\n(global-set-key (kbd \"C-c i SPC\") 'indent-spaces)\n\n当然以防万一你真的遇到一个脾气古怪的作者，一定要使用 Tab 缩进并修改 Tab 宽度，我也写了个修改 Tab 宽度的函数。这个我绑定到 C-c i w 了。\n(defun set-tab-width (num)\n  \"Mark this buffer to set tab width to NUM chars.\"\n  (interactive `(,(read-number \"Tab width (chars): \" tab-width)))\n  (when (/= tab-width num)\n    (setq tab-width num)))\n(global-set-key (kbd \"C-c i w\") 'set-tab-width)\n\n现在我们有了给每个 buffer 修改设定的办法了，但是通常你对某种语言有一个自己偏爱的风格，肯定希望以这个为默认值，所以我写了一些代码，给每个模式设置成我喜欢的默认风格。同样地如果你装了更多的模式，或者和我有不同的喜好，就修改这些列表好了。\n这里使用 (set-tab-width 8) 是因为有些模式比如 markdown-mode 把 tab-width 定义为 4，按照前面说的，我觉得这是错的，同时上游为了保持向前兼容不好修改，于是这里简单地覆盖掉。\n(defconst indent-tabs-modes '((prog-mode . 8)\n                              ;; `markdown-mode` is not a `prog-mode`.\n                              (markdown-mode . 8)\n                              (gfm-mode . 8))\n  \"Modes that will use tabs to indent.\")\n\n(defconst indent-spaces-modes '((lisp-mode . 2)\n                                (emacs-lisp-mode . 2)\n                                (js-mode . 2)\n                                (css-mode . 2)\n                                (html-mode . 2)\n                                (yaml-mode . 2)\n                                (lua-mode . 3)\n                                (python-mode . 4))\n  \"Modes that will use spaces to indent.\")\n\n(dolist (pair indent-tabs-modes)\n  (add-hook (intern (concat (symbol-name (car pair)) \"-hook\"))\n            `(lambda () (indent-tabs ,(cdr pair)) (set-tab-width 8))))\n\n(dolist (pair indent-spaces-modes)\n    (add-hook (intern (concat (symbol-name (car pair)) \"-hook\"))\n              `(lambda () (indent-spaces ,(cdr pair)) (set-tab-width 8))))\n\n有些 modeline 包含一个显示缩进信息的部分，比如 doom-modeline 显示 TAB 或者 SPC 表示使用 Tab 缩进或使用空格缩进，然后如果该模式有自己的缩进宽度变量就显示，没有就显示 Tab 宽度（我没仔细读，总之它对这俩不做显式区分）。而按照上文，我肯定是倾向显式区分这两个东西的，所以我们不用它的，而是自定义一段 modeline，第一部分显示 TAB 或 SPC，第二段显示 indent-offset，第三段显示 tab-width。我没太搞懂 Emacs 的 modeline constructor 的语法，我觉得我写对了但却没有，于是最后变成 :eval 一个 format 调用了。\n(setq mode-line-misc-info '(:eval (format \"%s %d %d\"\n                                          (if indent-tabs-mode \"TAB\" \"SPC\")\n                                          indent-offset\n                                          tab-width)))\n\n最后我在网上抄了两个配置，一个是让它按回车时候不要自动缩进。另一个是修改默认的删除缩进的行为，默认当你在对着一个 Tab 按下退格键的时候，Emacs 把这个 Tab 变成缩进宽度数量的空格，然后删掉一个空格，这太诡异了，我就让它删掉一个字符好了。\n(setq-default electric-indent-inhibit t)\n(setq backward-delete-char-untabify-method nil)\n\n这样当你遇到一个和自己习惯不一样的文件，基本只要看情况调用 indent-tabs 或者 indent-spaces 即可。我在 Atom 用的插件还有一个自动猜测文件是使用 Tab 还是几个空格作为缩进的功能，不过我看了一下代码，它并不能解决我之前说到的 GTK 的问题，也就是说它会猜成 SPC 2 2 而不是 TAB 2 8，我懒得自己想一个猜测算法，于是就还是靠自己判断了。\n一个我比较想实现的功能是记录每个目录我用了什么设定，因为基本上每个项目都用一样的风格，这样就不用每次编辑文件都手动设置。我知道可以在每个目录创建一个文件记录 Emacs 的一些目录范围变量，但是问题是不是所有项目都想让你添加一个编辑器相关的文件，甚至你都不好修改 .gitignore 排除你的文件。我比较想把这个存储记录丢到 Emacs 的目录里，不过并不知道怎么实现，如果哪天我搞清楚了，就去写一个。\n"},{"title":"Emacs 和 Monaco 字体和 Box-drawing Character","url":"/posts/Emacs-Monaco-Box-drawing-Character/","content":"2016 年的我开始用 Atom 这种“modern”的编辑器，2022 年的我却又开始用回岁数比我都大的 GNU Emacs。切换的理由其实很简单，我曾经以为一直能追上最新版 Electron 的 VSCode 会成为第一个纯 Wayland 的代码编辑器——只要 Chromium 那边支持纯 Wayland 就好了嘛，然而直到 Emacs 那边的 pgtk 分支合并进主线（以防有读者不太清楚来龙去脉我解释一下，Emacs 虽然有图形界面，但实际上只是用 X 实现了一个 Terminal 层，而传统的 GTK3 界面只是使用 GTK3 创建一个 X 窗口，然后其它操作都是通过 X 进行，这实际上非常不适合 GTK3，导致了很多 bug，同时也使 Emacs 没法利用 GTK 的 Wayland 后端。而 pgtk 分支则是在 X 部分之外另起炉灶，利用 GTK 实现了一个和 X 部分平行的 Terminal 层，全部的绘制操作都是以 GTK/Cairo 的现代程序方式进行，自然也就摆脱了对 X 的依赖。总之在 Emacs 这样又老又庞大的代码库上做如此大范围的工程我觉得可以称得上是一项壮举了。），Chromium 的 ozone backend 还是问题多多。虽然 Emacs/Vim 这种软件看起来确实有点老派作风，但没想到也有走在这些“现代”编辑器前面的地方。\n\n\n至于这和我换掉 Atom 有什么联系呢？主要是我发现在家里的台式机上，所有 XWayland 程序在 nvidia 驱动下面都会有闪回的情况，也就是说你打字的时候突然会闪回前几帧的画面，过一会再闪回来，你经常看不到自己输入的字符。Electron 程序尤其严重，也就导致我没有办法使用 Atom 写代码，于是不得不捡起以前东拼西凑的 Emacs 配置重新研究。（奇怪的是我自己的台式机也是 nvidia 驱动，没遇到过这种问题。）\n扯远了，这篇文章主要想记录的问题是什么呢？其实还要和 Emacs 绘制界面的方式有关系，对于 Atom/VSCode 这种基于浏览器的程序来说绘制点什么图形元素很简单，但是对于 Emacs/Vim 这种来自于终端里的程序，开发者们习惯的是处理字符而不是处理图形，于是你会发现比如 80 column ruler 或者 indent guide 这种东西，在 Emacs 里面其实是通过在对应的位置插入竖线字符实现的……我个人不太喜欢这样，一个原因是我以为竖线字符并不是占满整行而是上下有空白。我一直以为这是 Emacs 的问题——你干嘛用竖线字符画 UI 啊。\n\n直到有一天我输错了 alias 在 GNOME Terminal 里面打开了 Emacs，我惊讶的发现竖线竟然接上了头！\n\n我当时就震惊了，我的终端和 Emacs 用的是同样的 Monaco 字体，怎么会不一样呢？难道是 Monaco 字体有问题？于是我上网搜了一下，我以前一直以为是 Emacs 的哪个设置比如 line-spacing 我没搞好，怎么搜也搜不出来，这次换成搜字体一下子就找到原因了：一个 Alacritty 的 issue 里面和我有同样的问题，不过他是 tmux 的分割线接不上头，都是 Monaco 字体。\n为什么只有 Monaco 接不上头呢？原来在字符界面下画这些竖线的字符和平时用 Shift+\\ 输入的字符并不是一个，这类字符叫做 Box-drawing Character，主要的范围是 U+2500 到 U+257F，这些字符用于在终端里绘制方框或者其它形状，所以应该是没有 padding 的，才能接上头，而 Monaco 这里有问题，它给这些字符加上了 padding，导致接不上。我尝试着给 Emacs 的字体换成 Source Code Pro，竖线立刻就连上了。\n怎么解决？换字体？不可能的，我是 Monaco 的狂粉，看惯了 Monaco 再看别的字体都觉得傻了吧唧的。如果不是因为它好看我才不会忍受它这么多缺点（没有内置粗体，虽然是等宽字体内部的连字表却和非等宽字体一样，有版权不能二次分发）。解决方法其实比较简单，把 U+2500 到 U+257F 的字符换成正常字体里的就可以了。简单的解决办法是用 FontForge 的同个实例打开 Monaco 和另一款字体（我选择了 Menlo，Menlo 是苹果用来替代 Monaco 做内置默认等宽字体的，应该会比较接近），然后选择 Monaco 的这个范围清空，然后选择 Menlo 的复制粘贴过来。不过我没在 FontForge 里面找到连续区间选择的办法，上网搜了一下说可以用它的脚本 API 选，办法是打开 File 菜单里面的 Execute Script，执行 fontforge.activeFont().selection.select((\"ranges\", None), 0x2500, 0x257F)。如果你不知道怎么把 Menlo 里面的一段字形复制粘贴到 Monaco 里面，你也可以在 Menlo 里执行这段脚本，然后反选，全部清空，然后把这部分生成一个字体，再去 Monaco 里面选 Elements 菜单里面的 Merge Fonts。\n或者你也可以看看这个 叫 Menloco 的项目，是我偶然间搜索到的有同样问题的用户的解决方案，这个项目包含更多的细微 tweak 脚本，帮你利用 Monaco 和 Menlo 合并出一个 Box-drawing Character 能完美接头的字体。不过有几个地方需要注意，一个是这个项目的作者应该是 macOS 的用户，如果你不是 macOS，需要自己想办法搞到 Menlo.ttf 和 Monaco.ttf，简单的办法是找个用 Mac 的朋友让他发给你，不过有可能你得到的是 Menlo.ttc，需要用 FontForge 打开选择 Regular 字重，然后导出成单个的 ttf。你还需要修改 utils/find-font.sh，这个脚本的 font_paths 只包含 macOS 放置字体的目录，你得加上你自己放这两个字体的目录。以及这个项目默认生成的字体名（不是文件名）叫 Menloco，如果你不想修改已有的写着 Monaco 的配置文件的话，就把 merge 这一项下面的 --font-name=$(RESULT) 改成 --font-name=$(INTO) 就好了。\n生成一个没问题的字体之后你还可以像我一样用 FontForge 做一些修改，比如说我发现 Menlo 有很多 Monaco 没有的字符，于是我直接把 Menlo merge 进了生成的字体里。并且我之前提到过 Monaco 作为一个等宽字体，内置的连字表竟然是非等宽的，不是像 Fira Code 那种把不同的编程符号连字起来同时保持等宽的连字，而是像普通无衬线一样把 fi 一类的字符连起来变成单个字符宽度。我被坑得最狠的一次就是 review 同事的 patch，我问他这里是不是少了个空格，他说在他那看没问题，最后我发现是 Monaco 连字了！虽然你可以通过配置 fontconfig 关闭连字，但是 Firefox 是不吃这个配置的，而你也不可能给每个网页的代码块都加上关闭连字的 CSS。所以我直接在 FontForge 里面干掉了连字表，具体方法就是打开 Element 菜单下面的 Font Info，点击左侧的 Lookup，选中带 liga 的项 delete 之后导出字体即可。\n还有一个比较古怪的 Emacs 问题，Emacs 设置字体和其它程序不太一样，可以先设置一个默认字体然后针对不同的字符集设置不同的字体，一般要为中文单独设置字体才能得到合适的效果，就像下面这样：\n(set-face-attribute 'default nil\n                    :family \"Monaco\"\n                    ;; :slant 'normal\n                    :width 'normal\n                    :weight 'normal\n                    ;; 1 height is 1/10 pt.\n                    :height 140)\n\n(dolist (charset '(kana han symbol cjk-misc bopomofo))\n  (set-fontset-font t charset (font-spec :family \"Noto Sans Mono CJK SC\"\n                                         ;; :slant 'normal\n                                         :width 'normal\n                                         :weight 'normal)))\n\n首先要注意 height 的单位是 1/10，所以你想要的字号需要乘 10 才行。\n然后你会发现明明你只设置了一个字号，可是中文和英文字体却不是等高的！也就是说如果你在本来都是英文的一行里面输入一个中文字，那这行的高度就会突然跳一下变高，非常烦人，也许是这两个字体在同样的字号的时候尺寸并不完全一致，但是明明其它程序都能正常处理，为什么这里这么怪！\n你可能会想要对中文那段单独设置 size 缩小一点，但是这样不行，你用 C-x C-= 放大字体的时候中文字体就会固定大小不跟着你变了。正确的解决方法是加入下面一句：\n(setq face-font-rescale-alist '((\"Noto Sans Mono CJK SC\" . 0.85)))\n\n这里你可以对任意的字体指定缩放参数，不会影响按键放大缩小。我尝试了一下 0.85 比较合适，虽然可能这样汉字看起来会稍微小一点，但是 0.9 就太高了仍然会跳。或许你会问那这样中文字宽不是英文字宽两倍了？那没有办法，Monaco 本身就属于一个比较宽的字体，那些满足英文宽度是高度一半的字体都比较瘦长，我个人是不喜欢这样的风格的，所以对于我来说等高就行了，宽度我不太在乎。\n更新（2022-02-25T19:01:31）：我最近研究了一下，发现原来字号并不等于行高。虽然字体有一个 em size 作为基础的方块大小，但是设计师经常指定一些奇怪的 ascender 和 descender 值让字体的高度超出 em size……我不太清楚 Atom 或者说 Chromium 是怎么进行中英文混排的，不过 Emacs 排版时候是对齐 baseline，然后 ascender 和 descender 完全一致才能保证行高不会在切换字体时候变化。但是这实在是太难了，大部分中文字体和英文字体都对不上，特别是 Noto Sans CJK 系列，不知道为什么比其它的高特别多。一个简单的解决办法是使用等距更纱黑体，里面混合的英文等宽字体 Iosevka 和中文的思源黑体拥有一致的 ascender 和 descender，但我实在是不喜欢 Iosevka。并且不知道为什么，Noto Sans CJK 和等距更纱黑体里面的内置的思源黑体应该是同一种字体，Noto Sans CJK 就要比他高很多。我还尝试了修改 Monaco 的 ascender 和 descender，不过这部分非常复杂，涉及到好几个不同的值，而且不同字体比例尺也不一样。最关键的是修改之后相当于把字体拉高了，于是 box-drawing character 又接不上了……上面那个修改缩放参数其实也不是无级缩放，实际上是乘字号之后取整然后再去找对应大小的字符，所以其实就是找小几号的 Noto Sans CJK。具体的可以在 Emacs 里面 M-x describe-font 查看详情。\n更新（2022-02-26T09:51:25）：补充一下，浏览器的混排应该和 Emacs 是一样的，我刚才尝试了一下，我的博客行高固定是因为我给 &lt;pre&gt; 设置了 line-height: 1.5，这个大小超过了 Noto Sans CJK 的行高，如果删掉这一行，你就会发现含有 Noto Sans CJK 的行比只有 Monaco 的行要高很多。不过 Emacs 没办法像浏览器一样指定一个最小行高，只能是它自己根据这一行的字符计算行高，所以没什么比较好的解决办法。\n搞定这些之后，至少 Emacs 里面看起来比较顺眼了。\n\n"}]}