ui布局自适应

This commit is contained in:
tianmo committed 2026-11-09 10:08:29 +08:00
1 parent 98f44890f8
commit 3587129b90
40 files changed
+929 -142

No files matched your search

+3 -1
View File
@@ -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
View File
@@ -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 的桌面版
+1 -1
View File
@@ -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')
+110
View File
@@ -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
}
+36
View File
@@ -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];
+38 -5
View File
@@ -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;
}
+3
View File
@@ -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_;
};
+193 -12
View File
@@ -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
+6 -1
View File
@@ -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;
};
+13
View File
@@ -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
+178
View File
@@ -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
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 停止连接器')
+9
View File
@@ -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
}