diff --git a/Document/飞牛fpk打包与部署.md b/Document/飞牛fpk打包与部署.md index c8be6ed..942197a 100644 --- a/Document/飞牛fpk打包与部署.md +++ b/Document/飞牛fpk打包与部署.md @@ -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 + +`