ui布局自适应
This commit is contained in:
1 parent
98f44890f8
commit
3587129b90
40 files changed
+929
-142
No files matched your search
+116
-86
@@ -1,15 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import { createServer as createSecureServer } from 'node:https'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { platform } from 'node:os'
|
||||
import { findSpineExecutable, importSpineProject, normalizeSpineVersion } from './spine-cli.mjs'
|
||||
import { allowOrigin, originAllowed, validWebOrigin } from './connector-config.mjs'
|
||||
import { certificatePaths, ensureLocalCertificate } from './local-certificate.mjs'
|
||||
import { createConversionWorkspace, safeProjectName } from './spine-workspace.mjs'
|
||||
import { createZipArchive } from './zip-archive.mjs'
|
||||
|
||||
const VERSION = '1.0.0'
|
||||
const HOST = process.env.SPINE_CONNECTOR_HOST || '127.0.0.1'
|
||||
const PORT = Math.max(1024, Math.min(65535, Number(process.env.SPINE_CONNECTOR_PORT) || 27843))
|
||||
const HTTPS_PORT = Math.max(1024, Math.min(65535, Number(process.env.SPINE_CONNECTOR_TLS_PORT) || PORT + 1))
|
||||
const MAX_BODY = 200 * 1024 * 1024
|
||||
const pairing = new Map()
|
||||
|
||||
@@ -47,98 +52,123 @@ function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const origin = request.headers.origin || ''
|
||||
const url = new URL(request.url || '/', `http://${HOST}:${PORT}`)
|
||||
if (request.method === 'OPTIONS') { cors(response, origin); response.writeHead(204); response.end(); return }
|
||||
try {
|
||||
if (request.method === 'GET' && url.pathname === '/v1/health') {
|
||||
const spinePath = await findSpineExecutable()
|
||||
const authorized = await originAllowed(origin)
|
||||
json(response, 200, {
|
||||
connectorVersion: VERSION,
|
||||
authorized,
|
||||
spineFound: !!spinePath,
|
||||
platform: process.platform,
|
||||
supportedVersions: ['3.8', '4.0', '4.1', '4.2', '4.3'],
|
||||
}, origin)
|
||||
return
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/pair') {
|
||||
const requestedOrigin = validWebOrigin(url.searchParams.get('origin'))
|
||||
if (!requestedOrigin) { html(response, 400, '<h2>授权地址无效</h2>'); return }
|
||||
const nonce = randomBytes(24).toString('hex')
|
||||
pairing.set(nonce, { origin: requestedOrigin, expires: Date.now() + 5 * 60 * 1000 })
|
||||
html(response, 200, `<h2>授权 Spine粒子编辑器</h2><p>是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 <code>.spine</code> 文件?</p><p><code>${escapeHtml(requestedOrigin)}</code></p><form method="post" action="/pair"><input type="hidden" name="nonce" value="${nonce}"><button type="submit">允许此网站</button></form>`)
|
||||
return
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/pair') {
|
||||
const chunks = []
|
||||
for await (const chunk of request) chunks.push(chunk)
|
||||
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
|
||||
const nonce = form.get('nonce') || ''
|
||||
const pending = pairing.get(nonce)
|
||||
pairing.delete(nonce)
|
||||
if (!pending || pending.expires < Date.now()) { html(response, 400, '<h2>授权已过期,请返回编辑器重试</h2>'); return }
|
||||
await allowOrigin(pending.origin)
|
||||
html(response, 200, `<h2>授权成功</h2><p><code>${escapeHtml(pending.origin)}</code> 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。</p>`)
|
||||
return
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/v1/convert') {
|
||||
if (!(await originAllowed(origin))) {
|
||||
const pairUrl = `http://${HOST}:${PORT}/pair?origin=${encodeURIComponent(origin)}`
|
||||
json(response, 403, { code: 'ORIGIN_NOT_ALLOWED', message: '需要先授权当前网站使用本地 Spine 连接器', pairUrl }, origin)
|
||||
let secureAvailable = false
|
||||
|
||||
function createHandler(secure) {
|
||||
const baseUrl = `${secure ? 'https' : 'http'}://${HOST}:${secure ? HTTPS_PORT : PORT}`
|
||||
return async (request, response) => {
|
||||
const origin = request.headers.origin || ''
|
||||
const url = new URL(request.url || '/', baseUrl)
|
||||
if (request.method === 'OPTIONS') { cors(response, origin); response.writeHead(204); response.end(); return }
|
||||
try {
|
||||
if (request.method === 'GET' && url.pathname === '/v1/health') {
|
||||
const spinePath = await findSpineExecutable()
|
||||
const authorized = await originAllowed(origin)
|
||||
json(response, 200, {
|
||||
connectorVersion: VERSION,
|
||||
authorized,
|
||||
spineFound: !!spinePath,
|
||||
secureAvailable,
|
||||
pairUrl: authorized ? undefined : `${baseUrl}/pair?origin=${encodeURIComponent(origin)}`,
|
||||
platform: process.platform,
|
||||
supportedVersions: ['3.8', '4.0', '4.1', '4.2', '4.3'],
|
||||
}, origin)
|
||||
return
|
||||
}
|
||||
const spinePath = await findSpineExecutable()
|
||||
if (!spinePath) {
|
||||
json(response, 503, { code: 'SPINE_NOT_FOUND', message: '没有找到 Spine,请先安装 Spine 或设置 SPINE_EXECUTABLE' }, origin)
|
||||
if (request.method === 'GET' && url.pathname === '/pair') {
|
||||
const requestedOrigin = validWebOrigin(url.searchParams.get('origin'))
|
||||
if (!requestedOrigin) { html(response, 400, '<h2>授权地址无效</h2>'); return }
|
||||
const nonce = randomBytes(24).toString('hex')
|
||||
pairing.set(nonce, { origin: requestedOrigin, expires: Date.now() + 5 * 60 * 1000 })
|
||||
html(response, 200, `<h2>授权 Spine粒子编辑器</h2><p>是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 <code>.spine</code> 文件?</p><p><code>${escapeHtml(requestedOrigin)}</code></p><form method="post" action="/pair"><input type="hidden" name="nonce" value="${nonce}"><button type="submit">允许此网站</button></form>`)
|
||||
return
|
||||
}
|
||||
const payload = await readJson(request)
|
||||
const workspace = await createConversionWorkspace(payload)
|
||||
try {
|
||||
const spineVersion = normalizeSpineVersion(workspace.spineVersion)
|
||||
const result = await importSpineProject({
|
||||
executablePath: spinePath,
|
||||
inputPath: workspace.inputPath,
|
||||
outputPath: workspace.outputPath,
|
||||
projectName: workspace.projectName,
|
||||
spineVersion,
|
||||
workspaceRoot: workspace.root,
|
||||
})
|
||||
const project = await workspace.readOutput()
|
||||
const projectFileName = `${safeProjectName(workspace.projectName)}.spine`
|
||||
const archive = createZipArchive([
|
||||
{ name: projectFileName, data: project },
|
||||
...workspace.archiveImages,
|
||||
])
|
||||
const fileName = `${safeProjectName(workspace.projectName)}.zip`
|
||||
const asciiFileName = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
|
||||
cors(response, origin)
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Length': archive.length,
|
||||
'Content-Disposition': `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
'X-Spine-File-Name': encodeURIComponent(fileName),
|
||||
'X-Spine-Editor-Version': result.editorVersion,
|
||||
'Access-Control-Expose-Headers': 'X-Spine-File-Name, X-Spine-Editor-Version',
|
||||
})
|
||||
response.end(archive)
|
||||
} finally {
|
||||
await workspace.cleanup()
|
||||
if (request.method === 'POST' && url.pathname === '/pair') {
|
||||
const chunks = []
|
||||
for await (const chunk of request) chunks.push(chunk)
|
||||
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
|
||||
const nonce = form.get('nonce') || ''
|
||||
const pending = pairing.get(nonce)
|
||||
pairing.delete(nonce)
|
||||
if (!pending || pending.expires < Date.now()) { html(response, 400, '<h2>授权已过期,请返回编辑器重试</h2>'); return }
|
||||
await allowOrigin(pending.origin)
|
||||
html(response, 200, `<h2>授权成功</h2><p><code>${escapeHtml(pending.origin)}</code> 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。</p>`)
|
||||
return
|
||||
}
|
||||
return
|
||||
if (request.method === 'POST' && url.pathname === '/v1/convert') {
|
||||
if (!(await originAllowed(origin))) {
|
||||
const pairUrl = `${baseUrl}/pair?origin=${encodeURIComponent(origin)}`
|
||||
json(response, 403, { code: 'ORIGIN_NOT_ALLOWED', message: '需要先授权当前网站使用本地 Spine 连接器', pairUrl }, origin)
|
||||
return
|
||||
}
|
||||
const spinePath = await findSpineExecutable()
|
||||
if (!spinePath) {
|
||||
json(response, 503, { code: 'SPINE_NOT_FOUND', message: '没有找到 Spine,请先安装 Spine 或设置 SPINE_EXECUTABLE' }, origin)
|
||||
return
|
||||
}
|
||||
const payload = await readJson(request)
|
||||
const workspace = await createConversionWorkspace(payload)
|
||||
try {
|
||||
const spineVersion = normalizeSpineVersion(workspace.spineVersion)
|
||||
const result = await importSpineProject({
|
||||
executablePath: spinePath,
|
||||
inputPath: workspace.inputPath,
|
||||
outputPath: workspace.outputPath,
|
||||
projectName: workspace.projectName,
|
||||
spineVersion,
|
||||
workspaceRoot: workspace.root,
|
||||
})
|
||||
const project = await workspace.readOutput()
|
||||
const projectFileName = `${safeProjectName(workspace.projectName)}.spine`
|
||||
const archive = createZipArchive([
|
||||
{ name: projectFileName, data: project },
|
||||
...workspace.archiveImages,
|
||||
])
|
||||
const fileName = `${safeProjectName(workspace.projectName)}.zip`
|
||||
const asciiFileName = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
|
||||
cors(response, origin)
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Length': archive.length,
|
||||
'Content-Disposition': `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
'X-Spine-File-Name': encodeURIComponent(fileName),
|
||||
'X-Spine-Editor-Version': result.editorVersion,
|
||||
'Access-Control-Expose-Headers': 'X-Spine-File-Name, X-Spine-Editor-Version',
|
||||
})
|
||||
response.end(archive)
|
||||
} finally {
|
||||
await workspace.cleanup()
|
||||
}
|
||||
return
|
||||
}
|
||||
json(response, 404, { code: 'NOT_FOUND', message: '接口不存在' }, origin)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
json(response, 500, { code: 'CONVERSION_FAILED', message }, origin)
|
||||
}
|
||||
json(response, 404, { code: 'NOT_FOUND', message: '接口不存在' }, origin)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
json(response, 500, { code: 'CONVERSION_FAILED', message }, origin)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Spine粒子连接器 ${VERSION}`)
|
||||
createServer(createHandler(false)).listen(PORT, HOST, () => {
|
||||
console.log(`HTTP 监听地址:http://${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Spine粒子连接器 ${VERSION}`)
|
||||
console.log(`监听地址:http://${HOST}:${PORT}`)
|
||||
console.log('按 Ctrl+C 停止连接器')
|
||||
})
|
||||
async function startSecureServer() {
|
||||
try {
|
||||
const paths = await ensureLocalCertificate()
|
||||
const [key, cert] = await Promise.all([readFile(paths.serverKey), readFile(paths.serverCertificate)])
|
||||
const secureServer = createSecureServer({ key, cert }, createHandler(true))
|
||||
secureServer.listen(HTTPS_PORT, HOST, () => {
|
||||
secureAvailable = true
|
||||
console.log(`HTTPS 监听地址:https://${HOST}:${HTTPS_PORT}`)
|
||||
console.log('Safari 首次使用请运行:npm run connector:trust')
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Safari HTTPS 未启动:${error instanceof Error ? error.message : error}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (platform() === 'darwin') void startSecureServer()
|
||||
|
||||
console.log('按 Ctrl+C 停止连接器')
|
||||
Reference in new issue
Block a user