From 1a6e166101216a344c1be663bc5eb900aedb20f2 Mon Sep 17 00:00:00 2001 From: markm39 Date: Fri, 2 Oct 2026 16:21:10 -0500 Subject: [PATCH] fix(ios): never lay out the selection toolbar from non-finite bounds CGRect.width reports the standardized width, so inverted or overflowing selection bounds passed the existing > 0 guard while the rect's origin and midpoints were infinite or NaN, and setting the toolbar frame raised CALayerInvalidGeometry (OpenNotes 1.2 App Store crash group). Reject non-finite rects. Also derive the podspec version from package.json so the CocoaPods version and tag never drift. Release 0.3.6. --- CHANGELOG.md | 5 ++ MathNotesMobileInk.podspec | 6 +- ios/MobileInkModule/MobileInkCanvasView.swift | 63 ++++++------------- package-lock.json | 4 +- package.json | 2 +- 5 files changed, 33 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 255ac13..1412925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `@mathnotes/mobile-ink` will be documented here. +## [0.3.6] - 2026-10-02 + +- iOS: selection hit-testing, the selection toolbar and `onInkSelectionChange` now share one bounds helper that rejects non-finite geometry; the event sends `null` bounds instead of NaN. The selection toolbar ignores selection bounds that are not finite. Inverted or overflowing bounds previously produced a NaN frame and terminated the app with `CALayerInvalidGeometry` (seen in production after a pooled page was cleared with a live selection, which 0.3.5 also fixes at the source). +- The podspec now reads its version from `package.json`, so the CocoaPods version and source tag always match the published package. + ## [0.3.5] - 2026-10-02 - Fixed a crash when deleting a selection after Undo, Redo or Clear. The selection kept stroke indices from before the history change, and deleting it underflowed a `size_t` and aborted the app with an uncaught `std::length_error`. diff --git a/MathNotesMobileInk.podspec b/MathNotesMobileInk.podspec index 2b23486..e7e08d3 100644 --- a/MathNotesMobileInk.podspec +++ b/MathNotesMobileInk.podspec @@ -1,6 +1,10 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + Pod::Spec.new do |s| s.name = "MathNotesMobileInk" - s.version = "0.3.2" + s.version = package["version"] s.summary = "Native Skia/Metal mobile ink engine for React Native" s.homepage = "https://github.com/mathnotes-app/mobile-ink" s.license = { :type => "Apache-2.0", :file => "LICENSE" } diff --git a/ios/MobileInkModule/MobileInkCanvasView.swift b/ios/MobileInkModule/MobileInkCanvasView.swift index 2939e7d..a97737e 100644 --- a/ios/MobileInkModule/MobileInkCanvasView.swift +++ b/ios/MobileInkModule/MobileInkCanvasView.swift @@ -537,7 +537,13 @@ class MobileInkCanvasView: MTKView { height: CGFloat(bounds[3] - bounds[1]) / scaleY ) - guard rect.width > 0, rect.height > 0 else { + // CGRect.width reports the standardized (absolute) width, so a rect + // built from inverted or overflowing bounds can pass a `> 0` check + // while its origin and midpoints are infinite or NaN. Setting such a + // frame raises CALayerInvalidGeometry and terminates the app. + guard rect.width > 0, rect.height > 0, + rect.minX.isFinite, rect.minY.isFinite, + rect.maxX.isFinite, rect.maxY.isFinite else { return nil } @@ -588,36 +594,20 @@ class MobileInkCanvasView: MTKView { } private func selectionBoundsContain(_ location: CGPoint, engine: OpaquePointer, padding: CGFloat) -> Bool { - guard getSelectionCount(engine) > 0 else { + guard let selectionRect = selectionBoundsInView(engine: engine) else { return false } - - var bounds: [Float] = [0, 0, 0, 0] - getSelectionBounds(engine, &bounds) - let selectionRect = CGRect( - x: CGFloat(bounds[0]) / scaleX - padding, - y: CGFloat(bounds[1]) / scaleY - padding, - width: CGFloat(bounds[2] - bounds[0]) / scaleX + padding * 2, - height: CGFloat(bounds[3] - bounds[1]) / scaleY + padding * 2 - ) - - return selectionRect.contains(location) + return selectionRect.insetBy(dx: -padding, dy: -padding).contains(location) } private func selectionHandleHitTest(_ location: CGPoint, engine: OpaquePointer) -> Int32? { - guard getSelectionCount(engine) > 0 else { - return nil - } - - var bounds: [Float] = [0, 0, 0, 0] - getSelectionBounds(engine, &bounds) - let minX = CGFloat(bounds[0]) / scaleX - let minY = CGFloat(bounds[1]) / scaleY - let maxX = CGFloat(bounds[2]) / scaleX - let maxY = CGFloat(bounds[3]) / scaleY - guard maxX > minX, maxY > minY else { + guard let selectionRect = selectionBoundsInView(engine: engine) else { return nil } + let minX = selectionRect.minX + let minY = selectionRect.minY + let maxX = selectionRect.maxX + let maxY = selectionRect.maxY let centerX = (minX + maxX) * 0.5 let centerY = (minY + maxY) * 0.5 @@ -737,18 +727,7 @@ class MobileInkCanvasView: MTKView { cancelHoldToShapePreview() isHoldToShapeStrokeActive = false // Check if we're tapping inside an existing selection - let hasExistingSelection = getSelectionCount(engine) > 0 - var tappedInsideSelection = false - - if hasExistingSelection { - var bounds: [Float] = [0, 0, 0, 0] - getSelectionBounds(engine, &bounds) - let selectionRect = CGRect(x: CGFloat(bounds[0]) / scaleX, - y: CGFloat(bounds[1]) / scaleY, - width: CGFloat(bounds[2] - bounds[0]) / scaleX, - height: CGFloat(bounds[3] - bounds[1]) / scaleY) - tappedInsideSelection = selectionRect.contains(location) - } + let tappedInsideSelection = selectionBoundsInView(engine: engine)?.contains(location) ?? false if tappedInsideSelection { // Start moving the existing selection @@ -1519,14 +1498,12 @@ class MobileInkCanvasView: MTKView { guard let engine = drawingEngine else { return } let count = Int(getSelectionCount(engine)) var payload: [String: Any] = ["count": count] - if count > 0 { - var bounds: [Float] = [0, 0, 0, 0] - getSelectionBounds(engine, &bounds) + if count > 0, let selectionRect = selectionBoundsInView(engine: engine) { payload["bounds"] = [ - "x": CGFloat(bounds[0]) / scaleX, - "y": CGFloat(bounds[1]) / scaleY, - "width": CGFloat(bounds[2] - bounds[0]) / scaleX, - "height": CGFloat(bounds[3] - bounds[1]) / scaleY, + "x": selectionRect.minX, + "y": selectionRect.minY, + "width": selectionRect.width, + "height": selectionRect.height, ] } else { payload["bounds"] = NSNull() diff --git a/package-lock.json b/package-lock.json index 3915a00..2c80b20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mathnotes/mobile-ink", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mathnotes/mobile-ink", - "version": "0.3.5", + "version": "0.3.6", "license": "Apache-2.0", "devDependencies": { "@babel/core": "^7.25.2", diff --git a/package.json b/package.json index 3a57d9c..f9ec2e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mathnotes/mobile-ink", - "version": "0.3.5", + "version": "0.3.6", "description": "Production-grade React Native ink engine with native Skia drawing and continuous canvas primitives.", "license": "Apache-2.0", "author": "BuilderPro LLC",