ui布局自适应
This commit is contained in:
1 parent
98f44890f8
commit
3587129b90
40 files changed
+929
-142
No files matched your search
@@ -10,9 +10,11 @@
|
||||
4. 首次使用生产网站时,点击网页中的“授权当前网站”,在打开的本机页面确认完整域名后点击“允许此网站”。
|
||||
5. 可在应用内点击“断开连接”,或直接关闭应用来停止连接器。
|
||||
|
||||
使用 Safari 时,先在连接器窗口点击“启用 Safari 支持”并确认系统证书授权。连接器会生成每个用户独立的本地证书,只用于访问 `127.0.0.1:27844`,不会对局域网或公网开放。
|
||||
|
||||
若状态显示“未找到 Spine”,可在路径输入框中直接输入路径,或点击“选择 Spine.com”。Windows 请选择 `Spine.com`;macOS 请选择 `Spine.app/Contents/MacOS/Spine`。保存后下次启动仍然有效;“恢复自动检测”会清除手动路径。
|
||||
|
||||
连接器仅监听 `127.0.0.1:27843`,不会对局域网或公网开放。授权按完整来源区分,例如 `https://editor.example.com` 与 `https://editor.example.com:8443` 需要分别授权。
|
||||
连接器监听 `127.0.0.1:27843`(HTTP)和 `127.0.0.1:27844`(HTTPS),不会对局域网或公网开放。授权按完整来源区分,例如 `https://editor.example.com` 与 `https://editor.example.com:8443` 需要分别授权。
|
||||
|
||||
## 分发说明
|
||||
|
||||
|
||||
+17
-2
@@ -11,6 +11,21 @@
|
||||
|
||||
本地开发地址 `127.0.0.1:5173` 和 `localhost:5173` 默认允许,不需要授权。
|
||||
|
||||
### Safari
|
||||
|
||||
Safari 不允许 HTTPS 网页直接访问 HTTP 的本机服务。因此 macOS 连接器同时监听:
|
||||
|
||||
- `http://127.0.0.1:27843`:兼容 Chrome、Edge 和本地 HTTP 页面。
|
||||
- `https://127.0.0.1:27844`:供 HTTPS 网页和 Safari 使用。
|
||||
|
||||
首次使用 Safari 时,在“Spine粒子连接器”窗口点击“启用 Safari 支持”。连接器会为本机生成一套独立证书,并在用户确认后把这个仅用于 `127.0.0.1` 的本地 CA 加入当前用户的 SSL 信任列表。脚本版连接器可运行:
|
||||
|
||||
```text
|
||||
npm run connector:trust
|
||||
```
|
||||
|
||||
证书文件保存在连接器配置目录的 `tls/` 下;服务端私钥仅当前用户可读,签发完成后不保留 CA 私钥。删除证书后重新点击启用按钮会生成新证书。不再使用 Safari 时,可在“钥匙串访问”中搜索 `Spine Particle Connector Local CA` 并删除该证书信任项。
|
||||
|
||||
桌面窗口提供 Spine.com 路径输入框、系统文件选择器、“保存路径”“恢复自动检测”“启动连接”和“断开连接”。默认安装位置检测不到时,可手动指定命令行程序:Windows 选择 `Spine.com`;macOS 选择 `Spine.app/Contents/MacOS/Spine`。关闭桌面应用会停止它启动的连接器。
|
||||
|
||||
项目根目录原有 `.command` 和 `.bat` 脚本仍然保留,可继续启动 Node 脚本版连接器。桌面版与脚本版使用同一个端口,不能同时运行。
|
||||
@@ -44,7 +59,7 @@ SPINE_CONNECTOR_ORIGINS=https://particle.example.com,https://editor.example.com
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 服务只监听 `127.0.0.1:27843`,不会暴露到局域网或公网。
|
||||
- 服务只监听 `127.0.0.1:27843` 和 `127.0.0.1:27844`,不会暴露到局域网或公网。
|
||||
- 生产网站必须由用户在本机授权,授权结果保存在浏览器之外的连接器配置中。
|
||||
- 接口只接受 JSON、图片、项目名称和受支持的 Spine 版本,不能执行网页提供的任意命令。
|
||||
- 文件在系统临时目录转换,返回 `.spine` 后立即清理。
|
||||
@@ -58,7 +73,7 @@ SPINE_CONNECTOR_ORIGINS=https://particle.example.com,https://editor.example.com
|
||||
npm run connector
|
||||
```
|
||||
|
||||
健康检查地址:`http://127.0.0.1:27843/v1/health`。
|
||||
健康检查地址:`http://127.0.0.1:27843/v1/health`、`https://127.0.0.1:27844/v1/health`。
|
||||
|
||||
## 生成不依赖 Node.js 的桌面版
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir, platform } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
function configPath() {
|
||||
export function configPath() {
|
||||
if (process.env.SPINE_CONNECTOR_CONFIG) return process.env.SPINE_CONNECTOR_CONFIG
|
||||
if (platform() === 'darwin') return join(homedir(), 'Library/Application Support/SpineParticleConnector/config.json')
|
||||
if (platform() === 'win32') return join(process.env.APPDATA || homedir(), 'SpineParticleConnector/config.json')
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { access, chmod, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { constants } from 'node:fs'
|
||||
import { homedir, platform } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { configPath } from './connector-config.mjs'
|
||||
|
||||
const run = promisify(execFile)
|
||||
|
||||
export function certificateDirectory() {
|
||||
return process.env.SPINE_CONNECTOR_TLS_DIR || join(dirname(configPath()), 'tls')
|
||||
}
|
||||
|
||||
export function certificatePaths(directory = certificateDirectory()) {
|
||||
return {
|
||||
directory,
|
||||
caKey: join(directory, 'ca-key.pem'),
|
||||
ca: join(directory, 'ca.pem'),
|
||||
serverKey: join(directory, 'server-key.pem'),
|
||||
serverCertificate: join(directory, 'server.pem'),
|
||||
identity: join(directory, 'identity.p12'),
|
||||
request: join(directory, 'server.csr'),
|
||||
extensions: join(directory, 'server.ext'),
|
||||
}
|
||||
}
|
||||
|
||||
async function certificateIsUsable(path) {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
await run('/usr/bin/openssl', ['x509', '-checkend', '2592000', '-noout', '-in', path])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function identityIsUsable(path) {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function runOpenSSL(args) {
|
||||
try {
|
||||
await run('/usr/bin/openssl', args)
|
||||
} catch (error) {
|
||||
const detail = String(error?.stderr || error?.message || error).trim()
|
||||
throw new Error(`无法生成本机 HTTPS 证书:${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureLocalCertificate() {
|
||||
if (platform() !== 'darwin') throw new Error('Safari 本机 HTTPS 证书仅支持 macOS')
|
||||
const paths = certificatePaths()
|
||||
if (await certificateIsUsable(paths.serverCertificate) && await identityIsUsable(paths.identity)) return paths
|
||||
|
||||
await mkdir(paths.directory, { recursive: true, mode: 0o700 })
|
||||
await runOpenSSL(['genrsa', '-out', paths.caKey, '2048'])
|
||||
await runOpenSSL([
|
||||
'req', '-x509', '-new', '-key', paths.caKey, '-sha256', '-days', '3650',
|
||||
'-subj', '/CN=Spine Particle Connector Local CA', '-out', paths.ca,
|
||||
])
|
||||
await runOpenSSL(['genrsa', '-out', paths.serverKey, '2048'])
|
||||
await runOpenSSL(['req', '-new', '-key', paths.serverKey, '-subj', '/CN=127.0.0.1', '-out', paths.request])
|
||||
await writeFile(paths.extensions, 'subjectAltName=IP:127.0.0.1,DNS:localhost\nextendedKeyUsage=serverAuth\nbasicConstraints=CA:FALSE\n', 'utf8')
|
||||
await runOpenSSL([
|
||||
'x509', '-req', '-in', paths.request, '-CA', paths.ca, '-CAkey', paths.caKey,
|
||||
'-CAcreateserial', '-out', paths.serverCertificate, '-days', '825', '-sha256',
|
||||
'-extfile', paths.extensions,
|
||||
])
|
||||
await runOpenSSL([
|
||||
'pkcs12', '-export', '-out', paths.identity, '-inkey', paths.serverKey,
|
||||
'-in', paths.serverCertificate, '-passout', 'pass:spine-connector-local',
|
||||
])
|
||||
await Promise.all([paths.serverKey, paths.identity].map((path) => chmod(path, 0o600)))
|
||||
await rm(paths.caKey, { force: true })
|
||||
return paths
|
||||
}
|
||||
|
||||
export async function localCertificateTrusted() {
|
||||
const paths = certificatePaths()
|
||||
try {
|
||||
await run('/usr/bin/security', [
|
||||
'verify-cert', '-c', paths.serverCertificate, '-n', '127.0.0.1', '-p', 'ssl', '-L', '-q',
|
||||
])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function trustLocalCertificate() {
|
||||
if (platform() !== 'darwin') throw new Error('Safari 本机 HTTPS 证书仅支持 macOS')
|
||||
const paths = await ensureLocalCertificate()
|
||||
const loginKeychain = join(homedir(), 'Library', 'Keychains', 'login.keychain-db')
|
||||
try {
|
||||
await run('/usr/bin/security', [
|
||||
'add-trusted-cert', '-r', 'trustRoot', '-p', 'ssl', '-k', loginKeychain, paths.ca,
|
||||
])
|
||||
} catch (error) {
|
||||
const detail = String(error?.stderr || error?.message || error).trim()
|
||||
throw new Error(detail || '无法把本机证书加入信任列表')
|
||||
}
|
||||
if (!(await localCertificateTrusted())) throw new Error('证书已安装,但系统仍未信任它')
|
||||
return paths
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "config.hpp"
|
||||
#include "connector_service.hpp"
|
||||
#include "local_certificate.hpp"
|
||||
#include "platform.hpp"
|
||||
|
||||
using connector::ConnectorConfig;
|
||||
@@ -15,6 +16,7 @@ using connector::ConnectorService;
|
||||
@property(nonatomic, strong) NSTextField* statusLabel;
|
||||
@property(nonatomic, strong) NSArray<NSView*>* pathControls;
|
||||
@property(nonatomic, strong) NSButton* connectionButton;
|
||||
@property(nonatomic, strong) NSButton* safariButton;
|
||||
@property(nonatomic, assign) ConnectorService* service;
|
||||
@property(nonatomic, copy) NSString* startupError;
|
||||
@end
|
||||
@@ -72,6 +74,9 @@ using connector::ConnectorService;
|
||||
self.pathControls = @[pathLabel, self.pathField, browse, automatic, save];
|
||||
self.connectionButton = [self button:@"断开连接" frame:NSMakeRect(460, 25, 130, 36) action:@selector(toggleConnection:)];
|
||||
[content addSubview:self.connectionButton];
|
||||
self.safariButton = [self button:@"启用 Safari 支持" frame:NSMakeRect(315, 25, 130, 36) action:@selector(enableSafari:)];
|
||||
self.safariButton.toolTip = @"安装仅用于 127.0.0.1 的本地证书,使 Safari 可以访问 HTTPS 连接器。";
|
||||
[content addSubview:self.safariButton];
|
||||
[self refreshStatus:nil];
|
||||
[self.window makeKeyAndOrderFront:nil];
|
||||
[NSApp activateIgnoringOtherApps:YES];
|
||||
@@ -82,6 +87,9 @@ using connector::ConnectorService;
|
||||
const BOOL ownsPort = self.service && self.service->running();
|
||||
const BOOL portOccupied = ownsPort || connector::connector_port_in_use();
|
||||
self.connectionButton.title = portOccupied ? @"停止连接" : @"启动连接";
|
||||
const BOOL safariTrusted = connector::local_certificate_trusted();
|
||||
self.safariButton.title = safariTrusted ? @"Safari 支持已启用" : @"启用 Safari 支持";
|
||||
self.safariButton.enabled = !safariTrusted;
|
||||
if (self.startupError.length > 0 && !portOccupied) {
|
||||
self.statusLabel.stringValue = [@"● 连接器未启动 · " stringByAppendingString:self.startupError];
|
||||
self.statusLabel.textColor = NSColor.systemRedColor;
|
||||
@@ -121,6 +129,34 @@ using connector::ConnectorService;
|
||||
[self refreshStatus:nil];
|
||||
}
|
||||
|
||||
- (void)enableSafari:(id)sender {
|
||||
(void)sender;
|
||||
NSAlert* confirmation = [[NSAlert alloc] init];
|
||||
confirmation.messageText = @"启用 Safari 本机连接";
|
||||
confirmation.informativeText = @"连接器会把一个仅用于 127.0.0.1 的本地证书加入当前用户的 SSL 信任列表。系统可能要求你确认。";
|
||||
[confirmation addButtonWithTitle:@"继续"];
|
||||
[confirmation addButtonWithTitle:@"取消"];
|
||||
if ([confirmation runModal] != NSAlertFirstButtonReturn) return;
|
||||
|
||||
std::string error;
|
||||
if (!connector::trust_local_certificate(error)) {
|
||||
NSAlert* alert = [[NSAlert alloc] init];
|
||||
alert.messageText = @"无法启用 Safari 支持";
|
||||
alert.informativeText = [NSString stringWithUTF8String:error.c_str()];
|
||||
[alert runModal];
|
||||
return;
|
||||
}
|
||||
if (self.service && self.service->running() && !self.service->secure_running()) {
|
||||
self.service->stop();
|
||||
}
|
||||
if (self.service && !self.service->running()) {
|
||||
std::string startError;
|
||||
self.service->start(startError);
|
||||
self.startupError = [NSString stringWithUTF8String:startError.c_str()];
|
||||
}
|
||||
[self refreshStatus:nil];
|
||||
}
|
||||
|
||||
- (void)selectSpine:(id)sender {
|
||||
(void)sender;
|
||||
NSOpenPanel* panel = [NSOpenPanel openPanel];
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
#include "encoding.hpp"
|
||||
#include "json.hpp"
|
||||
#include "platform.hpp"
|
||||
#ifdef __APPLE__
|
||||
#include "local_certificate.hpp"
|
||||
#endif
|
||||
#include "workspace.hpp"
|
||||
#include "zip.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
@@ -20,6 +24,7 @@ namespace {
|
||||
|
||||
constexpr const char* kVersion = "2.0.0";
|
||||
constexpr unsigned short kPort = 27843;
|
||||
constexpr unsigned short kSecurePort = 27844;
|
||||
std::mutex pairing_mutex;
|
||||
struct Pairing { std::string origin; std::chrono::steady_clock::time_point expires; };
|
||||
std::map<std::string, Pairing> pairing;
|
||||
@@ -125,10 +130,27 @@ std::pair<std::vector<std::uint8_t>, std::string> convert(const Json& payload, c
|
||||
} // namespace
|
||||
|
||||
bool ConnectorService::start(std::string& error) {
|
||||
return server_.start(kPort, [this](const HttpRequest& request) { return handle(request); }, error);
|
||||
if (server_.running()) return true;
|
||||
if (!server_.start(kPort, [this](const HttpRequest& request) { return handle(request); }, error)) return false;
|
||||
#ifdef __APPLE__
|
||||
std::string certificate_error;
|
||||
if (!ensure_local_certificate(certificate_error)) {
|
||||
secure_error_ = certificate_error;
|
||||
std::cerr << "Safari HTTPS certificate error: " << secure_error_ << '\n';
|
||||
} else if (!secure_server_.start(
|
||||
kSecurePort, [this](const HttpRequest& request) { return handle(request); }, secure_error_,
|
||||
local_identity_path())) {
|
||||
// HTTP remains available for existing browsers even if the optional Safari endpoint cannot start.
|
||||
std::cerr << "Safari HTTPS server error: " << secure_error_ << '\n';
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConnectorService::stop() { server_.stop(); }
|
||||
void ConnectorService::stop() {
|
||||
secure_server_.stop();
|
||||
server_.stop();
|
||||
}
|
||||
|
||||
HttpResponse ConnectorService::handle(const HttpRequest& request) {
|
||||
const std::string origin = header(request, "origin");
|
||||
@@ -142,13 +164,21 @@ HttpResponse ConnectorService::handle(const HttpRequest& request) {
|
||||
if (request.method == "GET" && request.path == "/v1/health") {
|
||||
std::lock_guard<std::mutex> lock(config_mutex_);
|
||||
Json::Array versions{Json("3.8"), Json("4.0"), Json("4.1"), Json("4.2"), Json("4.3")};
|
||||
return json_response(200, {
|
||||
Json::Object health = {
|
||||
{"connectorVersion", Json(kVersion)},
|
||||
{"authorized", Json(origin_allowed(origin))},
|
||||
{"spineFound", Json(!find_spine_executable().empty())},
|
||||
{"secureAvailable", Json(secure_server_.running())},
|
||||
{"platform", Json(platform_name())},
|
||||
{"supportedVersions", Json(std::move(versions))},
|
||||
}, origin);
|
||||
};
|
||||
if (!origin_allowed(origin)) {
|
||||
const std::string base = request.secure
|
||||
? "https://127.0.0.1:" + std::to_string(kSecurePort)
|
||||
: "http://127.0.0.1:" + std::to_string(kPort);
|
||||
health["pairUrl"] = Json(base + "/pair?origin=" + url_encode(origin));
|
||||
}
|
||||
return json_response(200, std::move(health), origin);
|
||||
}
|
||||
if (request.method == "GET" && request.path == "/pair") {
|
||||
const auto found = request.query.find("origin");
|
||||
@@ -184,7 +214,10 @@ HttpResponse ConnectorService::handle(const HttpRequest& request) {
|
||||
if (!origin_allowed(origin)) {
|
||||
HttpResponse response = error_response(403, "ORIGIN_NOT_ALLOWED", "需要先授权当前网站使用本地 Spine 连接器", origin);
|
||||
Json body = Json::parse(response.body);
|
||||
body["pairUrl"] = Json("http://127.0.0.1:27843/pair?origin=" + url_encode(origin));
|
||||
const std::string base = request.secure
|
||||
? "https://127.0.0.1:" + std::to_string(kSecurePort)
|
||||
: "http://127.0.0.1:" + std::to_string(kPort);
|
||||
body["pairUrl"] = Json(base + "/pair?origin=" + url_encode(origin));
|
||||
response.body = body.dump();
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,13 @@ class ConnectorService {
|
||||
bool start(std::string& error);
|
||||
void stop();
|
||||
bool running() const { return server_.running(); }
|
||||
bool secure_running() const { return secure_server_.running(); }
|
||||
|
||||
private:
|
||||
HttpResponse handle(const HttpRequest& request);
|
||||
HttpServer server_;
|
||||
HttpServer secure_server_;
|
||||
std::string secure_error_;
|
||||
std::mutex config_mutex_;
|
||||
std::mutex conversion_mutex_;
|
||||
};
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
#include "encoding.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <thread>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
#define NOMINMAX
|
||||
#include <winsock2.h>
|
||||
@@ -19,6 +26,11 @@ constexpr Socket kInvalidSocket = INVALID_SOCKET;
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#ifdef __APPLE__
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <Security/SecImportExport.h>
|
||||
#include <Security/SecureTransport.h>
|
||||
#endif
|
||||
using Socket = int;
|
||||
constexpr Socket kInvalidSocket = -1;
|
||||
#endif
|
||||
@@ -37,6 +49,119 @@ void close_socket(Socket socket) {
|
||||
#endif
|
||||
}
|
||||
|
||||
struct ConnectionStream {
|
||||
Socket socket = kInvalidSocket;
|
||||
#ifdef __APPLE__
|
||||
SSLContextRef tls = nullptr;
|
||||
#endif
|
||||
};
|
||||
|
||||
#ifdef __APPLE__
|
||||
OSStatus socket_read(SSLConnectionRef reference, void* data, std::size_t* length) {
|
||||
const Socket socket = *static_cast<const Socket*>(reference);
|
||||
const long count = recv(socket, static_cast<char*>(data), static_cast<int>(*length), 0);
|
||||
if (count > 0) {
|
||||
*length = static_cast<std::size_t>(count);
|
||||
return noErr;
|
||||
}
|
||||
*length = 0;
|
||||
if (count == 0) return errSSLClosedGraceful;
|
||||
return errno == EAGAIN || errno == EWOULDBLOCK ? errSSLWouldBlock : errSSLInternal;
|
||||
}
|
||||
|
||||
OSStatus socket_write(SSLConnectionRef reference, const void* data, std::size_t* length) {
|
||||
const Socket socket = *static_cast<const Socket*>(reference);
|
||||
const long count = send(socket, static_cast<const char*>(data), static_cast<int>(*length), 0);
|
||||
if (count > 0) {
|
||||
*length = static_cast<std::size_t>(count);
|
||||
return noErr;
|
||||
}
|
||||
*length = 0;
|
||||
return errno == EAGAIN || errno == EWOULDBLOCK ? errSSLWouldBlock : errSSLInternal;
|
||||
}
|
||||
|
||||
void* load_identity(const std::filesystem::path& path, std::string& error) {
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
if (!input) {
|
||||
error = "无法读取 HTTPS 身份证书";
|
||||
return nullptr;
|
||||
}
|
||||
const std::string bytes((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
|
||||
CFDataRef data = CFDataCreate(kCFAllocatorDefault, reinterpret_cast<const UInt8*>(bytes.data()),
|
||||
static_cast<CFIndex>(bytes.size()));
|
||||
const void* keys[] = {kSecImportExportPassphrase};
|
||||
const void* values[] = {CFSTR("spine-connector-local")};
|
||||
CFDictionaryRef options = CFDictionaryCreate(kCFAllocatorDefault, keys, values, 1,
|
||||
&kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
|
||||
CFArrayRef items = nullptr;
|
||||
const OSStatus status = SecPKCS12Import(data, options, &items);
|
||||
CFRelease(options);
|
||||
CFRelease(data);
|
||||
if (status != errSecSuccess || !items || CFArrayGetCount(items) == 0) {
|
||||
if (items) CFRelease(items);
|
||||
error = "无法读取 HTTPS 身份证书";
|
||||
return nullptr;
|
||||
}
|
||||
CFDictionaryRef item = static_cast<CFDictionaryRef>(CFArrayGetValueAtIndex(items, 0));
|
||||
CFTypeRef identity = CFDictionaryGetValue(item, kSecImportItemIdentity);
|
||||
if (identity) CFRetain(identity);
|
||||
CFRelease(items);
|
||||
if (!identity) error = "HTTPS 身份证书缺少私钥";
|
||||
return const_cast<void*>(identity);
|
||||
}
|
||||
#endif
|
||||
|
||||
bool open_stream(ConnectionStream& stream, void* identity, std::string& error) {
|
||||
if (!identity) return true;
|
||||
#ifdef __APPLE__
|
||||
SSLContextRef context = SSLCreateContext(kCFAllocatorDefault, kSSLServerSide, kSSLStreamType);
|
||||
if (!context) {
|
||||
error = "无法创建 HTTPS 服务";
|
||||
return false;
|
||||
}
|
||||
SSLSetIOFuncs(context, socket_read, socket_write);
|
||||
SSLSetConnection(context, &stream.socket);
|
||||
SSLSetProtocolVersionMin(context, kTLSProtocol12);
|
||||
const void* certificates[] = {identity};
|
||||
CFArrayRef certificate_array = CFArrayCreate(kCFAllocatorDefault, certificates, 1, &kCFTypeArrayCallBacks);
|
||||
const OSStatus certificate_status = SSLSetCertificate(context, certificate_array);
|
||||
CFRelease(certificate_array);
|
||||
if (certificate_status != noErr) {
|
||||
CFRelease(context);
|
||||
error = "无法配置 HTTPS 身份证书";
|
||||
return false;
|
||||
}
|
||||
OSStatus status;
|
||||
do {
|
||||
status = SSLHandshake(context);
|
||||
} while (status == errSSLWouldBlock);
|
||||
if (status != noErr) {
|
||||
CFRelease(context);
|
||||
return false;
|
||||
}
|
||||
stream.tls = context;
|
||||
return true;
|
||||
#else
|
||||
(void)stream;
|
||||
error = "当前平台不支持 HTTPS 连接器";
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void close_stream(ConnectionStream& stream) {
|
||||
#ifdef __APPLE__
|
||||
if (stream.tls) {
|
||||
SSLClose(stream.tls);
|
||||
CFRelease(stream.tls);
|
||||
stream.tls = nullptr;
|
||||
}
|
||||
#endif
|
||||
if (stream.socket != kInvalidSocket) {
|
||||
close_socket(stream.socket);
|
||||
stream.socket = kInvalidSocket;
|
||||
}
|
||||
}
|
||||
|
||||
std::string lowercase(std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return value;
|
||||
@@ -56,21 +181,49 @@ std::string status_text(int status) {
|
||||
}
|
||||
}
|
||||
|
||||
void send_all(Socket socket, const std::string& data) {
|
||||
int read_stream(ConnectionStream& stream, char* buffer, int length) {
|
||||
#ifdef __APPLE__
|
||||
if (stream.tls) {
|
||||
std::size_t received = 0;
|
||||
const OSStatus status = SSLRead(stream.tls, buffer, static_cast<std::size_t>(length), &received);
|
||||
if (status == errSSLClosedGraceful || status == errSSLClosedNoNotify) return static_cast<int>(received);
|
||||
if (status != noErr && status != errSSLWouldBlock) return -1;
|
||||
return static_cast<int>(received);
|
||||
}
|
||||
#endif
|
||||
return recv(stream.socket, buffer, length, 0);
|
||||
}
|
||||
|
||||
void send_all(ConnectionStream& stream, const std::string& data) {
|
||||
std::size_t sent = 0;
|
||||
while (sent < data.size()) {
|
||||
const int count = send(socket, data.data() + sent, static_cast<int>((std::min)(data.size() - sent, static_cast<std::size_t>(1 << 20))), 0);
|
||||
int count;
|
||||
#ifdef __APPLE__
|
||||
if (stream.tls) {
|
||||
std::size_t written = 0;
|
||||
const OSStatus status = SSLWrite(stream.tls, data.data() + sent,
|
||||
(std::min)(data.size() - sent, static_cast<std::size_t>(1 << 20)), &written);
|
||||
if (status != noErr && status != errSSLWouldBlock) return;
|
||||
count = static_cast<int>(written);
|
||||
} else {
|
||||
count = send(stream.socket, data.data() + sent,
|
||||
static_cast<int>((std::min)(data.size() - sent, static_cast<std::size_t>(1 << 20))), 0);
|
||||
}
|
||||
#else
|
||||
count = send(stream.socket, data.data() + sent,
|
||||
static_cast<int>((std::min)(data.size() - sent, static_cast<std::size_t>(1 << 20))), 0);
|
||||
#endif
|
||||
if (count <= 0) return;
|
||||
sent += static_cast<std::size_t>(count);
|
||||
}
|
||||
}
|
||||
|
||||
bool parse_request(Socket socket, HttpRequest& request, int& error_status) {
|
||||
bool parse_request(ConnectionStream& stream, HttpRequest& request, int& error_status) {
|
||||
std::string buffer;
|
||||
char chunk[8192];
|
||||
std::size_t header_end = std::string::npos;
|
||||
while ((header_end = buffer.find("\r\n\r\n")) == std::string::npos) {
|
||||
const int count = recv(socket, chunk, sizeof(chunk), 0);
|
||||
const int count = read_stream(stream, chunk, sizeof(chunk));
|
||||
if (count <= 0) return false;
|
||||
buffer.append(chunk, count);
|
||||
if (buffer.size() > kMaxHeaders) { error_status = 413; return false; }
|
||||
@@ -109,7 +262,7 @@ bool parse_request(Socket socket, HttpRequest& request, int& error_status) {
|
||||
if (content_length > kMaxBody) { error_status = 413; return false; }
|
||||
request.body = buffer.substr(header_end + 4);
|
||||
while (request.body.size() < content_length) {
|
||||
const int count = recv(socket, chunk, sizeof(chunk), 0);
|
||||
const int count = read_stream(stream, chunk, sizeof(chunk));
|
||||
if (count <= 0) return false;
|
||||
request.body.append(chunk, count);
|
||||
}
|
||||
@@ -117,11 +270,20 @@ bool parse_request(Socket socket, HttpRequest& request, int& error_status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void serve_client(Socket client, const RequestHandler& handler) {
|
||||
void serve_client(Socket client, void* identity, const RequestHandler& handler) {
|
||||
ConnectionStream stream{client};
|
||||
std::string tls_error;
|
||||
if (!open_stream(stream, identity, tls_error)) {
|
||||
close_stream(stream);
|
||||
return;
|
||||
}
|
||||
HttpRequest request;
|
||||
int error_status = 400;
|
||||
HttpResponse response;
|
||||
if (parse_request(client, request, error_status)) {
|
||||
if (parse_request(stream, request, error_status)) {
|
||||
#ifdef __APPLE__
|
||||
request.secure = stream.tls != nullptr;
|
||||
#endif
|
||||
try { response = handler(request); }
|
||||
catch (const std::exception& error) {
|
||||
response.status = 500;
|
||||
@@ -138,9 +300,9 @@ void serve_client(Socket client, const RequestHandler& handler) {
|
||||
output << "HTTP/1.1 " << response.status << ' ' << status_text(response.status) << "\r\n";
|
||||
for (const auto& [name, value] : response.headers) output << name << ": " << value << "\r\n";
|
||||
output << "\r\n";
|
||||
send_all(client, output.str());
|
||||
send_all(client, response.body);
|
||||
close_socket(client);
|
||||
send_all(stream, output.str());
|
||||
send_all(stream, response.body);
|
||||
close_stream(stream);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -148,8 +310,21 @@ void serve_client(Socket client, const RequestHandler& handler) {
|
||||
HttpServer::HttpServer() = default;
|
||||
HttpServer::~HttpServer() { stop(); }
|
||||
|
||||
bool HttpServer::start(unsigned short port, RequestHandler handler, std::string& error) {
|
||||
bool HttpServer::start(unsigned short port, RequestHandler handler, std::string& error,
|
||||
const std::filesystem::path& identity) {
|
||||
if (running_) return true;
|
||||
#ifdef __APPLE__
|
||||
if (!identity.empty()) {
|
||||
void* loaded = load_identity(identity, error);
|
||||
if (loaded) tls_identity_ = std::shared_ptr<void>(loaded, [](void* value) { CFRelease(value); });
|
||||
if (!tls_identity_) return false;
|
||||
}
|
||||
#else
|
||||
if (!identity.empty()) {
|
||||
error = "当前平台不支持 HTTPS 连接器";
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
WSADATA data{};
|
||||
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) { error = "无法初始化网络服务"; return false; }
|
||||
@@ -182,7 +357,9 @@ void HttpServer::accept_loop() {
|
||||
if (running_) continue;
|
||||
break;
|
||||
}
|
||||
std::thread(serve_client, client, handler_).detach();
|
||||
std::thread([client, identity = tls_identity_, handler = handler_] {
|
||||
serve_client(client, identity.get(), handler);
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,3 +383,7 @@ void HttpServer::stop() {
|
||||
}
|
||||
|
||||
} // namespace connector
|
||||
|
||||
#ifdef __APPLE__
|
||||
#pragma clang diagnostic pop
|
||||
#endif
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace connector {
|
||||
@@ -15,6 +17,7 @@ struct HttpRequest {
|
||||
std::map<std::string, std::string> query;
|
||||
std::map<std::string, std::string> headers;
|
||||
std::string body;
|
||||
bool secure = false;
|
||||
};
|
||||
|
||||
struct HttpResponse {
|
||||
@@ -29,7 +32,8 @@ class HttpServer {
|
||||
public:
|
||||
HttpServer();
|
||||
~HttpServer();
|
||||
bool start(unsigned short port, RequestHandler handler, std::string& error);
|
||||
bool start(unsigned short port, RequestHandler handler, std::string& error,
|
||||
const std::filesystem::path& identity = {});
|
||||
void stop();
|
||||
bool running() const { return running_.load(); }
|
||||
|
||||
@@ -37,6 +41,7 @@ class HttpServer {
|
||||
void accept_loop();
|
||||
std::atomic<bool> running_{false};
|
||||
std::intptr_t socket_ = -1;
|
||||
std::shared_ptr<void> tls_identity_;
|
||||
RequestHandler handler_;
|
||||
void* thread_ = nullptr;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace connector {
|
||||
|
||||
std::filesystem::path local_identity_path();
|
||||
bool ensure_local_certificate(std::string& error);
|
||||
bool local_certificate_trusted();
|
||||
bool trust_local_certificate(std::string& error);
|
||||
|
||||
} // namespace connector
|
||||
@@ -0,0 +1,178 @@
|
||||
#ifdef __APPLE__
|
||||
|
||||
#include "local_certificate.hpp"
|
||||
|
||||
#include "config.hpp"
|
||||
#include "encoding.hpp"
|
||||
#include "platform.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <sys/stat.h>
|
||||
|
||||
namespace connector {
|
||||
namespace {
|
||||
|
||||
struct CertificatePaths {
|
||||
std::filesystem::path directory;
|
||||
std::filesystem::path ca_key;
|
||||
std::filesystem::path ca;
|
||||
std::filesystem::path server_key;
|
||||
std::filesystem::path server_certificate;
|
||||
std::filesystem::path identity;
|
||||
std::filesystem::path request;
|
||||
std::filesystem::path extensions;
|
||||
};
|
||||
|
||||
CertificatePaths paths() {
|
||||
const auto directory = config_path().parent_path() / "tls";
|
||||
return {
|
||||
directory,
|
||||
directory / "ca-key.pem",
|
||||
directory / "ca.pem",
|
||||
directory / "server-key.pem",
|
||||
directory / "server.pem",
|
||||
directory / "identity.p12",
|
||||
directory / "server.csr",
|
||||
directory / "server.ext",
|
||||
};
|
||||
}
|
||||
|
||||
std::string process_error(const ProcessResult& result) {
|
||||
std::string message = trim(result.output);
|
||||
return message.empty() ? "OpenSSL 执行失败" : message;
|
||||
}
|
||||
|
||||
bool run_openssl(const std::vector<std::string>& arguments, std::string& error) {
|
||||
const ProcessResult result = run_process("/usr/bin/openssl", arguments, {}, 30);
|
||||
if (result.timed_out) {
|
||||
error = "生成本机 HTTPS 证书超时";
|
||||
return false;
|
||||
}
|
||||
if (result.exit_code != 0) {
|
||||
error = process_error(result);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool certificate_is_usable(const std::filesystem::path& certificate) {
|
||||
std::error_code file_error;
|
||||
if (!std::filesystem::is_regular_file(certificate, file_error)) return false;
|
||||
const ProcessResult result = run_process(
|
||||
"/usr/bin/openssl", {"x509", "-checkend", "2592000", "-noout", "-in", certificate.string()}, {}, 10);
|
||||
return !result.timed_out && result.exit_code == 0;
|
||||
}
|
||||
|
||||
bool identity_is_usable(const std::filesystem::path& identity) {
|
||||
std::error_code error;
|
||||
return std::filesystem::is_regular_file(identity, error);
|
||||
}
|
||||
|
||||
void set_private_permissions(const std::filesystem::path& path) {
|
||||
chmod(path.c_str(), 0600);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::filesystem::path local_identity_path() {
|
||||
return paths().identity;
|
||||
}
|
||||
|
||||
bool ensure_local_certificate(std::string& error) {
|
||||
const CertificatePaths files = paths();
|
||||
if (certificate_is_usable(files.server_certificate) && identity_is_usable(files.identity)) {
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
std::filesystem::create_directories(files.directory);
|
||||
chmod(files.directory.c_str(), 0700);
|
||||
for (const auto& path : {files.ca_key, files.ca, files.server_key, files.server_certificate,
|
||||
files.identity, files.request, files.extensions}) {
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(path, ignored);
|
||||
}
|
||||
} catch (const std::exception& exception) {
|
||||
error = exception.what();
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string dir = files.directory.string();
|
||||
if (!run_openssl({"genrsa", "-out", files.ca_key.string(), "2048"}, error)
|
||||
|| !run_openssl({"req", "-x509", "-new", "-key", files.ca_key.string(), "-sha256", "-days", "3650",
|
||||
"-subj", "/CN=Spine Particle Connector Local CA", "-out", files.ca.string()}, error)
|
||||
|| !run_openssl({"genrsa", "-out", files.server_key.string(), "2048"}, error)
|
||||
|| !run_openssl({"req", "-new", "-key", files.server_key.string(), "-subj", "/CN=127.0.0.1",
|
||||
"-out", files.request.string()}, error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
{
|
||||
std::ofstream extensions(files.extensions, std::ios::binary | std::ios::trunc);
|
||||
if (!extensions) {
|
||||
error = "无法写入证书配置";
|
||||
return false;
|
||||
}
|
||||
extensions << "subjectAltName=IP:127.0.0.1,DNS:localhost\n"
|
||||
<< "extendedKeyUsage=serverAuth\n"
|
||||
<< "basicConstraints=CA:FALSE\n";
|
||||
}
|
||||
|
||||
if (!run_openssl({"x509", "-req", "-in", files.request.string(), "-CA", files.ca.string(),
|
||||
"-CAkey", files.ca_key.string(), "-CAcreateserial", "-out", files.server_certificate.string(),
|
||||
"-days", "825", "-sha256", "-extfile", files.extensions.string()}, error)
|
||||
|| !run_openssl({"pkcs12", "-export", "-out", files.identity.string(), "-inkey", files.server_key.string(),
|
||||
"-in", files.server_certificate.string(), "-passout", "pass:spine-connector-local"}, error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
set_private_permissions(files.server_key);
|
||||
set_private_permissions(files.identity);
|
||||
{
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(files.ca_key, ignored);
|
||||
}
|
||||
(void)dir;
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool local_certificate_trusted() {
|
||||
const CertificatePaths files = paths();
|
||||
const ProcessResult result = run_process(
|
||||
"/usr/bin/security",
|
||||
{"verify-cert", "-c", files.server_certificate.string(), "-n", "127.0.0.1", "-p", "ssl", "-L", "-q"},
|
||||
{}, 10);
|
||||
return !result.timed_out && result.exit_code == 0;
|
||||
}
|
||||
|
||||
bool trust_local_certificate(std::string& error) {
|
||||
const CertificatePaths files = paths();
|
||||
if (!ensure_local_certificate(error)) return false;
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home || !*home) {
|
||||
error = "无法确定当前用户主目录";
|
||||
return false;
|
||||
}
|
||||
const auto keychain = std::filesystem::path(home) / "Library" / "Keychains" / "login.keychain-db";
|
||||
const ProcessResult result = run_process(
|
||||
"/usr/bin/security",
|
||||
{"add-trusted-cert", "-r", "trustRoot", "-p", "ssl", "-k", keychain.string(), files.ca.string()},
|
||||
{}, 120);
|
||||
if (result.timed_out || result.exit_code != 0) {
|
||||
error = result.timed_out ? "等待系统证书授权超时" : process_error(result);
|
||||
return false;
|
||||
}
|
||||
if (!local_certificate_trusted()) {
|
||||
error = "证书已安装,但系统仍未信任它";
|
||||
return false;
|
||||
}
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace connector
|
||||
|
||||
#endif
|
||||
+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 停止连接器')
|
||||
@@ -0,0 +1,9 @@
|
||||
import { trustLocalCertificate } from './local-certificate.mjs'
|
||||
|
||||
try {
|
||||
const paths = await trustLocalCertificate()
|
||||
console.log(`Safari 本机 HTTPS 证书已启用:${paths.serverCertificate}`)
|
||||
} catch (error) {
|
||||
console.error(`启用 Safari 本机 HTTPS 失败:${error instanceof Error ? error.message : error}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
Reference in new issue
Block a user