objc-js 1.3.1 → 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 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
- export { NobjcLibrary, NobjcObject, NobjcMethod, NobjcProtocol, NobjcClass, getPointer, fromPointer, callFunction, callVariadicFunction };
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
@@ -605,4 +605,96 @@ function callVariadicFunction(name, ...rest) {
605
605
  const result = CallFunction(name, returnType, argTypes, fixedArgCount, ...args);
606
606
  return wrapObjCObjectIfNeeded(result);
607
607
  }
608
- export { NobjcLibrary, NobjcObject, NobjcMethod, NobjcProtocol, NobjcClass, getPointer, fromPointer, callFunction, callVariadicFunction };
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 run build && bun test",
35
- "test:node": "bun run build && npx vitest run",
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.3.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
@@ -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
+ }
@@ -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
 
@@ -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
- // Block ABI descriptor (minimum viable — no copy/dispose helpers)
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
- * Stored in a global registry for lifetime management (never freed in v1).
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
- // Note: tsfn and jsFunction cleanup is tricky across threads.
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: - Global Block Registry
336
+ // MARK: - Block Lifetime Management
325
337
 
326
- /**
327
- * Global registry of all created blocks.
328
- * Blocks are never freed in v1 — this prevents crashes from async callbacks
329
- * referencing freed memory.
330
- */
331
- static std::vector<std::unique_ptr<BlockInfo>> g_blockRegistry;
332
- static std::mutex g_blockRegistryMutex;
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 = std::make_unique<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 = CreateMethodTSFN(env, jsFunction.As<Napi::Function>(),
692
- "nobjc_block_tsfn");
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.get(), // userdata = 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 = (1 << 30); // BLOCK_HAS_SIGNATURE (not strictly needed but harmless)
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;