家里那台飞牛 NAS 一直吃灰。我想在上面放一个能随时写博客的客户端——不是把文件传上去、再回电脑编辑那种别扭的用法,而是像在本地一样随手写、随手看。

折腾了大半个月,最大的收获不是「跑起来了」,而是想明白了一件事:

给没有显示器的 NAS 做桌面应用,正确姿势往往是——别做桌面应用。

一、最初的方案:把 Electron 塞进 NAS

Gridea 是 Electron 写的静态博客客户端,界面完整、功能齐全。第一反应当然是原样搬过去:

Xvfb(虚拟屏 :99)→ Gridea(Electron)→ x11vnc(127.0.0.1:5900)→ websockify + noVNC(127.0.0.1:6080)

飞牛桌面点图标 → 桌面窗口(iframe)里加载 /vnc.html → 看到完整的客户端。

原理上完全可行,我确实把它跑起来了,代价是:

问题 表现
依赖一堆系统包 缺 xvfb / x11vnc / novnc / websockify 任一个就起不来
包体大 包里带一整个 Chromium,72 MB
内存高 Chromium + VNC 服务端常驻约 520 MB
画面是「远程桌面」 缩放模糊、输入法/滚动不跟手,本质上是在看视频
链路长 4 个进程串联,任一环挂掉整页白屏

最后一条尤其要命:白屏的时候,你根本不知道是哪一环断了,只能一个一个翻日志。

二、换个思路:它本来就是个网页

Gridea 的业务层(文章、标签、菜单、主题、渲染、部署)跟 Electron 一点关系都没有——Electron 只是那层「外壳」。既然 NAS 上根本没有显示器,为什么还要背着一整个 Chromium?

于是我加了一层传输层抽象:

  • Electron 端:IPC(ipcMain.handle / ipcRenderer.invoke)
  • Web 端:HTTP + SSE(/api/ipc/:channel 发请求,/api/events 推消息)

业务层一行没改。同一份代码,构建出两种形态:

  • 桌面端 → Electron 窗口
  • 服务端 → Express + 一个 HTTP 服务
浏览器 / 飞牛桌面 ──┐
                    │  HTTP + SSE
              ┌─────▼───────────────────────────┐
              │ headless.js(Express)           │
              │  /            前端静态产物        │
              │  /api/ipc/:ch  IPC 请求入口       │
              │  /api/events   SSE 推送           │
              │  /preview      渲染好的站点预览     │
              │  :52000        内置 MCP 服务       │
              └─────────────────────────────────┘
                       │
              server/  文章 / 标签 / 主题 / 渲染 / 部署 / MCP

包体从 72 MB 降到 44.7 MB,常驻内存从 520 MB 降到 120 MB,界面从「远程桌面」变成原生网页。飞牛桌面图标直接指向页面,链路从 4 层缩到 1 层。

三、那些把人卡住半小时的坑

这一节是本文的重点,因为它们全都是看起来毫不相关的错,而且症状极具迷惑性。

1. fpk 里 node_modules 是空的

resources/app/node_modules 目录在,打包进去却是空的,主进程 require 直接崩。依赖收集脚本只扫了 src/,漏了 helpers/ 目录。

更坑的是本机跑得好好的——仓库根目录的 node_modules 把问题盖住了,一到 NAS 上才炸。

修完依赖还不够,我又在打包后加了一道自检:拿包内实际内容再扫一遍第三方 require,缺一个就直接中止构建。

2. 局域网访问被自己的安全策略挡住

Web 版起来了,浏览器访问 NAS IP 却「拒绝连接」。查了半天,最后发现是 websockify 默认只绑 127.0.0.1——安全的默认值在局域网场景下直接变成了 bug。

改成默认 0.0.0.0,同时保留一个 loopback-only 开关给需要收紧的场景。

3. 预览页全白,样式一个都不生效

端口是 0。initServer({listen:false}) 之后拿到的 preview port 就是 0,于是渲染时 themeConfig.domain 被写成 http://localhost:0,所有 CSS、图片、链接都指向空端口。

改用占位域名 http://gridea.preview,出 HTML 时按本次请求的真实前缀替换。

4. 按钮点了没反应,也不报错

MCP 的开关在网页上第一次点不动。所有带参数的 invoke 通道「点了没反应且不报错」。

原因:Web 端的 ipcMain.handle 漏传了 Electron 的第一个 event 参数,导致处理器的业务参数整体左移了一位。

// 错:业务参数被当成 event
invokers.set(channel, (...args) => fn(args[0], ...args.slice(1)))
// 对:补上 event
invokers.set(channel, (...args) => fn(createWebEvent(), ...args))

这类「签名约定靠约定」的封装,一旦写错就是全盘静默。

5. 渲染静默失败,产物是空的

__non_webpack_require__ is not defined。renderer.ts 用它加载主题的 style-override.js,但这个标识符只存在于 webpack 产物里,tsc 编译的 Web 版没有。

表现是:构建成功、页面有壳、产物是空的——因为抛错的 Promise 没人接。

const req = typeof __non_webpack_require__ !== 'undefined'
  ? __non_webpack_require__
  : eval('require')

6. | head 一下,进程就死了

stdout 的 EPIPE 被升级成了 uncaughtException,直接把进程带走。这个坑很隐蔽:我只是想 tail 一下日志。

7. 装完不知道装到哪去了

fnOS 的应用卷是 /vol2,不是 /vol1。我查 /vol1/@appdata 得出「没装过」的错误结论——其实五个目录都在。

而且应用中心卸载后不会清干净:@appmeta / @appconf / @appdata / @apphome / @appshare 五个目录和系统用户都会留在盘上。

四、MCP:让 AI 直接写博客

这才是我觉得最值的部分。

Gridea 内置了一个 MCP 服务(Streamable HTTP / JSON-RPC 2.0),暴露 8 个工具:

工具 作用
gridea_status 站点状态、目录、文章数量
gridea_list_posts / gridea_get_post 读文章
gridea_create_post / gridea_update_post / gridea_delete_post 写文章(复用应用自己的保存逻辑)
gridea_build 重新渲染静态站点
gridea_publish 推送到远端(GitHub / Gitee / Vercel / SFTP)

本文就是用 MCP 写出来的。 完整链路:

AI 客户端 ──HTTP POST /mcp (Bearer token)──→ Gridea MCP
                                              │
                                       复用 Posts.savePostToFile()
                                              │
                                    posts/<slug>.md
                                              │
                                       文件监听自动刷新界面

两个关键设计:

  1. 写文章复用应用自己的 savePostToFile,不是另写一套序列化。否则 AI 写出来的 front matter 和编辑器保存的格式对不上,界面会解析异常。
  2. Bearer token 强制校验,并提供「允许局域网访问」开关。默认只绑 127.0.0.1,勾选后才改绑 0.0.0.0;无论开关如何都必须带 token。

五、现在的样子

  • 飞牛应用中心装一个 44.7 MB 的 fpk,桌面点图标打开就是完整客户端
  • MCP 开着,AI 可以直接读写文章
  • 文章写在 NAS 上,随时换台设备接着写
  • 桌面端(Windows 便携版)照旧用 Electron,两条线互不干扰

六、还没解决的

  • 站点部署到 GitHub 那条路还没在 NAS 上跑通(repository 还是空的)
  • 多端同时编辑的冲突处理没做
  • 主题切换后 MCP 端偶发缓存没刷新

折腾下来,绕的最大一个弯是:一开始就该问「NAS 上需不需要桌面」,而不是「怎么把桌面搬上去」。 少背一个 Chromium,少拉一条 VNC 链路,稳定性直接翻倍。