飞牛 fnOS 应用开发实战:CGI + HTML 升级面板避坑指南
前言
在开发飞牛 fnOS 应用商店的过程中,我们为 fn-memos 应用新增了 CGI 升级面板。整个过程踩了不少坑,从环境变量不可用到路径解析错误,再到 stderr 污染 CGI 响应。本文将完整记录使用 CGI + HTML 开发飞牛应用的正确姿势和常见陷阱。
为什么需要 CGI?
fn-memos 是一个直接运行 CLI 二进制文件的飞牛应用,没有独立的 Web 界面。但用户希望能通过 Web 页面管理 Memos 的升级,这时就需要用到飞牛的 CGI 机制——在应用内提供一个 Web 入口,用户点击桌面图标即可打开升级管理界面。
CGI 文件结构
应用目录结构如下:
fn-memos/
├── app/
│ ├── bin/
│ │ └── memos # Memos CLI 二进制文件
│ ├── ui/
│ │ ├── images/
│ │ │ ├── icon_64.png
│ │ │ └── icon_256.png
│ │ ├── config # 桌面入口配置
│ │ ├── index.cgi # CGI 可执行文件(必须叫这个名字)
│ │ └── api.cgi # 升级 API 接口
│ └── www/
│ ├── index.html # 页面结构
│ ├── index.css # 样式
│ └── index.js # 交互逻辑
├── cmd/
│ ├── main # 应用启停脚本
│ ├── install_callback # 安装回调
│ └── ...
├── wizard/
│ └── install # 安装向导配置
├── manifest
└── ...
入口配置
在 app/ui/config 中定义桌面图标入口:
{
".url": {
"fn-memos.upgrade": {
"title": "Memos 管理",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "http",
"url": "/cgi/ThirdParty/fn-memos/index.cgi/",
"allUsers": false
}
}
}
关键点:url 必须以
index.cgi/ 结尾。系统会将请求路由到
/cgi/ThirdParty/{appname}/index.cgi/ 下。
index.cgi:必须叫这个名字
CGI 可执行文件必须命名为
index.cgi。系统只识别这个固定文件名,不能叫
upgrade.cgi 或其他名字。
最简单的 index.cgi 模板:
#!/bin/bash
# 全局抑制 stderr,避免污染 CGI 响应
exec 2>/dev/null
# 输出 HTTP 头
echo "Content-Type: text/html; charset=utf-8"
echo ""
HTML_PATH="/var/apps/fn-memos/target/www/index.html"
if [ ! -f "$HTML_PATH" ]; then
echo "<h1>错误:index.html 不存在</h1>"
exit 1
fi
cat "$HTML_PATH"
如果需要支持 CSS、JS 等静态资源,需要加上路径解析和 MIME 分发:
#!/bin/bash
exec 2>/dev/null
BASE="/var/apps/fn-memos/target/www"
# 从 REQUEST_URI 提取 index.cgi 后面的路径
URI_NO_QUERY="${REQUEST_URI%%\?*}"
REL_PATH="/"
case "$URI_NO_QUERY" in
*index.cgi*)
REL_PATH="${URI_NO_QUERY#*index.cgi}"
;;
esac
# 缺省返回 index.html
[ -z "$REL_PATH" ] || [ "$REL_PATH" = "/" ] && REL_PATH="/index.html"
# 防 .. 越级
case "$REL_PATH" in *..*)
echo "Status: 400"
echo "Content-Type: text/plain"
echo ""
echo "Bad Request"
exit 0 ;;
esac
FILE="${BASE}${REL_PATH}"
[ ! -f "$FILE" ] && exit 0
# MIME 分发
case "${FILE##*.}" in
html) mime="text/html; charset=utf-8" ;;
css) mime="text/css; charset=utf-8" ;;
js) mime="application/javascript; charset=utf-8" ;;
*) mime="application/octet-stream" ;;
esac
echo "Content-Type: $mime"
echo ""
cat "$FILE"
坑一:TRIM_* 环境变量在 CGI 中不可用
飞牛在 cmd/ 生命周期脚本中提供了大量
TRIM_* 环境变量(如
TRIM_APPDEST、TRIM_PKGTMP),详见官方文档《环境变量》。但这些变量在 CGI 脚本中不存在!
CGI 由飞牛的 Web 服务器执行,只注入标准 CGI
变量(REQUEST_URI、QUERY_STRING、REQUEST_METHOD、CONTENT_LENGTH
等)。
错误写法:
BIN_DIR="${TRIM_APPDEST}/bin" # ❌ CGI 中为空
正确写法:
# 硬编码系统路径,对应 target 目录
BIN_DIR="/var/apps/fn-memos/target/bin"
MEMOS_BIN="${BIN_DIR}/memos"
TEMP_DIR="/var/apps/fn-memos/tmp"
坑二:stderr 污染 CGI 响应
CGI 脚本的任何 stderr 输出都会混入 HTTP 响应体,导致前端收到非 JSON 内容而报
Unexpected token 错误。
必须在脚本开头全局抑制:
exec 2>/dev/null # 在脚本开头加上这一行
所有子命令的错误输出也要小心处理,建议重定向到日志文件:
curl ... 2>>/var/apps/fn-memos/var/upgrade.log
坑三:前端 fetch 的相对路径陷阱
页面 URL 是 /cgi/ThirdParty/fn-memos/index.cgi/,如果 JS
中写相对路径 fetch("api.cgi"),浏览器会解析为:
/cgi/ThirdParty/fn-memos/index.cgi/api.cgi ❌ 错误!
因为 Web 服务器把
index.cgi/ 当成目录前缀,相对路径会追加到后面。必须使用绝对路径:
fetch("/cgi/ThirdParty/fn-memos/api.cgi?action=check") ✅ 正确
坑四:向导配置在 CGI 中不可用
安装向导(wizard/install)中用户选择的端口号、存储类型等配置,只在
cmd/install_callback 等生命周期脚本中作为环境变量可用(如
$wizard_port)。CGI 中无法直接获取。
解决方案:在 install_callback 中将配置持久化到文件,CGI 中读取:
#!/bin/bash
# cmd/install_callback
CNF_FILE="${TRIM_APPDEST}/wizard/install.cnf"
mkdir -p "$(dirname "$CNF_FILE")"
cat > "$CNF_FILE" <<EOF
MEMOS_PORT="${wizard_port:-5230}"
MEMOS_STORAGE="${wizard_storage_type:-local}"
MEMOS_DSN="${wizard_dsn:-}"
EOF
CGI 中读取:
CNF_FILE="/var/apps/fn-memos/target/wizard/install.cnf"
if [ -r "$CNF_FILE" ]; then
. "$CNF_FILE"
fi
坑五:CGI 中不能调用 cmd/main 启停应用
cmd/main 脚本依赖飞牛注入的环境变量(如
$wizard_port、$wizard_storage_type),在 CGI
中直接调用会导致端口和存储配置丢失。
正确做法:读取持久化的配置文件,自行拼装启动命令:
case "$MEMOS_STORAGE" in
mysql)
CMD="${MEMOS_BIN} --port ${MEMOS_PORT} --data ${DATA_DIR} --driver mysql --dsn \"${MEMOS_DSN}\""
;;
postgres)
CMD="${MEMOS_BIN} --port ${MEMOS_PORT} --data ${DATA_DIR} --driver postgres --dsn \"${MEMOS_DSN}\""
;;
*)
CMD="${MEMOS_BIN} --port ${MEMOS_PORT} --data ${DATA_DIR}"
;;
esac
bash -c "${CMD}" >> "${INFO_LOG}" 2>&1 &
printf "%s" "$!" > "${PID_FILE}"
升级面板实现效果
最终实现的升级面板包含以下功能:
- 自动检测当前版本和 GitHub 最新版本
- 一键下载最新 Memos 二进制文件并替换
- 替换后自动重启进程(读取 PID 文件 → TERM → KILL → 新进程 → 写 PID)
- 终端风格的升级日志面板,实时展示操作日志
- Dialog 弹窗确认升级和结果反馈
总结:CGI 开发要点清单
| 要点 | 说明 |
|---|---|
| 文件名 | 必须是 index.cgi |
| 路径 |
硬编码 /var/apps/{appname}/target/...,不用 TRIM 变量
|
| stderr | 开头加 exec 2>/dev/null |
| fetch 路径 | 用绝对路径 /cgi/ThirdParty/{app}/api.cgi |
| 权限 | chmod +x *.cgi |
| URL 配置 | 以 /index.cgi/ 结尾 |
| 向导配置 | install_callback 中写入文件,CGI 中 source 读取 |
| 启停应用 | 读取持久化配置自行启停,不要直接调用 cmd/main |