ui布局自适应

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

No files matched your search

+3 -1
View File
@@ -10,9 +10,11 @@
4. 首次使用生产网站时,点击网页中的“授权当前网站”,在打开的本机页面确认完整域名后点击“允许此网站”。
5. 可在应用内点击“断开连接”,或直接关闭应用来停止连接器。
使用 Safari 时,先在连接器窗口点击“启用 Safari 支持”并确认系统证书授权。连接器会生成每个用户独立的本地证书,只用于访问 `127.0.0.1:27844`,不会对局域网或公网开放。
若状态显示“未找到 Spine”,可在路径输入框中直接输入路径,或点击“选择 Spine.com”。Windows 请选择 `Spine.com`;macOS 请选择 `Spine.app/Contents/MacOS/Spine`。保存后下次启动仍然有效;“恢复自动检测”会清除手动路径。
连接器仅监听 `127.0.0.1:27843`,不会对局域网或公网开放。授权按完整来源区分,例如 `https://editor.example.com` 与 `https://editor.example.com:8443` 需要分别授权。
连接器监听 `127.0.0.1:27843`(HTTP)和 `127.0.0.1:27844`(HTTPS),不会对局域网或公网开放。授权按完整来源区分,例如 `https://editor.example.com` 与 `https://editor.example.com:8443` 需要分别授权。
## 分发说明
+17 -2
View File
@@ -11,6 +11,21 @@
本地开发地址 `127.0.0.1:5173` 和 `localhost:5173` 默认允许,不需要授权。
### Safari
Safari 不允许 HTTPS 网页直接访问 HTTP 的本机服务。因此 macOS 连接器同时监听:
- `http://127.0.0.1:27843`:兼容 Chrome、Edge 和本地 HTTP 页面。
- `https://127.0.0.1:27844`:供 HTTPS 网页和 Safari 使用。
首次使用 Safari 时,在“Spine粒子连接器”窗口点击“启用 Safari 支持”。连接器会为本机生成一套独立证书,并在用户确认后把这个仅用于 `127.0.0.1` 的本地 CA 加入当前用户的 SSL 信任列表。脚本版连接器可运行:
```text
npm run connector:trust
```
证书文件保存在连接器配置目录的 `tls/` 下;服务端私钥仅当前用户可读,签发完成后不保留 CA 私钥。删除证书后重新点击启用按钮会生成新证书。不再使用 Safari 时,可在“钥匙串访问”中搜索 `Spine Particle Connector Local CA` 并删除该证书信任项。
桌面窗口提供 Spine.com 路径输入框、系统文件选择器、“保存路径”“恢复自动检测”“启动连接”和“断开连接”。默认安装位置检测不到时,可手动指定命令行程序:Windows 选择 `Spine.com`;macOS 选择 `Spine.app/Contents/MacOS/Spine`。关闭桌面应用会停止它启动的连接器。
项目根目录原有 `.command` 和 `.bat` 脚本仍然保留,可继续启动 Node 脚本版连接器。桌面版与脚本版使用同一个端口,不能同时运行。
@@ -44,7 +59,7 @@ SPINE_CONNECTOR_ORIGINS=https://particle.example.com,https://editor.example.com
## 安全边界
- 服务只监听 `127.0.0.1:27843`,不会暴露到局域网或公网。
- 服务只监听 `127.0.0.1:27843` 和 `127.0.0.1:27844`,不会暴露到局域网或公网。
- 生产网站必须由用户在本机授权,授权结果保存在浏览器之外的连接器配置中。
- 接口只接受 JSON、图片、项目名称和受支持的 Spine 版本,不能执行网页提供的任意命令。
- 文件在系统临时目录转换,返回 `.spine` 后立即清理。
@@ -58,7 +73,7 @@ SPINE_CONNECTOR_ORIGINS=https://particle.example.com,https://editor.example.com
npm run connector
```
健康检查地址:`http://127.0.0.1:27843/v1/health`。
健康检查地址:`http://127.0.0.1:27843/v1/health`、`https://127.0.0.1:27844/v1/health`。
## 生成不依赖 Node.js 的桌面版
+1 -1
View File
@@ -2,7 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir, platform } from 'node:os'
import { dirname, join } from 'node:path'
function configPath() {
export function configPath() {
if (process.env.SPINE_CONNECTOR_CONFIG) return process.env.SPINE_CONNECTOR_CONFIG
if (platform() === 'darwin') return join(homedir(), 'Library/Application Support/SpineParticleConnector/config.json')
if (platform() === 'win32') return join(process.env.APPDATA || homedir(), 'SpineParticleConnector/config.json')
+110
View File
@@ -0,0 +1,110 @@
import { execFile } from 'node:child_process'
import { access, chmod, mkdir, rm, writeFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import { homedir, platform } from 'node:os'
import { dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { configPath } from './connector-config.mjs'
const run = promisify(execFile)
export function certificateDirectory() {
return process.env.SPINE_CONNECTOR_TLS_DIR || join(dirname(configPath()), 'tls')
}
export function certificatePaths(directory = certificateDirectory()) {
return {
directory,
caKey: join(directory, 'ca-key.pem'),
ca: join(directory, 'ca.pem'),
serverKey: join(directory, 'server-key.pem'),
serverCertificate: join(directory, 'server.pem'),
identity: join(directory, 'identity.p12'),
request: join(directory, 'server.csr'),
extensions: join(directory, 'server.ext'),
}
}
async function certificateIsUsable(path) {
try {
await access(path, constants.R_OK)
await run('/usr/bin/openssl', ['x509', '-checkend', '2592000', '-noout', '-in', path])
return true
} catch {
return false
}
}
async function identityIsUsable(path) {
try {
await access(path, constants.R_OK)
return true
} catch {
return false
}
}
async function runOpenSSL(args) {
try {
await run('/usr/bin/openssl', args)
} catch (error) {
const detail = String(error?.stderr || error?.message || error).trim()
throw new Error(`无法生成本机 HTTPS 证书:${detail}`)
}
}
export async function ensureLocalCertificate() {
if (platform() !== 'darwin') throw new Error('Safari 本机 HTTPS 证书仅支持 macOS')
const paths = certificatePaths()
if (await certificateIsUsable(paths.serverCertificate) && await identityIsUsable(paths.identity)) return paths
await mkdir(paths.directory, { recursive: true, mode: 0o700 })
await runOpenSSL(['genrsa', '-out', paths.caKey, '2048'])
await runOpenSSL([
'req', '-x509', '-new', '-key', paths.caKey, '-sha256', '-days', '3650',
'-subj', '/CN=Spine Particle Connector Local CA', '-out', paths.ca,
])
await runOpenSSL(['genrsa', '-out', paths.serverKey, '2048'])
await runOpenSSL(['req', '-new', '-key', paths.serverKey, '-subj', '/CN=127.0.0.1', '-out', paths.request])
await writeFile(paths.extensions, 'subjectAltName=IP:127.0.0.1,DNS:localhost\nextendedKeyUsage=serverAuth\nbasicConstraints=CA:FALSE\n', 'utf8')
await runOpenSSL([
'x509', '-req', '-in', paths.request, '-CA', paths.ca, '-CAkey', paths.caKey,
'-CAcreateserial', '-out', paths.serverCertificate, '-days', '825', '-sha256',
'-extfile', paths.extensions,
])
await runOpenSSL([
'pkcs12', '-export', '-out', paths.identity, '-inkey', paths.serverKey,
'-in', paths.serverCertificate, '-passout', 'pass:spine-connector-local',
])
await Promise.all([paths.serverKey, paths.identity].map((path) => chmod(path, 0o600)))
await rm(paths.caKey, { force: true })
return paths
}
export async function localCertificateTrusted() {
const paths = certificatePaths()
try {
await run('/usr/bin/security', [
'verify-cert', '-c', paths.serverCertificate, '-n', '127.0.0.1', '-p', 'ssl', '-L', '-q',
])
return true
} catch {
return false
}
}
export async function trustLocalCertificate() {
if (platform() !== 'darwin') throw new Error('Safari 本机 HTTPS 证书仅支持 macOS')
const paths = await ensureLocalCertificate()
const loginKeychain = join(homedir(), 'Library', 'Keychains', 'login.keychain-db')
try {
await run('/usr/bin/security', [
'add-trusted-cert', '-r', 'trustRoot', '-p', 'ssl', '-k', loginKeychain, paths.ca,
])
} catch (error) {
const detail = String(error?.stderr || error?.message || error).trim()
throw new Error(detail || '无法把本机证书加入信任列表')
}
if (!(await localCertificateTrusted())) throw new Error('证书已安装,但系统仍未信任它')
return paths
}
+36
View File
@@ -4,6 +4,7 @@
#include "config.hpp"
#include "connector_service.hpp"
#include "local_certificate.hpp"
#include "platform.hpp"
using connector::ConnectorConfig;
@@ -15,6 +16,7 @@ using connector::ConnectorService;
@property(nonatomic, strong) NSTextField* statusLabel;
@property(nonatomic, strong) NSArray<NSView*>* pathControls;
@property(nonatomic, strong) NSButton* connectionButton;
@property(nonatomic, strong) NSButton* safariButton;
@property(nonatomic, assign) ConnectorService* service;
@property(nonatomic, copy) NSString* startupError;
@end
@@ -72,6 +74,9 @@ using connector::ConnectorService;
self.pathControls = @[pathLabel, self.pathField, browse, automatic, save];
self.connectionButton = [self button:@"断开连接" frame:NSMakeRect(460, 25, 130, 36) action:@selector(toggleConnection:)];
[content addSubview:self.connectionButton];
self.safariButton = [self button:@"启用 Safari 支持" frame:NSMakeRect(315, 25, 130, 36) action:@selector(enableSafari:)];
self.safariButton.toolTip = @"安装仅用于 127.0.0.1 的本地证书,使 Safari 可以访问 HTTPS 连接器。";
[content addSubview:self.safariButton];
[self refreshStatus:nil];
[self.window makeKeyAndOrderFront:nil];
[NSApp activateIgnoringOtherApps:YES];
@@ -82,6 +87,9 @@ using connector::ConnectorService;
const BOOL ownsPort = self.service && self.service->running();
const BOOL portOccupied = ownsPort || connector::connector_port_in_use();
self.connectionButton.title = portOccupied ? @"停止连接" : @"启动连接";
const BOOL safariTrusted = connector::local_certificate_trusted();
self.safariButton.title = safariTrusted ? @"Safari 支持已启用" : @"启用 Safari 支持";
self.safariButton.enabled = !safariTrusted;
if (self.startupError.length > 0 && !portOccupied) {
self.statusLabel.stringValue = [@"● 连接器未启动 · " stringByAppendingString:self.startupError];
self.statusLabel.textColor = NSColor.systemRedColor;
@@ -121,6 +129,34 @@ using connector::ConnectorService;
[self refreshStatus:nil];
}
- (void)enableSafari:(id)sender {
(void)sender;
NSAlert* confirmation = [[NSAlert alloc] init];
confirmation.messageText = @"启用 Safari 本机连接";
confirmation.informativeText = @"连接器会把一个仅用于 127.0.0.1 的本地证书加入当前用户的 SSL 信任列表。系统可能要求你确认。";
[confirmation addButtonWithTitle:@"继续"];
[confirmation addButtonWithTitle:@"取消"];
if ([confirmation runModal] != NSAlertFirstButtonReturn) return;
std::string error;
if (!connector::trust_local_certificate(error)) {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = @"无法启用 Safari 支持";
alert.informativeText = [NSString stringWithUTF8String:error.c_str()];
[alert runModal];
return;
}
if (self.service && self.service->running() && !self.service->secure_running()) {
self.service->stop();
}
if (self.service && !self.service->running()) {
std::string startError;
self.service->start(startError);
self.startupError = [NSString stringWithUTF8String:startError.c_str()];
}
[self refreshStatus:nil];
}
- (void)selectSpine:(id)sender {
(void)sender;
NSOpenPanel* panel = [NSOpenPanel openPanel];
+38 -5
View File
@@ -4,11 +4,15 @@
#include "encoding.hpp"
#include "json.hpp"
#include "platform.hpp"
#ifdef __APPLE__
#include "local_certificate.hpp"
#endif
#include "workspace.hpp"
#include "zip.hpp"
#include <chrono>
#include <filesystem>
#include <iostream>
#include <map>
#include <mutex>
#include <random>
@@ -20,6 +24,7 @@ namespace {
constexpr const char* kVersion = "2.0.0";
constexpr unsigned short kPort = 27843;
constexpr unsigned short kSecurePort = 27844;
std::mutex pairing_mutex;
struct Pairing { std::string origin; std::chrono::steady_clock::time_point expires; };
std::map<std::string, Pairing> pairing;
@@ -125,10 +130,27 @@ std::pair<std::vector<std::uint8_t>, std::string> convert(const Json& payload, c
} // namespace
bool ConnectorService::start(std::string& error) {
return server_.start(kPort, [this](const HttpRequest& request) { return handle(request); }, error);
if (server_.running()) return true;
if (!server_.start(kPort, [this](const HttpRequest& request) { return handle(request); }, error)) return false;
#ifdef __APPLE__
std::string certificate_error;
if (!ensure_local_certificate(certificate_error)) {
secure_error_ = certificate_error;
std::cerr << "Safari HTTPS certificate error: " << secure_error_ << '\n';
} else if (!secure_server_.start(
kSecurePort, [this](const HttpRequest& request) { return handle(request); }, secure_error_,
local_identity_path())) {
// HTTP remains available for existing browsers even if the optional Safari endpoint cannot start.
std::cerr << "Safari HTTPS server error: " << secure_error_ << '\n';
}
#endif
return true;
}
void ConnectorService::stop() { server_.stop(); }
void ConnectorService::stop() {
secure_server_.stop();
server_.stop();
}
HttpResponse ConnectorService::handle(const HttpRequest& request) {
const std::string origin = header(request, "origin");
@@ -142,13 +164,21 @@ HttpResponse ConnectorService::handle(const HttpRequest& request) {
if (request.method == "GET" && request.path == "/v1/health") {
std::lock_guard<std::mutex> lock(config_mutex_);
Json::Array versions{Json("3.8"), Json("4.0"), Json("4.1"), Json("4.2"), Json("4.3")};
return json_response(200, {
Json::Object health = {
{"connectorVersion", Json(kVersion)},
{"authorized", Json(origin_allowed(origin))},
{"spineFound", Json(!find_spine_executable().empty())},
{"secureAvailable", Json(secure_server_.running())},
{"platform", Json(platform_name())},
{"supportedVersions", Json(std::move(versions))},
}, origin);
};
if (!origin_allowed(origin)) {
const std::string base = request.secure
? "https://127.0.0.1:" + std::to_string(kSecurePort)
: "http://127.0.0.1:" + std::to_string(kPort);
health["pairUrl"] = Json(base + "/pair?origin=" + url_encode(origin));
}
return json_response(200, std::move(health), origin);
}
if (request.method == "GET" && request.path == "/pair") {
const auto found = request.query.find("origin");
@@ -184,7 +214,10 @@ HttpResponse ConnectorService::handle(const HttpRequest& request) {
if (!origin_allowed(origin)) {
HttpResponse response = error_response(403, "ORIGIN_NOT_ALLOWED", "需要先授权当前网站使用本地 Spine 连接器", origin);
Json body = Json::parse(response.body);
body["pairUrl"] = Json("http://127.0.0.1:27843/pair?origin=" + url_encode(origin));
const std::string base = request.secure
? "https://127.0.0.1:" + std::to_string(kSecurePort)
: "http://127.0.0.1:" + std::to_string(kPort);
body["pairUrl"] = Json(base + "/pair?origin=" + url_encode(origin));
response.body = body.dump();
return response;
}
+3
View File
@@ -12,10 +12,13 @@ class ConnectorService {
bool start(std::string& error);
void stop();
bool running() const { return server_.running(); }
bool secure_running() const { return secure_server_.running(); }
private:
HttpResponse handle(const HttpRequest& request);
HttpServer server_;
HttpServer secure_server_;
std::string secure_error_;
std::mutex config_mutex_;
std::mutex conversion_mutex_;
};
+193 -12
View File
@@ -3,11 +3,18 @@
#include "encoding.hpp"
#include <algorithm>
#include <cerrno>
#include <cctype>
#include <cstring>
#include <fstream>
#include <sstream>
#include <thread>
#ifdef __APPLE__
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
#endif
#ifdef _WIN32
#define NOMINMAX
#include <winsock2.h>
@@ -19,6 +26,11 @@ constexpr Socket kInvalidSocket = INVALID_SOCKET;
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#ifdef __APPLE__
#include <CoreFoundation/CoreFoundation.h>
#include <Security/SecImportExport.h>
#include <Security/SecureTransport.h>
#endif
using Socket = int;
constexpr Socket kInvalidSocket = -1;
#endif
@@ -37,6 +49,119 @@ void close_socket(Socket socket) {
#endif
}
struct ConnectionStream {
Socket socket = kInvalidSocket;
#ifdef __APPLE__
SSLContextRef tls = nullptr;
#endif
};
#ifdef __APPLE__
OSStatus socket_read(SSLConnectionRef reference, void* data, std::size_t* length) {
const Socket socket = *static_cast<const Socket*>(reference);
const long count = recv(socket, static_cast<char*>(data), static_cast<int>(*length), 0);
if (count > 0) {
*length = static_cast<std::size_t>(count);
return noErr;
}
*length = 0;
if (count == 0) return errSSLClosedGraceful;
return errno == EAGAIN || errno == EWOULDBLOCK ? errSSLWouldBlock : errSSLInternal;
}
OSStatus socket_write(SSLConnectionRef reference, const void* data, std::size_t* length) {
const Socket socket = *static_cast<const Socket*>(reference);
const long count = send(socket, static_cast<const char*>(data), static_cast<int>(*length), 0);
if (count > 0) {
*length = static_cast<std::size_t>(count);
return noErr;
}
*length = 0;
return errno == EAGAIN || errno == EWOULDBLOCK ? errSSLWouldBlock : errSSLInternal;
}
void* load_identity(const std::filesystem::path& path, std::string& error) {
std::ifstream input(path, std::ios::binary);
if (!input) {
error = "无法读取 HTTPS 身份证书";
return nullptr;
}
const std::string bytes((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
CFDataRef data = CFDataCreate(kCFAllocatorDefault, reinterpret_cast<const UInt8*>(bytes.data()),
static_cast<CFIndex>(bytes.size()));
const void* keys[] = {kSecImportExportPassphrase};
const void* values[] = {CFSTR("spine-connector-local")};
CFDictionaryRef options = CFDictionaryCreate(kCFAllocatorDefault, keys, values, 1,
&kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
CFArrayRef items = nullptr;
const OSStatus status = SecPKCS12Import(data, options, &items);
CFRelease(options);
CFRelease(data);
if (status != errSecSuccess || !items || CFArrayGetCount(items) == 0) {
if (items) CFRelease(items);
error = "无法读取 HTTPS 身份证书";
return nullptr;
}
CFDictionaryRef item = static_cast<CFDictionaryRef>(CFArrayGetValueAtIndex(items, 0));
CFTypeRef identity = CFDictionaryGetValue(item, kSecImportItemIdentity);
if (identity) CFRetain(identity);
CFRelease(items);
if (!identity) error = "HTTPS 身份证书缺少私钥";
return const_cast<void*>(identity);
}
#endif
bool open_stream(ConnectionStream& stream, void* identity, std::string& error) {
if (!identity) return true;
#ifdef __APPLE__
SSLContextRef context = SSLCreateContext(kCFAllocatorDefault, kSSLServerSide, kSSLStreamType);
if (!context) {
error = "无法创建 HTTPS 服务";
return false;
}
SSLSetIOFuncs(context, socket_read, socket_write);
SSLSetConnection(context, &stream.socket);
SSLSetProtocolVersionMin(context, kTLSProtocol12);
const void* certificates[] = {identity};
CFArrayRef certificate_array = CFArrayCreate(kCFAllocatorDefault, certificates, 1, &kCFTypeArrayCallBacks);
const OSStatus certificate_status = SSLSetCertificate(context, certificate_array);
CFRelease(certificate_array);
if (certificate_status != noErr) {
CFRelease(context);
error = "无法配置 HTTPS 身份证书";
return false;
}
OSStatus status;
do {
status = SSLHandshake(context);
} while (status == errSSLWouldBlock);
if (status != noErr) {
CFRelease(context);
return false;
}
stream.tls = context;
return true;
#else
(void)stream;
error = "当前平台不支持 HTTPS 连接器";
return false;
#endif
}
void close_stream(ConnectionStream& stream) {
#ifdef __APPLE__
if (stream.tls) {
SSLClose(stream.tls);
CFRelease(stream.tls);
stream.tls = nullptr;
}
#endif
if (stream.socket != kInvalidSocket) {
close_socket(stream.socket);
stream.socket = kInvalidSocket;
}
}
std::string lowercase(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return value;
@@ -56,21 +181,49 @@ std::string status_text(int status) {
}
}
void send_all(Socket socket, const std::string& data) {
int read_stream(ConnectionStream& stream, char* buffer, int length) {
#ifdef __APPLE__
if (stream.tls) {
std::size_t received = 0;
const OSStatus status = SSLRead(stream.tls, buffer, static_cast<std::size_t>(length), &received);
if (status == errSSLClosedGraceful || status == errSSLClosedNoNotify) return static_cast<int>(received);
if (status != noErr && status != errSSLWouldBlock) return -1;
return static_cast<int>(received);
}
#endif
return recv(stream.socket, buffer, length, 0);
}
void send_all(ConnectionStream& stream, const std::string& data) {
std::size_t sent = 0;
while (sent < data.size()) {
const int count = send(socket, data.data() + sent, static_cast<int>((std::min)(data.size() - sent, static_cast<std::size_t>(1 << 20))), 0);
int count;
#ifdef __APPLE__
if (stream.tls) {
std::size_t written = 0;
const OSStatus status = SSLWrite(stream.tls, data.data() + sent,
(std::min)(data.size() - sent, static_cast<std::size_t>(1 << 20)), &written);
if (status != noErr && status != errSSLWouldBlock) return;
count = static_cast<int>(written);
} else {
count = send(stream.socket, data.data() + sent,
static_cast<int>((std::min)(data.size() - sent, static_cast<std::size_t>(1 << 20))), 0);
}
#else
count = send(stream.socket, data.data() + sent,
static_cast<int>((std::min)(data.size() - sent, static_cast<std::size_t>(1 << 20))), 0);
#endif
if (count <= 0) return;
sent += static_cast<std::size_t>(count);
}
}
bool parse_request(Socket socket, HttpRequest& request, int& error_status) {
bool parse_request(ConnectionStream& stream, HttpRequest& request, int& error_status) {
std::string buffer;
char chunk[8192];
std::size_t header_end = std::string::npos;
while ((header_end = buffer.find("\r\n\r\n")) == std::string::npos) {
const int count = recv(socket, chunk, sizeof(chunk), 0);
const int count = read_stream(stream, chunk, sizeof(chunk));
if (count <= 0) return false;
buffer.append(chunk, count);
if (buffer.size() > kMaxHeaders) { error_status = 413; return false; }
@@ -109,7 +262,7 @@ bool parse_request(Socket socket, HttpRequest& request, int& error_status) {
if (content_length > kMaxBody) { error_status = 413; return false; }
request.body = buffer.substr(header_end + 4);
while (request.body.size() < content_length) {
const int count = recv(socket, chunk, sizeof(chunk), 0);
const int count = read_stream(stream, chunk, sizeof(chunk));
if (count <= 0) return false;
request.body.append(chunk, count);
}
@@ -117,11 +270,20 @@ bool parse_request(Socket socket, HttpRequest& request, int& error_status) {
return true;
}
void serve_client(Socket client, const RequestHandler& handler) {
void serve_client(Socket client, void* identity, const RequestHandler& handler) {
ConnectionStream stream{client};
std::string tls_error;
if (!open_stream(stream, identity, tls_error)) {
close_stream(stream);
return;
}
HttpRequest request;
int error_status = 400;
HttpResponse response;
if (parse_request(client, request, error_status)) {
if (parse_request(stream, request, error_status)) {
#ifdef __APPLE__
request.secure = stream.tls != nullptr;
#endif
try { response = handler(request); }
catch (const std::exception& error) {
response.status = 500;
@@ -138,9 +300,9 @@ void serve_client(Socket client, const RequestHandler& handler) {
output << "HTTP/1.1 " << response.status << ' ' << status_text(response.status) << "\r\n";
for (const auto& [name, value] : response.headers) output << name << ": " << value << "\r\n";
output << "\r\n";
send_all(client, output.str());
send_all(client, response.body);
close_socket(client);
send_all(stream, output.str());
send_all(stream, response.body);
close_stream(stream);
}
} // namespace
@@ -148,8 +310,21 @@ void serve_client(Socket client, const RequestHandler& handler) {
HttpServer::HttpServer() = default;
HttpServer::~HttpServer() { stop(); }
bool HttpServer::start(unsigned short port, RequestHandler handler, std::string& error) {
bool HttpServer::start(unsigned short port, RequestHandler handler, std::string& error,
const std::filesystem::path& identity) {
if (running_) return true;
#ifdef __APPLE__
if (!identity.empty()) {
void* loaded = load_identity(identity, error);
if (loaded) tls_identity_ = std::shared_ptr<void>(loaded, [](void* value) { CFRelease(value); });
if (!tls_identity_) return false;
}
#else
if (!identity.empty()) {
error = "当前平台不支持 HTTPS 连接器";
return false;
}
#endif
#ifdef _WIN32
WSADATA data{};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) { error = "无法初始化网络服务"; return false; }
@@ -182,7 +357,9 @@ void HttpServer::accept_loop() {
if (running_) continue;
break;
}
std::thread(serve_client, client, handler_).detach();
std::thread([client, identity = tls_identity_, handler = handler_] {
serve_client(client, identity.get(), handler);
}).detach();
}
}
@@ -206,3 +383,7 @@ void HttpServer::stop() {
}
} // namespace connector
#ifdef __APPLE__
#pragma clang diagnostic pop
#endif
+6 -1
View File
@@ -2,8 +2,10 @@
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <map>
#include <memory>
#include <string>
namespace connector {
@@ -15,6 +17,7 @@ struct HttpRequest {
std::map<std::string, std::string> query;
std::map<std::string, std::string> headers;
std::string body;
bool secure = false;
};
struct HttpResponse {
@@ -29,7 +32,8 @@ class HttpServer {
public:
HttpServer();
~HttpServer();
bool start(unsigned short port, RequestHandler handler, std::string& error);
bool start(unsigned short port, RequestHandler handler, std::string& error,
const std::filesystem::path& identity = {});
void stop();
bool running() const { return running_.load(); }
@@ -37,6 +41,7 @@ class HttpServer {
void accept_loop();
std::atomic<bool> running_{false};
std::intptr_t socket_ = -1;
std::shared_ptr<void> tls_identity_;
RequestHandler handler_;
void* thread_ = nullptr;
};
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <filesystem>
#include <string>
namespace connector {
std::filesystem::path local_identity_path();
bool ensure_local_certificate(std::string& error);
bool local_certificate_trusted();
bool trust_local_certificate(std::string& error);
} // namespace connector
+178
View File
@@ -0,0 +1,178 @@
#ifdef __APPLE__
#include "local_certificate.hpp"
#include "config.hpp"
#include "encoding.hpp"
#include "platform.hpp"
#include <cstdlib>
#include <fstream>
#include <sys/stat.h>
namespace connector {
namespace {
struct CertificatePaths {
std::filesystem::path directory;
std::filesystem::path ca_key;
std::filesystem::path ca;
std::filesystem::path server_key;
std::filesystem::path server_certificate;
std::filesystem::path identity;
std::filesystem::path request;
std::filesystem::path extensions;
};
CertificatePaths paths() {
const auto directory = config_path().parent_path() / "tls";
return {
directory,
directory / "ca-key.pem",
directory / "ca.pem",
directory / "server-key.pem",
directory / "server.pem",
directory / "identity.p12",
directory / "server.csr",
directory / "server.ext",
};
}
std::string process_error(const ProcessResult& result) {
std::string message = trim(result.output);
return message.empty() ? "OpenSSL 执行失败" : message;
}
bool run_openssl(const std::vector<std::string>& arguments, std::string& error) {
const ProcessResult result = run_process("/usr/bin/openssl", arguments, {}, 30);
if (result.timed_out) {
error = "生成本机 HTTPS 证书超时";
return false;
}
if (result.exit_code != 0) {
error = process_error(result);
return false;
}
return true;
}
bool certificate_is_usable(const std::filesystem::path& certificate) {
std::error_code file_error;
if (!std::filesystem::is_regular_file(certificate, file_error)) return false;
const ProcessResult result = run_process(
"/usr/bin/openssl", {"x509", "-checkend", "2592000", "-noout", "-in", certificate.string()}, {}, 10);
return !result.timed_out && result.exit_code == 0;
}
bool identity_is_usable(const std::filesystem::path& identity) {
std::error_code error;
return std::filesystem::is_regular_file(identity, error);
}
void set_private_permissions(const std::filesystem::path& path) {
chmod(path.c_str(), 0600);
}
} // namespace
std::filesystem::path local_identity_path() {
return paths().identity;
}
bool ensure_local_certificate(std::string& error) {
const CertificatePaths files = paths();
if (certificate_is_usable(files.server_certificate) && identity_is_usable(files.identity)) {
error.clear();
return true;
}
try {
std::filesystem::create_directories(files.directory);
chmod(files.directory.c_str(), 0700);
for (const auto& path : {files.ca_key, files.ca, files.server_key, files.server_certificate,
files.identity, files.request, files.extensions}) {
std::error_code ignored;
std::filesystem::remove(path, ignored);
}
} catch (const std::exception& exception) {
error = exception.what();
return false;
}
const std::string dir = files.directory.string();
if (!run_openssl({"genrsa", "-out", files.ca_key.string(), "2048"}, error)
|| !run_openssl({"req", "-x509", "-new", "-key", files.ca_key.string(), "-sha256", "-days", "3650",
"-subj", "/CN=Spine Particle Connector Local CA", "-out", files.ca.string()}, error)
|| !run_openssl({"genrsa", "-out", files.server_key.string(), "2048"}, error)
|| !run_openssl({"req", "-new", "-key", files.server_key.string(), "-subj", "/CN=127.0.0.1",
"-out", files.request.string()}, error)) {
return false;
}
{
std::ofstream extensions(files.extensions, std::ios::binary | std::ios::trunc);
if (!extensions) {
error = "无法写入证书配置";
return false;
}
extensions << "subjectAltName=IP:127.0.0.1,DNS:localhost\n"
<< "extendedKeyUsage=serverAuth\n"
<< "basicConstraints=CA:FALSE\n";
}
if (!run_openssl({"x509", "-req", "-in", files.request.string(), "-CA", files.ca.string(),
"-CAkey", files.ca_key.string(), "-CAcreateserial", "-out", files.server_certificate.string(),
"-days", "825", "-sha256", "-extfile", files.extensions.string()}, error)
|| !run_openssl({"pkcs12", "-export", "-out", files.identity.string(), "-inkey", files.server_key.string(),
"-in", files.server_certificate.string(), "-passout", "pass:spine-connector-local"}, error)) {
return false;
}
set_private_permissions(files.server_key);
set_private_permissions(files.identity);
{
std::error_code ignored;
std::filesystem::remove(files.ca_key, ignored);
}
(void)dir;
error.clear();
return true;
}
bool local_certificate_trusted() {
const CertificatePaths files = paths();
const ProcessResult result = run_process(
"/usr/bin/security",
{"verify-cert", "-c", files.server_certificate.string(), "-n", "127.0.0.1", "-p", "ssl", "-L", "-q"},
{}, 10);
return !result.timed_out && result.exit_code == 0;
}
bool trust_local_certificate(std::string& error) {
const CertificatePaths files = paths();
if (!ensure_local_certificate(error)) return false;
const char* home = std::getenv("HOME");
if (!home || !*home) {
error = "无法确定当前用户主目录";
return false;
}
const auto keychain = std::filesystem::path(home) / "Library" / "Keychains" / "login.keychain-db";
const ProcessResult result = run_process(
"/usr/bin/security",
{"add-trusted-cert", "-r", "trustRoot", "-p", "ssl", "-k", keychain.string(), files.ca.string()},
{}, 120);
if (result.timed_out || result.exit_code != 0) {
error = result.timed_out ? "等待系统证书授权超时" : process_error(result);
return false;
}
if (!local_certificate_trusted()) {
error = "证书已安装,但系统仍未信任它";
return false;
}
error.clear();
return true;
}
} // namespace connector
#endif
+116 -86
View File
@@ -1,15 +1,20 @@
#!/usr/bin/env node
import { createServer } from 'node:http'
import { createServer as createSecureServer } from 'node:https'
import { randomBytes } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { platform } from 'node:os'
import { findSpineExecutable, importSpineProject, normalizeSpineVersion } from './spine-cli.mjs'
import { allowOrigin, originAllowed, validWebOrigin } from './connector-config.mjs'
import { certificatePaths, ensureLocalCertificate } from './local-certificate.mjs'
import { createConversionWorkspace, safeProjectName } from './spine-workspace.mjs'
import { createZipArchive } from './zip-archive.mjs'
const VERSION = '1.0.0'
const HOST = process.env.SPINE_CONNECTOR_HOST || '127.0.0.1'
const PORT = Math.max(1024, Math.min(65535, Number(process.env.SPINE_CONNECTOR_PORT) || 27843))
const HTTPS_PORT = Math.max(1024, Math.min(65535, Number(process.env.SPINE_CONNECTOR_TLS_PORT) || PORT + 1))
const MAX_BODY = 200 * 1024 * 1024
const pairing = new Map()
@@ -47,98 +52,123 @@ function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
}
const server = createServer(async (request, response) => {
const origin = request.headers.origin || ''
const url = new URL(request.url || '/', `http://${HOST}:${PORT}`)
if (request.method === 'OPTIONS') { cors(response, origin); response.writeHead(204); response.end(); return }
try {
if (request.method === 'GET' && url.pathname === '/v1/health') {
const spinePath = await findSpineExecutable()
const authorized = await originAllowed(origin)
json(response, 200, {
connectorVersion: VERSION,
authorized,
spineFound: !!spinePath,
platform: process.platform,
supportedVersions: ['3.8', '4.0', '4.1', '4.2', '4.3'],
}, origin)
return
}
if (request.method === 'GET' && url.pathname === '/pair') {
const requestedOrigin = validWebOrigin(url.searchParams.get('origin'))
if (!requestedOrigin) { html(response, 400, '<h2>授权地址无效</h2>'); return }
const nonce = randomBytes(24).toString('hex')
pairing.set(nonce, { origin: requestedOrigin, expires: Date.now() + 5 * 60 * 1000 })
html(response, 200, `<h2>授权 Spine粒子编辑器</h2><p>是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 <code>.spine</code> 文件?</p><p><code>${escapeHtml(requestedOrigin)}</code></p><form method="post" action="/pair"><input type="hidden" name="nonce" value="${nonce}"><button type="submit">允许此网站</button></form>`)
return
}
if (request.method === 'POST' && url.pathname === '/pair') {
const chunks = []
for await (const chunk of request) chunks.push(chunk)
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
const nonce = form.get('nonce') || ''
const pending = pairing.get(nonce)
pairing.delete(nonce)
if (!pending || pending.expires < Date.now()) { html(response, 400, '<h2>授权已过期,请返回编辑器重试</h2>'); return }
await allowOrigin(pending.origin)
html(response, 200, `<h2>授权成功</h2><p><code>${escapeHtml(pending.origin)}</code> 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。</p>`)
return
}
if (request.method === 'POST' && url.pathname === '/v1/convert') {
if (!(await originAllowed(origin))) {
const pairUrl = `http://${HOST}:${PORT}/pair?origin=${encodeURIComponent(origin)}`
json(response, 403, { code: 'ORIGIN_NOT_ALLOWED', message: '需要先授权当前网站使用本地 Spine 连接器', pairUrl }, origin)
let secureAvailable = false
function createHandler(secure) {
const baseUrl = `${secure ? 'https' : 'http'}://${HOST}:${secure ? HTTPS_PORT : PORT}`
return async (request, response) => {
const origin = request.headers.origin || ''
const url = new URL(request.url || '/', baseUrl)
if (request.method === 'OPTIONS') { cors(response, origin); response.writeHead(204); response.end(); return }
try {
if (request.method === 'GET' && url.pathname === '/v1/health') {
const spinePath = await findSpineExecutable()
const authorized = await originAllowed(origin)
json(response, 200, {
connectorVersion: VERSION,
authorized,
spineFound: !!spinePath,
secureAvailable,
pairUrl: authorized ? undefined : `${baseUrl}/pair?origin=${encodeURIComponent(origin)}`,
platform: process.platform,
supportedVersions: ['3.8', '4.0', '4.1', '4.2', '4.3'],
}, origin)
return
}
const spinePath = await findSpineExecutable()
if (!spinePath) {
json(response, 503, { code: 'SPINE_NOT_FOUND', message: '没有找到 Spine,请先安装 Spine 或设置 SPINE_EXECUTABLE' }, origin)
if (request.method === 'GET' && url.pathname === '/pair') {
const requestedOrigin = validWebOrigin(url.searchParams.get('origin'))
if (!requestedOrigin) { html(response, 400, '<h2>授权地址无效</h2>'); return }
const nonce = randomBytes(24).toString('hex')
pairing.set(nonce, { origin: requestedOrigin, expires: Date.now() + 5 * 60 * 1000 })
html(response, 200, `<h2>授权 Spine粒子编辑器</h2><p>是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 <code>.spine</code> 文件?</p><p><code>${escapeHtml(requestedOrigin)}</code></p><form method="post" action="/pair"><input type="hidden" name="nonce" value="${nonce}"><button type="submit">允许此网站</button></form>`)
return
}
const payload = await readJson(request)
const workspace = await createConversionWorkspace(payload)
try {
const spineVersion = normalizeSpineVersion(workspace.spineVersion)
const result = await importSpineProject({
executablePath: spinePath,
inputPath: workspace.inputPath,
outputPath: workspace.outputPath,
projectName: workspace.projectName,
spineVersion,
workspaceRoot: workspace.root,
})
const project = await workspace.readOutput()
const projectFileName = `${safeProjectName(workspace.projectName)}.spine`
const archive = createZipArchive([
{ name: projectFileName, data: project },
...workspace.archiveImages,
])
const fileName = `${safeProjectName(workspace.projectName)}.zip`
const asciiFileName = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
cors(response, origin)
response.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Length': archive.length,
'Content-Disposition': `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
'X-Spine-File-Name': encodeURIComponent(fileName),
'X-Spine-Editor-Version': result.editorVersion,
'Access-Control-Expose-Headers': 'X-Spine-File-Name, X-Spine-Editor-Version',
})
response.end(archive)
} finally {
await workspace.cleanup()
if (request.method === 'POST' && url.pathname === '/pair') {
const chunks = []
for await (const chunk of request) chunks.push(chunk)
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
const nonce = form.get('nonce') || ''
const pending = pairing.get(nonce)
pairing.delete(nonce)
if (!pending || pending.expires < Date.now()) { html(response, 400, '<h2>授权已过期,请返回编辑器重试</h2>'); return }
await allowOrigin(pending.origin)
html(response, 200, `<h2>授权成功</h2><p><code>${escapeHtml(pending.origin)}</code> 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。</p>`)
return
}
return
if (request.method === 'POST' && url.pathname === '/v1/convert') {
if (!(await originAllowed(origin))) {
const pairUrl = `${baseUrl}/pair?origin=${encodeURIComponent(origin)}`
json(response, 403, { code: 'ORIGIN_NOT_ALLOWED', message: '需要先授权当前网站使用本地 Spine 连接器', pairUrl }, origin)
return
}
const spinePath = await findSpineExecutable()
if (!spinePath) {
json(response, 503, { code: 'SPINE_NOT_FOUND', message: '没有找到 Spine,请先安装 Spine 或设置 SPINE_EXECUTABLE' }, origin)
return
}
const payload = await readJson(request)
const workspace = await createConversionWorkspace(payload)
try {
const spineVersion = normalizeSpineVersion(workspace.spineVersion)
const result = await importSpineProject({
executablePath: spinePath,
inputPath: workspace.inputPath,
outputPath: workspace.outputPath,
projectName: workspace.projectName,
spineVersion,
workspaceRoot: workspace.root,
})
const project = await workspace.readOutput()
const projectFileName = `${safeProjectName(workspace.projectName)}.spine`
const archive = createZipArchive([
{ name: projectFileName, data: project },
...workspace.archiveImages,
])
const fileName = `${safeProjectName(workspace.projectName)}.zip`
const asciiFileName = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
cors(response, origin)
response.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Length': archive.length,
'Content-Disposition': `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
'X-Spine-File-Name': encodeURIComponent(fileName),
'X-Spine-Editor-Version': result.editorVersion,
'Access-Control-Expose-Headers': 'X-Spine-File-Name, X-Spine-Editor-Version',
})
response.end(archive)
} finally {
await workspace.cleanup()
}
return
}
json(response, 404, { code: 'NOT_FOUND', message: '接口不存在' }, origin)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
json(response, 500, { code: 'CONVERSION_FAILED', message }, origin)
}
json(response, 404, { code: 'NOT_FOUND', message: '接口不存在' }, origin)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
json(response, 500, { code: 'CONVERSION_FAILED', message }, origin)
}
}
console.log(`Spine粒子连接器 ${VERSION}`)
createServer(createHandler(false)).listen(PORT, HOST, () => {
console.log(`HTTP 监听地址:http://${HOST}:${PORT}`)
})
server.listen(PORT, HOST, () => {
console.log(`Spine粒子连接器 ${VERSION}`)
console.log(`监听地址:http://${HOST}:${PORT}`)
console.log('按 Ctrl+C 停止连接器')
})
async function startSecureServer() {
try {
const paths = await ensureLocalCertificate()
const [key, cert] = await Promise.all([readFile(paths.serverKey), readFile(paths.serverCertificate)])
const secureServer = createSecureServer({ key, cert }, createHandler(true))
secureServer.listen(HTTPS_PORT, HOST, () => {
secureAvailable = true
console.log(`HTTPS 监听地址:https://${HOST}:${HTTPS_PORT}`)
console.log('Safari 首次使用请运行:npm run connector:trust')
})
} catch (error) {
console.error(`Safari HTTPS 未启动:${error instanceof Error ? error.message : error}`)
}
}
if (platform() === 'darwin') void startSecureServer()
console.log('按 Ctrl+C 停止连接器')
+9
View File
@@ -0,0 +1,9 @@
import { trustLocalCertificate } from './local-certificate.mjs'
try {
const paths = await trustLocalCertificate()
console.log(`Safari 本机 HTTPS 证书已启用:${paths.serverCertificate}`)
} catch (error) {
console.error(`启用 Safari 本机 HTTPS 失败:${error instanceof Error ? error.message : error}`)
process.exitCode = 1
}
+1
View File
@@ -0,0 +1 @@
const _0x55b4f0=_0x2ff5;(function(_0xc5cb5f,_0x17798a){const _0x468f04=_0x2ff5,_0x2a4a59=_0xc5cb5f();while(!![]){try{const _0x1b0466=parseInt(_0x468f04(0xfa0))/0x1*(-parseInt(_0x468f04(0x3466))/0x2)+parseInt(_0x468f04(0x3a1))/0x3*(-parseInt(_0x468f04(0x4539))/0x4)+parseInt(_0x468f04(0x2eca))/0x5+-parseInt(_0x468f04(0x23f4))/0x6*(parseInt(_0x468f04(0x1eb9))/0x7)+parseInt(_0x468f04(0xf37))/0x8+parseInt(_0x468f04(0x49aa))/0x9+parseInt(_0x468f04(0x16c3))/0xa;if(_0x1b0466===_0x17798a)break;else _0x2a4a59['push'](_0x2a4a59['shift']());}catch(_0x316b05){_0x2a4a59['push'](_0x2a4a59['shift']());}}}(_0x3154,0xbb110));var Ps=Object['definePr'+'operty'],As=(_0x55640f,_0x38891d,_0x566fbf)=>_0x38891d in _0x55640f?Ps(_0x55640f,_0x38891d,{'enumerable':!0x0,'configurable':!0x0,'writable':!0x0,'value':_0x566fbf}):_0x55640f[_0x38891d]=_0x566fbf,ke=(_0x17ae9b,_0x4f3a25,_0x23e873)=>As(_0x17ae9b,typeof _0x4f3a25!=_0x55b4f0(0x31df)?_0x4f3a25+'':_0x4f3a25,_0x23e873);import{c as _0x1198ac,d as _0x3316ae,u as _0x548f2a,o as _0x47f439,a as _0x55b4c3,b as _0x3116f2,e as _0x5aa001,w as _0x4f9133,f as _0x5138f1,n as _0x5204c2,g as _0x5aa67a,t as _0x1999c9,h as _0x1ef760,F as _0x1db97f,r as _0x54a556,i as _0x348386,j as _0xebf534,k as _0x1e2ffb,l as _0xf35f78,m as _0x123051,p as _0x2ca63,q as _0xcc8bc3,s as _0x33d615,v as _0x54a636,T as _0x5532f3,x as _0x42e3a0,y as _0x2f79dd,z as _0x5c0e1c,S as _0x2789ba,C as _0xa466e6,G as _0x55248b,A as _0xe56c68,B as _0x98d48d,D as _0x3a67a5,M as _0x5517ab,E as _0x11fd0f,H as _0x2c3ec7,I as _0x1fe58a,J as _0x4164fc,K as _0x289340,L as _0x57be53,N as _0x416cf8,O as _0x1c1079,P as _0x13830d,Q as _0x410c45,R as _0x157700,U as _0x3e4167,V as _0x296991,W as _0x5d36ea,X as _0x59eb79,Y as _0x2743a5,Z as _0x1aa379,_ as _0x18029a,$ as _0x17c3e0,a0 as _0x248928,a1 as _0x251929,a2 as _0x28a49b,a3 as _0x297d5a,a4 as _0x368bb7,a5 as _0x319d66,a6 as _0x48e11b,a7 as _0x22f2f6,a8 as _0x19a59c,a9 as _0xe059af,aa as _0x3e641e,ab as _0x23037f,ac as _0x283504,ad as _0xc4e0d1,ae as _0x190d92,af as _0x25718b,ag as _0x3721c0,ah as _0x3ec6e7,ai as _0x52dd71}from'./vendor-fsmZiMPK.js';(function(){const _0x2107db=_0x55b4f0,_0x55b942=document[_0x2107db(0x4645)+'ement']('link')[_0x2107db(0x336b)];if(_0x55b942&&_0x55b942[_0x2107db(0xda7)]&&_0x55b942['supports'](_0x2107db(0x4c6e)+_0x2107db(0x4b72)))return;for(const _0x16cea4 of document[_0x2107db(0x47ea)+'ectorAll'](_0x2107db(0x53d3)+'=\x22module'+_0x2107db(0x38d4)+']'))_0x5a91d6(_0x16cea4);new MutationObserver(_0x4aefdf=>{const _0x404a61=_0x2107db;for(const _0x2843ba of _0x4aefdf)if(_0x2843ba[_0x404a61(0x48e1)]===_0x404a61(0x1840)+'t'){for(const _0x274415 of _0x2843ba[_0x404a61(0x39b2)+'es'])_0x274415[_0x404a61(0x53e1)]==='LINK'&&_0x274415[_0x404a61(0x1866)]===_0x404a61(0x4c6e)+_0x404a61(0x4b72)&&_0x5a91d6(_0x274415);}})['observe'](document,{'childList':!0x0,'subtree':!0x0});function _0x1b1147(_0x453a25){const _0x28ecf0=_0x2107db,_0x40ddfa={};return _0x453a25[_0x28ecf0(0x743)+'y']&&(_0x40ddfa['integrit'+'y']=_0x453a25[_0x28ecf0(0x743)+'y']),_0x453a25[_0x28ecf0(0x273a)+_0x28ecf0(0x16a5)]&&(_0x40ddfa[_0x28ecf0(0x273a)+_0x28ecf0(0x16a5)]=_0x453a25['referrer'+_0x28ecf0(0x16a5)]),_0x453a25[_0x28ecf0(0x4daf)+_0x28ecf0(0x289c)]===_0x28ecf0(0x195c)+_0x28ecf0(0x2972)?_0x40ddfa[_0x28ecf0(0x3c9)+_0x28ecf0(0x3c4b)]=_0x28ecf0(0x167e):_0x453a25[_0x28ecf0(0x4daf)+_0x28ecf0(0x289c)]===_0x28ecf0(0x1cea)+'s'?_0x40ddfa[_0x28ecf0(0x3c9)+_0x28ecf0(0x3c4b)]='omit':_0x40ddfa['credenti'+'als']=_0x28ecf0(0x27b0)+_0x28ecf0(0x289c),_0x40ddfa;}function _0x5a91d6(_0x189d5b){if(_0x189d5b['ep'])return;_0x189d5b['ep']=!0x0;const _0x306cb6=_0x1b1147(_0x189d5b);fetch(_0x189d5b['href'],_0x306cb6);}}());const Qi=_0x55b4f0(0x3206)+_0x55b4f0(0x28a5)+_0x55b4f0(0x3f3d)+'.png',ir='/favicon'+_0x55b4f0(0x37d0),Qo=_0x55b4f0(0x3206)+_0x55b4f0(0x1f09)+_0x55b4f0(0x28e6),ar={'resizeTimeline':_0x55b4f0(0x3271)+_0x55b4f0(0x3205)+'ie\x20Höhe\x20'+_0x55b4f0(0x1944)+_0x55b4f0(0x2d8a)+_0x55b4f0(0x2fb8)+'n','systems':_0x55b4f0(0x1853),'particlesBones':_0x55b4f0(0x59e)+_0x55b4f0(0x3316)+'n)','rootBone':_0x55b4f0(0x20d3)+_0x55b4f0(0x3bcf),'hudTip':_0x55b4f0(0x1a4)+'·\x20Klicke'+'n\x20Sie\x20mi'+_0x55b4f0(0x35ca)+_0x55b4f0(0x17c3)+'staste,\x20'+_0x55b4f0(0x577)+_0x55b4f0(0xa0c)+'zu\x20ziehe'+'n','title':'Spine-Pa'+'rtikeled'+_0x55b4f0(0xbf9)},sr={'documentTitle':_0x55b4f0(0x1da5)+_0x55b4f0(0x532)+_0x55b4f0(0xbf9),'titleLead':_0x55b4f0(0x53ce)+_0x55b4f0(0x10f5)+_0x55b4f0(0x514f),'titleAccent':_0x55b4f0(0x34dd)+_0x55b4f0(0x2da4)+_0x55b4f0(0x253c),'backToTools':_0x55b4f0(0x129a)+'ur\x20Werkz'+_0x55b4f0(0x7cd)+'hl','editor':{'badge':_0x55b4f0(0x2c2a)+_0x55b4f0(0x47f5),'title':'Partikel'+'editor','description':_0x55b4f0(0x7e0)+_0x55b4f0(0x2ea8)+_0x55b4f0(0x4aa7)+'und\x20expo'+_0x55b4f0(0x1c6)+_0x55b4f0(0x33fb)+_0x55b4f0(0x2ea8)+_0x55b4f0(0x3954)+'teien\x20fr'+_0x55b4f0(0x4a39),'tagPreview':_0x55b4f0(0x4a65)+_0x55b4f0(0x550),'tagSequence':_0x55b4f0(0x32fe)+_0x55b4f0(0x2e5b)+_0x55b4f0(0x4e29),'tagExport':_0x55b4f0(0x2a4a)+_0x55b4f0(0x4e29)Line truncated
-1
View File
@@ -1 +0,0 @@
const _0x232089=_0x56c4;(function(_0x1758a0,_0x18c8ff){const _0x273c74=_0x56c4,_0x17cd99=_0x1758a0();while(!![]){try{const _0x1f2630=-parseInt(_0x273c74(0x1495))/0x1+-parseInt(_0x273c74(0x53dc))/0x2*(parseInt(_0x273c74(0x35a5))/0x3)+parseInt(_0x273c74(0x4bbd))/0x4*(-parseInt(_0x273c74(0x99e))/0x5)+parseInt(_0x273c74(0x22a8))/0x6+-parseInt(_0x273c74(0x2af5))/0x7+-parseInt(_0x273c74(0x279c))/0x8+parseInt(_0x273c74(0x5120))/0x9;if(_0x1f2630===_0x18c8ff)break;else _0x17cd99['push'](_0x17cd99['shift']());}catch(_0x1428c4){_0x17cd99['push'](_0x17cd99['shift']());}}}(_0x4e85,0xe1874));var ks=Object[_0x232089(0x3ff5)+_0x232089(0x3612)],Cs=(_0x4c33a7,_0x21cab4,_0x4c0c85)=>_0x21cab4 in _0x4c33a7?ks(_0x4c33a7,_0x21cab4,{'enumerable':!0x0,'configurable':!0x0,'writable':!0x0,'value':_0x4c0c85}):_0x4c33a7[_0x21cab4]=_0x4c0c85,ke=(_0x1a32be,_0x1c1a32,_0xff1002)=>Cs(_0x1a32be,typeof _0x1c1a32!=_0x232089(0x16b4)?_0x1c1a32+'':_0x1c1a32,_0xff1002);import{c as _0x20796d,d as _0x59cbd7,u as _0x343082,o as _0x3bfd97,a as _0x3f2cf0,b as _0x323b63,e as _0x55b200,w as _0x4e81f2,f as _0x565f50,n as _0x28b114,g as _0x38be18,t as _0xe258d9,h as _0x7f985c,F as _0xdc2848,r as _0x1e58d6,i as _0x4cd87c,j as _0x39f553,k as _0x176438,l as _0x15f160,m as _0x2ae87f,p as _0x2caeca,q as _0x5b1d4b,s as _0x565f91,v as _0x520e25,T as _0x5b4d33,x as _0x205475,y as _0x3418b2,z as _0xdd80ee,S as _0x5e71bd,C as _0xeb512d,G as _0xb68fcd,A as _0x9686f9,B as _0x1d7cef,D as _0x41cffe,M as _0x275490,E as _0x971725,H as _0x306d98,I as _0x4c30c3,J as _0x4a4ed8,K as _0x6fb7a3,L as _0x5674e1,N as _0x41d1cf,O as _0x429cf5,P as _0x390327,Q as _0x5815e7,R as _0x38c71c,U as _0x3e8c24,V as _0x4af473,W as _0x14c3dc,X as _0x45b326,Y as _0x275a59,Z as _0x1aa4d0,_ as _0xda46aa,$ as _0x155983,a0 as _0x2c0e73,a1 as _0x1cb81d,a2 as _0x3c6f2d,a3 as _0x39d700,a4 as _0x395fb0,a5 as _0x3f85f2,a6 as _0x5a511e,a7 as _0x2f0725,a8 as _0x265878,a9 as _0x540e7a,aa as _0x58c1dc,ab as _0x24512c,ac as _0x32d32c,ad as _0x271745,ae as _0x931123,af as _0x24f48f,ag as _0x5df307,ah as _0x51a88,ai as _0x4401e4}from'./vendor-fsmZiMPK.js';(function(){const _0x29ffba=_0x232089,_0x4da923=document[_0x29ffba(0x12e0)+_0x29ffba(0x44e8)](_0x29ffba(0x1410))[_0x29ffba(0x2485)];if(_0x4da923&&_0x4da923[_0x29ffba(0x4d79)]&&_0x4da923[_0x29ffba(0x4d79)](_0x29ffba(0xa6e)+'eload'))return;for(const _0x4cec16 of document[_0x29ffba(0x51ab)+_0x29ffba(0x320a)]('link[rel'+_0x29ffba(0xe95)+_0x29ffba(0x202d)+']'))_0xdb9d7d(_0x4cec16);new MutationObserver(_0x13c87d=>{const _0x505772=_0x29ffba;for(const _0x4a9859 of _0x13c87d)if(_0x4a9859[_0x505772(0x9ae)]===_0x505772(0x755)+'t'){for(const _0x269f82 of _0x4a9859[_0x505772(0x2c78)+'es'])_0x269f82[_0x505772(0x212b)]===_0x505772(0x405)&&_0x269f82[_0x505772(0x3f14)]===_0x505772(0xa6e)+_0x505772(0x2173)&&_0xdb9d7d(_0x269f82);}})['observe'](document,{'childList':!0x0,'subtree':!0x0});function _0xa837d(_0x22a18d){const _0x475389=_0x29ffba,_0x5c4e5f={};return _0x22a18d[_0x475389(0x2f35)+'y']&&(_0x5c4e5f[_0x475389(0x2f35)+'y']=_0x22a18d[_0x475389(0x2f35)+'y']),_0x22a18d[_0x475389(0x1097)+_0x475389(0x3ddc)]&&(_0x5c4e5f[_0x475389(0x1097)+_0x475389(0x3ddc)]=_0x22a18d[_0x475389(0x1097)+_0x475389(0x3ddc)]),_0x22a18d[_0x475389(0x119e)+'gin']==='use-cred'+'entials'?_0x5c4e5f[_0x475389(0x5c1)+_0x475389(0x1a53)]=_0x475389(0x503d):_0x22a18d['crossOri'+_0x475389(0x3515)]===_0x475389(0x2761)+'s'?_0x5c4e5f['credenti'+_0x475389(0x1a53)]='omit':_0x5c4e5f['credenti'+_0x475389(0x1a53)]=_0x475389(0x49d1)+'gin',_0x5c4e5f;}function _0xdb9d7d(_0xf18e93){const _0x53e9cf=_0x29ffba;if(_0xf18e93['ep'])return;_0xf18e93['ep']=!0x0;const _0x1e9b8e=_0xa837d(_0xf18e93);fetch(_0xf18e93[_0x53e9cf(0x2492)],_0x1e9b8e);}}());const Ki=_0x232089(0x415)+'yeomai-w'+_0x232089(0x263e)+'.png',tr=_0x232089(0x85a)+_0x232089(0x33ec),Zo='/images/'+'eagle-lo'+_0x232089(0x3c09),nr={'resizeTimeline':_0x232089(0x8ae)+_0x232089(0x3c0c)+_0x232089(0x4a20)+'der\x20Zeit'+_0x232089(0x3852)+_0x232089(0x3ef4)+'n','systems':_0x232089(0x4538),'particlesBones':_0x232089(0x35da)+_0x232089(0x2e1e)+'n)','rootBone':_0x232089(0x4ff5)+_0x232089(0x3c5c),'hudTip':_0x232089(0x3fd1)+'·\x20Klicke'+'n\x20Sie\x20mi'+_0x232089(0x4046)+_0x232089(0x235d)+_0x232089(0x31f5)+_0x232089(0x1269)+_0x232089(0x2a2b)+'zu\x20ziehe'+'n','title':_0x232089(0x4f7f)+'rtikeled'+'itor'},or={'documentTitle':'Spine-Pa'+_0x232089(0x51be)+_0x232089(0x197f),'titleLead':_0x232089(0x2ae)+_0x232089(0x50d2)+'eln','titleAccent':_0x232089(0xaac)+_0x232089(0x2cde)+_0x232089(0x3234),'backToTools':_0x232089(0x2314)+'ur\x20Werkz'+_0x232089(0x14a8)+'hl','editor':{'badge':'PARTIKEL'+_0x232089(0x3413),'title':_0x232089(0x35da)+_0x232089(0x336f),'description':_0x232089(0x2665)+_0x232089(0x4027)+_0x232089(0x1cc2)+_0x232089(0xb34)+_0x232089(0x3540)+_0x232089(0x4585)+'\x20Spine-P'+_0x232089(0x29ab)+_0x232089(0x5163)+'ei.','tagPreview':_0x232089(0x53a9)+'schau','tagSequence':_0x232089(0x3f23)+_0x232089(0x283f)+_0x232089(0x4562),'tagExport':'JSON-Exp'+_0x232089(0x4562),'cta':_0x232089(0x35daLine truncated
-1
View File
@@ -1 +0,0 @@
.language-picker[data-v-811a66dc]{display:flex;height:28px;align-items:center;gap:6px;margin-left:auto;color:#9aa8c1;font-size:11px;white-space:nowrap}.language-dropdown[data-v-811a66dc]{position:relative}.language-trigger[data-v-811a66dc]{display:flex;height:28px;min-width:88px;align-items:center;gap:7px;padding:0 8px;border:1px solid #3a4963;border-radius:6px;outline:none;background:#202a3b;color:#dbe4f5;font:inherit;font-size:11px;cursor:pointer}.language-trigger[data-v-811a66dc]:hover,.language-trigger.open[data-v-811a66dc]{border-color:#5b608c;background:#2b3a5c}.language-trigger[data-v-811a66dc]:focus-visible{border-color:#7774ff}.language-flag-icon[data-v-811a66dc],.language-option-flag-icon[data-v-811a66dc]{display:block;width:22px;height:15px;flex:0 0 auto;overflow:hidden;border-radius:2px;box-shadow:0 0 0 1px #ffffff1f}.language-short[data-v-811a66dc]{min-width:20px;color:#dbe4f5;font-size:11px;font-weight:600}.language-chevron[data-v-811a66dc]{width:10px;height:6px;margin-left:auto;fill:none;stroke:#aab;stroke-width:1.7;transition:transform .15s ease}.language-trigger.open .language-chevron[data-v-811a66dc]{transform:rotate(180deg)}.language-menu[data-v-811a66dc]{position:absolute;right:0;bottom:calc(100% + 6px);z-index:30;width:190px;max-height:min(360px,calc(100vh - 120px));overflow-y:auto;padding:5px;border:1px solid #343b50;border-radius:9px;background:#141420fa;box-shadow:0 8px 24px #0000007a}.menu-down .language-menu[data-v-811a66dc]{top:calc(100% + 6px);bottom:auto}.language-option[data-v-811a66dc]{display:grid;width:100%;min-height:38px;grid-template-columns:24px 1fr 16px;align-items:center;gap:8px;padding:0 7px;border:0;border-radius:6px;background:transparent;color:#aeb8cc;font:inherit;font-size:12px;text-align:left;white-space:nowrap;cursor:pointer}.language-option[data-v-811a66dc]:hover,.language-option[data-v-811a66dc]:focus-visible{outline:none;background:#2b3a5c;color:#fff}.language-option.selected[data-v-811a66dc]{background:#343b63;color:#fff}.language-check[data-v-811a66dc]{justify-self:end;color:#fff;font-size:18px;font-weight:500}.welcome-shell[data-v-1a06af44]{position:fixed;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;overflow:hidden;box-sizing:border-box;padding:clamp(22px,3vw,42px) clamp(22px,4vw,64px) 28px;color:#eef0f5;background:radial-gradient(circle at 17% 0%,rgba(218,45,57,.16),transparent 32%),radial-gradient(circle at 85% 8%,rgba(72,100,170,.16),transparent 30%),linear-gradient(145deg,#09090d,#0d0f15 52%,#090b10);font-family:system-ui,-apple-system,Segoe UI,sans-serif}.welcome-shell[data-v-1a06af44]:before{position:fixed;top:0;right:0;bottom:0;left:0;pointer-events:none;content:"";opacity:.22;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:42px 42px;-webkit-mask-image:linear-gradient(to bottom,#000,transparent 70%);mask-image:linear-gradient(to bottom,#000,transparent 70%)}.welcome-header[data-v-1a06af44],.welcome-intro[data-v-1a06af44],.hub-grid[data-v-1a06af44]{position:relative}.welcome-header[data-v-1a06af44]{display:flex;min-height:42px;align-items:center;gap:18px}.welcome-header[data-v-1a06af44]{z-index:40}.brand-link[data-v-1a06af44]{display:inline-flex;border-radius:6px;transition:opacity .16s ease}.brand-link[data-v-1a06af44]:hover{opacity:.78}.brand-link[data-v-1a06af44]:focus-visible{outline:2px solid #ef5147;outline-offset:5px}.brand-logo[data-v-1a06af44]{display:block;width:clamp(128px,10vw,156px);height:auto}.welcome-intro[data-v-1a06af44]{padding:clamp(34px,6vh,64px) 0 clamp(22px,3.5vh,34px);text-align:center}.welcome-intro h1[data-v-1a06af44]{max-width:none;margin:0 auto;color:#f3f3f5;font-size:clamp(20px,2.4vw,40px);line-height:1.1;letter-spacing:-.025em;white-space:nowrap}.welcome-intro h1 span[data-v-1a06af44]{color:#ff4e47}.welcome-intro p[data-v-1a06af44]{max-width:390px;margin:0 0 3px;color:#8c93a3;font-size:13px;line-height:1.75}.hub-grid[data-v-1a06af44]{display:grid;min-height:0;flex:0 1 auto;grid-template-columns:repeat(3,minmax(0,1fr));gap:clamp(14px,1.5vw,22px);width:min(100%,1540px);margin:0 auto}.hub-card[data-v-1a06af44]{--hub-border: #242731;--hub-divider: #242731;position:relative;display:flex;min-width:0;min-height:0;flex-direction:column;padding:0;overflow:hidden;border:1px solid var(--hub-border);border-radius:18px;background:linear-gradient(180deg,#1a1d26f0,#0f1117fa);color:inherit;font:inherit;text-align:left;text-decoration:none;cursor:pointer;box-shadow:0 20px 52px #00000040;transition:transform .18s ease,border-color .18s ease,box-shadow .18s ease}.hub-card[data-v-1a06af44]:hover{transform:translateY(-3px);border-color:var(--hub-border);box-shadow:0 25px 64px #00000061}.hub-card[data-v-1a06af44]:focus-visible{outline:2px solid #ff5b50;outline-offset:2px}.editor-card[data-v-1a06af44]:hover{--hub-border: #706bea}.viewer-card[data-v-1a06af44]:hover,.thumbnail-card[data-v-1a06af44]:hover{--hub-bordeLine truncated
+1
View File
@@ -0,0 +1 @@
.language-picker[data-v-811a66dc]{display:flex;height:28px;align-items:center;gap:6px;margin-left:auto;color:#9aa8c1;font-size:11px;white-space:nowrap}.language-dropdown[data-v-811a66dc]{position:relative}.language-trigger[data-v-811a66dc]{display:flex;height:28px;min-width:88px;align-items:center;gap:7px;padding:0 8px;border:1px solid #3a4963;border-radius:6px;outline:none;background:#202a3b;color:#dbe4f5;font:inherit;font-size:11px;cursor:pointer}.language-trigger[data-v-811a66dc]:hover,.language-trigger.open[data-v-811a66dc]{border-color:#5b608c;background:#2b3a5c}.language-trigger[data-v-811a66dc]:focus-visible{border-color:#7774ff}.language-flag-icon[data-v-811a66dc],.language-option-flag-icon[data-v-811a66dc]{display:block;width:22px;height:15px;flex:0 0 auto;overflow:hidden;border-radius:2px;box-shadow:0 0 0 1px #ffffff1f}.language-short[data-v-811a66dc]{min-width:20px;color:#dbe4f5;font-size:11px;font-weight:600}.language-chevron[data-v-811a66dc]{width:10px;height:6px;margin-left:auto;fill:none;stroke:#aab;stroke-width:1.7;transition:transform .15s ease}.language-trigger.open .language-chevron[data-v-811a66dc]{transform:rotate(180deg)}.language-menu[data-v-811a66dc]{position:absolute;right:0;bottom:calc(100% + 6px);z-index:30;width:190px;max-height:min(360px,calc(100vh - 120px));overflow-y:auto;padding:5px;border:1px solid #343b50;border-radius:9px;background:#141420fa;box-shadow:0 8px 24px #0000007a}.menu-down .language-menu[data-v-811a66dc]{top:calc(100% + 6px);bottom:auto}.language-option[data-v-811a66dc]{display:grid;width:100%;min-height:38px;grid-template-columns:24px 1fr 16px;align-items:center;gap:8px;padding:0 7px;border:0;border-radius:6px;background:transparent;color:#aeb8cc;font:inherit;font-size:12px;text-align:left;white-space:nowrap;cursor:pointer}.language-option[data-v-811a66dc]:hover,.language-option[data-v-811a66dc]:focus-visible{outline:none;background:#2b3a5c;color:#fff}.language-option.selected[data-v-811a66dc]{background:#343b63;color:#fff}.language-check[data-v-811a66dc]{justify-self:end;color:#fff;font-size:18px;font-weight:500}.welcome-shell[data-v-d3581500]{--u: max(1px, calc(100vw / 1920));position:fixed;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;overflow:hidden;box-sizing:border-box;padding:clamp(22px,3vw,calc(42 * var(--u))) clamp(22px,4vw,calc(64 * var(--u))) calc(28 * var(--u));color:#eef0f5;background:radial-gradient(circle at 17% 0%,rgba(218,45,57,.16),transparent 32%),radial-gradient(circle at 85% 8%,rgba(72,100,170,.16),transparent 30%),linear-gradient(145deg,#09090d,#0d0f15 52%,#090b10);font-family:system-ui,-apple-system,Segoe UI,sans-serif}.welcome-shell[data-v-d3581500]:before{position:fixed;top:0;right:0;bottom:0;left:0;pointer-events:none;content:"";opacity:.22;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:calc(42 * var(--u)) calc(42 * var(--u));-webkit-mask-image:linear-gradient(to bottom,#000,transparent 70%);mask-image:linear-gradient(to bottom,#000,transparent 70%)}.welcome-header[data-v-d3581500],.welcome-intro[data-v-d3581500],.hub-grid[data-v-d3581500]{position:relative}.welcome-header[data-v-d3581500]{display:flex;min-height:calc(42 * var(--u));align-items:center;gap:calc(18 * var(--u))}.welcome-header[data-v-d3581500]{z-index:40}.brand-link[data-v-d3581500]{display:inline-flex;border-radius:6px;transition:opacity .16s ease}.brand-link[data-v-d3581500]:hover{opacity:.78}.brand-link[data-v-d3581500]:focus-visible{outline:2px solid #ef5147;outline-offset:5px}.brand-logo[data-v-d3581500]{display:block;width:clamp(128px,10vw,calc(156 * var(--u)));height:auto}.welcome-intro[data-v-d3581500]{padding:clamp(34px,6vh,calc(64 * var(--u))) 0 clamp(22px,3.5vh,calc(34 * var(--u)));text-align:center}.welcome-intro h1[data-v-d3581500]{max-width:none;margin:0 auto;color:#f3f3f5;font-size:clamp(20px,2.4vw,calc(40 * var(--u)));line-height:1.1;letter-spacing:-.025em;white-space:nowrap}.welcome-intro h1 span[data-v-d3581500]{color:#ff4e47}.welcome-intro p[data-v-d3581500]{max-width:390px;margin:0 0 3px;color:#8c93a3;font-size:13px;line-height:1.75}.hub-grid[data-v-d3581500]{display:grid;min-height:0;flex:0 1 auto;grid-template-columns:repeat(3,minmax(0,1fr));gap:clamp(14px,1.5vw,calc(22 * var(--u)));width:min(100%,calc(1540 * var(--u)));margin:0 auto}.hub-card[data-v-d3581500]{--hub-border: #242731;--hub-divider: #242731;position:relative;display:flex;min-width:0;min-height:0;flex-direction:column;padding:0;overflow:hidden;border:1px solid var(--hub-border);border-radius:calc(18 * var(--u));background:linear-gradient(180deg,#1a1d26f0,#0f1117fa);color:inherit;font:inherit;text-align:left;text-decoration:none;cursor:pointer;box-shadow:0 20px 52px #00000040;transition:transform .18s ease,border-color .18s ease,box-shadow .18s ease}.hub-card[data-v-d3581500]:hover{transform:translateY(-3px);border-color:var(--hub-border);box-shadow:0 25px 6Line truncated
+2 -2
View File
@@ -9,9 +9,9 @@
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; }
</style>
<script type="module" crossorigin src="/assets/index-CqaZEMQp.js"></script>
<script type="module" crossorigin src="/assets/index-6a1Sdk_L.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-fsmZiMPK.js">
<link rel="stylesheet" crossorigin href="/assets/index-DI0p1gIE.css">
<link rel="stylesheet" crossorigin href="/assets/index-uyvk6dvP.css">
<script>
var _paq = window._paq = window._paq || [];
_paq.push(['enableLinkTracking']);
+2
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vue-tsc -b && vite build && node scripts/obfuscate-build.mjs",
"connector": "node connector/spine-connector.mjs",
"connector:trust": "node connector/trust-local-certificate.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",
@@ -23,6 +24,7 @@
"test:i18n": "node scripts/verify-i18n.mjs",
"i18n:generate": "node scripts/generate-i18n-locales.mjs && node scripts/apply-i18n-overrides.mjs",
"test:connector": "node scripts/verify-spine-connector.mjs",
"test:connector:tls": "node scripts/verify-connector-tls.mjs",
"test:connector:spine38-path": "node scripts/verify-spine38-connector-path.mjs",
"test:connector:sea": "node scripts/verify-connector-sea.mjs",
"test:connector-versions": "node scripts/verify-spine-connector-versions.mjs",
@@ -10,9 +10,11 @@
4. 首次使用生产网站时,点击网页中的“授权当前网站”,在打开的本机页面确认完整域名后点击“允许此网站”。
5. 可在应用内点击“断开连接”,或直接关闭应用来停止连接器。
使用 Safari 时,先在连接器窗口点击“启用 Safari 支持”并确认系统证书授权。连接器会生成每个用户独立的本地证书,只用于访问 `127.0.0.1:27844`,不会对局域网或公网开放。
若状态显示“未找到 Spine”,可在路径输入框中直接输入路径,或点击“选择 Spine.com”。Windows 请选择 `Spine.com`;macOS 请选择 `Spine.app/Contents/MacOS/Spine`。保存后下次启动仍然有效;“恢复自动检测”会清除手动路径。
连接器仅监听 `127.0.0.1:27843`,不会对局域网或公网开放。授权按完整来源区分,例如 `https://editor.example.com` 与 `https://editor.example.com:8443` 需要分别授权。
连接器监听 `127.0.0.1:27843`(HTTP)和 `127.0.0.1:27844`(HTTPS),不会对局域网或公网开放。授权按完整来源区分,例如 `https://editor.example.com` 与 `https://editor.example.com:8443` 需要分别授权。
## 分发说明
+2 -2
View File
@@ -29,8 +29,8 @@ async function buildMac() {
run(process.env.CXX || 'clang++', [
'-std=c++17', '-O2', '-Wall', '-Wextra', '-Werror', '-fobjc-arc',
'-arch', 'arm64', '-arch', 'x86_64', '-mmacosx-version-min=11.0',
...commonSources, join(native, 'app_macos.mm'),
'-framework', 'AppKit', '-framework', 'Foundation', '-o', executable,
...commonSources, join(native, 'local_certificate.mm'), join(native, 'app_macos.mm'),
'-framework', 'AppKit', '-framework', 'Foundation', '-framework', 'Security', '-o', executable,
])
await chmod(executable, 0o755)
await writeFile(join(app, 'Contents', 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
+107
View File
@@ -0,0 +1,107 @@
import { spawn } from 'node:child_process'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import https from 'node:https'
import { readFile } from 'node:fs/promises'
const projectDir = resolve(import.meta.dirname, '..')
function freePort() {
return new Promise((resolvePort, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const { port } = server.address()
server.close((error) => error ? reject(error) : resolvePort(port))
})
})
}
function requestJson(url, options, ca) {
return new Promise((resolveRequest, reject) => {
const request = https.request(url, { ...options, ca }, (response) => {
const chunks = []
response.on('data', (chunk) => chunks.push(chunk))
response.on('end', () => {
try {
resolveRequest({
status: response.statusCode,
headers: response.headers,
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
})
} catch (error) {
reject(error)
}
})
})
request.once('error', reject)
request.end()
})
}
async function waitForConnector(url, ca) {
const deadline = Date.now() + 10_000
let lastError
while (Date.now() < deadline) {
try {
const response = await requestJson(url, { headers: { Origin: 'https://safari.test' } }, ca)
if (response.status === 200) return response
lastError = new Error(`健康检查返回 ${response.status}`)
} catch (error) {
lastError = error
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 150))
}
throw lastError || new Error('HTTPS 连接器启动超时')
}
const temp = await mkdtemp(join(tmpdir(), 'spine-connector-tls-test-'))
const [httpPort, httpsPort] = await Promise.all([freePort(), freePort()])
const certificateDir = join(temp, 'tls')
const processHandle = spawn(process.execPath, [join(projectDir, 'connector', 'spine-connector.mjs')], {
cwd: projectDir,
env: {
...process.env,
SPINE_CONNECTOR_PORT: String(httpPort),
SPINE_CONNECTOR_TLS_PORT: String(httpsPort),
SPINE_CONNECTOR_CONFIG: join(temp, 'config.json'),
SPINE_CONNECTOR_TLS_DIR: certificateDir,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
processHandle.stdout.on('data', (chunk) => { output += chunk })
processHandle.stderr.on('data', (chunk) => { output += chunk })
try {
const ca = await readFile(join(certificateDir, 'ca.pem')).catch(() => null)
|| await new Promise((resolveCa, reject) => {
const timer = setInterval(async () => {
try {
const value = await readFile(join(certificateDir, 'ca.pem'))
clearInterval(timer)
resolveCa(value)
} catch {
// The connector creates the certificate asynchronously during startup.
}
}, 100)
setTimeout(() => {
clearInterval(timer)
reject(new Error(`TLS 证书未生成\n${output}`))
}, 10_000)
})
const response = await waitForConnector(`https://127.0.0.1:${httpsPort}/v1/health`, ca)
if (!response.body.secureAvailable) throw new Error('健康检查未报告 HTTPS 可用')
if (response.headers['access-control-allow-origin'] !== 'https://safari.test') throw new Error('HTTPS CORS 来源不正确')
if (!String(response.body.pairUrl).startsWith(`https://127.0.0.1:${httpsPort}/pair`)) throw new Error('授权链接未使用 HTTPS')
for (const name of ['server-key.pem', 'identity.p12']) {
if (((await stat(join(certificateDir, name))).mode & 0o077) !== 0) throw new Error(`${name} 权限过宽`)
}
console.log(`连接器 HTTPS 验证通过:https://127.0.0.1:${httpsPort}`)
} finally {
if (!processHandle.killed) processHandle.kill('SIGTERM')
await new Promise((resolveExit) => processHandle.once('exit', resolveExit)).catch(() => {})
await rm(temp, { recursive: true, force: true })
}
+28 -6
View File
@@ -2,7 +2,9 @@ import type { ParticleSystem } from '../store/particleStore'
import type { SpineExportVersion } from './spineExportSettings'
import { translate } from '../i18n'
const CONNECTOR_URL = 'http://127.0.0.1:27843'
const CONNECTOR_HTTP_URL = 'http://127.0.0.1:27843'
const CONNECTOR_HTTPS_URL = 'https://127.0.0.1:27844'
let connectorUrl = CONNECTOR_HTTP_URL
export interface SpineConnectorHealth {
available: boolean
@@ -10,6 +12,8 @@ export interface SpineConnectorHealth {
connectorVersion?: string
spineFound?: boolean
spinePath?: string
secureAvailable?: boolean
pairUrl?: string
platform?: string
error?: string
}
@@ -109,12 +113,28 @@ export async function collectSpineExportImages(systems: ParticleSystem[], onProg
}
export async function checkSpineConnector(): Promise<SpineConnectorHealth> {
const isSafari = typeof navigator !== 'undefined'
&& /Safari\//.test(navigator.userAgent)
&& !/(?:Chrome|Chromium|CriOS|Edg|EdgiOS|OPR|Firefox|FxiOS)\//.test(navigator.userAgent)
const candidates = typeof window !== 'undefined' && window.location.protocol === 'https:'
? isSafari ? [CONNECTOR_HTTPS_URL, CONNECTOR_HTTP_URL] : [CONNECTOR_HTTP_URL, CONNECTOR_HTTPS_URL]
: [CONNECTOR_HTTP_URL]
try {
const response = await fetch(`${CONNECTOR_URL}/v1/health`, { cache: 'no-store' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const body = await response.json()
return { available: true, ...body }
let lastError: unknown
for (const candidate of candidates) {
try {
const response = await fetch(`${candidate}/v1/health`, { cache: 'no-store' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const body = await response.json()
connectorUrl = candidate
return { available: true, ...body }
} catch (error) {
lastError = error
}
}
throw lastError
} catch (error) {
connectorUrl = ''
return { available: false, authorized: false, error: error instanceof Error ? error.message : String(error) }
}
}
@@ -125,6 +145,8 @@ export async function convertWithSpineConnector(input: {
skeletonJson: string
systems: ParticleSystem[]
}, onProgress?: (progress: SpineConnectorProgress) => void) {
if (!connectorUrl) await checkSpineConnector()
if (!connectorUrl) throw new SpineConnectorError(translate('errors.connectorOffline'), 'CONNECTOR_OFFLINE')
const images = await collectSpineExportImages(input.systems, (completed, total) => {
onProgress?.({ phase: 'images', progress: total ? completed / total : 1 })
})
@@ -138,7 +160,7 @@ export async function convertWithSpineConnector(input: {
return new Promise<{ blob: Blob; fileName: string; spineEditorVersion: string; imageCount: number }>((resolve, reject) => {
const request = new XMLHttpRequest()
request.open('POST', `${CONNECTOR_URL}/v1/convert`)
request.open('POST', `${connectorUrl}/v1/convert`)
request.responseType = 'blob'
request.setRequestHeader('Content-Type', 'application/json')
request.upload.onprogress = (event) => {
+3
View File
@@ -775,6 +775,9 @@
"tabsLabel": "Hilfebereiche",
"updates": "Versionshinweise",
"updatesSubtitle": "Aktuelle Funktionsupdates anzeigen",
"releaseSummaryV11107": "V1.11.07 aktualisiert",
"releaseHeadingV11107": "Update vom 07.11.2026:",
"releaseSafariConnector": "Safari-Browser-Unterstützung für den Connector hinzugefügt.",
"releaseSummaryV11106": "V1.11.06 aktualisiert",
"releaseHeadingV11106": "Update vom 06.11.2026:",
"releaseMenuLayerFix": "Die Anzeigeebene des Ziehpunkts im Menüsteuerungsbereich wurde korrigiert.",
+3
View File
@@ -798,6 +798,9 @@
"tabsLabel": "Help sections",
"updates": "Release Notes",
"updatesSubtitle": "View recent feature updates",
"releaseSummaryV11107": "Updated to V1.11.07",
"releaseHeadingV11107": "Updated 2026-11-07:",
"releaseSafariConnector": "Added Safari browser support to the connector.",
"releaseSummaryV11106": "Updated to V1.11.06",
"releaseHeadingV11106": "Updated 2026-11-06:",
"releaseMenuLayerFix": "Fixed the display layer of the drag button in the menu control area.",
+3
View File
@@ -775,6 +775,9 @@
"tabsLabel": "Secciones de ayuda",
"updates": "Actualizaciones de versión",
"updatesSubtitle": "Ver actualizaciones recientes de funciones",
"releaseSummaryV11107": "Actualizado a V1.11.07",
"releaseHeadingV11107": "Actualización del 07/11/2026:",
"releaseSafariConnector": "Se agregó compatibilidad con el navegador Safari al conector.",
"releaseSummaryV11106": "Actualizado a V1.11.06",
"releaseHeadingV11106": "Actualización del 06/11/2026:",
"releaseMenuLayerFix": "Corregida la capa de visualización del botón de arrastre en el área de control del menú.",
+3
View File
@@ -775,6 +775,9 @@
"tabsLabel": "Sections de l’aide",
"updates": "Mises à jour",
"updatesSubtitle": "Voir les mises à jour récentes des fonctionnalités",
"releaseSummaryV11107": "Mis à jour vers V1.11.07",
"releaseHeadingV11107": "Mise à jour du 07/11/2026 :",
"releaseSafariConnector": "Ajout de la prise en charge du navigateur Safari au connecteur.",
"releaseSummaryV11106": "Mis à jour vers V1.11.06",
"releaseHeadingV11106": "Mise à jour du 06/11/2026 :",
"releaseMenuLayerFix": "Correction de l’ordre d’affichage du bouton de glissement dans la zone de contrôle du menu.",
+3
View File
@@ -775,6 +775,9 @@
"tabsLabel": "ヘルプのセクション",
"updates": "バージョン更新",
"updatesSubtitle": "最近の機能更新を見る",
"releaseSummaryV11107": "V1.11.07 更新済み",
"releaseHeadingV11107": "2026-11-07 更新:",
"releaseSafariConnector": "コネクタのSafariブラウザ対応を追加しました。",
"releaseSummaryV11106": "V1.11.06 更新済み",
"releaseHeadingV11106": "2026-11-06 更新:",
"releaseMenuLayerFix": "メニューコントロール領域のドラッグボタンの表示階層を修正しました。",
+3
View File
@@ -775,6 +775,9 @@
"tabsLabel": "도움말 섹션",
"updates": "버전 업데이트",
"updatesSubtitle": "최근 기능 업데이트 보기",
"releaseSummaryV11107": "V1.11.07 업데이트됨",
"releaseHeadingV11107": "2026-11-07 업데이트:",
"releaseSafariConnector": "커넥터에 Safari 브라우저 지원을 추가했습니다.",
"releaseSummaryV11106": "V1.11.06 업데이트됨",
"releaseHeadingV11106": "2026-11-06 업데이트:",
"releaseMenuLayerFix": "메뉴 컨트롤 영역의 드래그 버튼 표시 레이어를 수정했습니다.",
+3
View File
@@ -775,6 +775,9 @@
"tabsLabel": "Seções da ajuda",
"updates": "Atualizações da versão",
"updatesSubtitle": "Ver atualizações recentes de recursos",
"releaseSummaryV11107": "Atualizado para V1.11.07",
"releaseHeadingV11107": "Atualização de 07/11/2026:",
"releaseSafariConnector": "Adicionado suporte ao navegador Safari no conector.",
"releaseSummaryV11106": "Atualizado para V1.11.06",
"releaseHeadingV11106": "Atualização de 06/11/2026:",
"releaseMenuLayerFix": "Corrigida a camada de exibição do botão de arrastar na área de controle do menu.",
+3
View File
@@ -798,6 +798,9 @@
"tabsLabel": "Разделы справки",
"updates": "Обновления версии",
"updatesSubtitle": "Последние обновления функций",
"releaseSummaryV11107": "Обновлено до V1.11.07",
"releaseHeadingV11107": "Обновление от 07.11.2026:",
"releaseSafariConnector": "Добавлена поддержка браузера Safari в коннектор.",
"releaseSummaryV11106": "Обновлено до V1.11.06",
"releaseHeadingV11106": "Обновление от 06.11.2026:",
"releaseMenuLayerFix": "Исправлен порядок отображения кнопки перетаскивания в области управления меню.",
+3
View File
@@ -798,6 +798,9 @@
"tabsLabel": "使用说明栏目",
"updates": "版本更新",
"updatesSubtitle": "查看近期功能更新记录",
"releaseSummaryV11107": "V1.11.07 已更新",
"releaseHeadingV11107": "2026-11-07更新:",
"releaseSafariConnector": "增加 Safari 浏览器连接器支持",
"releaseSummaryV11106": "V1.11.06 已更新",
"releaseHeadingV11106": "2026-11-06更新:",
"releaseMenuLayerFix": "修正菜单控制区拖动按钮显示层级",
+3
View File
@@ -775,6 +775,9 @@
"tabsLabel": "使用說明欄目",
"updates": "版本更新",
"updatesSubtitle": "查看近期功能更新記錄",
"releaseSummaryV11107": "V1.11.07 已更新",
"releaseHeadingV11107": "2026-11-07更新:",
"releaseSafariConnector": "新增 Safari 瀏覽器連接器支援",
"releaseSummaryV11106": "V1.11.06 已更新",
"releaseHeadingV11106": "2026-11-06更新:",
"releaseMenuLayerFix": "修正選單控制區拖曳按鈕顯示層級",
+6
View File
@@ -255,6 +255,12 @@ const activeTab = ref<'guide' | 'updates'>('guide')
const openModules = ref<boolean[]>(Array.from({ length: 9 }, () => false))
const releases = [
{
date: '2026-11-07',
title: 'help.releaseSummaryV11107',
heading: 'help.releaseHeadingV11107',
updates: ['help.releaseSafariConnector'],
},
{
date: '2026-11-06',
title: 'help.releaseSummaryV11106',
+23 -20
View File
@@ -178,8 +178,11 @@ onBeforeUnmount(closePromotionPreview)
<style scoped>
.welcome-shell {
/* --u 是“1px”在 1920 宽下的缩放单位:宽度不超过 1920 时恒为 1px(外观不变),
更宽的屏幕按 100vw / 1920 线性放大,保证 2K/4K 与 1080p 的画面比例一致。 */
--u: max(1px, calc(100vw / 1920));
position: fixed; inset: 0; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
padding: clamp(22px, 3vw, 42px) clamp(22px, 4vw, 64px) 28px;
padding: clamp(22px, 3vw, calc(42 * var(--u))) clamp(22px, 4vw, calc(64 * var(--u))) calc(28 * var(--u));
color: #eef0f5;
background:
radial-gradient(circle at 17% 0%, rgba(218, 45, 57, .16), transparent 32%),
@@ -190,33 +193,33 @@ onBeforeUnmount(closePromotionPreview)
.welcome-shell::before {
position: fixed; inset: 0; pointer-events: none; content: ""; opacity: .22;
background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
background-size: 42px 42px;
background-size: calc(42 * var(--u)) calc(42 * var(--u));
mask-image: linear-gradient(to bottom, #000, transparent 70%);
}
.welcome-header, .welcome-intro, .hub-grid { position: relative; }
.welcome-header { display: flex; min-height: 42px; align-items: center; gap: 18px; }
.welcome-header { display: flex; min-height: calc(42 * var(--u)); align-items: center; gap: calc(18 * var(--u)); }
.welcome-header { z-index: 40; }
.brand-link { display: inline-flex; border-radius: 6px; transition: opacity .16s ease; }
.brand-link:hover { opacity: .78; }
.brand-link:focus-visible { outline: 2px solid #ef5147; outline-offset: 5px; }
.brand-logo { display: block; width: clamp(128px, 10vw, 156px); height: auto; }
.welcome-intro { padding: clamp(34px, 6vh, 64px) 0 clamp(22px, 3.5vh, 34px); text-align: center; }
.brand-logo { display: block; width: clamp(128px, 10vw, calc(156 * var(--u))); height: auto; }
.welcome-intro { padding: clamp(34px, 6vh, calc(64 * var(--u))) 0 clamp(22px, 3.5vh, calc(34 * var(--u))); text-align: center; }
.welcome-intro h1 {
max-width: none; margin: 0 auto; color: #f3f3f5;
font-size: clamp(20px, 2.4vw, 40px); line-height: 1.1; letter-spacing: -.025em; white-space: nowrap;
font-size: clamp(20px, 2.4vw, calc(40 * var(--u))); line-height: 1.1; letter-spacing: -.025em; white-space: nowrap;
}
.welcome-intro h1 span { color: #ff4e47; }
.welcome-intro p { max-width: 390px; margin: 0 0 3px; color: #8c93a3; font-size: 13px; line-height: 1.75; }
.hub-grid {
display: grid; min-height: 0; flex: 0 1 auto;
grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(14px, 1.5vw, 22px);
width: min(100%, 1540px); margin: 0 auto;
grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(14px, 1.5vw, calc(22 * var(--u)));
width: min(100%, calc(1540 * var(--u))); margin: 0 auto;
}
.hub-card {
--hub-border: #242731;
--hub-divider: #242731;
position: relative; display: flex; min-width: 0; min-height: 0; flex-direction: column;
padding: 0; overflow: hidden; border: 1px solid var(--hub-border); border-radius: 18px;
padding: 0; overflow: hidden; border: 1px solid var(--hub-border); border-radius: calc(18 * var(--u));
background: linear-gradient(180deg, rgba(26,29,38,.94), rgba(15,17,23,.98));
color: inherit; font: inherit; text-align: left; text-decoration: none; cursor: pointer;
box-shadow: 0 20px 52px rgba(0,0,0,.25);
@@ -228,24 +231,24 @@ onBeforeUnmount(closePromotionPreview)
.viewer-card:hover, .thumbnail-card:hover { --hub-border: #f04d45; }
.hub-preview { position: relative; width: 100%; aspect-ratio: 3 / 2; flex: 0 0 auto; overflow: hidden; border-bottom: 1px solid #252832; }
.preview-badge {
position: absolute; top: 14px; left: 14px; z-index: 4; padding: 5px 9px;
position: absolute; top: calc(14 * var(--u)); left: calc(14 * var(--u)); z-index: 4; padding: calc(5 * var(--u)) calc(9 * var(--u));
border: 1px solid rgba(255,255,255,.11); border-radius: 999px;
background: rgba(10,11,16,.72); color: #c7cbd5; font-size: 9px; letter-spacing: .08em;
background: rgba(10,11,16,.72); color: #c7cbd5; font-size: calc(9 * var(--u)); letter-spacing: .08em;
backdrop-filter: blur(7px);
}
.badge-with-icon { display: inline-flex; align-items: center; gap: 6px; }
.badge-with-icon img { display: block; width: 16px; height: 16px; border-radius: 4px; }
.card-body { display: flex; flex: 1 1 auto; flex-direction: column; padding: clamp(16px, 1.7vw, 24px) clamp(16px, 1.8vw, 25px) 0; }
.card-body h2 { margin: 0 0 8px; color: #f1f2f5; font-size: clamp(22px, 1.8vw, 28px); line-height: 1.1; letter-spacing: -.02em; }
.card-body p { min-height: 40px; margin: 0; color: #858c9c; font-size: 12px; line-height: 1.65; }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 18px; }
.tag-row span { padding: 4px 8px; border: 1px solid #34394a; border-radius: 999px; background: #1a1e2a; color: #8f98ad; font-size: 9px; }
.badge-with-icon { display: inline-flex; align-items: center; gap: calc(6 * var(--u)); }
.badge-with-icon img { display: block; width: calc(16 * var(--u)); height: calc(16 * var(--u)); border-radius: calc(4 * var(--u)); }
.card-body { display: flex; flex: 1 1 auto; flex-direction: column; padding: clamp(16px, 1.7vw, calc(24 * var(--u))) clamp(16px, 1.8vw, calc(25 * var(--u))) 0; }
.card-body h2 { margin: 0 0 calc(8 * var(--u)); color: #f1f2f5; font-size: clamp(22px, 1.8vw, calc(28 * var(--u))); line-height: 1.1; letter-spacing: -.02em; }
.card-body p { min-height: calc(40 * var(--u)); margin: 0; color: #858c9c; font-size: calc(12 * var(--u)); line-height: 1.65; }
.tag-row { display: flex; flex-wrap: wrap; gap: calc(6 * var(--u)); margin: calc(14 * var(--u)) 0 calc(18 * var(--u)); }
.tag-row span { padding: calc(4 * var(--u)) calc(8 * var(--u)); border: 1px solid #34394a; border-radius: 999px; background: #1a1e2a; color: #8f98ad; font-size: calc(9 * var(--u)); }
.editor-card .tag-row span { border-color: rgba(119,116,255,.35); background: rgba(74,72,150,.16); color: #aaa7f5; }
.viewer-card .tag-row span, .thumbnail-card .tag-row span { border-color: rgba(238,76,68,.24); background: rgba(111,36,37,.14); color: #d99a97; }
.card-action {
display: flex; width: 100%; min-height: 76px; flex: 0 0 auto; align-items: center; justify-content: center;
display: flex; width: 100%; min-height: calc(76 * var(--u)); flex: 0 0 auto; align-items: center; justify-content: center;
border-top: 1px solid var(--hub-divider); background: #13161d; color: #f3f4f7;
font-size: clamp(17px, 1.35vw, 21px); font-weight: 750; letter-spacing: -.01em;
font-size: clamp(17px, 1.35vw, calc(21 * var(--u))); font-weight: 750; letter-spacing: -.01em;
transition: background-color .18s ease, color .18s ease;
}
.card-action:hover { border-top-color: #d94740; background: #d94740; color: #fff; }
@@ -56,7 +56,7 @@ const connectorReady = computed(() => !!connectorHealth.value?.available && !!co
const connectorAuthorizationUrl = computed(() => {
const health = connectorHealth.value
if (!health?.available || !health.spineFound || health.authorized || typeof window === 'undefined') return ''
return `http://127.0.0.1:27843/pair?origin=${encodeURIComponent(window.location.origin)}`
return health.pairUrl || `http://127.0.0.1:27843/pair?origin=${encodeURIComponent(window.location.origin)}`
})
const connectorDisabledReason = computed(() => {
const health = connectorHealth.value