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

586 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 飞牛 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-<version>.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
`<script setup>` 顶部加 import:
```typescript
import { publicUrl } from '../editor/publicUrl'
```
「下载」那一项里的两个链接改成用 `publicUrl`(原来是 `/downloads/...` 绝对路径):
```html
<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 前提:
```html
<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>
```
在「下载」之后插入「首次放行」一项:
```html
<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 重建后自检
```bash
npm run pack:fnos
```
能打出 `release/fnos/SpineParticle-<version>.fpk` 就说明骨架完整。想先确认前端和服务没问题,可以不依赖飞牛设备本地跑一次:
```bash
cd packaging/fnos/SpineParticle/app/server && SPINE_PARTICLE_PORT=27850 node server.mjs
```
然后浏览 `http://127.0.0.1:27850/`,应能看到编辑器界面。