objc-js 1.3.0 → 1.4.0
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 +44 -0
- package/dist/index.d.ts +49 -1
- package/dist/index.js +109 -2
- package/dist/native.d.ts +2 -2
- package/dist/native.js +2 -2
- package/package.json +4 -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 +21 -1
- package/src/native/nobjc.mm +25 -0
- package/src/native/nobjc_block.h +95 -28
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ The documentation is organized into several guides:
|
|
|
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
39
|
- **[Blocks](./docs/blocks.md)** - Passing JavaScript functions as Objective-C blocks (closures)
|
|
40
|
+
- **[Run Loop](./docs/run-loop.md)** - Pumping the CFRunLoop for async callback delivery (completion handlers, etc.)
|
|
40
41
|
- **[Protocol Implementation](./docs/protocol-implementation.md)** - Creating delegate objects that implement protocols
|
|
41
42
|
- **[API Reference](./docs/api-reference.md)** - Complete API documentation for all classes and functions
|
|
42
43
|
|
|
@@ -55,3 +56,46 @@ console.log(str.toString());
|
|
|
55
56
|
```
|
|
56
57
|
|
|
57
58
|
For more examples and detailed guides, see the [documentation](./docs/basic-usage.md).
|
|
59
|
+
|
|
60
|
+
## Companion Packages
|
|
61
|
+
|
|
62
|
+
objc-js has two companion packages for TypeScript types and pure-C framework bindings:
|
|
63
|
+
|
|
64
|
+
### objcjs-types
|
|
65
|
+
|
|
66
|
+
Auto-generated TypeScript type definitions for macOS Objective-C frameworks. Provides IntelliSense and type checking for classes, protocols, enums, and methods across all major Apple frameworks.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install objcjs-types
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import type { NSWindow, NSApplicationDelegate } from "objcjs-types/AppKit";
|
|
74
|
+
import type { CGPoint, CGSize, CGRect } from "objcjs-types/structs";
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### objcjs-extra
|
|
78
|
+
|
|
79
|
+
Hand-written FFI bindings for macOS pure-C frameworks that have no Objective-C metadata (so they can't be auto-generated by objcjs-types). Works with both Bun (`bun:ffi`) and Node.js (`koffi`).
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm install objcjs-extra koffi # Node.js
|
|
83
|
+
bun add objcjs-extra # Bun
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Provides bindings for CoreFoundation, CoreGraphics, ApplicationServices (Accessibility), Security, CoreServices (FSEvents, Launch Services), IOKit, CoreText, ImageIO, CoreAudio, Network, CoreMedia, and Accelerate.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { AXUIElementCreateApplication, AXIsProcessTrusted } from "objcjs-extra/ApplicationServices";
|
|
90
|
+
import { CGEventCreateKeyboardEvent, CGEventPost } from "objcjs-extra/CoreGraphics";
|
|
91
|
+
import { getDefaultOutputDevice, setDeviceVolume } from "objcjs-extra/CoreAudio";
|
|
92
|
+
import { preventSleep } from "objcjs-extra/IOKit";
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### When to use which package
|
|
96
|
+
|
|
97
|
+
| Need | Package |
|
|
98
|
+
| --------------------------------------------------------------------- | -------------- |
|
|
99
|
+
| Call Objective-C methods (NSWindow, NSString, etc.) | `objc-js` |
|
|
100
|
+
| TypeScript types for Objective-C APIs | `objcjs-types` |
|
|
101
|
+
| Pure-C frameworks (CoreFoundation, CoreGraphics, Accessibility, etc.) | `objcjs-extra` |
|
package/dist/index.d.ts
CHANGED
|
@@ -249,4 +249,52 @@ declare function callFunction(name: string, ...rest: any[]): any;
|
|
|
249
249
|
* ```
|
|
250
250
|
*/
|
|
251
251
|
declare function callVariadicFunction(name: string, ...rest: any[]): any;
|
|
252
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Utilities for pumping the macOS CFRunLoop from a Node.js/Bun event loop.
|
|
254
|
+
*
|
|
255
|
+
* Required for async Objective-C callbacks (completion handlers, AppKit events, etc.)
|
|
256
|
+
* to be delivered, since Node.js/Bun don't automatically pump the CFRunLoop.
|
|
257
|
+
*
|
|
258
|
+
* Usage:
|
|
259
|
+
* RunLoop.run(); // Start pumping (returns a cleanup function)
|
|
260
|
+
* RunLoop.pump(); // Pump once (non-blocking)
|
|
261
|
+
*/
|
|
262
|
+
declare const RunLoop: {
|
|
263
|
+
_timer: ReturnType<typeof setInterval> | null;
|
|
264
|
+
_mainRunLoop: any;
|
|
265
|
+
_defaultMode: any;
|
|
266
|
+
_NSDate: any;
|
|
267
|
+
/**
|
|
268
|
+
* Lazily initialise NSRunLoop and NSDefaultRunLoopMode references.
|
|
269
|
+
* Foundation is already loaded by the time any ObjC work happens, so
|
|
270
|
+
* GetClassObject will succeed without an explicit LoadLibrary call.
|
|
271
|
+
*
|
|
272
|
+
* We use proxy-wrapped NobjcObjects (via wrapObjCObjectIfNeeded) rather
|
|
273
|
+
* than raw native ObjcObjects + $msgSend, because Bun's N-API crashes
|
|
274
|
+
* when CFRunLoopRunInMode is triggered from the $msgSend C++ path.
|
|
275
|
+
* The proxy path ($prepareSend + $msgSendPrepared) works on both runtimes.
|
|
276
|
+
*/
|
|
277
|
+
_ensureRunLoop(): void;
|
|
278
|
+
/**
|
|
279
|
+
* Pump the run loop once. Processes any pending run loop sources
|
|
280
|
+
* (AppKit events, dispatch_async to main queue, timers, etc.)
|
|
281
|
+
* without blocking.
|
|
282
|
+
*
|
|
283
|
+
* @param timeout Optional timeout in seconds (default: 0)
|
|
284
|
+
* @returns true if a source was processed
|
|
285
|
+
*/
|
|
286
|
+
pump(timeout?: number): boolean;
|
|
287
|
+
/**
|
|
288
|
+
* Start continuously pumping the run loop on a regular interval.
|
|
289
|
+
* This enables async Objective-C callbacks to be delivered.
|
|
290
|
+
*
|
|
291
|
+
* @param intervalMs Pump interval in milliseconds (default: 10)
|
|
292
|
+
* @returns A cleanup function that stops pumping
|
|
293
|
+
*/
|
|
294
|
+
run(intervalMs?: number): () => void;
|
|
295
|
+
/**
|
|
296
|
+
* Stop pumping the run loop.
|
|
297
|
+
*/
|
|
298
|
+
stop(): void;
|
|
299
|
+
};
|
|
300
|
+
export { NobjcLibrary, NobjcObject, NobjcMethod, NobjcProtocol, NobjcClass, RunLoop, getPointer, fromPointer, callFunction, callVariadicFunction };
|
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
|
|
@@ -590,4 +605,96 @@ function callVariadicFunction(name, ...rest) {
|
|
|
590
605
|
const result = CallFunction(name, returnType, argTypes, fixedArgCount, ...args);
|
|
591
606
|
return wrapObjCObjectIfNeeded(result);
|
|
592
607
|
}
|
|
593
|
-
|
|
608
|
+
/**
|
|
609
|
+
* Utilities for pumping the macOS CFRunLoop from a Node.js/Bun event loop.
|
|
610
|
+
*
|
|
611
|
+
* Required for async Objective-C callbacks (completion handlers, AppKit events, etc.)
|
|
612
|
+
* to be delivered, since Node.js/Bun don't automatically pump the CFRunLoop.
|
|
613
|
+
*
|
|
614
|
+
* Usage:
|
|
615
|
+
* RunLoop.run(); // Start pumping (returns a cleanup function)
|
|
616
|
+
* RunLoop.pump(); // Pump once (non-blocking)
|
|
617
|
+
*/
|
|
618
|
+
const RunLoop = {
|
|
619
|
+
_timer: null,
|
|
620
|
+
_mainRunLoop: null,
|
|
621
|
+
_defaultMode: null,
|
|
622
|
+
_NSDate: null,
|
|
623
|
+
/**
|
|
624
|
+
* Lazily initialise NSRunLoop and NSDefaultRunLoopMode references.
|
|
625
|
+
* Foundation is already loaded by the time any ObjC work happens, so
|
|
626
|
+
* GetClassObject will succeed without an explicit LoadLibrary call.
|
|
627
|
+
*
|
|
628
|
+
* We use proxy-wrapped NobjcObjects (via wrapObjCObjectIfNeeded) rather
|
|
629
|
+
* than raw native ObjcObjects + $msgSend, because Bun's N-API crashes
|
|
630
|
+
* when CFRunLoopRunInMode is triggered from the $msgSend C++ path.
|
|
631
|
+
* The proxy path ($prepareSend + $msgSendPrepared) works on both runtimes.
|
|
632
|
+
*/
|
|
633
|
+
_ensureRunLoop() {
|
|
634
|
+
if (this._mainRunLoop === null) {
|
|
635
|
+
// Ensure Foundation is loaded — NobjcLibrary uses lazy loading,
|
|
636
|
+
// so Foundation may not be loaded yet if no class has been accessed.
|
|
637
|
+
LoadLibrary("/System/Library/Frameworks/Foundation.framework/Foundation");
|
|
638
|
+
const NSRunLoopRaw = GetClassObject("NSRunLoop");
|
|
639
|
+
if (!NSRunLoopRaw) {
|
|
640
|
+
throw new Error("Foundation framework is not loaded. Create a NobjcLibrary for Foundation before using RunLoop.");
|
|
641
|
+
}
|
|
642
|
+
const NSRunLoop = wrapObjCObjectIfNeeded(NSRunLoopRaw);
|
|
643
|
+
this._mainRunLoop = NSRunLoop.mainRunLoop();
|
|
644
|
+
const NSStringRaw = GetClassObject("NSString");
|
|
645
|
+
const NSString = wrapObjCObjectIfNeeded(NSStringRaw);
|
|
646
|
+
this._defaultMode = NSString.stringWithUTF8String$("kCFRunLoopDefaultMode");
|
|
647
|
+
const NSDateRaw = GetClassObject("NSDate");
|
|
648
|
+
this._NSDate = wrapObjCObjectIfNeeded(NSDateRaw);
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
/**
|
|
652
|
+
* Pump the run loop once. Processes any pending run loop sources
|
|
653
|
+
* (AppKit events, dispatch_async to main queue, timers, etc.)
|
|
654
|
+
* without blocking.
|
|
655
|
+
*
|
|
656
|
+
* @param timeout Optional timeout in seconds (default: 0)
|
|
657
|
+
* @returns true if a source was processed
|
|
658
|
+
*/
|
|
659
|
+
pump(timeout) {
|
|
660
|
+
this._ensureRunLoop();
|
|
661
|
+
const limitDate = this._NSDate.dateWithTimeIntervalSinceNow$(timeout ?? 0);
|
|
662
|
+
const handled = this._mainRunLoop.runMode$beforeDate$(this._defaultMode, limitDate);
|
|
663
|
+
return !!handled;
|
|
664
|
+
},
|
|
665
|
+
/**
|
|
666
|
+
* Start continuously pumping the run loop on a regular interval.
|
|
667
|
+
* This enables async Objective-C callbacks to be delivered.
|
|
668
|
+
*
|
|
669
|
+
* @param intervalMs Pump interval in milliseconds (default: 10)
|
|
670
|
+
* @returns A cleanup function that stops pumping
|
|
671
|
+
*/
|
|
672
|
+
run(intervalMs = 10) {
|
|
673
|
+
if (this._timer !== null) {
|
|
674
|
+
clearInterval(this._timer);
|
|
675
|
+
}
|
|
676
|
+
// Eagerly initialise so the first interval tick is cheap
|
|
677
|
+
this._ensureRunLoop();
|
|
678
|
+
this._timer = setInterval(() => {
|
|
679
|
+
const limitDate = this._NSDate.dateWithTimeIntervalSinceNow$(0);
|
|
680
|
+
this._mainRunLoop.runMode$beforeDate$(this._defaultMode, limitDate);
|
|
681
|
+
}, intervalMs);
|
|
682
|
+
// Unref the timer so it doesn't prevent the process from exiting
|
|
683
|
+
// when there are no other active handles
|
|
684
|
+
if (this._timer && typeof this._timer === "object" && "unref" in this._timer) {
|
|
685
|
+
this._timer.unref();
|
|
686
|
+
}
|
|
687
|
+
const stop = () => this.stop();
|
|
688
|
+
return stop;
|
|
689
|
+
},
|
|
690
|
+
/**
|
|
691
|
+
* Stop pumping the run loop.
|
|
692
|
+
*/
|
|
693
|
+
stop() {
|
|
694
|
+
if (this._timer !== null) {
|
|
695
|
+
clearInterval(this._timer);
|
|
696
|
+
this._timer = null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
export { NobjcLibrary, NobjcObject, NobjcMethod, NobjcProtocol, NobjcClass, RunLoop, getPointer, fromPointer, callFunction, callVariadicFunction };
|
package/dist/native.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import * as _binding from "#nobjc_native";
|
|
2
|
-
declare const LoadLibrary: typeof _binding.LoadLibrary, GetClassObject: typeof _binding.GetClassObject, ObjcObject: typeof _binding.ObjcObject, GetPointer: typeof _binding.GetPointer, FromPointer: typeof _binding.FromPointer, CreateProtocolImplementation: typeof _binding.CreateProtocolImplementation, DefineClass: typeof _binding.DefineClass, CallSuper: typeof _binding.CallSuper, CallFunction: typeof _binding.CallFunction;
|
|
3
|
-
export { LoadLibrary, GetClassObject, ObjcObject, GetPointer, FromPointer, CreateProtocolImplementation, DefineClass, CallSuper, CallFunction };
|
|
2
|
+
declare const LoadLibrary: typeof _binding.LoadLibrary, GetClassObject: typeof _binding.GetClassObject, ObjcObject: typeof _binding.ObjcObject, GetPointer: typeof _binding.GetPointer, FromPointer: typeof _binding.FromPointer, CreateProtocolImplementation: typeof _binding.CreateProtocolImplementation, DefineClass: typeof _binding.DefineClass, CallSuper: typeof _binding.CallSuper, CallFunction: typeof _binding.CallFunction, PumpRunLoop: typeof _binding.PumpRunLoop;
|
|
3
|
+
export { LoadLibrary, GetClassObject, ObjcObject, GetPointer, FromPointer, CreateProtocolImplementation, DefineClass, CallSuper, CallFunction, PumpRunLoop };
|
|
4
4
|
export type { _binding as NobjcNative };
|
package/dist/native.js
CHANGED
|
@@ -5,5 +5,5 @@ const require = createRequire(import.meta.url);
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = dirname(__filename);
|
|
7
7
|
const binding = require("node-gyp-build")(join(__dirname, ".."));
|
|
8
|
-
const { LoadLibrary, GetClassObject, ObjcObject, GetPointer, FromPointer, CreateProtocolImplementation, DefineClass, CallSuper, CallFunction } = binding;
|
|
9
|
-
export { LoadLibrary, GetClassObject, ObjcObject, GetPointer, FromPointer, CreateProtocolImplementation, DefineClass, CallSuper, CallFunction };
|
|
8
|
+
const { LoadLibrary, GetClassObject, ObjcObject, GetPointer, FromPointer, CreateProtocolImplementation, DefineClass, CallSuper, CallFunction, PumpRunLoop } = binding;
|
|
9
|
+
export { LoadLibrary, GetClassObject, ObjcObject, GetPointer, FromPointer, CreateProtocolImplementation, DefineClass, CallSuper, CallFunction, PumpRunLoop };
|
package/package.json
CHANGED
|
@@ -31,13 +31,14 @@
|
|
|
31
31
|
"build": "npm run build-native && npm run build-scripts && npm run build-source",
|
|
32
32
|
"pretest": "npm run build",
|
|
33
33
|
"test": "bun run test:bun",
|
|
34
|
-
"test:bun": "bun
|
|
35
|
-
"test:node": "
|
|
34
|
+
"test:bun": "bun test",
|
|
35
|
+
"test:node": "npx vitest run",
|
|
36
36
|
"test:native": "bun test tests/test-native-code.test.ts",
|
|
37
37
|
"test:js": "bun test tests/test-js-code.test.ts",
|
|
38
38
|
"test:string-lifetime": "bun test tests/test-string-lifetime.test.ts",
|
|
39
39
|
"test:object-arguments": "bun test tests/test-object-arguments.test.ts",
|
|
40
40
|
"test:protocol-implementation": "bun test tests/test-protocol-implementation.test.ts",
|
|
41
|
+
"test:run-loop": "bun test tests/test-run-loop.test.ts",
|
|
41
42
|
"bench": "bun run build && bun run benchmarks/bench.ts",
|
|
42
43
|
"make-clangd-config": "node ./scripts/make-clangd-config.js",
|
|
43
44
|
"format": "prettier --write \"**/*.{ts,js,json,md}\"",
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
"prebuild:x64": "prebuildify --napi --strip --arch x64 -r node",
|
|
47
48
|
"prebuild:all": "bun run prebuild:arm64 && bun run prebuild:x64"
|
|
48
49
|
},
|
|
49
|
-
"version": "1.
|
|
50
|
+
"version": "1.4.0",
|
|
50
51
|
"description": "Objective-C bridge for Node.js",
|
|
51
52
|
"main": "dist/index.js",
|
|
52
53
|
"dependencies": {
|
|
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
|
@@ -442,10 +442,19 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
|
|
|
442
442
|
constexpr size_t kSmallArgCount = 4;
|
|
443
443
|
ObjcType smallArgBuf[kSmallArgCount];
|
|
444
444
|
std::vector<ObjcType> heapArgBuf;
|
|
445
|
+
std::vector<id> createdBlocks;
|
|
445
446
|
const bool useHeap = expectedArgCount > kSmallArgCount;
|
|
446
447
|
if (useHeap) {
|
|
447
448
|
heapArgBuf.reserve(expectedArgCount);
|
|
448
449
|
}
|
|
450
|
+
createdBlocks.reserve(expectedArgCount);
|
|
451
|
+
[[maybe_unused]] auto releaseCreatedBlocks = MakeScopeGuard([&createdBlocks] {
|
|
452
|
+
for (id block : createdBlocks) {
|
|
453
|
+
if (block != nil) {
|
|
454
|
+
_Block_release(block);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
});
|
|
449
458
|
|
|
450
459
|
// Store struct argument buffers to keep them alive until after invoke.
|
|
451
460
|
std::vector<std::vector<uint8_t>> structBuffers;
|
|
@@ -492,6 +501,7 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
|
|
|
492
501
|
id block = CreateBlockFromJSFunction(env, info[i], blockEncoding);
|
|
493
502
|
if (env.IsExceptionPending()) return env.Null();
|
|
494
503
|
[invocation setArgument:&block atIndex:i + 1];
|
|
504
|
+
createdBlocks.push_back(block);
|
|
495
505
|
// Store block as id in arg buffer to keep it alive until after invoke
|
|
496
506
|
if (useHeap) {
|
|
497
507
|
heapArgBuf.push_back(BaseObjcType{block});
|
|
@@ -735,10 +745,19 @@ Napi::Value ObjcObject::$MsgSendPrepared(const Napi::CallbackInfo &info) {
|
|
|
735
745
|
constexpr size_t kSmallArgCount = 4;
|
|
736
746
|
ObjcType smallArgBuf[kSmallArgCount];
|
|
737
747
|
std::vector<ObjcType> heapArgBuf;
|
|
748
|
+
std::vector<id> createdBlocks;
|
|
738
749
|
const bool useHeap = prepared->expectedArgCount > kSmallArgCount;
|
|
739
750
|
if (useHeap) {
|
|
740
751
|
heapArgBuf.reserve(prepared->expectedArgCount);
|
|
741
752
|
}
|
|
753
|
+
createdBlocks.reserve(prepared->expectedArgCount);
|
|
754
|
+
[[maybe_unused]] auto releaseCreatedBlocks = MakeScopeGuard([&createdBlocks] {
|
|
755
|
+
for (id block : createdBlocks) {
|
|
756
|
+
if (block != nil) {
|
|
757
|
+
_Block_release(block);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
});
|
|
742
761
|
|
|
743
762
|
std::vector<std::vector<uint8_t>> structBuffers;
|
|
744
763
|
|
|
@@ -779,6 +798,7 @@ Napi::Value ObjcObject::$MsgSendPrepared(const Napi::CallbackInfo &info) {
|
|
|
779
798
|
id block = CreateBlockFromJSFunction(env, info[jsArgIdx], blockEncoding);
|
|
780
799
|
if (env.IsExceptionPending()) return env.Null();
|
|
781
800
|
[invocation setArgument:&block atIndex:i + 2];
|
|
801
|
+
createdBlocks.push_back(block);
|
|
782
802
|
if (useHeap) {
|
|
783
803
|
heapArgBuf.push_back(BaseObjcType{block});
|
|
784
804
|
} else {
|
|
@@ -822,4 +842,4 @@ Napi::Value ObjcObject::$MsgSendPrepared(const Napi::CallbackInfo &info) {
|
|
|
822
842
|
}
|
|
823
843
|
|
|
824
844
|
return ConvertReturnValueToJSValue(env, invocation, prepared->methodSignature);
|
|
825
|
-
}
|
|
845
|
+
}
|
package/src/native/nobjc.mm
CHANGED
|
@@ -79,6 +79,30 @@ Napi::Value FromPointer(const Napi::CallbackInfo &info) {
|
|
|
79
79
|
return ObjcObject::NewInstance(env, reinterpret_cast<id>(ptr));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
Napi::Value PumpRunLoop(const Napi::CallbackInfo &info) {
|
|
83
|
+
Napi::Env env = info.Env();
|
|
84
|
+
|
|
85
|
+
// Default timeout
|
|
86
|
+
NSTimeInterval timeout = 0.0; // Don't block — just process pending sources
|
|
87
|
+
|
|
88
|
+
// Optional: accept a timeout in seconds as the first argument
|
|
89
|
+
if (info.Length() >= 1 && info[0].IsNumber()) {
|
|
90
|
+
timeout = info[0].As<Napi::Number>().DoubleValue();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Pump the main run loop via NSRunLoop API.
|
|
94
|
+
// We use NSRunLoop instead of CFRunLoopRunInMode because the CF function
|
|
95
|
+
// crashes under Bun's N-API implementation (segfault in the CF call).
|
|
96
|
+
// NSRunLoop.runMode:beforeDate: is functionally equivalent and works
|
|
97
|
+
// correctly in both Node.js and Bun.
|
|
98
|
+
@autoreleasepool {
|
|
99
|
+
NSRunLoop *mainLoop = [NSRunLoop mainRunLoop];
|
|
100
|
+
NSDate *limitDate = [NSDate dateWithTimeIntervalSinceNow:timeout];
|
|
101
|
+
BOOL handled = [mainLoop runMode:NSDefaultRunLoopMode beforeDate:limitDate];
|
|
102
|
+
return Napi::Boolean::New(env, handled);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
Napi::Object InitAll(Napi::Env env, Napi::Object exports) {
|
|
83
107
|
ObjcObject::Init(env, exports);
|
|
84
108
|
exports.Set("LoadLibrary", Napi::Function::New(env, LoadLibrary));
|
|
@@ -90,6 +114,7 @@ Napi::Object InitAll(Napi::Env env, Napi::Object exports) {
|
|
|
90
114
|
exports.Set("DefineClass", Napi::Function::New(env, DefineClass));
|
|
91
115
|
exports.Set("CallSuper", Napi::Function::New(env, CallSuper));
|
|
92
116
|
exports.Set("CallFunction", Napi::Function::New(env, CallFunction));
|
|
117
|
+
exports.Set("PumpRunLoop", Napi::Function::New(env, PumpRunLoop));
|
|
93
118
|
return exports;
|
|
94
119
|
}
|
|
95
120
|
|
package/src/native/nobjc_block.h
CHANGED
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
#include "type-conversion.h"
|
|
38
38
|
#include "struct-utils.h"
|
|
39
39
|
#include "ffi-utils.h"
|
|
40
|
+
#include <Block.h>
|
|
40
41
|
#include <Foundation/Foundation.h>
|
|
42
|
+
#include <atomic>
|
|
41
43
|
#include <ffi.h>
|
|
42
44
|
#include <napi.h>
|
|
43
45
|
#include <objc/runtime.h>
|
|
@@ -51,10 +53,14 @@
|
|
|
51
53
|
|
|
52
54
|
// MARK: - Block ABI Structures
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
struct BlockInfo;
|
|
57
|
+
|
|
58
|
+
// Block ABI descriptor with copy/dispose helpers for BlockInfo lifetime.
|
|
55
59
|
struct NobjcBlockDescriptor {
|
|
56
60
|
unsigned long reserved; // Always 0
|
|
57
61
|
unsigned long size; // sizeof(NobjcBlockLiteral)
|
|
62
|
+
void (*copy_helper)(void *dst, const void *src);
|
|
63
|
+
void (*dispose_helper)(const void *src);
|
|
58
64
|
};
|
|
59
65
|
|
|
60
66
|
// Block ABI literal struct
|
|
@@ -65,6 +71,7 @@ struct NobjcBlockLiteral {
|
|
|
65
71
|
int reserved; // Always 0
|
|
66
72
|
void *invoke; // Function pointer (FFI closure)
|
|
67
73
|
NobjcBlockDescriptor *descriptor;
|
|
74
|
+
BlockInfo *blockInfo; // Captured state retained/released by the block runtime
|
|
68
75
|
};
|
|
69
76
|
|
|
70
77
|
// _NSConcreteStackBlock is declared in <Block.h> (included via Foundation)
|
|
@@ -254,7 +261,7 @@ inline std::string GetExtendedBlockEncoding(Class cls, SEL selector, size_t argI
|
|
|
254
261
|
* BlockInfo holds all state for a single JS-function-backed block.
|
|
255
262
|
* It owns the FFI closure, CIF, arg types, JS function reference, and TSFN.
|
|
256
263
|
*
|
|
257
|
-
*
|
|
264
|
+
* Lifetime is tied to the Objective-C block copies plus any in-flight callbacks.
|
|
258
265
|
*/
|
|
259
266
|
struct BlockInfo {
|
|
260
267
|
// FFI closure and CIF
|
|
@@ -290,17 +297,22 @@ struct BlockInfo {
|
|
|
290
297
|
|
|
291
298
|
// The heap-copied block (after _Block_copy)
|
|
292
299
|
// Stored as void* since ARC is not enabled for .mm files in this project.
|
|
293
|
-
// The block is never freed in v1 (stored in global registry).
|
|
294
300
|
void *heapBlock;
|
|
295
301
|
|
|
302
|
+
// One ref for the initially created block copy, plus one for each additional
|
|
303
|
+
// Objective-C block copy, plus one for each in-flight invocation.
|
|
304
|
+
std::atomic<size_t> refCount{1};
|
|
305
|
+
|
|
306
|
+
// Ensures we only release the TSFN's initial ref once.
|
|
307
|
+
std::atomic<bool> cleanupScheduled{false};
|
|
308
|
+
|
|
296
309
|
~BlockInfo() {
|
|
297
310
|
// Free the FFI closure
|
|
298
311
|
if (closure) {
|
|
299
312
|
ffi_closure_free(closure);
|
|
300
313
|
closure = nullptr;
|
|
301
314
|
}
|
|
302
|
-
|
|
303
|
-
// In v1, BlockInfo is never destroyed, so this is moot.
|
|
315
|
+
jsFunction.Reset();
|
|
304
316
|
}
|
|
305
317
|
};
|
|
306
318
|
|
|
@@ -321,15 +333,59 @@ struct BlockCallData {
|
|
|
321
333
|
bool isComplete;
|
|
322
334
|
};
|
|
323
335
|
|
|
324
|
-
// MARK: -
|
|
336
|
+
// MARK: - Block Lifetime Management
|
|
325
337
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
338
|
+
constexpr int NOBJC_BLOCK_HAS_COPY_DISPOSE = (1 << 25);
|
|
339
|
+
|
|
340
|
+
inline void RetainBlockInfo(BlockInfo *info) {
|
|
341
|
+
if (!info) return;
|
|
342
|
+
info->refCount.fetch_add(1, std::memory_order_relaxed);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
inline void BlockTSFNFinalize(Napi::Env /*env*/, BlockInfo *info,
|
|
346
|
+
BlockInfo * /*data*/) {
|
|
347
|
+
delete info;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
inline void ScheduleBlockInfoCleanup(BlockInfo *info) {
|
|
351
|
+
if (!info) return;
|
|
352
|
+
|
|
353
|
+
bool expected = false;
|
|
354
|
+
if (!info->cleanupScheduled.compare_exchange_strong(
|
|
355
|
+
expected, true, std::memory_order_acq_rel)) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
napi_status status = info->tsfn.Release();
|
|
360
|
+
if (status != napi_ok) {
|
|
361
|
+
NOBJC_ERROR("ScheduleBlockInfoCleanup: TSFN release failed (status=%d)", status);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
inline void ReleaseBlockInfo(BlockInfo *info) {
|
|
366
|
+
if (!info) return;
|
|
367
|
+
|
|
368
|
+
size_t previous = info->refCount.fetch_sub(1, std::memory_order_acq_rel);
|
|
369
|
+
if (previous == 0) {
|
|
370
|
+
NOBJC_ERROR("ReleaseBlockInfo: refcount underflow");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (previous == 1) {
|
|
374
|
+
ScheduleBlockInfoCleanup(info);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
inline void NobjcBlockCopyHelper(void *dst, const void *src) {
|
|
379
|
+
auto *dstBlock = static_cast<NobjcBlockLiteral *>(dst);
|
|
380
|
+
auto *srcBlock = static_cast<const NobjcBlockLiteral *>(src);
|
|
381
|
+
dstBlock->blockInfo = srcBlock->blockInfo;
|
|
382
|
+
RetainBlockInfo(dstBlock->blockInfo);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
inline void NobjcBlockDisposeHelper(const void *src) {
|
|
386
|
+
auto *block = static_cast<const NobjcBlockLiteral *>(src);
|
|
387
|
+
ReleaseBlockInfo(block->blockInfo);
|
|
388
|
+
}
|
|
333
389
|
|
|
334
390
|
// MARK: - Block Argument Conversion (ObjC → JS)
|
|
335
391
|
|
|
@@ -547,6 +603,8 @@ inline void BlockTSFNCallback(Napi::Env env, Napi::Function /*jsCallback*/,
|
|
|
547
603
|
callData->isComplete = true;
|
|
548
604
|
callData->completionCv.notify_one();
|
|
549
605
|
}
|
|
606
|
+
|
|
607
|
+
ReleaseBlockInfo(info);
|
|
550
608
|
}
|
|
551
609
|
|
|
552
610
|
// MARK: - FFI Closure Callback (Block Invoke)
|
|
@@ -568,6 +626,7 @@ inline void BlockInvokeCallback(ffi_cif *cif, void *ret, void **args,
|
|
|
568
626
|
return;
|
|
569
627
|
}
|
|
570
628
|
|
|
629
|
+
RetainBlockInfo(info);
|
|
571
630
|
bool is_js_thread = pthread_equal(pthread_self(), info->js_thread);
|
|
572
631
|
|
|
573
632
|
if (is_js_thread) {
|
|
@@ -603,6 +662,7 @@ inline void BlockInvokeCallback(ffi_cif *cif, void *ret, void **args,
|
|
|
603
662
|
NOBJC_ERROR("BlockInvokeCallback: unknown exception");
|
|
604
663
|
}
|
|
605
664
|
}
|
|
665
|
+
ReleaseBlockInfo(info);
|
|
606
666
|
} else {
|
|
607
667
|
// Cross-thread call via TSFN
|
|
608
668
|
BlockCallData callData;
|
|
@@ -621,6 +681,7 @@ inline void BlockInvokeCallback(ffi_cif *cif, void *ret, void **args,
|
|
|
621
681
|
napi_status acq_status = info->tsfn.Acquire();
|
|
622
682
|
if (acq_status != napi_ok) {
|
|
623
683
|
NOBJC_ERROR("BlockInvokeCallback: Failed to acquire TSFN");
|
|
684
|
+
ReleaseBlockInfo(info);
|
|
624
685
|
return;
|
|
625
686
|
}
|
|
626
687
|
|
|
@@ -630,6 +691,7 @@ inline void BlockInvokeCallback(ffi_cif *cif, void *ret, void **args,
|
|
|
630
691
|
|
|
631
692
|
if (status != napi_ok) {
|
|
632
693
|
NOBJC_ERROR("BlockInvokeCallback: TSFN call failed (status=%d)", status);
|
|
694
|
+
ReleaseBlockInfo(info);
|
|
633
695
|
return;
|
|
634
696
|
}
|
|
635
697
|
|
|
@@ -677,7 +739,7 @@ inline id CreateBlockFromJSFunction(Napi::Env env,
|
|
|
677
739
|
}
|
|
678
740
|
|
|
679
741
|
// Create BlockInfo
|
|
680
|
-
auto blockInfo =
|
|
742
|
+
auto *blockInfo = new BlockInfo();
|
|
681
743
|
blockInfo->signature = sig;
|
|
682
744
|
blockInfo->env = env;
|
|
683
745
|
blockInfo->js_thread = pthread_self();
|
|
@@ -688,8 +750,15 @@ inline id CreateBlockFromJSFunction(Napi::Env env,
|
|
|
688
750
|
blockInfo->jsFunction = Napi::Persistent(jsFunction.As<Napi::Function>());
|
|
689
751
|
|
|
690
752
|
// Create TSFN for cross-thread calls
|
|
691
|
-
blockInfo->tsfn =
|
|
692
|
-
|
|
753
|
+
blockInfo->tsfn = Napi::ThreadSafeFunction::New(
|
|
754
|
+
env,
|
|
755
|
+
jsFunction.As<Napi::Function>(),
|
|
756
|
+
"nobjc_block_tsfn",
|
|
757
|
+
0,
|
|
758
|
+
1,
|
|
759
|
+
blockInfo,
|
|
760
|
+
BlockTSFNFinalize,
|
|
761
|
+
blockInfo);
|
|
693
762
|
|
|
694
763
|
// Build FFI types for the block invocation
|
|
695
764
|
// Block invoke signature: returnType (blockSelf, param1, param2, ...)
|
|
@@ -731,6 +800,7 @@ inline id CreateBlockFromJSFunction(Napi::Env env,
|
|
|
731
800
|
if (!blockInfo->closure || !codePtr) {
|
|
732
801
|
Napi::Error::New(env, "Failed to allocate FFI closure for block")
|
|
733
802
|
.ThrowAsJavaScriptException();
|
|
803
|
+
ReleaseBlockInfo(blockInfo);
|
|
734
804
|
return nil;
|
|
735
805
|
}
|
|
736
806
|
|
|
@@ -743,10 +813,9 @@ inline id CreateBlockFromJSFunction(Napi::Env env,
|
|
|
743
813
|
blockInfo->argFFIPtrs.data());
|
|
744
814
|
|
|
745
815
|
if (ffiStatus != FFI_OK) {
|
|
746
|
-
ffi_closure_free(blockInfo->closure);
|
|
747
|
-
blockInfo->closure = nullptr;
|
|
748
816
|
Napi::Error::New(env, "ffi_prep_cif failed for block")
|
|
749
817
|
.ThrowAsJavaScriptException();
|
|
818
|
+
ReleaseBlockInfo(blockInfo);
|
|
750
819
|
return nil;
|
|
751
820
|
}
|
|
752
821
|
|
|
@@ -755,32 +824,35 @@ inline id CreateBlockFromJSFunction(Napi::Env env,
|
|
|
755
824
|
blockInfo->closure,
|
|
756
825
|
&blockInfo->cif,
|
|
757
826
|
BlockInvokeCallback,
|
|
758
|
-
blockInfo
|
|
827
|
+
blockInfo, // userdata = BlockInfo*
|
|
759
828
|
codePtr);
|
|
760
829
|
|
|
761
830
|
if (ffiStatus != FFI_OK) {
|
|
762
|
-
ffi_closure_free(blockInfo->closure);
|
|
763
|
-
blockInfo->closure = nullptr;
|
|
764
831
|
Napi::Error::New(env, "ffi_prep_closure_loc failed for block")
|
|
765
832
|
.ThrowAsJavaScriptException();
|
|
833
|
+
ReleaseBlockInfo(blockInfo);
|
|
766
834
|
return nil;
|
|
767
835
|
}
|
|
768
836
|
|
|
769
837
|
// Build the block literal (stack block)
|
|
770
838
|
blockInfo->descriptor.reserved = 0;
|
|
771
839
|
blockInfo->descriptor.size = sizeof(NobjcBlockLiteral);
|
|
840
|
+
blockInfo->descriptor.copy_helper = NobjcBlockCopyHelper;
|
|
841
|
+
blockInfo->descriptor.dispose_helper = NobjcBlockDisposeHelper;
|
|
772
842
|
|
|
773
843
|
blockInfo->blockLiteral.isa = _NSConcreteStackBlock;
|
|
774
|
-
blockInfo->blockLiteral.flags =
|
|
844
|
+
blockInfo->blockLiteral.flags = NOBJC_BLOCK_HAS_COPY_DISPOSE;
|
|
775
845
|
blockInfo->blockLiteral.reserved = 0;
|
|
776
846
|
blockInfo->blockLiteral.invoke = codePtr;
|
|
777
847
|
blockInfo->blockLiteral.descriptor = &blockInfo->descriptor;
|
|
848
|
+
blockInfo->blockLiteral.blockInfo = blockInfo;
|
|
778
849
|
|
|
779
850
|
// Copy to heap via _Block_copy
|
|
780
851
|
void *heapBlockPtr = _Block_copy(&blockInfo->blockLiteral);
|
|
781
852
|
if (!heapBlockPtr) {
|
|
782
853
|
Napi::Error::New(env, "_Block_copy failed")
|
|
783
854
|
.ThrowAsJavaScriptException();
|
|
855
|
+
ReleaseBlockInfo(blockInfo);
|
|
784
856
|
return nil;
|
|
785
857
|
}
|
|
786
858
|
|
|
@@ -788,12 +860,7 @@ inline id CreateBlockFromJSFunction(Napi::Env env,
|
|
|
788
860
|
blockInfo->heapBlock = heapBlockPtr;
|
|
789
861
|
|
|
790
862
|
id result = (id)blockInfo->heapBlock;
|
|
791
|
-
|
|
792
|
-
// Store in global registry (never freed in v1)
|
|
793
|
-
{
|
|
794
|
-
std::lock_guard<std::mutex> lock(g_blockRegistryMutex);
|
|
795
|
-
g_blockRegistry.push_back(std::move(blockInfo));
|
|
796
|
-
}
|
|
863
|
+
ReleaseBlockInfo(blockInfo);
|
|
797
864
|
|
|
798
865
|
NOBJC_LOG("CreateBlockFromJSFunction: created block %p", result);
|
|
799
866
|
return result;
|