连接器客户端
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
# Build output (仅忽略根目录的 Vite 构建产物;不要用无锚点的 dist/,否则会误把
|
# Build output (仅忽略根目录的 Vite 构建产物;不要用无锚点的 dist/,否则会误把
|
||||||
# vendor/spine/*/dist 这类必需的第三方运行时文件一并忽略,导致 clone 后项目无法运行)
|
# vendor/spine/*/dist 这类必需的第三方运行时文件一并忽略,导致 clone 后项目无法运行)
|
||||||
/release
|
|
||||||
/release-tauri
|
/release-tauri
|
||||||
/src-tauri/target
|
/src-tauri/target
|
||||||
# TypeScript build cache
|
# TypeScript build cache
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
# Spine粒子连接器(独立版)
|
# Spine粒子连接器(桌面版)
|
||||||
|
|
||||||
此文件夹内的连接器已包含运行所需的 Node 运行时,**无需安装 Node.js**。
|
连接器是原生桌面应用,**无需安装 Node.js**。
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
1. 正常安装并激活 Spine;连接器不会包含或替代 Spine 软件。
|
1. 正常安装并激活 Spine;连接器不会包含或替代 Spine 软件。
|
||||||
2. macOS 双击 `启动Spine连接器.command`;Windows 双击 `启动Spine连接器.bat`。
|
2. macOS 打开“Spine粒子连接器.app”;Windows 打开“Spine粒子连接器.exe”。打开应用即启动连接器。
|
||||||
3. 回到网页,等待导出区域显示“连接器可用 · Spine 已就绪”。
|
3. 回到网页,等待导出区域显示“连接器可用 · Spine 已就绪”。
|
||||||
4. 首次使用生产网站时,点击网页中的“授权当前网站”,在打开的本机页面确认完整域名后点击“允许此网站”。
|
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` 需要分别授权。
|
连接器仅监听 `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 上构建。
|
- macOS 版本只适用于其标记的 CPU 架构(Apple Silicon 或 Intel)。Windows 版本也需在 Windows 上构建。
|
||||||
- 独立程序已进行本机临时签名,能直接运行;面向其他 Mac 分发时,建议使用 Apple Developer ID 重新签名并公证,避免 Gatekeeper 提示。
|
- 面向其他 Mac 分发时,建议使用 Apple Developer ID 签名并公证,避免 Gatekeeper 提示。
|
||||||
|
|||||||
+8
-4
@@ -5,12 +5,16 @@
|
|||||||
## 使用方法
|
## 使用方法
|
||||||
|
|
||||||
1. 正常安装、激活 Spine。
|
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 包”。
|
3. 打开 Spine粒子编辑器网页。导出区域显示“连接器可用 · Spine 已就绪”后,点击“导出 .spine 包”。
|
||||||
4. 服务器网站首次连接时,状态栏会显示“授权当前网站”。点击后会打开本机确认页,核对显示的完整网站来源(协议、域名和端口)后点击“允许此网站”。授权一次后,连接器会在本机保存允许列表;回到网页后最多等待 3 秒或点击“刷新”。
|
4. 服务器网站首次连接时,状态栏会显示“授权当前网站”。点击后会打开本机确认页,核对显示的完整网站来源(协议、域名和端口)后点击“允许此网站”。授权一次后,连接器会在本机保存允许列表;回到网页后最多等待 3 秒或点击“刷新”。
|
||||||
|
|
||||||
本地开发地址 `127.0.0.1:5173` 和 `localhost:5173` 默认允许,不需要授权。
|
本地开发地址 `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 与固定域名。
|
授权按完整来源区分:`https://editor.example.com`、`http://editor.example.com` 与 `https://editor.example.com:8443` 是三个不同的授权项。生产环境建议始终使用 HTTPS 与固定域名。
|
||||||
|
|
||||||
网页下载的是 ZIP 压缩包,内部包含 `.spine` 工程和 JSON 引用的图片目录。浏览器不能直接下载文件夹,因此使用 ZIP 保留完整目录结构;默认结构为:
|
网页下载的是 ZIP 压缩包,内部包含 `.spine` 工程和 JSON 引用的图片目录。浏览器不能直接下载文件夹,因此使用 ZIP 保留完整目录结构;默认结构为:
|
||||||
@@ -56,7 +60,7 @@ npm run connector
|
|||||||
|
|
||||||
健康检查地址:`http://127.0.0.1:27843/v1/health`。
|
健康检查地址:`http://127.0.0.1:27843/v1/health`。
|
||||||
|
|
||||||
## 生成不依赖 Node.js 的独立版
|
## 生成不依赖 Node.js 的桌面版
|
||||||
|
|
||||||
在目标操作系统上从项目根目录执行:
|
在目标操作系统上从项目根目录执行:
|
||||||
|
|
||||||
@@ -64,6 +68,6 @@ npm run connector
|
|||||||
npm run build: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 Clang;Windows 应在 Visual Studio Developer Command Prompt 中执行,默认使用 MSVC,也可通过 `CXX` 指定 MinGW g++。
|
||||||
|
|
||||||
构建后的 macOS 程序会进行临时本机签名。若要对外正式发布,仍建议使用 Apple Developer ID 重新签名并公证。
|
面向其他 Mac 正式分发时,建议使用 Apple Developer ID 对 `.app` 签名并公证,避免 Gatekeeper 提示。
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 += "&"; break;
|
||||||
|
case '<': output += "<"; break;
|
||||||
|
case '>': output += ">"; break;
|
||||||
|
case '"': output += """; break;
|
||||||
|
case '\'': output += "'"; 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"connector": "node connector/spine-connector.mjs",
|
"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:loop-seam": "node scripts/verify-loop-seam.mjs",
|
||||||
"test:timeline": "node scripts/verify-timeline.mjs",
|
"test:timeline": "node scripts/verify-timeline.mjs",
|
||||||
"test:spine-json": "node scripts/verify-spine-json.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>
|
||||||
BIN
Binary file not shown.
@@ -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 提示。
|
||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user