diff --git a/.github/tests/macos-native/hooks.test.cjs b/.github/tests/macos-native/hooks.test.cjs new file mode 100644 index 00000000..e3fbe99d --- /dev/null +++ b/.github/tests/macos-native/hooks.test.cjs @@ -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); + }); + } +} diff --git a/.github/tests/macos-native/wrapper.cpp b/.github/tests/macos-native/wrapper.cpp new file mode 100644 index 00000000..e5d88e67 --- /dev/null +++ b/.github/tests/macos-native/wrapper.cpp @@ -0,0 +1,317 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct alignas(8) QQString +{ + std::array 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(std::calloc(capacity, 1)); + std::memcpy(buffer, data, length); + WriteWord(value, dataOffset, reinterpret_cast(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(ReadWord(value, dataOffset)), ReadWord(value, 8)}; +#if defined(__aarch64__) + return {reinterpret_cast(value.bytes.data()), value.bytes[23]}; +#else + return {reinterpret_cast(value.bytes.data() + 1), static_cast(value.bytes[0] / 2)}; +#endif +} + +static void FreeString(const QQString &value) +{ + if ((value.bytes[flagOffset] & heapMask) != 0) + std::free(reinterpret_cast(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 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(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(MockCoerce), &image); + uintptr_t base = reinterpret_cast(image.dli_fbase); + napi_value result; + napi_create_object(env, &result); + const std::pair functions[] = { + {"coerce", reinterpret_cast(MockCoerce) - base}, + {"resolver", reinterpret_cast(MockResolver) - base}, + {"send", (reinterpret_cast(MockSend) - base) * 2}, + {"recv", (reinterpret_cast(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) diff --git a/.github/workflows/macos-native.yml b/.github/workflows/macos-native.yml new file mode 100644 index 00000000..903b0e60 --- /dev/null +++ b/.github/workflows/macos-native.yml @@ -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 diff --git a/packages/napcat-core/external/napi2native.json b/packages/napcat-core/external/napi2native.json index d6fc33e7..50a8bc9f 100644 --- a/packages/napcat-core/external/napi2native.json +++ b/packages/napcat-core/external/napi2native.json @@ -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" } } diff --git a/packages/napcat-core/external/packet.json b/packages/napcat-core/external/packet.json index b7964fc8..e5a18a23 100644 --- a/packages/napcat-core/external/packet.json +++ b/packages/napcat-core/external/packet.json @@ -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" } } diff --git a/packages/napcat-native/ffmpeg/ffmpegAddon.darwin.arm64.node b/packages/napcat-native/ffmpeg/ffmpegAddon.darwin.arm64.node index 86df801b..03263c80 100644 Binary files a/packages/napcat-native/ffmpeg/ffmpegAddon.darwin.arm64.node and b/packages/napcat-native/ffmpeg/ffmpegAddon.darwin.arm64.node differ diff --git a/packages/napcat-native/ffmpeg/ffmpegAddon.darwin.x64.node b/packages/napcat-native/ffmpeg/ffmpegAddon.darwin.x64.node new file mode 100644 index 00000000..f533b04b Binary files /dev/null and b/packages/napcat-native/ffmpeg/ffmpegAddon.darwin.x64.node differ diff --git a/packages/napcat-native/napi2native/napi2native.darwin.arm64.node b/packages/napcat-native/napi2native/napi2native.darwin.arm64.node index 4807861d..75c66dfe 100644 Binary files a/packages/napcat-native/napi2native/napi2native.darwin.arm64.node and b/packages/napcat-native/napi2native/napi2native.darwin.arm64.node differ diff --git a/packages/napcat-native/napi2native/napi2native.darwin.x64.node b/packages/napcat-native/napi2native/napi2native.darwin.x64.node new file mode 100644 index 00000000..70007051 Binary files /dev/null and b/packages/napcat-native/napi2native/napi2native.darwin.x64.node differ diff --git a/packages/napcat-native/packet/MoeHoo.darwin.arm64.node b/packages/napcat-native/packet/MoeHoo.darwin.arm64.node index 15fad35d..b46a5e63 100644 Binary files a/packages/napcat-native/packet/MoeHoo.darwin.arm64.node and b/packages/napcat-native/packet/MoeHoo.darwin.arm64.node differ diff --git a/packages/napcat-native/packet/MoeHoo.darwin.x64.node b/packages/napcat-native/packet/MoeHoo.darwin.x64.node new file mode 100644 index 00000000..75513086 Binary files /dev/null and b/packages/napcat-native/packet/MoeHoo.darwin.x64.node differ