连接器客户端

This commit is contained in:
tianmo
2026-09-05 23:35:45 +08:00
parent d10807d877
commit 41151b7f8b
28 changed files with 2195 additions and 12 deletions
-1
View File
@@ -2,7 +2,6 @@
node_modules/
# Build output (仅忽略根目录的 Vite 构建产物;不要用无锚点的 dist/,否则会误把
# vendor/spine/*/dist 这类必需的第三方运行时文件一并忽略,导致 clone 后项目无法运行)
/release
/release-tauri
/src-tauri/target
# TypeScript build cache
+8 -6
View File
@@ -1,19 +1,21 @@
# Spine粒子连接器(独立版)
# Spine粒子连接器(桌面版)
此文件夹内的连接器已包含运行所需的 Node 运行时**无需安装 Node.js**。
连接器是原生桌面应用**无需安装 Node.js**。
## 使用
1. 正常安装并激活 Spine;连接器不会包含或替代 Spine 软件。
2. macOS 双击 `启动Spine连接器.command`Windows 双击 `启动Spine连接器.bat`
2. macOS 打开“Spine粒子连接器.app”Windows 打开“Spine粒子连接器.exe”。打开应用即启动连接器
3. 回到网页,等待导出区域显示“连接器可用 · Spine 已就绪”。
4. 首次使用生产网站时,点击网页中的“授权当前网站”,在打开的本机页面确认完整域名后点击“允许此网站”。
5. 不使用时双击对应的“停止”脚本
5. 可在应用内点击“断开连接”,或直接关闭应用来停止连接器
若状态显示“未找到 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` 需要分别授权。
## 分发说明
- 请保持整个文件夹完整,不能只复制可执行文件;启停脚本和说明文件也应一起分发。也可以直接分发同级生成的 ZIP 压缩包
- macOS 请完整分发 `.app`Windows 可直接分发 `.exe` 和本说明
- macOS 版本只适用于其标记的 CPU 架构(Apple Silicon 或 Intel)。Windows 版本也需在 Windows 上构建。
- 独立程序已进行本机临时签名,能直接运行;面向其他 Mac 分发时,建议使用 Apple Developer ID 重新签名并公证,避免 Gatekeeper 提示。
- 面向其他 Mac 分发时,建议使用 Apple Developer ID 签名并公证,避免 Gatekeeper 提示。
+8 -4
View File
@@ -5,12 +5,16 @@
## 使用方法
1. 正常安装、激活 Spine。
2. 使用 `release/connector/` 中的独立版时,macOS 双击 `启动Spine连接器.command`Windows 双击 `启动Spine连接器.bat`。独立版已内置运行环境,不需要安装 Node.js。
2. 使用 `release/connector/` 中的桌面版时,macOS 打开“Spine粒子连接器.app”,Windows 打开“Spine粒子连接器.exe”。应用打开后会自动启动连接器,不需要安装 Node.js。
3. 打开 Spine粒子编辑器网页。导出区域显示“连接器可用 · Spine 已就绪”后,点击“导出 .spine 包”。
4. 服务器网站首次连接时,状态栏会显示“授权当前网站”。点击后会打开本机确认页,核对显示的完整网站来源(协议、域名和端口)后点击“允许此网站”。授权一次后,连接器会在本机保存允许列表;回到网页后最多等待 3 秒或点击“刷新”。
本地开发地址 `127.0.0.1:5173``localhost:5173` 默认允许,不需要授权。
桌面窗口提供 Spine.com 路径输入框、系统文件选择器、“保存路径”“恢复自动检测”“启动连接”和“断开连接”。默认安装位置检测不到时,可手动指定命令行程序:Windows 选择 `Spine.com`macOS 选择 `Spine.app/Contents/MacOS/Spine`。关闭桌面应用会停止它启动的连接器。
项目根目录原有 `.command``.bat` 脚本仍然保留,可继续启动 Node 脚本版连接器。桌面版与脚本版使用同一个端口,不能同时运行。
授权按完整来源区分:`https://editor.example.com``http://editor.example.com``https://editor.example.com:8443` 是三个不同的授权项。生产环境建议始终使用 HTTPS 与固定域名。
网页下载的是 ZIP 压缩包,内部包含 `.spine` 工程和 JSON 引用的图片目录。浏览器不能直接下载文件夹,因此使用 ZIP 保留完整目录结构;默认结构为:
@@ -56,7 +60,7 @@ npm run connector
健康检查地址:`http://127.0.0.1:27843/v1/health`
## 生成不依赖 Node.js 的独立
## 生成不依赖 Node.js 的桌面
在目标操作系统上从项目根目录执行:
@@ -64,6 +68,6 @@ npm run connector
npm run build:connector
```
会生成 `release/connector/<系统-架构>/` 以及同目录下的 ZIP 分发包。把整个文件夹或 ZIP 交给用户即可,里面包含独立可执行文件和不依赖 Node.js 的启停脚本。构建必须在对应系统上完成:例如 Apple Silicon、Intel Mac、Windows 分别构建各自的版本;Node SEA 不支持把当前 Mac 可执行文件直接交叉打成 Windows `.exe`
macOS 会生成 `release/connector/macos-<架构>/Spine粒子连接器.app`Windows 会生成 `release/connector/windows-<架构>/Spine粒子连接器.exe`。桌面程序由 C++17 编写,运行时不依赖 Node.js。默认使用 macOS 的 Apple ClangWindows 应在 Visual Studio Developer Command Prompt 中执行,默认使用 MSVC,也可通过 `CXX` 指定 MinGW g++
构建后的 macOS 程序会进行临时本机签名。若要对外正式发布,仍建议使用 Apple Developer ID 重新签名并公证
面向其他 Mac 正式分发时,建议使用 Apple Developer ID 对 `.app` 签名并公证,避免 Gatekeeper 提示
+183
View File
@@ -0,0 +1,183 @@
#ifdef __APPLE__
#import <AppKit/AppKit.h>
#include "config.hpp"
#include "connector_service.hpp"
#include "platform.hpp"
using connector::ConnectorConfig;
using connector::ConnectorService;
@interface ConnectorAppDelegate : NSObject <NSApplicationDelegate, NSWindowDelegate>
@property(nonatomic, strong) NSWindow* window;
@property(nonatomic, strong) NSTextField* pathField;
@property(nonatomic, strong) NSTextField* statusLabel;
@property(nonatomic, strong) NSArray<NSView*>* pathControls;
@property(nonatomic, strong) NSButton* connectionButton;
@property(nonatomic, assign) ConnectorService* service;
@property(nonatomic, copy) NSString* startupError;
@end
@implementation ConnectorAppDelegate
- (NSTextField*)label:(NSString*)text frame:(NSRect)frame size:(CGFloat)size color:(NSColor*)color {
NSTextField* label = [[NSTextField alloc] initWithFrame:frame];
label.stringValue = text;
label.editable = NO;
label.bezeled = NO;
label.drawsBackground = NO;
label.font = [NSFont systemFontOfSize:size];
label.textColor = color;
return label;
}
- (NSButton*)button:(NSString*)title frame:(NSRect)frame action:(SEL)action {
NSButton* button = [[NSButton alloc] initWithFrame:frame];
button.title = title;
button.bezelStyle = NSBezelStyleRounded;
button.target = self;
button.action = action;
return button;
}
- (void)applicationDidFinishLaunching:(NSNotification*)notification {
(void)notification;
self.window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 620, 326)
styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable
backing:NSBackingStoreBuffered defer:NO];
self.window.title = @"Spine粒子连接器";
self.window.delegate = self;
[self.window center];
NSView* content = self.window.contentView;
[content addSubview:[self label:@"Spine粒子连接器" frame:NSMakeRect(28, 268, 560, 34) size:24 color:NSColor.labelColor]];
[content addSubview:[self label:@"应用打开且连接已启动时,网页可调用本机 Spine 生成工程文件。" frame:NSMakeRect(29, 237, 560, 24) size:14 color:NSColor.secondaryLabelColor]];
self.statusLabel = [self label:@"" frame:NSMakeRect(29, 201, 560, 24) size:14 color:NSColor.systemGreenColor];
[content addSubview:self.statusLabel];
NSTextField* pathLabel = [self label:@"Spine.com 路径" frame:NSMakeRect(29, 162, 130, 24) size:14 color:NSColor.labelColor];
[content addSubview:pathLabel];
self.pathField = [[NSTextField alloc] initWithFrame:NSMakeRect(29, 126, 390, 32)];
self.pathField.placeholderString = @"留空时自动检测 Spine";
const ConnectorConfig config = connector::load_config();
self.pathField.stringValue = [NSString stringWithUTF8String:config.spine_executable.c_str()];
[content addSubview:self.pathField];
NSButton* browse = [self button:@"选择 Spine.com" frame:NSMakeRect(430, 124, 160, 36) action:@selector(selectSpine:)];
[content addSubview:browse];
NSButton* automatic = [self button:@"恢复自动检测" frame:NSMakeRect(29, 78, 130, 36) action:@selector(clearPath:)];
[content addSubview:automatic];
NSButton* save = [self button:@"保存路径" frame:NSMakeRect(460, 78, 130, 36) action:@selector(savePath:)];
save.keyEquivalent = @"\r";
[content addSubview:save];
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 refreshStatus:nil];
[self.window makeKeyAndOrderFront:nil];
[NSApp activateIgnoringOtherApps:YES];
}
- (void)refreshStatus:(id)sender {
(void)sender;
self.connectionButton.title = self.service && self.service->running() ? @"断开连接" : @"启动连接";
if (self.startupError.length > 0) {
self.statusLabel.stringValue = [@"● 连接器未启动 · " stringByAppendingString:self.startupError];
self.statusLabel.textColor = NSColor.systemRedColor;
return;
}
if (!self.service || !self.service->running()) {
self.statusLabel.stringValue = @"● 连接器已断开";
self.statusLabel.textColor = NSColor.secondaryLabelColor;
return;
}
const std::string spine = connector::find_spine_executable();
const BOOL showPathControls = spine.empty();
for (NSView* view in self.pathControls) view.hidden = !showPathControls;
self.statusLabel.stringValue = spine.empty() ? @"● 连接器运行中 · 未找到 Spine" : @"● 连接器运行中 · Spine 已就绪";
self.statusLabel.textColor = spine.empty() ? NSColor.systemOrangeColor : NSColor.systemGreenColor;
}
- (void)toggleConnection:(id)sender {
(void)sender;
if (self.service && self.service->running()) {
self.service->stop();
self.startupError = @"";
} else if (self.service) {
std::string error;
self.service->start(error);
self.startupError = [NSString stringWithUTF8String:error.c_str()];
}
[self refreshStatus:nil];
}
- (void)selectSpine:(id)sender {
(void)sender;
NSOpenPanel* panel = [NSOpenPanel openPanel];
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.allowsMultipleSelection = NO;
panel.message = @"请选择 Spine 命令行可执行文件(macOS 通常为 Spine.app/Contents/MacOS/Spine";
if ([panel runModal] == NSModalResponseOK) self.pathField.stringValue = panel.URL.path;
}
- (void)clearPath:(id)sender {
(void)sender;
self.pathField.stringValue = @"";
[self savePath:nil];
}
- (void)savePath:(id)sender {
(void)sender;
const std::string path = self.pathField.stringValue.UTF8String ?: "";
if (!path.empty() && !connector::valid_spine_executable(path)) {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = @"Spine 路径无效";
alert.informativeText = @"请选择实际存在的 Spine 命令行可执行文件。";
[alert runModal];
return;
}
try {
ConnectorConfig config = connector::load_config();
config.spine_executable = path;
connector::save_config(config);
[self refreshStatus:nil];
} catch (const std::exception& error) {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = @"保存失败";
alert.informativeText = [NSString stringWithUTF8String:error.what()];
[alert runModal];
}
}
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication*)sender {
(void)sender;
return YES;
}
- (void)applicationWillTerminate:(NSNotification*)notification {
(void)notification;
if (self.service) self.service->stop();
}
@end
int main(int argc, char* argv[]) {
(void)argc;
(void)argv;
@autoreleasepool {
ConnectorService service;
std::string startup_error;
service.start(startup_error);
NSApplication* application = [NSApplication sharedApplication];
application.activationPolicy = NSApplicationActivationPolicyRegular;
ConnectorAppDelegate* delegate = [[ConnectorAppDelegate alloc] init];
delegate.service = &service;
delegate.startupError = [NSString stringWithUTF8String:startup_error.c_str()];
application.delegate = delegate;
[application run];
service.stop();
}
return 0;
}
#endif
+168
View File
@@ -0,0 +1,168 @@
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#include <commdlg.h>
#include "config.hpp"
#include "connector_service.hpp"
#include "platform.hpp"
#include <string>
namespace {
connector::ConnectorService service;
HWND path_field = nullptr;
HWND status_label = nullptr;
HWND path_label = nullptr;
HWND browse_button = nullptr;
HWND automatic_button = nullptr;
HWND save_button = nullptr;
HWND connection_button = nullptr;
std::string startup_error;
std::wstring wide(const std::string& value) {
if (value.empty()) return {};
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
return result;
}
std::string utf8(const std::wstring& value) {
if (value.empty()) return {};
const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
std::string result(size, '\0');
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
return result;
}
std::wstring field_text(HWND field) {
const int length = GetWindowTextLengthW(field);
std::wstring value(static_cast<std::size_t>(length) + 1, L'\0');
GetWindowTextW(field, value.data(), length + 1);
value.resize(static_cast<std::size_t>(length));
return value;
}
void refresh_status() {
std::wstring status;
if (!startup_error.empty()) status = L"连接器未启动 · " + wide(startup_error);
else if (!service.running()) status = L"连接器已断开";
else status = connector::find_spine_executable().empty() ? L"连接器运行中 · 未找到 Spine" : L"连接器运行中 · Spine 已就绪";
SetWindowTextW(status_label, status.c_str());
SetWindowTextW(connection_button, service.running() ? L"断开连接" : L"启动连接");
const bool show_path = connector::find_spine_executable().empty();
for (HWND control : {path_label, path_field, browse_button, automatic_button, save_button}) {
if (control) ShowWindow(control, show_path ? SW_SHOW : SW_HIDE);
}
}
void save_path(HWND window) {
const std::string path = utf8(field_text(path_field));
if (!path.empty() && !connector::valid_spine_executable(path)) {
MessageBoxW(window, L"请选择实际存在的 Spine.com 文件。", L"Spine 路径无效", MB_OK | MB_ICONWARNING);
return;
}
try {
auto config = connector::load_config();
config.spine_executable = path;
connector::save_config(config);
refresh_status();
} catch (const std::exception& error) {
MessageBoxW(window, wide(error.what()).c_str(), L"保存失败", MB_OK | MB_ICONERROR);
}
}
LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
(void)lparam;
switch (message) {
case WM_CREATE: {
HFONT font = static_cast<HFONT>(GetStockObject(DEFAULT_GUI_FONT));
HWND title = CreateWindowW(L"STATIC", L"Spine粒子连接器", WS_CHILD | WS_VISIBLE, 24, 20, 540, 34, window, nullptr, nullptr, nullptr);
SendMessageW(title, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
HWND help = CreateWindowW(L"STATIC", L"应用打开期间,网页可调用本机 Spine 生成工程文件。", WS_CHILD | WS_VISIBLE, 24, 58, 540, 24, window, nullptr, nullptr, nullptr);
SendMessageW(help, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
status_label = CreateWindowW(L"STATIC", L"", WS_CHILD | WS_VISIBLE, 24, 91, 540, 24, window, nullptr, nullptr, nullptr);
SendMessageW(status_label, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
path_label = CreateWindowW(L"STATIC", L"Spine.com 路径", WS_CHILD | WS_VISIBLE, 24, 128, 160, 24, window, nullptr, nullptr, nullptr);
SendMessageW(path_label, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
path_field = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"", WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL, 24, 153, 390, 30, window, reinterpret_cast<HMENU>(100), nullptr, nullptr);
SendMessageW(path_field, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
const auto config = connector::load_config();
SetWindowTextW(path_field, wide(config.spine_executable).c_str());
browse_button = CreateWindowW(L"BUTTON", L"选择 Spine.com", WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 425, 151, 145, 34, window, reinterpret_cast<HMENU>(101), nullptr, nullptr);
SendMessageW(browse_button, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
automatic_button = CreateWindowW(L"BUTTON", L"恢复自动检测", WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 24, 202, 135, 34, window, reinterpret_cast<HMENU>(102), nullptr, nullptr);
SendMessageW(automatic_button, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
save_button = CreateWindowW(L"BUTTON", L"保存路径", WS_CHILD | WS_VISIBLE | BS_DEFPUSHBUTTON, 435, 202, 135, 34, window, reinterpret_cast<HMENU>(103), nullptr, nullptr);
SendMessageW(save_button, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
connection_button = CreateWindowW(L"BUTTON", L"断开连接", WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 435, 250, 135, 34, window, reinterpret_cast<HMENU>(104), nullptr, nullptr);
SendMessageW(connection_button, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
refresh_status();
return 0;
}
case WM_COMMAND:
switch (LOWORD(wparam)) {
case 101: {
wchar_t filename[MAX_PATH] = L"";
OPENFILENAMEW dialog{};
dialog.lStructSize = sizeof(dialog);
dialog.hwndOwner = window;
dialog.lpstrFilter = L"Spine.com\0Spine.com\0所有文件\0*.*\0";
dialog.lpstrFile = filename;
dialog.nMaxFile = MAX_PATH;
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST;
if (GetOpenFileNameW(&dialog)) SetWindowTextW(path_field, filename);
return 0;
}
case 102: SetWindowTextW(path_field, L""); save_path(window); return 0;
case 103: save_path(window); return 0;
case 104:
if (service.running()) {
service.stop();
startup_error.clear();
} else {
startup_error.clear();
service.start(startup_error);
}
refresh_status();
return 0;
}
break;
case WM_CLOSE: DestroyWindow(window); return 0;
case WM_DESTROY: service.stop(); PostQuitMessage(0); return 0;
}
return DefWindowProcW(window, message, wparam, lparam);
}
} // namespace
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE previous, PWSTR command_line, int show) {
(void)previous;
(void)command_line;
service.start(startup_error);
const wchar_t* class_name = L"SpineParticleConnectorWindow";
WNDCLASSW window_class{};
window_class.lpfnWndProc = window_proc;
window_class.hInstance = instance;
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
window_class.hbrBackground = reinterpret_cast<HBRUSH>(COLOR_WINDOW + 1);
window_class.lpszClassName = class_name;
RegisterClassW(&window_class);
HWND window = CreateWindowW(class_name, L"Spine粒子连接器", WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX,
CW_USEDEFAULT, CW_USEDEFAULT, 620, 350, nullptr, nullptr, instance, nullptr);
if (!window) return 1;
ShowWindow(window, show);
UpdateWindow(window);
MSG message{};
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
TranslateMessage(&message);
DispatchMessageW(&message);
}
service.stop();
return static_cast<int>(message.wParam);
}
#endif
+120
View File
@@ -0,0 +1,120 @@
#include "config.hpp"
#include "json.hpp"
#include <algorithm>
#include <cstdlib>
#include <fstream>
#include <regex>
#include <stdexcept>
namespace connector {
namespace {
std::string environment(const char* name) {
const char* value = std::getenv(name);
return value ? value : "";
}
std::filesystem::path home_directory() {
#ifdef _WIN32
std::string home = environment("USERPROFILE");
if (home.empty()) home = environment("HOMEDRIVE") + environment("HOMEPATH");
#else
std::string home = environment("HOME");
#endif
return home.empty() ? std::filesystem::current_path() : std::filesystem::u8path(home);
}
bool local_origin(const std::string& origin) {
static const std::vector<std::string> origins = {
"http://127.0.0.1:5173", "http://localhost:5173",
"http://127.0.0.1:4173", "http://localhost:4173"};
return std::find(origins.begin(), origins.end(), origin) != origins.end();
}
} // namespace
std::filesystem::path config_path() {
const std::string override_path = environment("SPINE_CONNECTOR_CONFIG");
if (!override_path.empty()) return std::filesystem::u8path(override_path);
#ifdef _WIN32
std::string app_data = environment("APPDATA");
const auto root = app_data.empty() ? home_directory() : std::filesystem::u8path(app_data);
return root / "SpineParticleConnector" / "config.json";
#elif defined(__APPLE__)
return home_directory() / "Library" / "Application Support" / "SpineParticleConnector" / "config.json";
#else
std::string xdg = environment("XDG_CONFIG_HOME");
const auto root = xdg.empty() ? home_directory() / ".config" : std::filesystem::u8path(xdg);
return root / "spine-particle-connector" / "config.json";
#endif
}
ConnectorConfig load_config() {
ConnectorConfig result;
try {
std::ifstream input(config_path(), std::ios::binary);
if (!input) return result;
const std::string text((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
const Json root = Json::parse(text);
if (const Json* origins = root.find("allowedOrigins"); origins && origins->is_array()) {
for (const Json& item : origins->array()) if (item.is_string()) result.allowed_origins.push_back(item.string());
}
if (const Json* path = root.find("spineExecutable"); path && path->is_string()) {
result.spine_executable = path->string();
}
} catch (...) {
// A damaged preference file must not prevent the connector from starting.
}
return result;
}
void save_config(const ConnectorConfig& config) {
Json::Array origins;
for (const std::string& origin : config.allowed_origins) origins.emplace_back(origin);
Json::Object root;
root.emplace("allowedOrigins", Json(std::move(origins)));
root.emplace("spineExecutable", Json(config.spine_executable));
const auto path = config_path();
std::filesystem::create_directories(path.parent_path());
const auto temporary = path.string() + ".tmp";
{
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) throw std::runtime_error("无法写入连接器配置");
output << Json(std::move(root)).dump(2) << '\n';
}
std::error_code remove_error;
std::filesystem::remove(path, remove_error);
std::filesystem::rename(temporary, path);
}
bool origin_allowed(const std::string& origin) {
if (origin.empty() || local_origin(origin)) return true;
const std::string environment_origins = environment("SPINE_CONNECTOR_ORIGINS");
std::size_t start = 0;
while (start <= environment_origins.size()) {
const std::size_t end = environment_origins.find(',', start);
const std::string item = environment_origins.substr(start, end - start);
if (item == origin) return true;
if (end == std::string::npos) break;
start = end + 1;
}
const auto config = load_config();
return std::find(config.allowed_origins.begin(), config.allowed_origins.end(), origin) != config.allowed_origins.end();
}
void allow_origin(const std::string& origin) {
ConnectorConfig config = load_config();
if (std::find(config.allowed_origins.begin(), config.allowed_origins.end(), origin) == config.allowed_origins.end()) {
config.allowed_origins.push_back(origin);
save_config(config);
}
}
bool valid_web_origin(const std::string& origin) {
static const std::regex pattern(R"(^https?://(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(?::[0-9]{1,5})?$)");
return std::regex_match(origin, pattern);
}
} // namespace connector
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
namespace connector {
struct ConnectorConfig {
std::vector<std::string> allowed_origins;
std::string spine_executable;
};
std::filesystem::path config_path();
ConnectorConfig load_config();
void save_config(const ConnectorConfig& config);
bool origin_allowed(const std::string& origin);
void allow_origin(const std::string& origin);
bool valid_web_origin(const std::string& origin);
} // namespace connector
+203
View File
@@ -0,0 +1,203 @@
#include "connector_service.hpp"
#include "config.hpp"
#include "encoding.hpp"
#include "json.hpp"
#include "platform.hpp"
#include "workspace.hpp"
#include "zip.hpp"
#include <chrono>
#include <filesystem>
#include <map>
#include <mutex>
#include <random>
#include <regex>
#include <stdexcept>
namespace connector {
namespace {
constexpr const char* kVersion = "2.0.0";
constexpr unsigned short kPort = 27843;
std::mutex pairing_mutex;
struct Pairing { std::string origin; std::chrono::steady_clock::time_point expires; };
std::map<std::string, Pairing> pairing;
std::string header(const HttpRequest& request, const std::string& name) {
const auto found = request.headers.find(name);
return found == request.headers.end() ? "" : found->second;
}
void add_cors(HttpResponse& response, const std::string& origin) {
if (!origin.empty()) response.headers["Access-Control-Allow-Origin"] = origin;
response.headers["Vary"] = "Origin";
response.headers["Access-Control-Allow-Private-Network"] = "true";
response.headers["Access-Control-Allow-Headers"] = "Content-Type";
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
}
HttpResponse json_response(int status, Json::Object object, const std::string& origin) {
HttpResponse response;
response.status = status;
response.headers["Content-Type"] = "application/json; charset=utf-8";
response.body = Json(std::move(object)).dump();
add_cors(response, origin);
return response;
}
HttpResponse error_response(int status, const std::string& code, const std::string& message, const std::string& origin) {
return json_response(status, {{"code", Json(code)}, {"message", Json(message)}}, origin);
}
HttpResponse html_response(int status, const std::string& body) {
HttpResponse response;
response.status = status;
response.headers["Content-Type"] = "text/html; charset=utf-8";
response.headers["Cache-Control"] = "no-store";
response.body = "<!doctype html><meta charset=\"utf-8\"><title>Spine粒子连接器</title><style>body{font:16px system-ui;max-width:620px;margin:60px auto;padding:24px;color:#e9ebff;background:#10131e}main{padding:28px;border:1px solid #38415d;border-radius:12px;background:#181e2c}button{padding:10px 18px;border:0;border-radius:7px;color:white;background:#5b5ce2;cursor:pointer}code{word-break:break-all;color:#aeb8e8}</style><main>" + body + "</main>";
return response;
}
std::string nonce() {
std::random_device device;
static const char* digits = "0123456789abcdef";
std::string value(48, '0');
for (char& character : value) character = digits[device() & 15];
return value;
}
std::string form_value(const std::string& body, const std::string& key) {
std::size_t start = 0;
while (start <= body.size()) {
const auto end = body.find('&', start);
const std::string part = body.substr(start, end - start);
const auto equal = part.find('=');
if (url_decode(part.substr(0, equal)) == key) return url_decode(equal == std::string::npos ? "" : part.substr(equal + 1));
if (end == std::string::npos) break;
start = end + 1;
}
return "";
}
std::string sanitize_process_error(std::string output) {
output = std::regex_replace(output, std::regex(R"(^Licensed to:.*$)", std::regex::icase | std::regex::multiline), "Spine 授权信息已隐藏");
return trim(output);
}
std::pair<std::vector<std::uint8_t>, std::string> convert(const Json& payload, const std::string& executable) {
ConversionWorkspace workspace(payload);
const bool spine38 = workspace.spine_version == "3.8";
const std::string editor_version = workspace.spine_version + ".xx";
const std::string input = spine38 ? workspace.input_path.filename().u8string() : workspace.input_path.u8string();
const std::string output = spine38 ? workspace.output_path.filename().u8string() : workspace.output_path.u8string();
const ProcessResult result = run_process(executable, {
"--update", editor_version, "--input", input, "--output", output, "--import", workspace.project_name
}, workspace.root, 180);
if (result.timed_out) throw std::runtime_error("Spine 转换超时");
if (result.exit_code != 0) {
const std::string details = sanitize_process_error(result.output);
throw std::runtime_error(details.empty() ? "Spine 命令执行失败" : details);
}
std::vector<std::uint8_t> project = workspace.read_output();
if (project.empty()) throw std::runtime_error("Spine 未生成有效的工程文件");
std::vector<ArchiveFile> files;
files.push_back({workspace.project_name + ".spine", std::move(project)});
for (auto& image : workspace.archive_images) files.push_back(std::move(image));
std::smatch match;
std::string actual = editor_version;
if (std::regex_search(result.output, match, std::regex(R"(Starting:\s+Spine\s+([0-9.]+))", std::regex::icase))) actual = match[1].str();
return {create_zip(files), actual};
}
} // namespace
bool ConnectorService::start(std::string& error) {
return server_.start(kPort, [this](const HttpRequest& request) { return handle(request); }, error);
}
void ConnectorService::stop() { server_.stop(); }
HttpResponse ConnectorService::handle(const HttpRequest& request) {
const std::string origin = header(request, "origin");
if (request.method == "OPTIONS") {
HttpResponse response;
response.status = 204;
add_cors(response, origin);
return response;
}
try {
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, {
{"connectorVersion", Json(kVersion)},
{"authorized", Json(origin_allowed(origin))},
{"spineFound", Json(!find_spine_executable().empty())},
{"platform", Json(platform_name())},
{"supportedVersions", Json(std::move(versions))},
}, origin);
}
if (request.method == "GET" && request.path == "/pair") {
const auto found = request.query.find("origin");
const std::string requested = found == request.query.end() ? "" : found->second;
if (!valid_web_origin(requested)) return html_response(400, "<h2>授权地址无效</h2>");
const std::string token = nonce();
{
std::lock_guard<std::mutex> lock(pairing_mutex);
pairing[token] = {requested, std::chrono::steady_clock::now() + std::chrono::minutes(5)};
}
return html_response(200, "<h2>授权 Spine粒子编辑器</h2><p>是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 <code>.spine</code> 文件?</p><p><code>" + html_escape(requested) + "</code></p><form method=\"post\" action=\"/pair\"><input type=\"hidden\" name=\"nonce\" value=\"" + token + "\"><button type=\"submit\">允许此网站</button></form>");
}
if (request.method == "POST" && request.path == "/pair") {
const std::string token = form_value(request.body, "nonce");
Pairing pending;
{
std::lock_guard<std::mutex> lock(pairing_mutex);
const auto found = pairing.find(token);
if (found == pairing.end()) return html_response(400, "<h2>授权已过期,请返回编辑器重试</h2>");
pending = found->second;
pairing.erase(found);
}
if (pending.expires < std::chrono::steady_clock::now()) return html_response(400, "<h2>授权已过期,请返回编辑器重试</h2>");
{
std::lock_guard<std::mutex> lock(config_mutex_);
allow_origin(pending.origin);
}
return html_response(200, "<h2>授权成功</h2><p><code>" + html_escape(pending.origin) + "</code> 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。</p>");
}
if (request.method == "POST" && request.path == "/v1/convert") {
{
std::lock_guard<std::mutex> lock(config_mutex_);
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));
response.body = body.dump();
return response;
}
}
const std::string executable = find_spine_executable();
if (executable.empty()) return error_response(503, "SPINE_NOT_FOUND", "没有找到 Spine,请在连接器应用中选择 Spine 路径", origin);
const Json payload = Json::parse(request.body);
std::lock_guard<std::mutex> lock(conversion_mutex_);
auto [archive, editor_version] = convert(payload, executable);
const std::string project_name = safe_project_name(payload.find("projectName") && payload.find("projectName")->is_string() ? payload.find("projectName")->string() : "SpineParticle");
const std::string file_name = project_name + ".zip";
HttpResponse response;
response.headers["Content-Type"] = "application/zip";
response.headers["Content-Disposition"] = "attachment; filename=\"SpineParticle.zip\"; filename*=UTF-8''" + url_encode(file_name);
response.headers["X-Spine-File-Name"] = url_encode(file_name);
response.headers["X-Spine-Editor-Version"] = editor_version;
response.headers["Access-Control-Expose-Headers"] = "X-Spine-File-Name, X-Spine-Editor-Version";
add_cors(response, origin);
response.body.assign(reinterpret_cast<const char*>(archive.data()), archive.size());
return response;
}
return error_response(404, "NOT_FOUND", "接口不存在", origin);
} catch (const std::exception& error) {
return error_response(500, "CONVERSION_FAILED", error.what(), origin);
}
}
} // namespace connector
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "http_server.hpp"
#include <mutex>
#include <string>
namespace connector {
class ConnectorService {
public:
bool start(std::string& error);
void stop();
bool running() const { return server_.running(); }
private:
HttpResponse handle(const HttpRequest& request);
HttpServer server_;
std::mutex config_mutex_;
std::mutex conversion_mutex_;
};
} // namespace connector
+106
View File
@@ -0,0 +1,106 @@
#include "encoding.hpp"
#include <array>
#include <cctype>
#include <iomanip>
#include <sstream>
#include <stdexcept>
namespace connector {
std::vector<std::uint8_t> decode_base64(const std::string& value) {
static const std::string alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::array<int, 256> table{};
table.fill(-1);
for (std::size_t index = 0; index < alphabet.size(); ++index) {
table[static_cast<unsigned char>(alphabet[index])] = static_cast<int>(index);
}
if (value.size() % 4 != 0) throw std::runtime_error("图片数据格式无效");
std::vector<std::uint8_t> output;
output.reserve(value.size() / 4 * 3);
int accumulator = 0;
int bits = -8;
bool padding = false;
for (const unsigned char c : value) {
if (c == '=') {
padding = true;
continue;
}
if (padding || table[c] < 0) throw std::runtime_error("图片数据格式无效");
accumulator = (accumulator << 6) | table[c];
bits += 6;
if (bits >= 0) {
output.push_back(static_cast<std::uint8_t>((accumulator >> bits) & 0xff));
bits -= 8;
}
}
return output;
}
namespace {
int hex_value(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
} // namespace
std::string url_decode(const std::string& value) {
std::string output;
output.reserve(value.size());
for (std::size_t index = 0; index < value.size(); ++index) {
if (value[index] == '+') {
output.push_back(' ');
} else if (value[index] == '%' && index + 2 < value.size()) {
const int high = hex_value(value[index + 1]);
const int low = hex_value(value[index + 2]);
if (high < 0 || low < 0) throw std::runtime_error("URL 编码无效");
output.push_back(static_cast<char>((high << 4) | low));
index += 2;
} else {
output.push_back(value[index]);
}
}
return output;
}
std::string url_encode(const std::string& value) {
std::ostringstream output;
output << std::uppercase << std::hex;
for (const unsigned char c : value) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
output << static_cast<char>(c);
} else {
output << '%' << std::setw(2) << std::setfill('0') << static_cast<int>(c);
}
}
return output.str();
}
std::string html_escape(const std::string& value) {
std::string output;
output.reserve(value.size());
for (const char c : value) {
switch (c) {
case '&': output += "&amp;"; break;
case '<': output += "&lt;"; break;
case '>': output += "&gt;"; break;
case '"': output += "&quot;"; break;
case '\'': output += "&#39;"; break;
default: output.push_back(c);
}
}
return output;
}
std::string trim(const std::string& value) {
std::size_t start = 0;
while (start < value.size() && std::isspace(static_cast<unsigned char>(value[start]))) ++start;
std::size_t end = value.size();
while (end > start && std::isspace(static_cast<unsigned char>(value[end - 1]))) --end;
return value.substr(start, end - start);
}
} // namespace connector
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace connector {
std::vector<std::uint8_t> decode_base64(const std::string& value);
std::string url_decode(const std::string& value);
std::string url_encode(const std::string& value);
std::string html_escape(const std::string& value);
std::string trim(const std::string& value);
} // namespace connector
+208
View File
@@ -0,0 +1,208 @@
#include "http_server.hpp"
#include "encoding.hpp"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <sstream>
#include <thread>
#ifdef _WIN32
#define NOMINMAX
#include <winsock2.h>
#include <ws2tcpip.h>
using Socket = SOCKET;
constexpr Socket kInvalidSocket = INVALID_SOCKET;
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
using Socket = int;
constexpr Socket kInvalidSocket = -1;
#endif
namespace connector {
namespace {
constexpr std::size_t kMaxBody = 200 * 1024 * 1024;
constexpr std::size_t kMaxHeaders = 64 * 1024;
void close_socket(Socket socket) {
#ifdef _WIN32
closesocket(socket);
#else
close(socket);
#endif
}
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;
}
std::string status_text(int status) {
switch (status) {
case 200: return "OK";
case 204: return "No Content";
case 400: return "Bad Request";
case 403: return "Forbidden";
case 404: return "Not Found";
case 413: return "Payload Too Large";
case 500: return "Internal Server Error";
case 503: return "Service Unavailable";
default: return "Response";
}
}
void send_all(Socket socket, 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);
if (count <= 0) return;
sent += static_cast<std::size_t>(count);
}
}
bool parse_request(Socket socket, 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);
if (count <= 0) return false;
buffer.append(chunk, count);
if (buffer.size() > kMaxHeaders) { error_status = 413; return false; }
}
std::istringstream headers(buffer.substr(0, header_end));
std::string line;
if (!std::getline(headers, line)) return false;
if (!line.empty() && line.back() == '\r') line.pop_back();
std::istringstream first(line);
std::string version;
if (!(first >> request.method >> request.target >> version)) return false;
while (std::getline(headers, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
const auto separator = line.find(':');
if (separator != std::string::npos) request.headers[lowercase(trim(line.substr(0, separator)))] = trim(line.substr(separator + 1));
}
const auto query_position = request.target.find('?');
request.path = request.target.substr(0, query_position);
if (query_position != std::string::npos) {
const std::string query = request.target.substr(query_position + 1);
std::size_t start = 0;
while (start <= query.size()) {
const auto end = query.find('&', start);
const std::string part = query.substr(start, end - start);
const auto equal = part.find('=');
request.query[url_decode(part.substr(0, equal))] = url_decode(equal == std::string::npos ? "" : part.substr(equal + 1));
if (end == std::string::npos) break;
start = end + 1;
}
}
std::size_t content_length = 0;
if (const auto found = request.headers.find("content-length"); found != request.headers.end()) {
try { content_length = static_cast<std::size_t>(std::stoull(found->second)); }
catch (...) { error_status = 400; return false; }
}
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);
if (count <= 0) return false;
request.body.append(chunk, count);
}
if (request.body.size() > content_length) request.body.resize(content_length);
return true;
}
void serve_client(Socket client, const RequestHandler& handler) {
HttpRequest request;
int error_status = 400;
HttpResponse response;
if (parse_request(client, request, error_status)) {
try { response = handler(request); }
catch (const std::exception& error) {
response.status = 500;
response.headers["Content-Type"] = "application/json; charset=utf-8";
response.body = std::string("{\"code\":\"INTERNAL_ERROR\",\"message\":\"") + error.what() + "\"}";
}
} else {
response.status = error_status;
response.body = "Bad request";
}
response.headers["Content-Length"] = std::to_string(response.body.size());
response.headers["Connection"] = "close";
std::ostringstream output;
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);
}
} // namespace
HttpServer::HttpServer() = default;
HttpServer::~HttpServer() { stop(); }
bool HttpServer::start(unsigned short port, RequestHandler handler, std::string& error) {
if (running_) return true;
#ifdef _WIN32
WSADATA data{};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) { error = "无法初始化网络服务"; return false; }
#endif
Socket server = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (server == kInvalidSocket) { error = "无法创建本地服务"; return false; }
int reuse = 1;
setsockopt(server, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&reuse), sizeof(reuse));
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(port);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(server, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 || listen(server, 16) != 0) {
close_socket(server);
error = "端口 27843 已被占用,可能已有连接器正在运行";
return false;
}
socket_ = static_cast<std::intptr_t>(server);
handler_ = std::move(handler);
running_ = true;
thread_ = new std::thread([this] { accept_loop(); });
return true;
}
void HttpServer::accept_loop() {
const Socket server = static_cast<Socket>(socket_);
while (running_) {
Socket client = accept(server, nullptr, nullptr);
if (client == kInvalidSocket) {
if (running_) continue;
break;
}
std::thread(serve_client, client, handler_).detach();
}
}
void HttpServer::stop() {
if (!running_.exchange(false)) return;
#ifdef _WIN32
shutdown(static_cast<Socket>(socket_), SD_BOTH);
#else
shutdown(static_cast<Socket>(socket_), SHUT_RDWR);
#endif
close_socket(static_cast<Socket>(socket_));
if (thread_) {
auto* thread = static_cast<std::thread*>(thread_);
if (thread->joinable()) thread->join();
delete thread;
thread_ = nullptr;
}
#ifdef _WIN32
WSACleanup();
#endif
}
} // namespace connector
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <atomic>
#include <functional>
#include <map>
#include <string>
namespace connector {
struct HttpRequest {
std::string method;
std::string target;
std::string path;
std::map<std::string, std::string> query;
std::map<std::string, std::string> headers;
std::string body;
};
struct HttpResponse {
int status = 200;
std::map<std::string, std::string> headers;
std::string body;
};
using RequestHandler = std::function<HttpResponse(const HttpRequest&)>;
class HttpServer {
public:
HttpServer();
~HttpServer();
bool start(unsigned short port, RequestHandler handler, std::string& error);
void stop();
bool running() const { return running_.load(); }
private:
void accept_loop();
std::atomic<bool> running_{false};
std::intptr_t socket_ = -1;
RequestHandler handler_;
void* thread_ = nullptr;
};
} // namespace connector
+318
View File
@@ -0,0 +1,318 @@
#include "json.hpp"
#include <cmath>
#include <cstdlib>
#include <iomanip>
#include <limits>
#include <sstream>
#include <stdexcept>
namespace connector {
namespace {
void append_utf8(std::string& out, unsigned codepoint) {
if (codepoint <= 0x7f) {
out.push_back(static_cast<char>(codepoint));
} else if (codepoint <= 0x7ff) {
out.push_back(static_cast<char>(0xc0 | (codepoint >> 6)));
out.push_back(static_cast<char>(0x80 | (codepoint & 0x3f)));
} else if (codepoint <= 0xffff) {
out.push_back(static_cast<char>(0xe0 | (codepoint >> 12)));
out.push_back(static_cast<char>(0x80 | ((codepoint >> 6) & 0x3f)));
out.push_back(static_cast<char>(0x80 | (codepoint & 0x3f)));
} else {
out.push_back(static_cast<char>(0xf0 | (codepoint >> 18)));
out.push_back(static_cast<char>(0x80 | ((codepoint >> 12) & 0x3f)));
out.push_back(static_cast<char>(0x80 | ((codepoint >> 6) & 0x3f)));
out.push_back(static_cast<char>(0x80 | (codepoint & 0x3f)));
}
}
class Parser {
public:
explicit Parser(const std::string& text) : text_(text) {}
Json parse() {
skip_space();
Json result = value();
skip_space();
if (position_ != text_.size()) fail("JSON 末尾存在多余内容");
return result;
}
private:
const std::string& text_;
std::size_t position_ = 0;
[[noreturn]] void fail(const std::string& message) const {
throw std::runtime_error(message + "(位置 " + std::to_string(position_) + "");
}
void skip_space() {
while (position_ < text_.size()) {
const char c = text_[position_];
if (c != ' ' && c != '\t' && c != '\r' && c != '\n') break;
++position_;
}
}
bool consume(char expected) {
skip_space();
if (position_ >= text_.size() || text_[position_] != expected) return false;
++position_;
return true;
}
void literal(const char* expected) {
while (*expected) {
if (position_ >= text_.size() || text_[position_++] != *expected++) fail("JSON 字面量无效");
}
}
unsigned hex4() {
unsigned value = 0;
for (int index = 0; index < 4; ++index) {
if (position_ >= text_.size()) fail("JSON Unicode 转义不完整");
const char c = text_[position_++];
value <<= 4;
if (c >= '0' && c <= '9') value |= static_cast<unsigned>(c - '0');
else if (c >= 'a' && c <= 'f') value |= static_cast<unsigned>(c - 'a' + 10);
else if (c >= 'A' && c <= 'F') value |= static_cast<unsigned>(c - 'A' + 10);
else fail("JSON Unicode 转义无效");
}
return value;
}
std::string string_value() {
if (!consume('"')) fail("JSON 字符串缺少引号");
std::string out;
while (position_ < text_.size()) {
const char c = text_[position_++];
if (c == '"') return out;
if (static_cast<unsigned char>(c) < 0x20) fail("JSON 字符串包含控制字符");
if (c != '\\') {
out.push_back(c);
continue;
}
if (position_ >= text_.size()) fail("JSON 转义不完整");
switch (text_[position_++]) {
case '"': out.push_back('"'); break;
case '\\': out.push_back('\\'); break;
case '/': out.push_back('/'); break;
case 'b': out.push_back('\b'); break;
case 'f': out.push_back('\f'); break;
case 'n': out.push_back('\n'); break;
case 'r': out.push_back('\r'); break;
case 't': out.push_back('\t'); break;
case 'u': {
unsigned codepoint = hex4();
if (codepoint >= 0xd800 && codepoint <= 0xdbff) {
if (position_ + 2 > text_.size() || text_[position_++] != '\\' || text_[position_++] != 'u') {
fail("JSON Unicode 代理对无效");
}
const unsigned low = hex4();
if (low < 0xdc00 || low > 0xdfff) fail("JSON Unicode 代理对无效");
codepoint = 0x10000 + ((codepoint - 0xd800) << 10) + (low - 0xdc00);
}
append_utf8(out, codepoint);
break;
}
default: fail("JSON 转义字符无效");
}
}
fail("JSON 字符串未结束");
}
Json number_value() {
const std::size_t start = position_;
if (position_ < text_.size() && text_[position_] == '-') ++position_;
if (position_ >= text_.size()) fail("JSON 数字无效");
if (text_[position_] == '0') {
++position_;
} else {
if (text_[position_] < '1' || text_[position_] > '9') fail("JSON 数字无效");
while (position_ < text_.size() && text_[position_] >= '0' && text_[position_] <= '9') ++position_;
}
if (position_ < text_.size() && text_[position_] == '.') {
++position_;
if (position_ >= text_.size() || text_[position_] < '0' || text_[position_] > '9') fail("JSON 小数无效");
while (position_ < text_.size() && text_[position_] >= '0' && text_[position_] <= '9') ++position_;
}
if (position_ < text_.size() && (text_[position_] == 'e' || text_[position_] == 'E')) {
++position_;
if (position_ < text_.size() && (text_[position_] == '+' || text_[position_] == '-')) ++position_;
if (position_ >= text_.size() || text_[position_] < '0' || text_[position_] > '9') fail("JSON 指数无效");
while (position_ < text_.size() && text_[position_] >= '0' && text_[position_] <= '9') ++position_;
}
const std::string token = text_.substr(start, position_ - start);
char* end = nullptr;
const double parsed = std::strtod(token.c_str(), &end);
if (!end || *end || !std::isfinite(parsed)) fail("JSON 数字超出范围");
return Json(parsed);
}
Json array_value() {
consume('[');
Json::Array values;
if (consume(']')) return Json(std::move(values));
while (true) {
values.push_back(value());
if (consume(']')) break;
if (!consume(',')) fail("JSON 数组缺少逗号");
}
return Json(std::move(values));
}
Json object_value() {
consume('{');
Json::Object values;
if (consume('}')) return Json(std::move(values));
while (true) {
skip_space();
if (position_ >= text_.size() || text_[position_] != '"') fail("JSON 对象键名无效");
const std::string key = string_value();
if (!consume(':')) fail("JSON 对象缺少冒号");
values[key] = value();
if (consume('}')) break;
if (!consume(',')) fail("JSON 对象缺少逗号");
}
return Json(std::move(values));
}
Json value() {
skip_space();
if (position_ >= text_.size()) fail("JSON 内容不完整");
switch (text_[position_]) {
case 'n': literal("null"); return Json();
case 't': literal("true"); return Json(true);
case 'f': literal("false"); return Json(false);
case '"': return Json(string_value());
case '[': return array_value();
case '{': return object_value();
default: return number_value();
}
}
};
std::string indent_text(int indent, int depth) {
return indent > 0 ? std::string(static_cast<std::size_t>(indent * depth), ' ') : std::string();
}
void dump_string(std::ostringstream& out, const std::string& value) {
out << '"';
for (const unsigned char c : value) {
switch (c) {
case '"': out << "\\\""; break;
case '\\': out << "\\\\"; break;
case '\b': out << "\\b"; break;
case '\f': out << "\\f"; break;
case '\n': out << "\\n"; break;
case '\r': out << "\\r"; break;
case '\t': out << "\\t"; break;
default:
if (c < 0x20) {
out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast<int>(c)
<< std::dec << std::setfill(' ');
} else {
out << static_cast<char>(c);
}
}
}
out << '"';
}
void dump_value(std::ostringstream& out, const Json& value, int indent, int depth) {
if (value.is_null()) {
out << "null";
} else if (value.is_bool()) {
out << (value.boolean() ? "true" : "false");
} else if (value.is_number()) {
out << std::setprecision(std::numeric_limits<double>::max_digits10) << value.number();
} else if (value.is_string()) {
dump_string(out, value.string());
} else if (value.is_array()) {
out << '[';
const auto& values = value.array();
for (std::size_t index = 0; index < values.size(); ++index) {
if (index) out << ',';
if (indent > 0) out << '\n' << indent_text(indent, depth + 1);
dump_value(out, values[index], indent, depth + 1);
}
if (indent > 0 && !values.empty()) out << '\n' << indent_text(indent, depth);
out << ']';
} else {
out << '{';
const auto& values = value.object();
std::size_t index = 0;
for (const auto& [key, child] : values) {
if (index++) out << ',';
if (indent > 0) out << '\n' << indent_text(indent, depth + 1);
dump_string(out, key);
out << (indent > 0 ? ": " : ":");
dump_value(out, child, indent, depth + 1);
}
if (indent > 0 && !values.empty()) out << '\n' << indent_text(indent, depth);
out << '}';
}
}
} // namespace
Json::Json() : value_(nullptr) {}
Json::Json(std::nullptr_t) : value_(nullptr) {}
Json::Json(bool value) : value_(value) {}
Json::Json(double value) : value_(value) {}
Json::Json(const char* value) : value_(std::string(value ? value : "")) {}
Json::Json(std::string value) : value_(std::move(value)) {}
Json::Json(Array value) : value_(std::move(value)) {}
Json::Json(Object value) : value_(std::move(value)) {}
Json Json::parse(const std::string& text) { return Parser(text).parse(); }
std::string Json::dump(int indent) const {
std::ostringstream out;
dump_value(out, *this, indent, 0);
return out.str();
}
bool Json::is_null() const { return std::holds_alternative<std::nullptr_t>(value_); }
bool Json::is_bool() const { return std::holds_alternative<bool>(value_); }
bool Json::is_number() const { return std::holds_alternative<double>(value_); }
bool Json::is_string() const { return std::holds_alternative<std::string>(value_); }
bool Json::is_array() const { return std::holds_alternative<Array>(value_); }
bool Json::is_object() const { return std::holds_alternative<Object>(value_); }
bool Json::boolean(bool fallback) const { return is_bool() ? std::get<bool>(value_) : fallback; }
double Json::number(double fallback) const { return is_number() ? std::get<double>(value_) : fallback; }
const std::string& Json::string() const {
static const std::string empty;
return is_string() ? std::get<std::string>(value_) : empty;
}
const Json::Array& Json::array() const {
static const Array empty;
return is_array() ? std::get<Array>(value_) : empty;
}
Json::Array& Json::array() {
if (!is_array()) value_ = Array{};
return std::get<Array>(value_);
}
const Json::Object& Json::object() const {
static const Object empty;
return is_object() ? std::get<Object>(value_) : empty;
}
Json::Object& Json::object() {
if (!is_object()) value_ = Object{};
return std::get<Object>(value_);
}
const Json* Json::find(const std::string& key) const {
if (!is_object()) return nullptr;
const auto found = object().find(key);
return found == object().end() ? nullptr : &found->second;
}
Json* Json::find(const std::string& key) {
if (!is_object()) return nullptr;
const auto found = object().find(key);
return found == object().end() ? nullptr : &found->second;
}
Json& Json::operator[](const std::string& key) { return object()[key]; }
} // namespace connector
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <map>
#include <string>
#include <variant>
#include <vector>
namespace connector {
class Json {
public:
using Array = std::vector<Json>;
using Object = std::map<std::string, Json>;
Json();
Json(std::nullptr_t);
Json(bool value);
Json(double value);
Json(const char* value);
Json(std::string value);
Json(Array value);
Json(Object value);
static Json parse(const std::string& text);
std::string dump(int indent = -1) const;
bool is_null() const;
bool is_bool() const;
bool is_number() const;
bool is_string() const;
bool is_array() const;
bool is_object() const;
bool boolean(bool fallback = false) const;
double number(double fallback = 0) const;
const std::string& string() const;
const Array& array() const;
Array& array();
const Object& object() const;
Object& object();
const Json* find(const std::string& key) const;
Json* find(const std::string& key);
Json& operator[](const std::string& key);
private:
using Value = std::variant<std::nullptr_t, bool, double, std::string, Array, Object>;
Value value_;
};
} // namespace connector
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
namespace connector {
struct ProcessResult {
int exit_code = -1;
bool timed_out = false;
std::string output;
};
std::string platform_name();
std::string find_spine_executable();
bool valid_spine_executable(const std::string& path);
ProcessResult run_process(const std::string& executable,
const std::vector<std::string>& arguments,
const std::filesystem::path& working_directory,
int timeout_seconds);
} // namespace connector
+86
View File
@@ -0,0 +1,86 @@
#include "platform.hpp"
#include "config.hpp"
#include <cstdlib>
#include <filesystem>
namespace connector {
namespace {
std::string environment(const char* name) {
const char* value = std::getenv(name);
return value ? value : "";
}
void append_path_candidates(std::vector<std::filesystem::path>& candidates) {
const std::string value = environment("PATH");
#ifdef _WIN32
constexpr char delimiter = ';';
constexpr const char* name = "Spine.com";
#else
constexpr char delimiter = ':';
constexpr const char* name = "Spine";
#endif
std::size_t start = 0;
while (start <= value.size()) {
const auto end = value.find(delimiter, start);
std::string item = value.substr(start, end - start);
if (item.size() >= 2 && item.front() == '"' && item.back() == '"') item = item.substr(1, item.size() - 2);
if (!item.empty()) candidates.push_back(std::filesystem::u8path(item) / name);
if (end == std::string::npos) break;
start = end + 1;
}
}
} // namespace
std::string platform_name() {
#ifdef _WIN32
return "win32";
#elif defined(__APPLE__)
return "darwin";
#else
return "linux";
#endif
}
bool valid_spine_executable(const std::string& path) {
if (path.empty()) return false;
std::error_code error;
const auto status = std::filesystem::status(std::filesystem::u8path(path), error);
return !error && std::filesystem::is_regular_file(status);
}
std::string find_spine_executable() {
std::vector<std::filesystem::path> candidates;
const std::string environment_path = environment("SPINE_EXECUTABLE");
if (!environment_path.empty()) candidates.push_back(std::filesystem::u8path(environment_path));
const ConnectorConfig config = load_config();
if (!config.spine_executable.empty()) candidates.push_back(std::filesystem::u8path(config.spine_executable));
#ifdef _WIN32
for (const char* variable : {"ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"}) {
const std::string root = environment(variable);
if (root.empty()) continue;
candidates.push_back(std::filesystem::u8path(root) / "Spine" / "Spine.com");
candidates.push_back(std::filesystem::u8path(root) / "Esoteric Software" / "Spine" / "Spine.com");
candidates.push_back(std::filesystem::u8path(root) / "Programs" / "Spine" / "Spine.com");
}
#elif defined(__APPLE__)
candidates.emplace_back("/Applications/Spine.app/Contents/MacOS/Spine");
const std::string home = environment("HOME");
if (!home.empty()) candidates.push_back(std::filesystem::u8path(home) / "Applications" / "Spine.app" / "Contents" / "MacOS" / "Spine");
#else
candidates.emplace_back("/opt/Spine/Spine.sh");
candidates.emplace_back("/usr/local/bin/Spine");
candidates.emplace_back("/usr/bin/Spine");
#endif
append_path_candidates(candidates);
for (const auto& candidate : candidates) {
const std::string path = candidate.u8string();
if (valid_spine_executable(path)) return path;
}
return "";
}
} // namespace connector
+75
View File
@@ -0,0 +1,75 @@
#ifndef _WIN32
#include "platform.hpp"
#include <chrono>
#include <csignal>
#include <fcntl.h>
#include <stdexcept>
#include <sys/types.h>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>
namespace connector {
ProcessResult run_process(const std::string& executable,
const std::vector<std::string>& arguments,
const std::filesystem::path& working_directory,
int timeout_seconds) {
int output_pipe[2];
if (pipe(output_pipe) != 0) throw std::runtime_error("无法创建 Spine 输出管道");
const pid_t child = fork();
if (child < 0) {
close(output_pipe[0]);
close(output_pipe[1]);
throw std::runtime_error("无法启动 Spine 进程");
}
if (child == 0) {
close(output_pipe[0]);
dup2(output_pipe[1], STDOUT_FILENO);
dup2(output_pipe[1], STDERR_FILENO);
close(output_pipe[1]);
if (!working_directory.empty()) chdir(working_directory.c_str());
std::vector<char*> argv;
argv.push_back(const_cast<char*>(executable.c_str()));
for (const std::string& argument : arguments) argv.push_back(const_cast<char*>(argument.c_str()));
argv.push_back(nullptr);
execv(executable.c_str(), argv.data());
_exit(127);
}
close(output_pipe[1]);
fcntl(output_pipe[0], F_SETFL, fcntl(output_pipe[0], F_GETFL) | O_NONBLOCK);
ProcessResult result;
int status = 0;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_seconds);
while (true) {
char buffer[8192];
const ssize_t count = read(output_pipe[0], buffer, sizeof(buffer));
if (count > 0 && result.output.size() < 4 * 1024 * 1024) result.output.append(buffer, static_cast<std::size_t>(count));
const pid_t state = waitpid(child, &status, WNOHANG);
if (state == child) break;
if (std::chrono::steady_clock::now() >= deadline) {
result.timed_out = true;
kill(child, SIGKILL);
waitpid(child, &status, 0);
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
while (true) {
char buffer[8192];
const ssize_t count = read(output_pipe[0], buffer, sizeof(buffer));
if (count <= 0) break;
if (result.output.size() < 4 * 1024 * 1024) result.output.append(buffer, static_cast<std::size_t>(count));
}
close(output_pipe[0]);
if (WIFEXITED(status)) result.exit_code = WEXITSTATUS(status);
else if (WIFSIGNALED(status)) result.exit_code = 128 + WTERMSIG(status);
return result;
}
} // namespace connector
#endif
+98
View File
@@ -0,0 +1,98 @@
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#include "platform.hpp"
#include <chrono>
#include <stdexcept>
#include <thread>
namespace connector {
namespace {
std::wstring wide(const std::string& value) {
if (value.empty()) return {};
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
return result;
}
std::wstring quote(const std::string& value) {
std::wstring source = wide(value);
std::wstring result = L"\"";
unsigned backslashes = 0;
for (wchar_t character : source) {
if (character == L'\\') { ++backslashes; continue; }
if (character == L'\"') result.append(backslashes * 2 + 1, L'\\');
else result.append(backslashes, L'\\');
backslashes = 0;
result.push_back(character);
}
result.append(backslashes * 2, L'\\');
return result + L'\"';
}
} // namespace
ProcessResult run_process(const std::string& executable,
const std::vector<std::string>& arguments,
const std::filesystem::path& working_directory,
int timeout_seconds) {
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE read_pipe = nullptr;
HANDLE write_pipe = nullptr;
if (!CreatePipe(&read_pipe, &write_pipe, &attributes, 0)) throw std::runtime_error("无法创建 Spine 输出管道");
SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0);
std::wstring command = quote(executable);
for (const std::string& argument : arguments) command += L" " + quote(argument);
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
startup.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
startup.wShowWindow = SW_HIDE;
startup.hStdOutput = write_pipe;
startup.hStdError = write_pipe;
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION process{};
const std::wstring cwd = working_directory.wstring();
const BOOL started = CreateProcessW(nullptr, command.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW,
nullptr, cwd.empty() ? nullptr : cwd.c_str(), &startup, &process);
CloseHandle(write_pipe);
if (!started) {
CloseHandle(read_pipe);
throw std::runtime_error("无法启动 Spine.com,请检查路径和权限");
}
ProcessResult result;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_seconds);
while (true) {
DWORD available = 0;
if (PeekNamedPipe(read_pipe, nullptr, 0, nullptr, &available, nullptr) && available) {
char buffer[8192];
DWORD count = 0;
if (ReadFile(read_pipe, buffer, (std::min)(available, static_cast<DWORD>(sizeof(buffer))), &count, nullptr) && count && result.output.size() < 4 * 1024 * 1024) {
result.output.append(buffer, count);
}
}
const DWORD state = WaitForSingleObject(process.hProcess, 20);
if (state == WAIT_OBJECT_0) break;
if (std::chrono::steady_clock::now() >= deadline) {
result.timed_out = true;
TerminateProcess(process.hProcess, 1);
WaitForSingleObject(process.hProcess, INFINITE);
break;
}
}
DWORD exit_code = 1;
GetExitCodeProcess(process.hProcess, &exit_code);
result.exit_code = static_cast<int>(exit_code);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
CloseHandle(read_pipe);
return result;
}
} // namespace connector
#endif
+145
View File
@@ -0,0 +1,145 @@
#include "workspace.hpp"
#include "encoding.hpp"
#include <chrono>
#include <fstream>
#include <random>
#include <regex>
#include <stdexcept>
namespace connector {
namespace {
constexpr std::size_t kMaxJson = 64 * 1024 * 1024;
constexpr std::size_t kMaxImage = 32 * 1024 * 1024;
constexpr std::size_t kMaxImages = 128 * 1024 * 1024;
std::string required_string(const Json& object, const std::string& key) {
const Json* value = object.find(key);
if (!value || !value->is_string()) throw std::runtime_error("连接器请求缺少字段:" + key);
return value->string();
}
std::string safe_relative_path(const std::string& value, const char* label) {
std::string normalized = value;
for (char& character : normalized) if (character == '\\') character = '/';
while (normalized.rfind("./", 0) == 0) normalized.erase(0, 2);
while (!normalized.empty() && normalized.back() == '/') normalized.pop_back();
if (normalized.empty()) throw std::runtime_error(std::string(label) + "路径无效");
const std::filesystem::path path = std::filesystem::u8path(normalized).lexically_normal();
if (path.is_absolute() || path.has_root_name()) throw std::runtime_error(std::string(label) + "必须使用相对路径");
for (const auto& part : path) if (part == "..") throw std::runtime_error(std::string(label) + "路径不能超出工程目录");
return path.generic_u8string();
}
std::filesystem::path make_temporary_directory() {
std::random_device device;
std::mt19937_64 generator(device());
for (int attempt = 0; attempt < 32; ++attempt) {
const auto candidate = std::filesystem::temp_directory_path() /
("spine-particle-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + "-" + std::to_string(generator()));
std::error_code error;
if (std::filesystem::create_directory(candidate, error)) return candidate;
}
throw std::runtime_error("无法创建连接器临时目录");
}
void write_binary(const std::filesystem::path& path, const std::vector<std::uint8_t>& data) {
std::filesystem::create_directories(path.parent_path());
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) throw std::runtime_error("无法写入临时文件");
output.write(reinterpret_cast<const char*>(data.data()), static_cast<std::streamsize>(data.size()));
}
} // namespace
std::string safe_project_name(const std::string& value) {
std::string result = trim(value.empty() ? "SpineParticle" : value);
const std::string forbidden = "\\/:*?\"<>|";
for (char& character : result) {
if (static_cast<unsigned char>(character) < 0x20 || forbidden.find(character) != std::string::npos) character = '_';
}
while (!result.empty() && result.front() == '.') result.erase(result.begin());
return result.empty() ? "SpineParticle" : result;
}
std::string normalize_spine_version(const std::string& value) {
static const std::regex pattern(R"(^(3\.8|4\.[0-3])(?:\.\d+)?$)");
std::smatch match;
const std::string cleaned = trim(value);
if (!std::regex_match(cleaned, match, pattern)) throw std::runtime_error("不支持的 Spine 版本:" + value);
return match[1].str();
}
ConversionWorkspace::ConversionWorkspace(const Json& payload) {
try {
const Json* schema = payload.find("schemaVersion");
if (!schema || !schema->is_number() || schema->number() != 1) throw std::runtime_error("不支持的连接器请求格式");
const std::string skeleton_text = required_string(payload, "skeletonJson");
if (skeleton_text.size() > kMaxJson) throw std::runtime_error("Spine JSON 不能超过 64MB");
Json skeleton;
try { skeleton = Json::parse(skeleton_text); }
catch (...) { throw std::runtime_error("Spine JSON 格式无效"); }
const Json* skeleton_info = skeleton.find("skeleton");
const Json* version_value = skeleton_info ? skeleton_info->find("spine") : nullptr;
if (!version_value || !version_value->is_string()) throw std::runtime_error("JSON 中缺少 Spine 版本");
spine_version = normalize_spine_version(version_value->string());
const Json* requested = payload.find("spineVersion");
if (requested && requested->is_string() && normalize_spine_version(requested->string()) != spine_version) {
throw std::runtime_error("请求版本与 JSON 版本不一致");
}
std::string images_directory = "images";
if (skeleton_info) {
if (const Json* images = skeleton_info->find("images"); images && images->is_string() && !images->string().empty()) images_directory = images->string();
}
images_directory = safe_relative_path(images_directory, "Spine 图片");
project_name = safe_project_name(required_string(payload, "projectName"));
root = make_temporary_directory();
input_path = root / std::filesystem::u8path(project_name + ".json");
output_path = root / std::filesystem::u8path(project_name + ".spine");
if (spine_version == "3.8") {
Json* mutable_info = skeleton.find("skeleton");
if (mutable_info) (*mutable_info)["images"] = Json(images_directory);
}
{
std::ofstream output(input_path, std::ios::binary | std::ios::trunc);
if (!output) throw std::runtime_error("无法写入 Spine JSON 临时文件");
output << (spine_version == "3.8" ? skeleton.dump(2) + "\n" : skeleton_text);
}
std::filesystem::create_directories(root / std::filesystem::u8path(images_directory));
std::size_t total = 0;
if (const Json* images = payload.find("images"); images && images->is_array()) {
for (const Json& image : images->array()) {
const std::string relative = safe_relative_path(required_string(image, "path"), "图片");
std::vector<std::uint8_t> data = decode_base64(required_string(image, "dataBase64"));
if (data.size() > kMaxImage) throw std::runtime_error("单张图片不能超过 32MB");
total += data.size();
if (total > kMaxImages) throw std::runtime_error("图片总大小不能超过 128MB");
write_binary(root / std::filesystem::u8path(images_directory) / std::filesystem::u8path(relative), data);
archive_images.push_back({images_directory + "/" + relative, std::move(data)});
}
}
} catch (...) {
if (!root.empty()) {
std::error_code error;
std::filesystem::remove_all(root, error);
}
throw;
}
}
ConversionWorkspace::~ConversionWorkspace() {
if (!root.empty()) {
std::error_code error;
std::filesystem::remove_all(root, error);
}
}
std::vector<std::uint8_t> ConversionWorkspace::read_output() const {
std::ifstream input(output_path, std::ios::binary);
if (!input) throw std::runtime_error("Spine 未生成有效的工程文件");
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
} // namespace connector
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "json.hpp"
#include "zip.hpp"
#include <filesystem>
#include <string>
#include <vector>
namespace connector {
std::string safe_project_name(const std::string& value);
std::string normalize_spine_version(const std::string& value);
class ConversionWorkspace {
public:
explicit ConversionWorkspace(const Json& payload);
~ConversionWorkspace();
ConversionWorkspace(const ConversionWorkspace&) = delete;
ConversionWorkspace& operator=(const ConversionWorkspace&) = delete;
std::filesystem::path root;
std::filesystem::path input_path;
std::filesystem::path output_path;
std::string project_name;
std::string spine_version;
std::vector<ArchiveFile> archive_images;
std::vector<std::uint8_t> read_output() const;
};
} // namespace connector
+136
View File
@@ -0,0 +1,136 @@
#include "zip.hpp"
#include <array>
#include <chrono>
#include <ctime>
#include <filesystem>
#include <stdexcept>
namespace connector {
namespace {
void append_u16(std::vector<std::uint8_t>& output, std::uint16_t value) {
output.push_back(static_cast<std::uint8_t>(value));
output.push_back(static_cast<std::uint8_t>(value >> 8));
}
void append_u32(std::vector<std::uint8_t>& output, std::uint32_t value) {
append_u16(output, static_cast<std::uint16_t>(value));
append_u16(output, static_cast<std::uint16_t>(value >> 16));
}
std::uint32_t crc32(const std::vector<std::uint8_t>& data) {
static const std::array<std::uint32_t, 256> table = [] {
std::array<std::uint32_t, 256> values{};
for (std::uint32_t value = 0; value < values.size(); ++value) {
std::uint32_t crc = value;
for (int bit = 0; bit < 8; ++bit) crc = (crc & 1) ? 0xedb88320U ^ (crc >> 1) : crc >> 1;
values[value] = crc;
}
return values;
}();
std::uint32_t crc = 0xffffffffU;
for (const auto byte : data) crc = table[(crc ^ byte) & 0xff] ^ (crc >> 8);
return crc ^ 0xffffffffU;
}
std::string archive_path(const std::string& raw) {
std::string value = raw;
for (char& c : value) if (c == '\\') c = '/';
while (value.rfind("./", 0) == 0) value.erase(0, 2);
if (value.empty() || value.front() == '/' || value == ".." || value.rfind("../", 0) == 0 ||
value.find("/../") != std::string::npos) {
throw std::runtime_error("ZIP 文件路径无效");
}
return value;
}
struct Timestamp {
std::uint16_t time;
std::uint16_t date;
};
Timestamp dos_timestamp() {
const std::time_t now = std::time(nullptr);
std::tm local{};
#ifdef _WIN32
localtime_s(&local, &now);
#else
localtime_r(&now, &local);
#endif
const int year = std::max(1980, local.tm_year + 1900);
return {
static_cast<std::uint16_t>((local.tm_hour << 11) | (local.tm_min << 5) | (local.tm_sec / 2)),
static_cast<std::uint16_t>(((year - 1980) << 9) | ((local.tm_mon + 1) << 5) | local.tm_mday),
};
}
} // namespace
std::vector<std::uint8_t> create_zip(const std::vector<ArchiveFile>& files) {
if (files.size() > 65535) throw std::runtime_error("ZIP 文件数量过多");
struct CentralRecord {
std::string name;
std::uint32_t crc;
std::uint32_t size;
std::uint32_t offset;
};
std::vector<std::uint8_t> output;
std::vector<CentralRecord> records;
const Timestamp timestamp = dos_timestamp();
for (const auto& file : files) {
const std::string name = archive_path(file.name);
if (name.size() > 65535 || file.data.size() > 0xffffffffULL || output.size() > 0xffffffffULL) {
throw std::runtime_error("ZIP 文件超过 Store 格式限制");
}
const auto checksum = crc32(file.data);
records.push_back({name, checksum, static_cast<std::uint32_t>(file.data.size()),
static_cast<std::uint32_t>(output.size())});
append_u32(output, 0x04034b50);
append_u16(output, 20);
append_u16(output, 0x0800);
append_u16(output, 0);
append_u16(output, timestamp.time);
append_u16(output, timestamp.date);
append_u32(output, checksum);
append_u32(output, static_cast<std::uint32_t>(file.data.size()));
append_u32(output, static_cast<std::uint32_t>(file.data.size()));
append_u16(output, static_cast<std::uint16_t>(name.size()));
append_u16(output, 0);
output.insert(output.end(), name.begin(), name.end());
output.insert(output.end(), file.data.begin(), file.data.end());
}
const auto central_offset = static_cast<std::uint32_t>(output.size());
for (const auto& record : records) {
append_u32(output, 0x02014b50);
append_u16(output, 20);
append_u16(output, 20);
append_u16(output, 0x0800);
append_u16(output, 0);
append_u16(output, timestamp.time);
append_u16(output, timestamp.date);
append_u32(output, record.crc);
append_u32(output, record.size);
append_u32(output, record.size);
append_u16(output, static_cast<std::uint16_t>(record.name.size()));
append_u16(output, 0);
append_u16(output, 0);
append_u16(output, 0);
append_u16(output, 0);
append_u32(output, 0);
append_u32(output, record.offset);
output.insert(output.end(), record.name.begin(), record.name.end());
}
const auto central_size = static_cast<std::uint32_t>(output.size()) - central_offset;
append_u32(output, 0x06054b50);
append_u16(output, 0);
append_u16(output, 0);
append_u16(output, static_cast<std::uint16_t>(records.size()));
append_u16(output, static_cast<std::uint16_t>(records.size()));
append_u32(output, central_size);
append_u32(output, central_offset);
append_u16(output, 0);
return output;
}
} // namespace connector
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace connector {
struct ArchiveFile {
std::string name;
std::vector<std::uint8_t> data;
};
std::vector<std::uint8_t> create_zip(const std::vector<ArchiveFile>& files);
} // namespace connector
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vue-tsc -b && vite build",
"connector": "node connector/spine-connector.mjs",
"build:connector": "node scripts/build-connector-sea.mjs",
"build:connector": "node scripts/build-connector-app.mjs",
"build:connector:legacy-sea": "node scripts/build-connector-sea.mjs",
"test:loop-seam": "node scripts/verify-loop-seam.mjs",
"test:timeline": "node scripts/verify-timeline.mjs",
"test:spine-json": "node scripts/verify-spine-json.mjs",
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDisplayName</key><string>Spine粒子连接器</string>
<key>CFBundleExecutable</key><string>SpineParticleConnector</string>
<key>CFBundleIdentifier</key><string>com.spineparticle.connector</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>Spine粒子连接器</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>2.0.0</string>
<key>CFBundleVersion</key><string>2.0.0</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>NSHighResolutionCapable</key><true/>
</dict></plist>
@@ -0,0 +1,21 @@
# Spine粒子连接器(桌面版)
连接器是原生桌面应用,**无需安装 Node.js**。
## 使用
1. 正常安装并激活 Spine;连接器不会包含或替代 Spine 软件。
2. macOS 打开“Spine粒子连接器.app”;Windows 打开“Spine粒子连接器.exe”。打开应用即启动连接器。
3. 回到网页,等待导出区域显示“连接器可用 · Spine 已就绪”。
4. 首次使用生产网站时,点击网页中的“授权当前网站”,在打开的本机页面确认完整域名后点击“允许此网站”。
5. 可在应用内点击“断开连接”,或直接关闭应用来停止连接器。
若状态显示“未找到 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` 需要分别授权。
## 分发说明
- macOS 请完整分发 `.app`Windows 可直接分发 `.exe` 和本说明。
- macOS 版本只适用于其标记的 CPU 架构(Apple Silicon 或 Intel)。Windows 版本也需在 Windows 上构建。
- 面向其他 Mac 分发时,建议使用 Apple Developer ID 签名并公证,避免 Gatekeeper 提示。
+72
View File
@@ -0,0 +1,72 @@
import { chmod, cp, mkdir, rm, writeFile } from 'node:fs/promises'
import { arch, platform } from 'node:os'
import { join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const root = resolve(import.meta.dirname, '..')
const native = join(root, 'connector', 'native')
const releaseRoot = join(root, 'release', 'connector')
const commonSources = [
'json.cpp', 'encoding.cpp', 'zip.cpp', 'config.cpp', 'platform_common.cpp',
'process_posix.cpp', 'process_windows.cpp', 'workspace.cpp', 'http_server.cpp',
'connector_service.cpp',
].map((name) => join(native, name))
function run(command, args) {
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', shell: false })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} 构建失败(退出码 ${result.status}`)
}
async function buildMac() {
const packageDir = join(releaseRoot, `macos-${arch()}`)
const app = join(packageDir, 'Spine粒子连接器.app')
const executable = join(app, 'Contents', 'MacOS', 'SpineParticleConnector')
await rm(packageDir, { recursive: true, force: true })
await mkdir(join(app, 'Contents', 'MacOS'), { recursive: true })
await mkdir(join(app, 'Contents', 'Resources'), { recursive: true })
run(process.env.CXX || 'clang++', [
'-std=c++17', '-O2', '-Wall', '-Wextra', '-Werror', '-fobjc-arc',
...commonSources, join(native, 'app_macos.mm'),
'-framework', 'AppKit', '-framework', 'Foundation', '-o', executable,
])
await chmod(executable, 0o755)
await writeFile(join(app, 'Contents', 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDisplayName</key><string>Spine粒子连接器</string>
<key>CFBundleExecutable</key><string>SpineParticleConnector</string>
<key>CFBundleIdentifier</key><string>com.spineparticle.connector</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>Spine粒子连接器</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>2.0.0</string>
<key>CFBundleVersion</key><string>2.0.0</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>NSHighResolutionCapable</key><true/>
</dict></plist>
`, 'utf8')
await cp(join(root, 'connector', 'DISTRIBUTION.md'), join(packageDir, '使用说明.md'))
console.log(`已生成 macOS 应用:${app}`)
}
async function buildWindows() {
const packageDir = join(releaseRoot, `windows-${arch()}`)
const executable = join(packageDir, 'Spine粒子连接器.exe')
await rm(packageDir, { recursive: true, force: true })
await mkdir(packageDir, { recursive: true })
const compiler = process.env.CXX || 'cl.exe'
if (/cl(?:\.exe)?$/i.test(compiler)) {
run(compiler, ['/nologo', '/std:c++17', '/O2', '/EHsc', '/utf-8', '/W4', ...commonSources, join(native, 'app_windows.cpp'),
'/link', '/SUBSYSTEM:WINDOWS', `'/OUT:${executable}'`.replaceAll("'", ''), 'ws2_32.lib', 'comdlg32.lib', 'user32.lib', 'gdi32.lib'])
} else {
run(compiler, ['-std=c++17', '-O2', '-Wall', '-Wextra', '-Werror', ...commonSources, join(native, 'app_windows.cpp'),
'-municode', '-mwindows', '-lws2_32', '-lcomdlg32', '-o', executable])
}
await cp(join(root, 'connector', 'DISTRIBUTION.md'), join(packageDir, '使用说明.md'))
console.log(`已生成 Windows 应用:${executable}`)
}
if (platform() === 'darwin') await buildMac()
else if (platform() === 'win32') await buildWindows()
else throw new Error('桌面连接器 App 当前支持 macOS 和 Windows')