飞牛部署文档
This commit is contained in:
1 parent
61b93f590c
commit
1ecbb6fc48
1 file changed
+469
-2
+469
-2
@@ -24,12 +24,16 @@ fnpack --help
|
||||
| `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`(已加入 `.gitignore`,不入库) |
|
||||
| `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
|
||||
@@ -114,5 +118,468 @@ ctl_stop=true
|
||||
## 9. 提交代码时注意
|
||||
|
||||
- `dist/` 在版本库里是**跟踪状态**,重新构建会带出 assets 的增删(文件名带哈希),要和源码改动一起提交,否则别人拉下来跑出的页面和打包不一致。
|
||||
- `packaging/fnos/SpineParticle/app/www/`、`release/fnos/`、以及包工程根目录的 `*.fpk` 都是构建产物,已在 `.gitignore` 中排除。
|
||||
- `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/`,应能看到编辑器界面。
|
||||
Reference in new issue
Block a user