fix(macos): support verified QQ builds on arm64 and x64

This commit is contained in:
手瓜一十雪
2026-10-10 01:05:09 +08:00
parent b31e1c0f0c
commit 1c0f7cce4f
11 changed files with 628 additions and 2 deletions
+93
View File
@@ -0,0 +1,93 @@
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const { join } = require('node:path');
const { test } = require('node:test');
const nativePath = join(__dirname, '../../../packages/napcat-native');
const napiPath = join(nativePath, 'napi2native', 'napi2native.darwin.' + process.arch + '.node');
const packetPath = join(nativePath, 'packet', 'MoeHoo.darwin.' + process.arch + '.node');
async function probe (mode) {
if (mode === 'missing-wrapper') {
const napi = require(napiPath);
assert.throws(() => napi.initHook('1', '2'), /Failed to initialize send hook/);
const packet = require(packetPath);
assert.equal(packet.initHook('2', '4', () => {}, false), 'error search');
return;
}
const wrapper = require('./wrapper.node');
const addresses = wrapper.addresses();
const napi = require(napiPath);
assert.equal(napi.initHook(addresses.coerce, addresses.resolver), true);
const lengths = [0, 1, 21, 22, 23, 24, 255, 4096];
for (const length of lengths) {
const payload = Buffer.from(Array.from({ length }, (_, index) => index % 256));
assert.deepEqual(wrapper.convert(payload), payload, 'convert length ' + length);
const error = Buffer.from('e'.repeat(length));
const result = await wrapper.resolve(-17, error, payload);
assert.equal(result.result, -17);
assert.equal(result.errMsg, error.toString());
assert.equal(result.rsp, payload.toString());
assert.deepEqual(result.rspbuffer, payload, 'resolve length ' + length);
}
for (const value of ['', '文🙂'.repeat(20), 12345, null, true]) {
assert.deepEqual(wrapper.convert(value), Buffer.from(String(value)));
}
const responses = await Promise.all(Array.from({ length: 64 }, (_, index) => {
const payload = Buffer.from([0, index, 255, 128]);
return wrapper.resolve(index, Buffer.alloc(0), payload).then(result => {
assert.equal(result.result, index);
assert.deepEqual(result.rspbuffer, payload);
});
}));
assert.equal(responses.length, 64);
const packet = require(packetPath);
const received = [];
assert.equal(packet.initHook(addresses.send, addresses.recv, (...args) => received.push(args), false), 'success');
assert.equal(wrapper.emptyPackets(), 7);
const expected = [];
for (const length of lengths) {
const uin = Buffer.from('1'.repeat(length));
const command = Buffer.from('C'.repeat(length));
const payload = Buffer.from(Array.from({ length }, (_, index) => index % 256));
for (const direction of [0, 1]) {
const sequence = length + 3000;
const result = wrapper.packet(direction, uin, command, sequence, payload);
assert.equal(result.returned, direction === 0 ? 102 : 209);
assert.deepEqual(result.command, command);
expected.push([direction, direction === 0 ? '0' : uin.toString(), command.toString(), sequence, payload.toString('hex').toUpperCase()]);
}
}
const embeddedNull = Buffer.from('A\0B');
wrapper.packet(1, embeddedNull, embeddedNull, 99, Buffer.from([255, 0, 128]));
expected.push([1, 'A\0B', 'A\0B', 99, 'FF0080']);
packet.setO3Setting(true);
const replaced = wrapper.packet(0, Buffer.from('1'), Buffer.from('trpc.o3.report.Report.SsoReport'), 0, Buffer.alloc(0));
assert.equal(replaced.command.toString(), 'fuck.trpc.o3');
packet.setO3Setting(false);
for (let attempt = 0; received.length < expected.length && attempt < 100; attempt++) {
await new Promise(resolve => setTimeout(resolve, 10));
}
assert.deepEqual(received, expected);
}
if (process.argv[2] === '--probe') {
probe(process.argv[3]).catch(error => {
console.error(error);
process.exitCode = 1;
});
} else {
for (const mode of ['missing-wrapper', 'hooks']) {
test('macOS ' + process.arch + ' native ' + mode, () => {
const result = spawnSync(process.execPath, [__filename, '--probe', mode], {
encoding: 'utf8',
timeout: 20000,
});
assert.ifError(result.error);
assert.equal(result.signal, null, result.stderr);
assert.equal(result.status, 0, result.stderr + result.stdout);
});
}
}
+317
View File
@@ -0,0 +1,317 @@
#include <node_api.h>
#include <array>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <dlfcn.h>
#include <sstream>
#include <string>
#include <vector>
struct alignas(8) QQString
{
std::array<uint8_t, 24> bytes{};
};
#if defined(__aarch64__)
constexpr size_t flagOffset = 23;
constexpr size_t dataOffset = 0;
constexpr uint8_t heapMask = 0x80;
#else
constexpr size_t flagOffset = 0;
constexpr size_t dataOffset = 16;
constexpr uint8_t heapMask = 1;
#endif
static uint64_t ReadWord(const QQString &value, size_t offset)
{
uint64_t word;
std::memcpy(&word, value.bytes.data() + offset, sizeof(word));
return word;
}
static void WriteWord(QQString &value, size_t offset, uint64_t word)
{
std::memcpy(value.bytes.data() + offset, &word, sizeof(word));
}
static QQString MakeString(const void *data, size_t length)
{
QQString value;
if (length <= 22)
{
#if defined(__aarch64__)
std::memcpy(value.bytes.data(), data, length);
value.bytes[23] = length;
#else
std::memcpy(value.bytes.data() + 1, data, length);
value.bytes[0] = length * 2;
#endif
}
else
{
size_t capacity = (length | 15) + 1;
auto buffer = static_cast<uint8_t *>(std::calloc(capacity, 1));
std::memcpy(buffer, data, length);
WriteWord(value, dataOffset, reinterpret_cast<uintptr_t>(buffer));
WriteWord(value, 8, length);
#if defined(__aarch64__)
WriteWord(value, 16, capacity | (uint64_t{1} << 63));
#else
WriteWord(value, 0, capacity | 1);
#endif
}
return value;
}
static std::string ReadString(const QQString &value)
{
if ((value.bytes[flagOffset] & heapMask) != 0)
return {reinterpret_cast<const char *>(ReadWord(value, dataOffset)), ReadWord(value, 8)};
#if defined(__aarch64__)
return {reinterpret_cast<const char *>(value.bytes.data()), value.bytes[23]};
#else
return {reinterpret_cast<const char *>(value.bytes.data() + 1), static_cast<size_t>(value.bytes[0] / 2)};
#endif
}
static void FreeString(const QQString &value)
{
if ((value.bytes[flagOffset] & heapMask) != 0)
std::free(reinterpret_cast<void *>(ReadWord(value, dataOffset)));
}
static QQString FromBuffer(napi_env env, napi_value input)
{
void *data;
size_t length;
napi_get_buffer_info(env, input, &data, &length);
return MakeString(data, length);
}
extern "C" __attribute__((noinline)) QQString MockCoerce(napi_env env, napi_value input)
{
napi_value text;
napi_coerce_to_string(env, input, &text);
size_t length;
napi_get_value_string_utf8(env, text, nullptr, 0, &length);
std::vector<char> bytes(length + 1);
napi_get_value_string_utf8(env, text, bytes.data(), bytes.size(), &length);
return MakeString(bytes.data(), length);
}
struct Context
{
uint8_t padding[24]{};
napi_env env;
napi_deferred deferred;
};
struct AsyncResult
{
uint8_t padding[16]{};
Context *context;
int32_t result;
int32_t alignment{};
QQString error;
QQString response;
};
extern "C" __attribute__((noinline)) void MockResolver(AsyncResult *result)
{
napi_throw_error(result->context->env, nullptr, "Resolver was not intercepted");
}
static volatile int64_t sendCalls = 0;
static volatile int64_t recvCalls = 0;
extern "C" __attribute__((noinline)) int64_t MockSend(void *self, void **packet)
{
sendCalls += 1;
return 101 + (self != nullptr) + (packet != nullptr);
}
extern "C" __attribute__((noinline)) int64_t MockRecv(void *self, void **packet, uint32_t marker)
{
recvCalls += 1;
return 201 + marker + (self != nullptr) + (packet != nullptr);
}
static napi_value Convert(napi_env env, napi_callback_info info)
{
size_t argc = 1;
napi_value input;
napi_get_cb_info(env, info, &argc, &input, nullptr, nullptr);
auto output = MockCoerce(env, input);
auto bytes = ReadString(output);
napi_value result;
napi_create_buffer_copy(env, bytes.size(), bytes.data(), nullptr, &result);
FreeString(output);
return result;
}
static napi_value Resolve(napi_env env, napi_callback_info info)
{
size_t argc = 3;
napi_value args[3];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
Context context{};
context.env = env;
napi_value promise;
napi_create_promise(env, &context.deferred, &promise);
AsyncResult result{};
result.context = &context;
napi_get_value_int32(env, args[0], &result.result);
result.error = FromBuffer(env, args[1]);
result.response = FromBuffer(env, args[2]);
MockResolver(&result);
FreeString(result.error);
FreeString(result.response);
return promise;
}
struct ByteVector
{
uint8_t *begin;
uint8_t *end;
};
struct SendInner
{
QQString command;
uint64_t padding{};
ByteVector *payload;
};
struct SendPacket
{
SendInner *inner;
uint8_t padding[48]{};
QQString uin;
uint64_t alignment{};
int64_t sequence;
uint8_t tail[16]{};
};
struct RecvPacket
{
QQString uin;
int64_t sequence;
QQString command;
ByteVector *payload;
};
static_assert(offsetof(AsyncResult, error) == 32);
static_assert(offsetof(AsyncResult, response) == 56);
static_assert(offsetof(SendPacket, uin) == 56);
static_assert(offsetof(SendPacket, sequence) == 88);
static_assert(offsetof(RecvPacket, payload) == 56);
static napi_value Packet(napi_env env, napi_callback_info info)
{
size_t argc = 5;
napi_value args[5];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
int32_t direction;
int64_t sequence;
napi_get_value_int32(env, args[0], &direction);
napi_get_value_int64(env, args[3], &sequence);
auto uin = FromBuffer(env, args[1]);
auto command = FromBuffer(env, args[2]);
void *payloadData;
size_t payloadLength;
napi_get_buffer_info(env, args[4], &payloadData, &payloadLength);
uint8_t emptyPayload = 0;
auto bytes = payloadLength ? static_cast<uint8_t *>(payloadData) : &emptyPayload;
ByteVector payload{bytes, bytes + payloadLength};
int64_t returnValue;
if (direction == 0)
{
SendInner inner{command, 0, &payload};
SendPacket packet{};
packet.inner = &inner;
packet.uin = uin;
packet.sequence = sequence;
void *pointer = &packet;
returnValue = MockSend(nullptr, &pointer);
command = inner.command;
}
else
{
RecvPacket packet{uin, sequence, command, &payload};
void *pointer = &packet;
returnValue = MockRecv(nullptr, &pointer, 7);
}
napi_value result;
napi_create_object(env, &result);
napi_value returned;
napi_create_int64(env, returnValue, &returned);
napi_set_named_property(env, result, "returned", returned);
auto commandBytes = ReadString(command);
napi_value outputCommand;
napi_create_buffer_copy(env, commandBytes.size(), commandBytes.data(), nullptr, &outputCommand);
napi_set_named_property(env, result, "command", outputCommand);
FreeString(uin);
FreeString(command);
return result;
}
static napi_value EmptyPackets(napi_env env, napi_callback_info info)
{
void *missing = nullptr;
MockSend(nullptr, nullptr);
MockSend(nullptr, &missing);
SendPacket send{};
void *sendPointer = &send;
MockSend(nullptr, &sendPointer);
SendInner inner{};
send.inner = &inner;
MockSend(nullptr, &sendPointer);
MockRecv(nullptr, nullptr, 0);
MockRecv(nullptr, &missing, 0);
RecvPacket recv{};
void *recvPointer = &recv;
MockRecv(nullptr, &recvPointer, 0);
napi_value result;
napi_create_int64(env, sendCalls + recvCalls, &result);
return result;
}
static napi_value Addresses(napi_env env, napi_callback_info info)
{
Dl_info image{};
dladdr(reinterpret_cast<void *>(MockCoerce), &image);
uintptr_t base = reinterpret_cast<uintptr_t>(image.dli_fbase);
napi_value result;
napi_create_object(env, &result);
const std::pair<const char *, uintptr_t> functions[] = {
{"coerce", reinterpret_cast<uintptr_t>(MockCoerce) - base},
{"resolver", reinterpret_cast<uintptr_t>(MockResolver) - base},
{"send", (reinterpret_cast<uintptr_t>(MockSend) - base) * 2},
{"recv", (reinterpret_cast<uintptr_t>(MockRecv) - base) * 2},
};
for (const auto &entry : functions)
{
std::ostringstream hex;
hex << std::hex << entry.second;
napi_value value;
napi_create_string_utf8(env, hex.str().c_str(), NAPI_AUTO_LENGTH, &value);
napi_set_named_property(env, result, entry.first, value);
}
return result;
}
static napi_value Init(napi_env env, napi_value exports)
{
const napi_property_descriptor properties[] = {
{"addresses", nullptr, Addresses, nullptr, nullptr, nullptr, napi_default, nullptr},
{"convert", nullptr, Convert, nullptr, nullptr, nullptr, napi_default, nullptr},
{"resolve", nullptr, Resolve, nullptr, nullptr, nullptr, napi_default, nullptr},
{"packet", nullptr, Packet, nullptr, nullptr, nullptr, napi_default, nullptr},
{"emptyPackets", nullptr, EmptyPackets, nullptr, nullptr, nullptr, napi_default, nullptr},
};
napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties);
return exports;
}
NAPI_MODULE(wrapper, Init)
+56
View File
@@ -0,0 +1,56 @@
name: macOS native compatibility
on:
push:
paths:
- packages/napcat-native/**
- .github/tests/macos-native/**
- .github/workflows/macos-native.yml
pull_request:
paths:
- packages/napcat-native/**
- .github/tests/macos-native/**
- .github/workflows/macos-native.yml
workflow_dispatch:
permissions:
contents: read
jobs:
native:
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
arch: arm64
- runner: macos-15-intel
arch: x64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
architecture: ${{ matrix.arch }}
- name: Build QQ ABI mock
working-directory: .github/tests/macos-native
run: |
npm install --prefix headers --ignore-scripts --no-package-lock node-api-headers@1.6.0
clang++ -std=c++17 -O0 -g -bundle -undefined dynamic_lookup -I headers/node_modules/node-api-headers/include wrapper.cpp -o wrapper.node
codesign --force --sign - wrapper.node
- name: Exercise native packet hooks
run: node --test .github/tests/macos-native/hooks.test.cjs
- uses: actions/checkout@v4
with:
repository: NapNeko/ffmpegAddon
ref: 3a6e99cea9a856ca71f7d70e11d6d769498121d9
path: ffmpeg-native-tests
- name: Verify packaged FFmpeg
working-directory: ffmpeg-native-tests
env:
FFMPEG_ADDON_PATH: ${{ github.workspace }}/packages/napcat-native/ffmpeg/ffmpegAddon.darwin.${{ matrix.arch }}.node
run: |
npm ci --ignore-scripts
node --test test/video-info.test.js
+82 -2
View File
@@ -165,7 +165,7 @@
},
"6.9.88-44725-x64": {
"send": "2756EF6",
"recv": "0A36152"
"recv": "A36152"
},
"6.9.88-44725-arm64": {
"send": "2313C68",
@@ -241,7 +241,7 @@
},
"6.9.93-47354-x64": {
"send": "28ADE46",
"recv": "0A8FD5A"
"recv": "A8FD5A"
},
"9.9.30-48517-x64": {
"send": "0A9B1BC",
@@ -402,5 +402,85 @@
"9.9.36-53644-x64": {
"send": "C3EE2F",
"recv": "24A6355"
},
"6.9.82-40768-x64": {
"send": "6587B8",
"recv": "C45C6E"
},
"6.9.82-40990-x64": {
"send": "6587B8",
"recv": "C45C6E"
},
"6.9.83-41679-x64": {
"send": "271A206",
"recv": "A1E04A"
},
"6.9.83-41785-x64": {
"send": "2744756",
"recv": "A1DE4A"
},
"6.9.83-41857-x64": {
"send": "2745186",
"recv": "A1E96E"
},
"6.9.85-42086-x64": {
"send": "27462F6",
"recv": "A1FC0A"
},
"6.9.86-42744-x64": {
"send": "27770E6",
"recv": "A2725A"
},
"6.9.86-42905-x64": {
"send": "2786206",
"recv": "A2D6A6"
},
"6.9.86-42941-x64": {
"send": "2786366",
"recv": "A2D2E2"
},
"6.9.87-44343-x64": {
"send": "2750306",
"recv": "A2F432"
},
"6.9.89-45758-x64": {
"send": "288CBB6",
"recv": "A87816"
},
"6.9.90-46494-x64": {
"send": "28A2F96",
"recv": "A8D2FE"
},
"6.9.91-46928-x64": {
"send": "28A2F96",
"recv": "A8D2FE"
},
"6.9.95-48517-x64": {
"send": "28DC616",
"recv": "AAED2A"
},
"6.9.96-49738-x64": {
"send": "2B8C936",
"recv": "AFF03E"
},
"6.9.98-51102-x64": {
"send": "2C6CD06",
"recv": "B42922"
},
"6.9.99-51802-x64": {
"send": "2F6C1AB",
"recv": "C16A4A"
},
"7.0.0-52194-x64": {
"send": "2FAC58B",
"recv": "C20DB2"
},
"7.0.1-52892-x64": {
"send": "304CC8B",
"recv": "C3A54E"
},
"7.0.2-53644-x64": {
"send": "309B1BB",
"recv": "C47262"
}
}
+80
View File
@@ -914,5 +914,85 @@
"9.9.36-53644-x64": {
"send": "38255F0",
"recv": "3828E30"
},
"6.9.82-40768-x64": {
"send": "489A168",
"recv": "489CD18"
},
"6.9.82-40990-x64": {
"send": "489A168",
"recv": "489CD18"
},
"6.9.83-41679-x64": {
"send": "450C8E0",
"recv": "450F490"
},
"6.9.83-41785-x64": {
"send": "450C540",
"recv": "450F0F0"
},
"6.9.83-41857-x64": {
"send": "450D998",
"recv": "4510548"
},
"6.9.85-42086-x64": {
"send": "450E430",
"recv": "4510FE0"
},
"6.9.86-42744-x64": {
"send": "4570018",
"recv": "4572BC8"
},
"6.9.86-42905-x64": {
"send": "457E7C0",
"recv": "4581370"
},
"6.9.86-42941-x64": {
"send": "457EA78",
"recv": "4581628"
},
"6.9.87-44343-x64": {
"send": "45126C0",
"recv": "4515270"
},
"6.9.89-45758-x64": {
"send": "478B860",
"recv": "478E410"
},
"6.9.90-46494-x64": {
"send": "47B8018",
"recv": "47BABC8"
},
"6.9.91-46928-x64": {
"send": "47B8018",
"recv": "47BABC8"
},
"6.9.95-48517-x64": {
"send": "48A92C0",
"recv": "48ABE70"
},
"6.9.96-49738-x64": {
"send": "4ACD9D8",
"recv": "4AD0588"
},
"6.9.98-51102-x64": {
"send": "4C953E8",
"recv": "4C97F98"
},
"6.9.99-51802-x64": {
"send": "5293B28",
"recv": "52966D8"
},
"7.0.0-52194-x64": {
"send": "53143C0",
"recv": "5316F70"
},
"7.0.1-52892-x64": {
"send": "54552F8",
"recv": "5457EA8"
},
"7.0.2-53644-x64": {
"send": "54F11F0",
"recv": "54F3DA8"
}
}
Binary file not shown.