# 飞牛 fnOS fpk 打包与部署 把网页粒子编辑器打包成飞牛 fnOS 应用包(`.fpk`),装到 NAS 后从桌面图标直接打开。本文是完整流程,照着做即可重复出包。 ## 1. 一次性准备 **打包机**:开发用的 Mac/Windows/Linux 即可,需要 Node 环境(项目本来就有)。 **fnpack**:飞牛官方的打包工具,从 https://developer.fnnas.com/docs/cli/fnpack/ 下载对应平台版本(本项目实测 `fnpack-1.2.3-darwin-arm64`): ```text chmod +x fnpack-1.2.3-darwin-arm64 mv fnpack-1.2.3-darwin-arm64 /usr/local/bin/fnpack # 或任意位置,后面用 FNPACK 指定 fnpack --help ``` **目标设备**:已初始化的飞牛 fnOS,管理员账号,有可用存储空间,开发机可访问。 ## 2. 目录与职责 | 路径 | 作用 | | --- | --- | | `src/` | 前端源码,编辑器本体 | | `dist/` | vite 构建产物,**网页端和飞牛包共用这一份** | | `packaging/fnos/SpineParticle/` | fnOS 包工程:`manifest`、`cmd/`、`config/`、`app/ui/`、`app/server/`、图标 | | `scripts/build-fnos-package.mjs` | 把 `dist/` 复制进包的 `app/www/` 并调用 fnpack | | `release/fnos/` | 打好的 `.fpk`,作为发布物入库(和 `release/connector/` 下的连接器产物一样) | 包工程里 `app/www/` 由脚本生成,不要手改,改了也会被下次打包覆盖。 **注意**:`packaging/`、`scripts/build-fnos-package.mjs`、`src/editor/publicUrl.ts` 以及前端那几处配套改动**没有入库**,仓库里只有本文档和 1.0.2 安装包。第一次打包前按第 10 章重建一次即可,内容与原版一致。 ## 3. 打包 (如果 `npm run pack:fnos` 不存在,先完成第 10 章的重建。) **第一步,改版本号**:编辑 `packaging/fnos/SpineParticle/manifest`,改这两行(飞牛按版本号识别升级,重打包务必递增): ```ini version=1.0.3 changelog=这次改了什么,面向用户写一句话 ``` **第二步,执行打包**: ```text npm run pack:fnos ``` 它等价于 `npm run build && node scripts/build-fnos-package.mjs`:先做 vue-tsc 类型检查和 vite 构建,然后清空 `app/www/`、复制 `dist/`(自动排除 `.DS_Store`)、调用 `fnpack build`,最后把产物移到 `release/fnos/`。 **产物**:`release/fnos/SpineParticle-.fpk`,当前约 2.6MB。 如果 `fnpack` 不在 PATH 里: ```text FNPACK=/path/to/fnpack npm run pack:fnos ``` ## 4. 安装到飞牛 方式一(图形界面):飞牛桌面 → 应用中心 → 左下角「手动安装」→ 选 `.fpk`。 方式二(命令行,需 SSH 到 NAS): ```text appcenter-cli install-fpk SpineParticle-1.0.3.fpk ``` 安装完成后 `cmd/install_callback` 会自动启动内置静态服务;如果应用中心显示未运行,手动点一次「启动」。装好刷新一次飞牛桌面(F5 或重新登录),桌面图标才会出现。 ## 5. 装完必须验证的几项 | 检查项 | 预期 | | --- | --- | | 桌面图标 | 出现「Spine粒子编辑器」,点开是编辑器界面(`http://<飞牛地址>:27850/`) | | 应用中心状态 | 显示运行中,可启动/停止 | | 预设下拉 | 能加载出预设列表(读取 `/Preset/index.json`) | | 连接器下载 | 帮助弹窗第 9 节两个下载按钮可用,能下到 exe 和 zip | | 远程备用入口 | `<飞牛地址>/cgi/ThirdParty/SpineParticle/index.cgi/` 也能打开 | | 连接器联动 | 本机启动连接器后,导出区显示「连接器可用 · Spine 已就绪」 | ## 6. 包形态说明(重要,避免走回头路) 当前用的是**端口服务**形态: ```ini service_port=27850 install_dep_apps=nodejs_v22 ctl_stop=true ``` `app/server/server.mjs` 是 Node 写的静态服务,把 `app/www` 挂到 27850 端口,`cmd/main` 负责启停,桌面入口配置在 `app/ui/config`,指向该端口。 **为什么不用 CGI 入口**:`/cgi/ThirdParty/{appname}/index.cgi/` 这套(纯静态、不占端口)在开发机上打包校验能通过,页面直连也能打开,但在目标设备上桌面入口始终没注册——表现就是桌面没有图标、应用中心「打开」点了没反应。试过补 `protocol`/`port` 字段也无效,换成端口服务后立即正常。所以后续请沿用端口形态。 `app/ui/index.cgi` 保留着,仅作为远程访问备用入口,和端口走同一份 `www`。 ## 7. 常见问题 | 现象 | 处理 | | --- | --- | | 桌面没有图标 / 「打开」没反应 | 确认应用状态是运行中;刷新(F5)或重新登录飞牛桌面 | | 安装报依赖错误 | 确认 `manifest` 里 `install_dep_apps=nodejs_v22`,且设备能安装该运行时 | | 端口被占用 | 同时改两处并保持一致:`manifest` 的 `service_port`、`app/ui/config` 的 `port`,然后重打包 | | 外网点图标打不开 | 端口形态需要该端口可达;外网请用 `/cgi/ThirdParty/SpineParticle/index.cgi/` | | 页面白屏或资源 404 | 前端构建已改为相对路径(vite `base: './'`),若做过路径相关改动,先跑 `npm run build` 再用新 `dist` 打包 | | 服务起不来 | 看设备上 `/var/apps/SpineParticle/var/app.log` | | 安装异常 | 看设备上 `/var/log/trim_app_center/error.log` | ## 8. 上架应用中心前待办 - 替换图标:`packaging/fnos/SpineParticle/` 下 `ICON.PNG`(64×64)、`ICON_256.PNG`(256×256),以及 `app/ui/images/icon_64.png`、`icon_256.png`,规格 sRGB、圆角方形、单文件 ≤1MB。目前是脚本生成的占位图。 - 核对 `manifest` 里的 `maintainer`、`maintainer_url`、`distributor`、`os_min_version`(现填 Yeomai / gitlab 地址 / 1.0.0)。 - 准备真实界面截图和测试材料。 - 通过飞牛开发者先锋群 / 开发者后台提交。 ## 9. 提交代码时注意 - `dist/` 在版本库里是**跟踪状态**,重新构建会带出 assets 的增删(文件名带哈希),要和源码改动一起提交,否则别人拉下来跑出的页面和打包不一致。 - `release/fnos/*.fpk` 是发布物,按现有惯例入库;`packaging/fnos/SpineParticle/app/www/` 是构建中间产物,重建骨架后如果想让它不出现在 `git status` 里,自行往 `.gitignore` 加三行: ```text /packaging/fnos/SpineParticle/app/www/ /packaging/fnos/SpineParticle/*.fpk ``` - 包的形态、端口、入口配置一旦上架,跨版本要保持稳定,改端口等于改变用户访问方式。 ## 10. 附录:重建打包骨架 仓库里没有打包源码,只有本文档和安装包。按 10.1~10.6 重建一次,`npm run pack:fnos` 就能完整跑通。每个小标题就是文件路径,代码块原样存成该文件即可;下面的内容与打进 1.0.2 安装包的那份一致,重建后打出的包与 `release/fnos/SpineParticle-1.0.2.fpk` 相同(重建时把版本号往上加,见第 3 章)。 ### 10.1 建目录 ```bash mkdir -p packaging/fnos/SpineParticle/{app/ui/images,app/server,cmd,config,wizard} ``` ### 10.2 fnOS 包工程 #### packaging/fnos/SpineParticle/manifest ```ini appname=SpineParticle version=1.0.2 display_name=Spine粒子编辑器 desc=把粒子系统编辑并导出为 Spine 工程的网页编辑器:编辑、预览、导出 Spine Json 与图片包都在浏览器内完成,导出 .spine 工程需自备本机已安装并激活的 Spine,并运行应用内可下载的桌面连接器。本应用不包含 Spine 本体,与 Esoteric Software 无隶属关系。 changelog=改为端口服务形态:安装后自动启动内置静态服务,桌面图标与「打开」可正常使用。 platform=all source=thirdparty maintainer=Yeomai maintainer_url=http://gitlab.yeomai.site/Yeomai/SpineParticlesWeb distributor=Yeomai distributor_url=http://gitlab.yeomai.site/Yeomai/SpineParticlesWeb os_min_version=1.0.0 service_port=27850 install_dep_apps=nodejs_v22 desktop_uidir=ui desktop_applaunchname=SpineParticle.Application ctl_stop=true ``` #### packaging/fnos/SpineParticle/config/privilege ```json { "defaults": { "run-as": "package" }, "username": "spineparticle", "groupname": "spineparticle" } ``` #### packaging/fnos/SpineParticle/config/resource ```json {} ``` #### packaging/fnos/SpineParticle/app/ui/config ```json { ".url": { "SpineParticle.Application": { "title": "Spine粒子编辑器", "icon": "images/icon_{0}.png", "type": "url", "protocol": "http", "port": "27850", "url": "/", "allUsers": true } } } ``` #### packaging/fnos/SpineParticle/app/ui/index.cgi 仅作为远程访问的备用入口,和端口形态共用同一份 `www`。 ```bash #!/bin/bash # 静态页面入口:把 /cgi/ThirdParty/SpineParticle/index.cgi/ 之后的路径映射到应用目录下的 www。 # 前端由 vite 以相对路径构建(base: './'),所以入口地址必须带结尾斜杠。 BASE_PATH="/var/apps/SpineParticle/target/www" URI_NO_QUERY="${REQUEST_URI%%\?*}" case "$URI_NO_QUERY" in *index.cgi/*) REL_PATH="${URI_NO_QUERY#*index.cgi}" ;; *index.cgi) printf 'Status: 301 Moved Permanently\r\nLocation: %s/\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n' "$URI_NO_QUERY" exit 0 ;; *) REL_PATH="/" ;; esac if [ -z "$REL_PATH" ] || [ "$REL_PATH" = "/" ]; then REL_PATH="/index.html" fi TARGET_FILE="${BASE_PATH}${REL_PATH}" if echo "$TARGET_FILE" | grep -q '\.\.'; then echo "Status: 400 Bad Request" echo "Content-Type: text/plain; charset=utf-8" echo echo "Bad Request" exit 0 fi if [ -d "$TARGET_FILE" ]; then TARGET_FILE="${TARGET_FILE%/}/index.html" fi if [ ! -f "$TARGET_FILE" ]; then echo "Status: 404 Not Found" echo "Content-Type: text/plain; charset=utf-8" echo echo "Not Found" exit 0 fi case "$TARGET_FILE" in *.html) CONTENT_TYPE="text/html; charset=utf-8" ;; *.js) CONTENT_TYPE="text/javascript; charset=utf-8" ;; *.css) CONTENT_TYPE="text/css; charset=utf-8" ;; *.json) CONTENT_TYPE="application/json; charset=utf-8" ;; *.png) CONTENT_TYPE="image/png" ;; *.jpg|*.jpeg) CONTENT_TYPE="image/jpeg" ;; *.svg) CONTENT_TYPE="image/svg+xml" ;; *.zip) CONTENT_TYPE="application/zip" ;; *) CONTENT_TYPE="application/octet-stream" ;; esac echo "Content-Type: ${CONTENT_TYPE}" echo cat "$TARGET_FILE" ``` #### packaging/fnos/SpineParticle/app/server/server.mjs ```javascript // 端口服务形态的静态文件服务:把安装目录下的 www 暴露给飞牛桌面入口。 import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { createServer } from 'node:http' import { extname, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = resolve(fileURLToPath(new URL('../www/', import.meta.url))) const PORT = Number(process.env.SPINE_PARTICLE_PORT || process.env.TRIM_SERVICE_PORT || 27850) const TYPES = { html: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8', json: 'application/json; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', svg: 'image/svg+xml', zip: 'application/zip', exe: 'application/octet-stream', } async function resolveFile(pathname) { const target = resolve(ROOT, `.${decodeURIComponent(pathname)}`) if (target !== ROOT && !target.startsWith(ROOT + sep)) return null const info = await stat(target).catch(() => null) if (!info) return null if (info.isDirectory()) return resolveFile(`${pathname.replace(/\/?$/, '/')}index.html`) return info.isFile() ? target : null } createServer(async (request, response) => { const file = await resolveFile(new URL(request.url || '/', 'http://localhost').pathname) if (!file) { response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }) response.end('Not Found') return } response.writeHead(200, { 'Content-Type': TYPES[extname(file).slice(1).toLowerCase()] || 'application/octet-stream' }) createReadStream(file).pipe(response) }).listen(PORT, '0.0.0.0', () => { console.log(`Spine粒子编辑器已启动:http://0.0.0.0:${PORT}/ ,静态目录 ${ROOT}`) }) ``` #### packaging/fnos/SpineParticle/cmd/main ```bash #!/bin/bash # 端口服务:启动内置 Node 静态服务,供桌面入口通过 service_port 访问。 LOG_FILE="${TRIM_PKGVAR}/app.log" PID_FILE="${TRIM_PKGVAR}/app.pid" APP_DIR="${TRIM_APPDEST}/server" PORT="${TRIM_SERVICE_PORT:-27850}" export PATH=/var/apps/nodejs_v22/target/bin:$PATH log_msg() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE" } check_process() { kill -0 "$1" 2>/dev/null } status_app() { if [ -f "$PID_FILE" ]; then local pid pid="$(head -n 1 "$PID_FILE" | tr -d '[:space:]')" if [ -n "$pid" ] && check_process "$pid"; then return 0 fi rm -f "$PID_FILE" fi return 1 } start_app() { if status_app; then return 0 fi log_msg "Starting SpineParticle on port ${PORT}..." cd "$APP_DIR" || exit 1 SPINE_PARTICLE_PORT="$PORT" node server.mjs >> "$LOG_FILE" 2>&1 & echo "$!" > "$PID_FILE" } stop_app() { log_msg "Stopping SpineParticle..." if [ ! -f "$PID_FILE" ]; then return 0 fi local pid pid="$(head -n 1 "$PID_FILE" | tr -d '[:space:]')" if [ -z "$pid" ] || ! check_process "$pid"; then rm -f "$PID_FILE" return 0 fi kill -TERM "$pid" 2>> "$LOG_FILE" || true local count=0 while check_process "$pid" && [ "$count" -lt 10 ]; do sleep 1 count=$((count + 1)) done if check_process "$pid"; then kill -KILL "$pid" 2>> "$LOG_FILE" || true fi rm -f "$PID_FILE" } case "$1" in start) start_app ;; stop) stop_app ;; status) if status_app; then exit 0 fi exit 3 ;; *) echo "Unknown command: $1" > "$TRIM_TEMP_LOGFILE" exit 1 ;; esac ``` #### packaging/fnos/SpineParticle/cmd/install_callback ```bash #!/bin/bash # 安装后直接拉起静态服务,省去用户手动点“启动”。 cd "$(dirname "$0")" && ./main start exit 0 ``` #### packaging/fnos/SpineParticle/cmd/uninstall_init ```bash #!/bin/bash # 卸载前停掉静态服务。 cd "$(dirname "$0")" && ./main stop exit 0 ``` #### packaging/fnos/SpineParticle/cmd/ 其余六个脚本 `install_init`、`upgrade_init`、`upgrade_callback`、`uninstall_callback`、`config_init`、`config_callback` 内容相同: ```bash #!/bin/bash exit 0 ``` ### 10.3 构建脚本 #### scripts/build-fnos-package.mjs ```javascript // 把 dist/ 装进飞牛 fpk 项目并调用 fnpack 打包。 import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const projectDir = join(root, 'packaging/fnos/SpineParticle') const wwwDir = join(projectDir, 'app/www') const distDir = join(root, 'dist') if (!existsSync(join(distDir, 'index.html'))) { console.error('缺少 dist/index.html,请先执行 npm run build') process.exit(1) } if (!wwwDir.endsWith('/app/www')) throw new Error(`拒绝清空的目录:${wwwDir}`) rmSync(wwwDir, { recursive: true, force: true }) cpSync(distDir, wwwDir, { recursive: true, filter: (source) => !source.endsWith('.DS_Store') }) execFileSync(process.env.FNPACK || 'fnpack', ['build'], { cwd: projectDir, stdio: 'inherit' }) const version = /^version\s*=\s*(.+)$/m.exec(readFileSync(join(projectDir, 'manifest'), 'utf8'))?.[1].trim() const outDir = join(root, 'release/fnos') const target = join(outDir, `SpineParticle-${version}.fpk`) mkdirSync(outDir, { recursive: true }) rmSync(target, { force: true }) renameSync(join(projectDir, 'SpineParticle.fpk'), target) console.log(`已生成 ${target}`) ``` ### 10.4 恢复可执行权限 ```bash chmod +x packaging/fnos/SpineParticle/cmd/* packaging/fnos/SpineParticle/app/ui/index.cgi ``` ### 10.5 图标(4 个 PNG) 规格:PNG 或 JPG、sRGB、正方形、单文件 ≤1MB、主体的圆角和留白贴近系统图标。 - `packaging/fnos/SpineParticle/ICON.PNG`(64×64) - `packaging/fnos/SpineParticle/ICON_256.PNG`(256×256) - `packaging/fnos/SpineParticle/app/ui/images/icon_64.png` - `packaging/fnos/SpineParticle/app/ui/images/icon_256.png` 只是跑通流程的话,随便找一张方形图用 macOS 自带的 `sips` 缩放就行(正式上架要换成设计稿): ```bash sips -z 256 256 源图.png --out packaging/fnos/SpineParticle/ICON_256.PNG sips -z 64 64 源图.png --out packaging/fnos/SpineParticle/ICON.PNG cp packaging/fnos/SpineParticle/ICON_256.PNG packaging/fnos/SpineParticle/app/ui/images/icon_256.png cp packaging/fnos/SpineParticle/ICON.PNG packaging/fnos/SpineParticle/app/ui/images/icon_64.png ``` ### 10.6 前端侧配套改动 这几处让前端在「域名根目录」和「子路径」两种部署下都能取到资源;少了 `publicUrl.ts` 会直接导致 `vue-tsc` 编译失败。 #### 新增 src/editor/publicUrl.ts ```typescript // 应用可能部署在域名根路径,也可能部署在飞牛 fpk 的 /cgi/.../index.cgi/ 子路径下。 // 用文档地址解析相对路径,两种部署方式都能取到 public/ 下的静态资源。 export function publicUrl(path: string) { return new URL(`${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`, document.baseURI).href } ``` #### vite.config.ts 在 `defineConfig({` 里的 `plugins` 之后加两行: ```typescript // 相对路径:既要能部署在域名根目录,也要能跑在飞牛 fpk 的 /cgi/ThirdParty/SpineParticle/index.cgi/ 子路径下。 base: './', ``` #### package.json `scripts` 里加一条: ```json "pack:fnos": "npm run build && node scripts/build-fnos-package.mjs", ``` #### src/views/particle-panel/useProjectPresets.ts 顶部 import 区加一行: ```typescript import { publicUrl } from '../../editor/publicUrl' ``` `refreshPresets` 和 `loadSelectedPreset` 里各有一处 fetch,改成用 `publicUrl` 解析: ```typescript const response = await fetch(`${publicUrl('Preset/index.json')}?t=${Date.now()}`, { cache: 'no-store' }) ``` ```typescript const response = await fetch(publicUrl(`Preset/${encodeURIComponent(file)}`), { cache: 'no-store' }) ``` #### src/views/HelpDialog.vue `