作者: Zeeko

  • 早知道 zswap 这么配置就好了

    swap 对于 Linux Server 来说可能是一剂毒药,但对于我这种经常要打开几十个 Tab 的 Linux Desktop 用户来说则可能是救命稻草。网上关于 swap 的调优众说纷纭,我决定给这个充满争议的话题再添一些乱。

    简单来说,我发现使用完全不写入硬盘的 zswap 搭配一个 swap 文件(或分区)要比单纯只用 swap + zswap 要更加适合日常办公场景。

    zswap 是一种 swap 跟 RAM 中间的一个压缩池。当一块内存将被送进 swap 的时候,zswap 会将其先压缩然后放进一个单独的内存池中,而不是直接放进 swap 设备。当 zswap 内存池快满或者这块内存不适合压缩(比如压缩后体积变大了)的时候,zswap 则会将其直接放进 swap 设备。

    通过安装 aur/zswap-disable-writeback ,可以几乎禁用掉 zswap 将内存解压写入 swap 的行为,这时 zswap 就会像 zram/swap 一样工作——仅压缩内存到内存池,不写入到更慢的 swap 设备,例如,硬盘分区或者 swap 文件。

    这样做的好处就是 swap 设备可以空出来用于系统休眠,而且 zswap 仍可以给操作系统提供更多的内存空间,更高效的使用内存。

    比如,在一个有 32GB RAM 的设备上,默认预留 20% 的空间来创建 zswap 的内存池,假设压缩比在 4.0 左右,那么这台设备在理论上可以给应用程序分配的内存将高达 51.2GB ,立省一条 16GB 内存

    在上面的配置下,还可以将 swappiness 设置到 100 以上,让操作系统更加激进地将内存交换出 RAM,给当前正在运行的活跃操作空出更多的空间,以应对突发性的内存需求(比如,打开新的浏览器 Tab)。

    相较于只使用硬盘作为 swap 设备,禁用 writeback 的 zswap 还有经济性上的优势,只要我们的内存需求在大多数情况下能够被 zswap 的内存池满足(在上面的例子中大约是 51GB),就不会发生由 swap 引起的硬盘 IO,有助于延长硬盘的寿命。

  • 在机械革命无界 15XPro 暴风雪上运行 Linux

    This entry is part 1 of 3 in the series 机械革命无界 15XPro

    最近把工作电脑换成了机械革命无界 15XPro 暴风雪,在收货后小小折腾了一两天后,终于让他大部分的功能工作正常了。

    键盘背光跟散热风扇的驱动

    可以参考这篇文章中的介绍安装来自德国一家公司维护的驱动。驱动安装好后,还可以安装类似鸡哥控制面板的 TUXEDO 控制面板,切换性能模式跟调整键盘背光 RGB:

    内置屏幕掉帧

    笔记本内置屏幕在 60Hz 时会频繁随机掉帧,这个现象跟 VRR 有关,需要强制启用 VRR。

    除此之外我还遇到了使用 Spectacle 截图时,外置显示器闪烁的问题,需要强制关闭外置显示器的 VRR。

    睡眠(suspend)后被立即唤醒

    当通过睡眠按键(Fn + F1)或者 systemctl suspend 进入睡眠状态时,系统会被立即唤醒,需要第二次尝试才能成功睡眠。

    反复阅读 https://wiki.archlinux.org/title/Power_management 系列文章后,我在 deepseek-r1 的帮助下成功地找出了这个问题的根因。

    TLDR

    创建一个 udev rule,禁用 PS/2 键盘的 Wakeup Trigger。

    # sudoedit /etc/udev/rules.d/99-disable-keyboard-wakeup.rules
    # Disable wakeup for PS/2 keyboard controller
    ACTION=="add", SUBSYSTEM=="serio", KERNEL=="serio0", ATTR{power/wakeup}="disabled"

    然后重载 udev rules:

    sudo udevadm control --reload-rules
    sudo udevadm trigger

    重启系统后验证是否生效:

    $ cat /sys/devices/platform/i8042/serio0/power/wakeup
    disabled

    现在尝试睡眠,就不会被立即唤醒了。

    排查过程

    首先安装 amd-debug-tools,运行 amd-s2idle test ,查看当前系统是否满足 s2idle 的要求:

    $ amd-s2idle test
    💻 AMD Ryzen AI 9 H 365 w/ Radeon 880M (family 1a model 24)
    💻 MECHREVO WUJIE Series (STX\KRK)
    🐧 Arch Linux
    🐧 Kernel 6.12.32-1-lts
    🔋 Battery BAT0 (OEM standard) is operating at 100.00% of design
    ✅ ASPM policy set to 'default'
    ✅ GPIO driver `pinctrl_amd` available
    ✅ PMC driver `amd_pmc` loaded (Program 11 Firmware 93.4.0)
    ✅ USB3 driver `xhci_hcd` bound to 0000:65:00.4, 0000:67:00.0, 0000:67:00.3, 0000:67:00.4
    ✅ USB4 driver `thunderbolt` bound to 0000:67:00.6
    ✅ System is configured for s2idle
    ✅ GPU driver `amdgpu` bound to 0000:65:00.0
    ✅ PC6 and CC6 enabled
    ✅ SMT enabled
    ✅ IOMMU properly configured
    ✅ ACPI FADT supports Low-power S0 idle
    🚦 Logs are provided via dmesg, timestamps may not be accurate over multiple cycles
    ✅ LPS0 _DSM enabled
    ✅ WLAN driver `mt7921e` bound to 0000:62:00.0
    ❌ Kernel is tainted: 12288
    💯 Your system does not meet s2idle prerequisites!
    🗣️ Explanations for your system
    🚦 Kernel is tainted
    A tainted kernel may exhibit unpredictable bugs that are difficult for this script to characterize. If this is intended behavior run the tool with --force. 
    For more information on this failure see:https://gitlab.freedesktop.org/drm/amd/-/issues/3089

    可以看到这里提示我的 kernel 被 tainted 了,这是安装 mechrevo-drivers-dkms 引起的,可以忽略。

    既然系统符合 s2idle 的要求,说明可能是某个设备在唤醒系统,deepseek 给出了接下来的排查思路,启用 PM 日志:

    # Enable verbose PM debugging
    echo 1 | sudo tee /sys/power/pm_print_times<br>echo 1 | sudo tee /sys/power/pm_debug_messages
    # Now try to suspend again
    systemctl suspend
    # Check dmesg after resume
    dmesg | grep -i "wake\|resume\|acpi"

    再次执行 systemctl suspend 复现问题后,可以在 dmesg 中看到下面的日志:

    [ 4636.873132] xhci_hcd 0000:67:00.4: PM: pci_pm_suspend_noirq returned 0 after 42252 usecs  
    [ 4636.873146] thunderbolt 0000:67:00.6: PM: pci_pm_suspend_noirq returned 0 after 42269 usecs  
    [ 4636.873169] pcieport 0000:00:08.3: PM: calling pci_pm_suspend_noirq @ 29723, parent: pci0000:00  
    [ 4636.878632] pcieport 0000:00:02.1: PM: pci_pm_suspend_noirq returned 0 after 10561 usecs  
    [ 4636.878653] pcieport 0000:00:03.2: PM: pci_pm_suspend_noirq returned 0 after 11025 usecs  
    [ 4636.885192] pcieport 0000:00:08.1: PM: pci_pm_suspend_noirq returned 0 after 12279 usecs  
    [ 4636.885300] pcieport 0000:00:08.3: PM: pci_pm_suspend_noirq returned 0 after 12119 usecs  
    [ 4636.885359] PM: noirq suspend of devices complete after 54.690 msecs  
    [ 4636.885376] ACPI: _SB_.PCI0.GPP5: LPI: Constraint not met; min power state:D1 current power state:D0  
    [ 4636.885381] ACPI: _SB_.PCI0.GPP6: LPI: Constraint not met; min power state:D1 current power state:D0  
    [ 4636.885388] ACPI: _SB_.PCI0.GPP4.SDCR: LPI: Constraint not met; min power state:D3hot current power state:D0  
    [ 4636.886012] PM: Triggering wakeup from IRQ 9  
    [ 4636.886504] ACPI: _SB_.PEP_: Successfully transitioned to state screen off  
    [ 4636.887420] ACPI: _SB_.PEP_: Successfully transitioned to state lps0 ms entry  
    [ 4636.887625] ACPI: _SB_.PEP_: Successfully transitioned to state lps0 entry  
    [ 4636.888555] PM: suspend-to-idle  
    [ 4636.888592] ACPI: EC: ACPI EC GPE status set  
    [ 4636.888623] ACPI: PM: Rearming ACPI SCI for wakeup  
    [ 4636.891279] PM: Triggering wakeup from IRQ 1  
    [ 4639.593124] amd_pmc: SMU idlemask s0i3: 0xffff1abd  
    [ 4639.593188] ACPI: PM: Wakeup unrelated to ACPI SCI  
    [ 4639.593189] PM: resume from suspend-to-idle  
    [ 4639.594699] amd_pmc AMDI000A:00: Last suspend didn't reach deepest state  
    [ 4639.595185] ACPI: _SB_.PEP_: Successfully transitioned to state lps0 exit  
    [ 4639.595760] PM: Triggering wakeup from IRQ 9  
    [ 4639.596746] ACPI: _SB_.PEP_: Successfully transitioned to state lps0 ms exit  
    [ 4639.597719] ACPI BIOS Error (bug): Could not resolve symbol [_SB.ACDC.RTAC], AE_NOT_FOUND (20240827/psargs-332)  
    [ 4639.597729] ACPI Error: Aborting method _SB.PEP._DSM due to previous error (AE_NOT_FOUND) (20240827/psparse-529)  
    [ 4639.597737] ACPI: _SB_.PEP_: Failed to transitioned to state screen on

    Deepseek 大胆推断是 IRQ 1 (typically the i8042 keyboard controller) 造成的问题,可以通过下面的方法来验证:

    echo "disabled" | sudo tee /sys/devices/platform/i8042/serio0/power/wakeup

    重试睡眠后,系统果然安然入睡了~

    进入 s2idle 睡眠后,一晚上大概会消耗 10% 左右的电量。

    Firefox 播放视频时会触发 GPU 图形崩溃

    当启用 Firefox 的硬件加速后,播放视频会让 GPU 崩溃,丢失全部的显存内容。还好这个问题已经被 AMD 修复,升级到最新的内核即可。

    色彩管理

    机械革命提供了官方的色彩文件,但必须要在 Windows 下安装机械革命控制台才能下载(官方客服并不受理此类咨询)。加载了官方的色彩配置文件后,可以比较明显地缓解低亮度下对比度过低的问题。为了方面读到这篇文章的你,点击下面的链接就可以下载到这份色彩配置文件~

    ipfs://QmaR5ZxFXNwcsjn9XZq6CpDfRFyoffDEr9fpi15Hmb31K7

    开机后有概率屏幕卡死

    开启自适应同步后可解决,感谢 LY 提供的解决方案。

  • 给 QNAP 文件分享外链套个壳

    This entry is part 4 of 5 in the series homelab 历险记

    QNAP 提供的在线服务 MyQNAPCloud 的下载速度有时真的非常令人捉急。为了方便他人下载我分享的文件,得提供一个允许公网访问 NAS 共享链接的方式。

    基础设施改动

    考虑到我现在 Homelab 的基础设施中已经有了 cloudflared,所以可以用它设置一个 tunnel,但是为了避免将 NAS 其他功能暴露到公网,还需要再用 Nginx 设置一个代理,拒绝所有访问非共享链接页面的请求:

    location ^~ /share.cgi {
      proxy_pass https://qnap.lan:5001;
    }
    
    location ~* \.(js|svg|gif|png)$ {
      proxy_pass https://qnap.lan:5001;
    }
    
    
    location ^~ /shareLink/ {
      proxy_pass https://qnap.lan:5001;
    }
    
    location / {
      return 302 "https://zeeko.dev";
    }

    接下来,设置 Cloudflared 的 tunnel,将 Local Service 的地址改成这台 nginx 服务器。

    上面之所以还配置了 Host Header 是因为我的 nginx 上配置的域名是给局域网专用的,跟公网域名不同,为了让 Nginx 能够正常识别请求,就需要让 Cloudflared 设置转发 Host Header。

    UI 体验优化

    设置好网络功能后,还需要优化一下 QNAP 管理页面的 UI,方便我复制公网链接。这里我选择使用油猴脚本来实现我的目的。

    // ==UserScript==
    // @name         My QNAP
    // @namespace    https://qnap.lan/
    // @version      2025-05-31
    // @description  try to take over the QNAP!
    // @author       You
    // @match        https://qnap.lan/*
    // @icon         https://www.google.com/s2/favicons?sz=64&domain=tampermonkey.net
    // @grant        none
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        // Configuration for the observer
        const observerConfig = {
            childList: true,
            subtree: true,
            attributes: false,
            characterData: false
        };
    
        // Create an observer instance
        const observer = new MutationObserver(mutations => {
            requestIdleCallback(() => {
                // DEBUG: Get all label elements
                const allLabels = Array.from(document.querySelectorAll('label.x-form-item-label'));
                // DEBUG: Find Local IP label
                const localIPLabel = allLabels
                .find(it => it.innerText === 'Local IP');
                console.debug('[DEBUG] localIPLabel:', localIPLabel);
                if (localIPLabel == null) {
                    console.warn('[DEBUG] Local IP label not found');
                    return;
                }
                const localIP = localIPLabel.nextElementSibling.innerText.trim();
                console.debug('[DEBUG] localIP:', localIP);
    
                if(localIP == null || localIP === '') {
                    console.warn('[DEBUG] localIP is null or empty');
                    return;
                }
    
                const query = new URL(localIP).search;
                console.debug('[DEBUG] query:', query);
    
                // DEBUG: Find SmartShare label
                const smartShareLabel = allLabels
                .find(it => it.innerText === 'SmartShare');
                if (smartShareLabel == null) {
                    console.warn('[DEBUG] SmartShare label not found');
                    return;
                }
                const copyButton = smartShareLabel.nextElementSibling.querySelector('button');
                if (copyButton == null) {
                    console.warn('[DEBUG] Copy button not found');
                    return;
                }
    
                copyButton.addEventListener('click', () => {
                    const shareUrl = 'https://smartshare.zeeko.dev/share.cgi' + query;
                    navigator.clipboard.writeText(shareUrl);
                });
    
            });
        });
    
        // Start observing the document body
        observer.observe(document.body, observerConfig);
    
        // Cleanup observer when page unloads
        window.addEventListener('unload', () => {
            observer.disconnect();
        });
    })();

    启用上面的脚本后,就可以在 UI 上直接复制文件共享的公网链接:

    最终效果

    相比通过 QNAP 的云下载,速度要快了 10 倍。

  • Zerotier: No route to host

    This entry is part 3 of 5 in the series homelab 历险记

    一觉醒来,突然发现家里的 OpenWRT 软路由无法通过 Zerotier 网络访问了,虽然面板上显示设备在线,但不管是 Ping 还是 curl ,都会报错:No route to host。

    09:54:21.055775 [0-0] * [HTTPS-CONNECT] adjust_pollset -> 1 socks
    09:54:22.048545 [0-0] * [HTTPS-CONNECT] connect, check h21
    09:54:22.049187 [0-0] * connect to 192.168.100.22 port 443 from 10.1.1.111 port 59496 failed: No route to host

    首先,我们需要判断这个问题是否能在 Zerotier 网络中其他节点复现,通过登录不同节点执行 ping 跟 curl,发现只有 OpenWRT 在 Zerotier 网络中不可达。

    接下来登录 OpenWRT 上查看 Zerotier 客户端的状态:

    $ zerotier-cli info -j
    {
     "address": "....",
     "clock": 1748052290108,
     "config": {
      "settings": {
       "allowTcpFallbackRelay": true,
       "forceTcpRelay": false,
       "homeDir": "/var/lib/zerotier-one",
       "listeningOn": [
        "192.168.100.1/9993",
        "192.168.1.2/9993"
       ],
       "portMappingEnabled": true,
       "primaryPort": 9993,
       "secondaryPort": 44537,
       "softwareUpdate": "disable",
       "softwareUpdateChannel": "release",
       "surfaceAddresses": [
        "家宽公网 IP"
       ],
       "tertiaryPort": 44486
      }
     },
     "online": true,
     "planetWorldId": 149604618,
     "planetWorldTimestamp": 1738848951118,
     "publicIdentity": "....",
     "tcpFallbackActive": false,
     "version": "1.14.1",
     "versionBuild": 0,
     "versionMajor": 1,
     "versionMinor": 14,
     "versionRev": 1
    }
    
    $ zerotier-cli peers
    200 peers
    <ztaddr>   <ver>  <role> <lat> <link>   <lastTX> <lastRX> <path>
    778cde7190 -      PLANET   225 DIRECT   44578    134480   103.195.103.66/9993
    cafe04eba9 -      PLANET   318 DIRECT   44578    134387   84.17.53.155/9993
    cafe80ed74 -      PLANET   165 DIRECT   4519     129532   185.152.67.145/9993
    cafefd6717 -      PLANET   263 DIRECT   44578    129434   79.127.159.187/9993

    可以看到 OpenWRT 上的客户端并没有显示任何异常信息,这也对应了控制面板上看到的设备在线状态。但值得注意的是,在 surfaceAddresses 字段上显示的是我的家庭宽带公网出口地址,这也对应了控制面板上显示的该设备的物理地址。考虑到 Zerotier 可能已经被中国联通给 Ban 了,所以接下来可以尝试让 Zerotier 客户端通过代理连接 Planet。

    这时,有意思的事情出现了,尽管我在代理软件中正确地设置了 Zerotier 的协议转发规则(转发目标端口为 9993 的 TCP/UDP 流量),但我在代理软件的日志中却无法查看到 Zerotier 相关的流量信息。

    我的规则设置有误还是代理软件在这个场景下存在 Bug ?这可以通过一个实验来验证,使用 ncat -vuz 84.17.53.155 9993 就可以向 Zerotier 的 Planet 发送一个用来测试连接性的 UDP 包。

    Ncat: Version 7.93 ( https://nmap.org/ncat )
    Ncat: Connected to 84.17.53.155:9993.
    Ncat: UDP packet sent successfully
    Ncat: 1 bytes sent, 0 bytes received in 2.00 seconds.

    可以看到这个 UDP 包被成功发送了,而且在代理软件的日志中也能找到相关的转发记录。这说明我的代理软件规则配置是正确的,而且代理软件也没有任何问题。那为什么在代理软件的日志中看不到 Zerotier 的流量呢,难道 Zerotier 客户端压根没有去连接 Planet ?这就需要使用 Wireshark 来分析 OpenWRT 的流量了。

    # on my laptop
    $ ssh openwrt 'tcpdump  -s 0 -U -n -w - -i eth0' | sudo wireshark -k -i -<br>

    通过分析 OpenWRT 各个网络设备上的流量,可以发现 Zerotier 的 UDP 包确实被发送了,但它是通过 OpenWRT 的 WAN 地址发送的,而我的代理软件只会通过 nft 拦截来自 localhost 跟 LAN 的流量,这就解释了为什么代理软件无法转发 Zerotier 的流量。

    确实,在网络编程中,当机器被绑定了多个 IP 地址时,我们可以指定某个特定的 IP 作为源 IP,对于 VPN 软件 Zerotier 来说,这样做更是基操。所以,在 Zerotier 的配置文件中肯定存在某个配置项,用来设置 Zerotier 流量的出口网络。

    通过翻阅它的文档,可以看到有两个选项会影响 Zerotier 流量的发送地址:

    {
      "settings": {
        "interfacePrefixBlacklist": [ "XXX",... ], /* Array of interface name prefixes (e.g. eth for eth#) to blacklist for ZT traffic */
        "bind": [ "ip",... ], /* If present and non-null, bind to these IPs instead of to each interface (wildcard IP allowed) */
      }
    }

    在我的测试下,需要指定 bind 为 LAN 地址,才能真正解决这个问题。这样设置后,Zerotier 就会通过代理服务跟 Planet 沟通,在管理面板上,OpenWRT 的物理地址也会被展示成代理服务的 IP。

    实测下来,从外网访问 OpenWRT 的速度还是能达到家庭宽带的上限,网络延迟也还不错,问题终于解决了~

  • 解决 Cloudflare Tunnel 无法通过 Passwall2 连接的问题

    This entry is part 3 of 3 in the series 网上冲浪指南

    最近发现家里 NAS 上部署的 cloudflared 很容易掉线,切换节点也不好使。

    找了一圈互联网资料后,最后根据这篇文章的描述,成功修复了 passwall2 的配置。

    简单来说, Cloudflare Tunnel 客户端(Cloudflared)会先解析 region1.v2.argotunnel.com/region2.v2.argotunnel.com 的地址,再用 quic.cftunnel.com/h2.cftunnel.com 作为 SNI 去请求。

    当开启 Passwall2 上的覆盖连接目标地址功能时,Xray 就会在转发流量时用 *.cftunnel.com 替换掉 Cloudflared 客户端获取的真实地址,当流量达到远程 Xray 时,由于 *.cftunnel.com 并没有 A/AAAA DNS 记录,就会导致流量转发失败,从而出现这样的错误:

    2025-05-09T14:07:05Z ERR Serve tunnel error error="TLS handshake with edge error: EOF" connIndex=0 event=0 ip=198.41.192.57

    要解决这个问题,需要禁用掉 Passwall2 上对 Cloudflare tunnel 相关域名的嗅探功能。

    或者,如果你能保证客户端 DNS 解析没有问题的话,可以干脆禁用掉「覆盖连接目标地址」功能,这也是 Xray 文档中推荐的做法:

    在能保证 被代理连接能得到正确的 DNS 解析 时,使用 routeOnly 且开启 destOverride 的同时,将路由匹配策略 domainStrategy 设置为 AsIs 即可实现全程无 DNS 解析进行域名及 IP 分流。此时遇到 IP 规则匹配时使用的 IP 为域名原始 IP。

    https://xtls.github.io/config/inbound.html#sniffingobject
  • CodeMirror 的 MatchDecorator

    在使用 CodeMirror 编辑器时,Decoration.markMatchDecorator 是两种非常有用的工具,可以帮助我们为编辑器中的内容创建自定义标记。Decoration.mark 用于为指定内容添加类名或标签名,而 MatchDecorator 则基于正则表达式与 Decoration 为编辑内容生成 DecorationSet

    要使用 MatchDecorator,可以将其封装成一个 CodeMirror 扩展,如下所示:

    function matcher(decorator: MatchDecorator): Extension {
      return ViewPlugin.define(view => ({
        decorations: decorator.createDeco(view),
        update(u): void {
          this.decorations = decorator.updateDeco(u, this.decorations);
        },
      }), {
        decorations: v => v.decorations,
      });
    }

    下面是一个完整的示例,展示了如何为 ==mark== 语法创建自定义标记类型:

    import { Decoration, EditorView, MatchDecorator, ViewPlugin } from '@codemirror/view';
    import { Extension } from '@uiw/react-codemirror';
    
    function matcher(decorator: MatchDecorator): Extension {
      return ViewPlugin.define(view => ({
        decorations: decorator.createDeco(view),
        update(u): void {
          this.decorations = decorator.updateDeco(u, this.decorations);
        },
      }), {
        decorations: v => v.decorations,
      });
    }
    
    export const HighlightMark = Decoration.mark({
      class: 'md-highlight',
    });
    
    const mdMarkSyntaxMatcher = new MatchDecorator({
      regexp: /==[^=]+==/g,
      decoration: HighlightMark,
    });
    
    const mdMarkSyntaxStyling = matcher(mdMarkSyntaxMatcher);

    通过这种方式,我们可以轻松地为编辑器中的特定语法添加自定义样式,提升用户体验。

  • 当一位拥有 root 权限的安卓用户升级到了 ColorOS 15

    大版本号的系统更新往往意味着 A Lot of BREAKING CHANGES!

    LSPosed

    需要安装一个第三方修改版的 LSPosed 以支持 Android 15。
    https://github.com/JingMatrix/LSPosed/releases/tag/v1.10.1

    或者申请加入 LSPosed 内测版本

    允许后台应用访问粘贴板

    https://github.com/Kr328/Riru-ClipboardWhitelist
    这个模块其实在 Android 14 时就已经无法正常工作了,升级到 Android 15 后完全可以卸载了。

    为了能够让 KDE Connect 可以正常访问剪贴板,需要用搭配一个 Magisk 模块跟一个 XPosed 模块使用。

    draumaz/kdeconnectbidirectionalclipboard 模块允许 KDE Connect 在开机后自动获取剪贴板相关的权限。

    entr0pia/xposed-clipboard-whitelist 则允许 KDE Connect 在后台读取剪贴板内容。

    让后台应用能够接收 FCM 通知

    允许 GMS 熄屏后继续运行

    参考酷安网友(丛雨不是粽子精)的帖子,需要在开机后以 root 权限运行这条命令:

    settings put secure google_restric_info 0

    或者你可以直接使用我制作的 Magisk 模块

    使用 fcmfix 启动未运行的应用

    kooritea/fcmfix: [xposed]让fcm唤醒已完全停止的应用 模块允许在国行系统上收到 fcm 推送后唤醒未启动的应用。

    推荐选择如下作用域:

    为了允许被唤醒的应用能够被启动,需要手动在 ColorOS 的 Settings/Apps/Auto launch 中授予自启动权限。否则当消息推送到手机上时,由于目标应用无法自启动,fcmfix 会代为生成一条仅包含应用包名的消息。

    fcmfix 支持有选择性的唤醒应用,我建议为了方便,可以勾选全部的包含 fcm 的应用,因为在 ColorOS 上,如果你不单独地为各个应用配置自启动权限,应用是无法在后台启动的。

    让 NoActive 支持 fcm

    如果你像我一样使用 NoActive 模块来严格地管理后台,你还需要参考 NoActive 的文档,启用全局 fcm 支持

    分享菜单清理

    每个应用都很想占据用户的分享菜单,但是 Android 的分享菜单管理功能又过于弱鸡,一个比较简单的方案是在分享菜单页面长按图标,将最常用的应用置顶。

    如果非常想要清理分享菜单而且不嫌麻烦的话,可以使用爱玩机工具箱提供的组件状态管理功能,禁用掉分享、批量发送之类的活动。

  • 跟 LLM 一起折腾 Web Audio

    最近在开发一个与 TTS(Text-to-Speech)相关的功能,其中需要对播放的人声音频进行加速或减速处理。这个领域我之前完全没有接触过,DeepSeek 推荐我使用 Howler.js 和 Tone.js 这样的库来播放音频。

    在实现加速功能时,我发现需要修改 playbackRate,但通常 playbackRate 和音调(Pitch)的修改是联动的。然而,Howler.js 并没有提供直接修改 Pitch 的方法。在折腾了很久之后,DeepSeek 也没能解决 Tone.js 带来的噪音问题。使用在线处理(Pitch Shift 效果器)时,会产生“dadadada”的噪音;而使用离线处理(GrainPlayer)时,又会出现类似回声的杂音。

    最终,我查到 HTML5 的 Audio Element 提供了 preservePitch 的功能,这似乎是一个比较理想的解决方案。

    一些经验总结

    1. 代码生成与抽象
      即使通过 LLM 生成代码的成本较低,也尽量让它生成易于拓展和复用的代码。在我的尝试中,即便切换了多个不同的音频库,我都不需要修改业务代码,这正是因为我在最初就让 LLM 对音频处理部分进行了足够的抽象。
    2. LLM 在处理开放性问题时的局限性
      如果待解决的开放性问题足够普遍,LLM 通常能给出令人满意的答案。但对于像 Web Audio 这样 API 复杂且不太常见的技术领域,LLM 可能就很难解决问题了。例如,在解决 Pitch Shift 效果器的噪音问题时,DeepSeek 很难坚定地指出 Tone.js 的实现本身就存在缺陷(事实上也是如此)。这大概率是因为 LLM 只是一个互联网文本预测器,容易产生“幻觉”,尤其是在处理小众领域的问题时。

    总结

    在开发过程中,抽象和代码复用是非常重要的,尤其是在处理新技术领域时。虽然 LLM 在很多常见问题上表现出色,但在处理复杂或小众领域的问题时,仍需要结合人工经验和深入的技术调研来找到更优的解决方案。

  • Obsidian 数据同步方案:从 Git 到 Syncthing 的平滑切换

    Obsidian 作为一款本地优先的笔记软件,数据安全始终是用户关注的重点。我之前选择 Git 来进行版本控制,但随着 Obsidian 插件的不断增加,有些插件会生成大量二进制文件,这些文件如果直接存储在 Git 仓库中,会快速消耗 Git LFS 的免费存储额度。

    我最初考虑使用 Remotely Save 插件的 WebDAV 功能将数据备份到 NAS,但在 QNAP 上频繁遇到 403 错误,最终决定采用更稳定的 Syncthing 方案。

    Syncthing 在 QNAP 上的部署要点

    在 QNAP 上安装 Syncthing 需要通过第三方软件源 MyQNAP.org,该源提供了两个 Syncthing 安装包:

    • Syncthing:以普通用户权限运行
    • Syncthing Run As Root:以 root 用户权限运行

    建议选择 Syncthing,原因如下:

    1. 权限管控更安全:避免 root 权限带来的安全隐患
    2. 多用户兼容性更好:非管理员用户也能正常访问 Syncthing 同步的文件

    :安装后系统会自动创建 syncthing 用户,默认属于 everyone 用户组

    配置优化建议

    1. 文件夹权限设置: 同步文件夹的权限配置需要特别注意,建议设置为 <user>:everyone 模式,确保 Syncthing 有完整的读写权限。
    2. Ignore Permissions 选项:在 Syncthing 中启用该选项,因为普通用户权限的 Syncthing 无法修改其他 NAS 用户的文件权限。
    3. Linux 桌面端推荐:建议使用 Syncthing GTK,它提供了更友好的图形界面,并支持后台运行。
    4. QNAP 共享文件夹的位置:在 QNAP 中,用户创建的共享文件夹都位于 /share/ 目录下。

    Web UI 访问优化

    如果通过 Nginx Proxy Manager 反向代理 Syncthing 的 Web UI,请务必执行以下设置:

    • 取消 Cache Assets 选项
    • 原因:Syncthing 依赖 /meta.js 文件验证登录状态,缓存 Assets 会导致登录功能异常

    这种同步方案既解决了 Git LFS 的存储限制,又避免了 Remotely Save 的 403 问题,是目前我认为比较理想的 Obsidian 数据同步方案。建议在切换前做好数据备份,确保万无一失。

  • 划词翻译插件的 Prompt

    沉浸式翻译固然好用,但是不利于学习单词,当遇到不认识的单词时,我更倾向于使用划词翻译插件。

    下面就是我用来执行翻译任务的 Prompt:

    你是一名优秀的翻译人员,熟练掌握各种语言,拥有母语般的掌握程度。
    
    在翻译任务中,你需要将「原文语言」翻译成「目标语言」,任务要求如下:
    
    1. **单词或词组的翻译**
       - 直接给出音标、词性跟含义。
       - 用「原文语言」给出简单易懂的解释,确保初学者能够理解。
       - 示例:
         - 翻译:
           - 音标:/ˈæp.əl/
           - 词性:名词
           - 含义:苹果
           - 原文语言解释:A round fruit with red, green, or yellow skin and white flesh.
    
    2. **句子或段落的翻译**:把握原文的精髓,用「目标语言」复述,而不是机械的翻译。
    
    3. **输出格式**:纯文本,不要使用 markdown 标记。
    
    现在,翻译的「目标语言」是 {{target}},以「原文语言」编写的待翻译文本如下:
    
    {{text}}

    上面的 prompt 使用了一些 prompt 技巧,使得 gpt-4o-mini 级别的模型也能够很好的完成翻译目标,这些技巧包括:

    • 结构化地组织语言,使得任务目标更加清晰,减少 LLM 的阅读障碍
    • 提供样例,就算是弱智一些的 LLM 也会严格按照样例输出。
    • 定义变量,由于翻译场景的限制,Prompt 阶段我们不可能得知待翻译文本的书写语言,通过定义「原文语言」跟「目标语言」并明确它们之间的关系,使得我们可以在 prompt 中通过这些「变量」来指代它们。