Files
SpineParticlesWeb/Document/飞牛fpk打包与部署.md
T
2026-10-10 15:35:51 +08:00

20 KiB
Raw Blame History

飞牛 fnOS fpk 打包与部署

把网页粒子编辑器打包成飞牛 fnOS 应用包(.fpk),装到 NAS 后从桌面图标直接打开。本文是完整流程,照着做即可重复出包。

1. 一次性准备

打包机:开发用的 Mac/Windows/Linux 即可,需要 Node 环境(项目本来就有)。

fnpack:飞牛官方的打包工具,从 https://developer.fnnas.com/docs/cli/fnpack/ 下载对应平台版本(本项目实测 fnpack-1.2.3-darwin-arm64):

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,改这两行(飞牛按版本号识别升级,重打包务必递增):

version=1.0.3
changelog=这次改了什么,面向用户写一句话

第二步,执行打包:

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-<version>.fpk,当前约 2.6MB。

如果 fnpack 不在 PATH 里:

FNPACK=/path/to/fnpack npm run pack:fnos

4. 安装到飞牛

方式一(图形界面):飞牛桌面 → 应用中心 → 左下角「手动安装」→ 选 .fpk。

方式二(命令行,需 SSH 到 NAS):

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. 包形态说明(重要,避免走回头路)

当前用的是端口服务形态:

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 加三行:
/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 建目录

mkdir -p packaging/fnos/SpineParticle/{app/ui/images,app/server,cmd,config,wizard}

10.2 fnOS 包工程

packaging/fnos/SpineParticle/manifest

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

{
  "defaults": {
    "run-as": "package"
  },
  "username": "spineparticle",
  "groupname": "spineparticle"
}

packaging/fnos/SpineParticle/config/resource

{}

packaging/fnos/SpineParticle/app/ui/config

{
  ".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。

#!/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

// 端口服务形态的静态文件服务:把安装目录下的 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

#!/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

#!/bin/bash
# 安装后直接拉起静态服务,省去用户手动点“启动”。

cd "$(dirname "$0")" && ./main start
exit 0

packaging/fnos/SpineParticle/cmd/uninstall_init

#!/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 内容相同:

#!/bin/bash

exit 0

10.3 构建脚本

scripts/build-fnos-package.mjs

// 把 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 恢复可执行权限

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 缩放就行(正式上架要换成设计稿):

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

// 应用可能部署在域名根路径,也可能部署在飞牛 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 之后加两行:

  // 相对路径:既要能部署在域名根目录,也要能跑在飞牛 fpk 的 /cgi/ThirdParty/SpineParticle/index.cgi/ 子路径下。
  base: './',

package.json

scripts 里加一条:

    "pack:fnos": "npm run build && node scripts/build-fnos-package.mjs",

src/views/particle-panel/useProjectPresets.ts

顶部 import 区加一行:

import { publicUrl } from '../../editor/publicUrl'

refreshPresets 和 loadSelectedPreset 里各有一处 fetch,改成用 publicUrl 解析:

    const response = await fetch(`${publicUrl('Preset/index.json')}?t=${Date.now()}`, { cache: 'no-store' })
    const response = await fetch(publicUrl(`Preset/${encodeURIComponent(file)}`), { cache: 'no-store' })

src/views/HelpDialog.vue

<script setup> 顶部加 import:

import { publicUrl } from '../editor/publicUrl'

「下载」那一项里的两个链接改成用 publicUrl(原来是 /downloads/... 绝对路径):

                <a class="dl-link" :href="publicUrl('downloads/connector/SpineParticleConnector-windows-x64.exe')" download>Windows 版<br><small>SpineParticleConnector-windows-x64.exe</small></a>
                <a class="dl-link" :href="publicUrl('downloads/connector/SpineParticleConnector-macos-universal.zip')" download>macOS 版(Universal)<br><small>SpineParticleConnector-macos-universal.zip</small></a>

「前置」那一项改写为说明 Spine 前提:

              <div><dt>前置</dt><dd>编辑器本身不包含 Spine:导出 <b>.spine</b> 工程需要你电脑上已安装并<b>激活</b>的 Spine(3.8 / 4.0 / 4.1 / 4.2 / 4.3),并启动下面下载的桌面版连接器。其余功能(编辑、预览、导出 Spine Json、导出 images 包)不依赖 Spine,也不需要连接器。</dd></div>

在「下载」之后插入「首次放行」一项:

              <div><dt>首次放行</dt><dd>连接器未做商业代码签名,系统会拦截一次,属正常提示:<b>Windows</b> 双击 exe,出现“Windows 已保护你的电脑”时点“更多信息”→“仍要运行”;<b>macOS</b> 解压后把 App 拖进“应用程序”,在 Finder 里按住 Control 点图标选“打开”再确认,若仍提示“已损坏”或“无法验证开发者”,到“系统设置 → 隐私与安全性”底部点“仍要打开”,或执行 <code>xattr -dr com.apple.quarantine "Spine粒子连接器.app"</code>。</dd></div>

10.7 重建后自检

npm run pack:fnos

能打出 release/fnos/SpineParticle-<version>.fpk 就说明骨架完整。想先确认前端和服务没问题,可以不依赖飞牛设备本地跑一次:

cd packaging/fnos/SpineParticle/app/server && SPINE_PARTICLE_PORT=27850 node server.mjs

然后浏览 http://127.0.0.1:27850/,应能看到编辑器界面。