objc-js 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.js +30 -1
- package/package.json +6 -3
- package/prebuilds/darwin-arm64/node.napi.armv8.node +0 -0
- package/prebuilds/darwin-x64/node.napi.node +0 -0
- package/src/native/ObjcObject.h +21 -1
- package/src/native/ObjcObject.mm +49 -0
- package/src/native/ffi-utils.h +9 -1
- package/src/native/forwarding-common.h +45 -0
- package/src/native/forwarding-common.mm +2 -16
- package/src/native/method-forwarding.mm +1 -10
- package/src/native/nobjc_block.h +802 -0
- package/src/native/protocol-impl.mm +4 -12
- package/src/native/struct-utils.h +1 -1
- package/src/native/subclass-impl.mm +2 -3
- package/src/native/type-conversion.h +77 -49
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ The documentation is organized into several guides:
|
|
|
36
36
|
- **[C Functions](./docs/c-functions.md)** - Calling C functions like NSLog, NSHomeDirectory, NSStringFromClass
|
|
37
37
|
- **[Structs](./docs/structs.md)** - Passing and receiving C structs (CGRect, NSRange, etc.)
|
|
38
38
|
- **[Subclassing Objective-C Classes](./docs/subclassing.md)** - Creating and subclassing Objective-C classes from JavaScript
|
|
39
|
+
- **[Blocks](./docs/blocks.md)** - Passing JavaScript functions as Objective-C blocks (closures)
|
|
39
40
|
- **[Protocol Implementation](./docs/protocol-implementation.md)** - Creating delegate objects that implement protocols
|
|
40
41
|
- **[API Reference](./docs/api-reference.md)** - Complete API documentation for all classes and functions
|
|
41
42
|
|
package/dist/index.js
CHANGED
|
@@ -32,7 +32,12 @@ class NobjcLibrary {
|
|
|
32
32
|
LoadLibrary(library);
|
|
33
33
|
this.wasLoaded = true;
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
const classObject = GetClassObject(className);
|
|
36
|
+
if (classObject === undefined) {
|
|
37
|
+
// Class not found. Make sure the class exists before trying to access it.
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
cls = new NobjcObject(classObject);
|
|
36
41
|
classCache.set(className, cls);
|
|
37
42
|
return cls;
|
|
38
43
|
}
|
|
@@ -55,6 +60,9 @@ class NobjcObject {
|
|
|
55
60
|
// Return true for the special Symbol to enable unwrapping
|
|
56
61
|
if (p === NATIVE_OBJC_OBJECT)
|
|
57
62
|
return true;
|
|
63
|
+
// Return true for inspect symbols so console.log uses custom inspect
|
|
64
|
+
if (p === customInspectSymbol)
|
|
65
|
+
return true;
|
|
58
66
|
// guard against other symbols
|
|
59
67
|
if (typeof p === "symbol")
|
|
60
68
|
return Reflect.has(target, p);
|
|
@@ -117,6 +125,10 @@ class NobjcObject {
|
|
|
117
125
|
// object (avoids triggering proxy 'has' trap which would be a second FFI call)
|
|
118
126
|
const selector = NobjcMethodNameToObjcSelector(methodName);
|
|
119
127
|
if (!target.$respondsToSelector(selector)) {
|
|
128
|
+
// special case since JS checks for `.then` on Promise objects
|
|
129
|
+
if (methodName === "then")
|
|
130
|
+
return undefined;
|
|
131
|
+
// Otherwise, throw an error
|
|
120
132
|
throw new Error(`Method ${methodName} not found on object`);
|
|
121
133
|
}
|
|
122
134
|
method = NobjcMethod(object, methodName);
|
|
@@ -127,6 +139,9 @@ class NobjcObject {
|
|
|
127
139
|
};
|
|
128
140
|
// Create the proxy
|
|
129
141
|
const proxy = new Proxy(object, handler);
|
|
142
|
+
// Set custom inspect on the native object so console.log works through the Proxy.
|
|
143
|
+
// Runtimes (Node, Bun) bypass Proxy traps during inspect and read the target directly.
|
|
144
|
+
object[customInspectSymbol] = () => proxy.toString();
|
|
130
145
|
// Store proxy → native mapping in WeakMap for O(1) unwrap (bypasses Proxy traps)
|
|
131
146
|
nativeObjectMap.set(proxy, object);
|
|
132
147
|
// Return the proxy
|
|
@@ -137,6 +152,20 @@ function unwrapArg(arg) {
|
|
|
137
152
|
if (arg && typeof arg === "object") {
|
|
138
153
|
return nativeObjectMap.get(arg) ?? arg;
|
|
139
154
|
}
|
|
155
|
+
// Wrap function arguments so that when called from native (e.g., as ObjC blocks),
|
|
156
|
+
// the native ObjcObject args are automatically wrapped in NobjcObject proxies.
|
|
157
|
+
if (typeof arg === "function") {
|
|
158
|
+
const wrapped = function (...nativeArgs) {
|
|
159
|
+
for (let i = 0; i < nativeArgs.length; i++) {
|
|
160
|
+
nativeArgs[i] = wrapObjCObjectIfNeeded(nativeArgs[i]);
|
|
161
|
+
}
|
|
162
|
+
return unwrapArg(arg(...nativeArgs));
|
|
163
|
+
};
|
|
164
|
+
// Preserve the original function's .length so the native layer can read it
|
|
165
|
+
// (used to infer block parameter count when extended encoding is unavailable)
|
|
166
|
+
Object.defineProperty(wrapped, "length", { value: arg.length });
|
|
167
|
+
return wrapped;
|
|
168
|
+
}
|
|
140
169
|
return arg;
|
|
141
170
|
}
|
|
142
171
|
function wrapObjCObjectIfNeeded(result) {
|
package/package.json
CHANGED
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"prebuild": "node-gyp clean && node-gyp configure",
|
|
31
31
|
"build": "npm run build-native && npm run build-scripts && npm run build-source",
|
|
32
32
|
"pretest": "npm run build",
|
|
33
|
-
"test": "bun test",
|
|
33
|
+
"test": "bun run test:bun",
|
|
34
|
+
"test:bun": "bun run build && bun test",
|
|
35
|
+
"test:node": "bun run build && npx vitest run",
|
|
34
36
|
"test:native": "bun test tests/test-native-code.test.ts",
|
|
35
37
|
"test:js": "bun test tests/test-js-code.test.ts",
|
|
36
38
|
"test:string-lifetime": "bun test tests/test-string-lifetime.test.ts",
|
|
@@ -44,7 +46,7 @@
|
|
|
44
46
|
"prebuild:x64": "prebuildify --napi --strip --arch x64 -r node",
|
|
45
47
|
"prebuild:all": "bun run prebuild:arm64 && bun run prebuild:x64"
|
|
46
48
|
},
|
|
47
|
-
"version": "1.
|
|
49
|
+
"version": "1.3.1",
|
|
48
50
|
"description": "Objective-C bridge for Node.js",
|
|
49
51
|
"main": "dist/index.js",
|
|
50
52
|
"dependencies": {
|
|
@@ -57,7 +59,8 @@
|
|
|
57
59
|
"node-gyp": "^12.1.0",
|
|
58
60
|
"prebuildify": "^6.0.1",
|
|
59
61
|
"prettier": "^3.7.4",
|
|
60
|
-
"typescript": "^5.9.3"
|
|
62
|
+
"typescript": "^5.9.3",
|
|
63
|
+
"vitest": "^4.0.18"
|
|
61
64
|
},
|
|
62
65
|
"gypfile": true,
|
|
63
66
|
"patchedDependencies": {
|
|
Binary file
|
|
Binary file
|
package/src/native/ObjcObject.h
CHANGED
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
#include <napi.h>
|
|
5
5
|
#include <objc/objc.h>
|
|
6
6
|
#include <objc/runtime.h>
|
|
7
|
+
#include <objc/message.h>
|
|
7
8
|
#include <optional>
|
|
8
9
|
#include <variant>
|
|
9
10
|
#include <unordered_map>
|
|
10
11
|
#include <vector>
|
|
11
12
|
|
|
13
|
+
// objc_retain/objc_release are part of the stable ObjC ABI (macOS 10.12+)
|
|
14
|
+
// but not declared in public headers. We use them for manual reference counting
|
|
15
|
+
// since ARC is not enabled for .mm files in this project.
|
|
16
|
+
extern "C" id objc_retain(id value);
|
|
17
|
+
extern "C" void objc_release(id value);
|
|
18
|
+
|
|
12
19
|
#ifdef __OBJC__
|
|
13
20
|
@class NSMethodSignature;
|
|
14
21
|
#else
|
|
@@ -51,6 +58,14 @@ public:
|
|
|
51
58
|
// This better be an Napi::External<id>! We lost the type info at runtime.
|
|
52
59
|
Napi::External<id> external = info[0].As<Napi::External<id>>();
|
|
53
60
|
objcObject = *(external.Data());
|
|
61
|
+
// Retain the ObjC object so it stays alive as long as this JS wrapper
|
|
62
|
+
// exists. Without this, ARC/autorelease can deallocate the object while
|
|
63
|
+
// JS still holds a reference, causing Use-After-Free crashes (SIGTRAP)
|
|
64
|
+
// in completion handler callbacks and other async contexts.
|
|
65
|
+
// Note: ARC is not enabled for .mm files in this project (the -fobjc-arc
|
|
66
|
+
// flag is in OTHER_CFLAGS, not OTHER_CPLUSPLUSFLAGS), so __strong has
|
|
67
|
+
// no effect — we must manage retain/release manually.
|
|
68
|
+
if (objcObject) objc_retain(objcObject);
|
|
54
69
|
return;
|
|
55
70
|
}
|
|
56
71
|
// If someone tries `new ObjcObject()` from JS, forbid it:
|
|
@@ -58,7 +73,12 @@ public:
|
|
|
58
73
|
.ThrowAsJavaScriptException();
|
|
59
74
|
}
|
|
60
75
|
static Napi::Object NewInstance(Napi::Env env, id obj);
|
|
61
|
-
~ObjcObject()
|
|
76
|
+
~ObjcObject() {
|
|
77
|
+
if (objcObject) {
|
|
78
|
+
objc_release(objcObject);
|
|
79
|
+
objcObject = nil;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
62
82
|
|
|
63
83
|
private:
|
|
64
84
|
Napi::Value $MsgSend(const Napi::CallbackInfo &info);
|
package/src/native/ObjcObject.mm
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
#include "bridge.h"
|
|
3
3
|
#include "pointer-utils.h"
|
|
4
4
|
#include "struct-utils.h"
|
|
5
|
+
#include "nobjc_block.h"
|
|
5
6
|
#include <Foundation/Foundation.h>
|
|
6
7
|
#include <napi.h>
|
|
7
8
|
#include <objc/objc.h>
|
|
@@ -153,6 +154,8 @@ static bool TryFastMsgSend(Napi::Env env, id target, SEL selector,
|
|
|
153
154
|
const char *argType = SimplifyTypeEncoding(
|
|
154
155
|
[methodSignature getArgumentTypeAtIndex:i + 2]);
|
|
155
156
|
char code = *argType;
|
|
157
|
+
// Block args (@?) need special handling — bail out of fast path
|
|
158
|
+
if (code == '@' && argType[1] == '?') return false;
|
|
156
159
|
if (!IsFastPathArgTypeCode(code)) return false;
|
|
157
160
|
argTypeCodes[i] = code;
|
|
158
161
|
if (code == 'f' || code == 'd') hasFloatArgs = true;
|
|
@@ -476,6 +479,28 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
|
|
|
476
479
|
continue;
|
|
477
480
|
}
|
|
478
481
|
|
|
482
|
+
// Block argument: convert JS function to ObjC block
|
|
483
|
+
if (IsBlockTypeEncoding(typeEncoding) && info[i].IsFunction()) {
|
|
484
|
+
// Get extended encoding from method_getTypeEncoding() which preserves @?<...>
|
|
485
|
+
// NSMethodSignature strips the extended encoding, so we use the runtime directly.
|
|
486
|
+
// Argument index in NSInvocation is i+1 (0=self, 1=_cmd, 2+=user args)
|
|
487
|
+
std::string extEncoding = GetExtendedBlockEncoding(
|
|
488
|
+
object_getClass(objcObject), selector, i + 1);
|
|
489
|
+
const char *blockEncoding = extEncoding.empty()
|
|
490
|
+
? [methodSignature getArgumentTypeAtIndex:i + 1]
|
|
491
|
+
: extEncoding.c_str();
|
|
492
|
+
id block = CreateBlockFromJSFunction(env, info[i], blockEncoding);
|
|
493
|
+
if (env.IsExceptionPending()) return env.Null();
|
|
494
|
+
[invocation setArgument:&block atIndex:i + 1];
|
|
495
|
+
// Store block as id in arg buffer to keep it alive until after invoke
|
|
496
|
+
if (useHeap) {
|
|
497
|
+
heapArgBuf.push_back(BaseObjcType{block});
|
|
498
|
+
} else {
|
|
499
|
+
smallArgBuf[argIdx] = BaseObjcType{block};
|
|
500
|
+
}
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
479
504
|
auto arg = AsObjCArgument(info[i], typeEncoding, context);
|
|
480
505
|
if (!arg.has_value()) {
|
|
481
506
|
std::string errorMessageStr = std::format("Unsupported argument type {}",
|
|
@@ -634,6 +659,10 @@ Napi::Value ObjcObject::$PrepareSend(const Napi::CallbackInfo &info) {
|
|
|
634
659
|
[methodSignature getArgumentTypeAtIndex:i + 2]);
|
|
635
660
|
char code = *argType;
|
|
636
661
|
prepared->argInfos[i] = {code, code == '{'};
|
|
662
|
+
// Block args (@?) need slow path for JS function → block conversion
|
|
663
|
+
if (code == '@' && argType[1] == '?') {
|
|
664
|
+
canFast = false;
|
|
665
|
+
}
|
|
637
666
|
if (code == '{' || !IsFastPathArgTypeCode(code)) {
|
|
638
667
|
canFast = false;
|
|
639
668
|
}
|
|
@@ -738,6 +767,26 @@ Napi::Value ObjcObject::$MsgSendPrepared(const Napi::CallbackInfo &info) {
|
|
|
738
767
|
continue;
|
|
739
768
|
}
|
|
740
769
|
|
|
770
|
+
// Block argument: convert JS function to ObjC block
|
|
771
|
+
if (IsBlockTypeEncoding(typeEncoding) && info[jsArgIdx].IsFunction()) {
|
|
772
|
+
// Get extended encoding from method_getTypeEncoding() which preserves @?<...>
|
|
773
|
+
// Argument index in NSInvocation is i+2 (0=self, 1=_cmd, 2+=user args)
|
|
774
|
+
std::string extEncoding = GetExtendedBlockEncoding(
|
|
775
|
+
object_getClass(objcObject), prepared->selector, i + 2);
|
|
776
|
+
const char *blockEncoding = extEncoding.empty()
|
|
777
|
+
? [prepared->methodSignature getArgumentTypeAtIndex:i + 2]
|
|
778
|
+
: extEncoding.c_str();
|
|
779
|
+
id block = CreateBlockFromJSFunction(env, info[jsArgIdx], blockEncoding);
|
|
780
|
+
if (env.IsExceptionPending()) return env.Null();
|
|
781
|
+
[invocation setArgument:&block atIndex:i + 2];
|
|
782
|
+
if (useHeap) {
|
|
783
|
+
heapArgBuf.push_back(BaseObjcType{block});
|
|
784
|
+
} else {
|
|
785
|
+
smallArgBuf[i] = BaseObjcType{block};
|
|
786
|
+
}
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
|
|
741
790
|
auto arg = AsObjCArgument(info[jsArgIdx], typeEncoding, context);
|
|
742
791
|
if (!arg.has_value()) {
|
|
743
792
|
Napi::TypeError::New(env, std::string("Unsupported argument type ") + typeEncoding)
|
package/src/native/ffi-utils.h
CHANGED
|
@@ -208,7 +208,7 @@ inline ffi_type* ParseStructEncoding(const char* encoding, size_t* outSize,
|
|
|
208
208
|
|
|
209
209
|
if (*ptr == '}') break;
|
|
210
210
|
|
|
211
|
-
std::string fieldEncoding =
|
|
211
|
+
std::string fieldEncoding = SkipOneTypeEncoding(ptr);
|
|
212
212
|
NOBJC_LOG("ParseStructEncoding: parsing field '%s'", fieldEncoding.c_str());
|
|
213
213
|
|
|
214
214
|
// Recursively get FFI type for this field
|
|
@@ -271,6 +271,14 @@ inline ffi_type* GetFFITypeForEncoding(const char* encoding, size_t* outSize,
|
|
|
271
271
|
|
|
272
272
|
char firstChar = simpleEncoding[0];
|
|
273
273
|
|
|
274
|
+
// Handle block type (@?) — blocks are pointers
|
|
275
|
+
if (firstChar == '@' && simpleEncoding[1] == '?') {
|
|
276
|
+
if (outSize) {
|
|
277
|
+
*outSize = sizeof(void*);
|
|
278
|
+
}
|
|
279
|
+
return &ffi_type_pointer;
|
|
280
|
+
}
|
|
281
|
+
|
|
274
282
|
// Handle structs and unions
|
|
275
283
|
if (firstChar == '{') {
|
|
276
284
|
return ParseStructEncoding(simpleEncoding, outSize, allocatedTypes);
|
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
#include "memory-utils.h"
|
|
5
5
|
#include "protocol-storage.h"
|
|
6
|
+
#include "constants.h"
|
|
7
|
+
#include "debug.h"
|
|
6
8
|
#include <cstring>
|
|
9
|
+
#include <condition_variable>
|
|
7
10
|
#include <functional>
|
|
11
|
+
#include <mutex>
|
|
8
12
|
#include <napi.h>
|
|
9
13
|
#include <optional>
|
|
14
|
+
#include <CoreFoundation/CoreFoundation.h>
|
|
10
15
|
|
|
11
16
|
#ifdef __OBJC__
|
|
12
17
|
@class NSInvocation;
|
|
@@ -106,6 +111,46 @@ struct ForwardingCallbacks {
|
|
|
106
111
|
CallbackType callbackType;
|
|
107
112
|
};
|
|
108
113
|
|
|
114
|
+
// MARK: - Shared Helpers
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Pump the CFRunLoop until a completion flag is set.
|
|
118
|
+
* Used by protocol forwarding, subclass forwarding, and block invocation
|
|
119
|
+
* to wait for cross-thread JS callbacks to complete.
|
|
120
|
+
*
|
|
121
|
+
* @param mutex Mutex protecting the isComplete flag
|
|
122
|
+
* @param isComplete Flag set to true when the JS callback completes
|
|
123
|
+
* @param label Optional label for debug logging (nullptr to disable)
|
|
124
|
+
*/
|
|
125
|
+
inline void PumpRunLoopUntilComplete(std::mutex &mutex, bool &isComplete,
|
|
126
|
+
const char *label = nullptr) {
|
|
127
|
+
int iterations = 0;
|
|
128
|
+
while (true) {
|
|
129
|
+
{
|
|
130
|
+
std::unique_lock<std::mutex> lock(mutex);
|
|
131
|
+
if (isComplete) break;
|
|
132
|
+
}
|
|
133
|
+
iterations++;
|
|
134
|
+
if (label && iterations % nobjc::kRunLoopDebugLogInterval == 0) {
|
|
135
|
+
NOBJC_LOG("%s: Still waiting... (%d iterations)", label, iterations);
|
|
136
|
+
}
|
|
137
|
+
CFRunLoopRunInMode(kCFRunLoopDefaultMode, nobjc::kRunLoopPumpInterval, true);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create a ThreadSafeFunction for method forwarding.
|
|
143
|
+
* Shared factory for protocol, subclass, and block TSFN creation.
|
|
144
|
+
*
|
|
145
|
+
* @param env Napi environment
|
|
146
|
+
* @param fn The JS function to wrap
|
|
147
|
+
* @param name Resource name for debugging
|
|
148
|
+
*/
|
|
149
|
+
inline Napi::ThreadSafeFunction CreateMethodTSFN(
|
|
150
|
+
Napi::Env env, const Napi::Function &fn, const std::string &name) {
|
|
151
|
+
return Napi::ThreadSafeFunction::New(env, fn, name, 0, 1, [](Napi::Env) {});
|
|
152
|
+
}
|
|
153
|
+
|
|
109
154
|
// MARK: - Common Implementation
|
|
110
155
|
|
|
111
156
|
/**
|
|
@@ -135,22 +135,8 @@ void ForwardInvocationCommon(NSInvocation *invocation,
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
// Wait for callback by pumping CFRunLoop
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
while (true) {
|
|
141
|
-
{
|
|
142
|
-
std::unique_lock<std::mutex> lock(completionMutex);
|
|
143
|
-
if (isComplete) {
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
iterations++;
|
|
148
|
-
if (iterations % nobjc::kRunLoopDebugLogInterval == 0) {
|
|
149
|
-
NOBJC_LOG("ForwardInvocationCommon: Still waiting... (%d iterations)",
|
|
150
|
-
iterations);
|
|
151
|
-
}
|
|
152
|
-
CFRunLoopRunInMode(kCFRunLoopDefaultMode, nobjc::kRunLoopPumpInterval, true);
|
|
153
|
-
}
|
|
138
|
+
PumpRunLoopUntilComplete(completionMutex, isComplete,
|
|
139
|
+
"ForwardInvocationCommon");
|
|
154
140
|
// Data cleaned up in callback
|
|
155
141
|
}
|
|
156
142
|
|
|
@@ -158,16 +158,7 @@ bool FallbackToTSFN(Napi::ThreadSafeFunction &tsfn, InvocationData *data,
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
// Wait for callback by pumping CFRunLoop
|
|
161
|
-
|
|
162
|
-
while (true) {
|
|
163
|
-
{
|
|
164
|
-
std::unique_lock<std::mutex> lock(completionMutex);
|
|
165
|
-
if (isComplete) {
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
CFRunLoopRunInMode(kCFRunLoopDefaultMode, timeout, true);
|
|
170
|
-
}
|
|
161
|
+
PumpRunLoopUntilComplete(completionMutex, isComplete);
|
|
171
162
|
|
|
172
163
|
return true;
|
|
173
164
|
}
|