diff --git a/.github/workflows/scripts-mac-native.yml b/.github/workflows/scripts-mac-native.yml new file mode 100644 index 0000000000..70b2f61f85 --- /dev/null +++ b/.github/workflows/scripts-mac-native.yml @@ -0,0 +1,256 @@ +name: Test Mac native UI build scripts + +# Mac native = the macNative.enabled=true variant of the iOS build +# pipeline. IPhoneBuilder routes the generated project to +# target/-mac-source/ and injects Mac Catalyst settings +# (SUPPORTS_MACCATALYST, MACOSX_DEPLOYMENT_TARGET, signing/team via +# [sdk=macosx*] qualifiers, AppStore + Developer ID entitlements, +# ExportOptions plists, Mac.appiconset). This workflow exercises that +# path end-to-end: sample app -> Xcode project -> Mac Catalyst .app -> +# screenshot suite -> golden comparison. +# +# Mirrors .github/workflows/scripts-ios.yml's build-ios-metal job +# closely; the Mac slice shares the iOS port artifact (built by the +# reusable _build-ios-port.yml workflow) so cache hits across the three +# Mac/iOS workflows on the same SHA stay fast. + +on: + pull_request: + paths: + - '.github/workflows/scripts-mac-native.yml' + - '.github/workflows/_build-ios-port.yml' + - 'scripts/setup-workspace.sh' + - 'scripts/build-ios-port.sh' + - 'scripts/build-mac-native-app.sh' + - 'scripts/run-mac-native-ui-tests.sh' + - 'scripts/hellocodenameone/**' + - 'scripts/ios/tests/**' + - 'scripts/mac-native/**' + - 'scripts/templates/**' + - '!scripts/templates/**/*.md' + - 'scripts/common/java/**' + - 'scripts/lib/cn1ss.sh' + - 'CodenameOne/src/**' + - '!CodenameOne/src/**/*.md' + - 'Ports/iOSPort/**' + - '!Ports/iOSPort/**/*.md' + - 'native-themes/ios-modern/**' + - '!native-themes/ios-modern/**/*.md' + - 'vm/**' + - '!vm/**/*.md' + - 'tests/**' + - '!tests/**/*.md' + - 'maven/**' + - '!maven/core-unittests/**' + - '!docs/**' + push: + branches: [ master ] + paths: + - '.github/workflows/scripts-mac-native.yml' + - '.github/workflows/_build-ios-port.yml' + - 'scripts/setup-workspace.sh' + - 'scripts/build-ios-port.sh' + - 'scripts/build-mac-native-app.sh' + - 'scripts/run-mac-native-ui-tests.sh' + - 'scripts/hellocodenameone/**' + - 'scripts/ios/tests/**' + - 'scripts/mac-native/**' + - 'scripts/templates/**' + - '!scripts/templates/**/*.md' + - 'scripts/common/java/**' + - 'scripts/lib/cn1ss.sh' + - 'CodenameOne/src/**' + - 'Ports/iOSPort/**' + - 'native-themes/ios-modern/**' + - 'vm/**' + - 'tests/**' + - 'maven/**' + - '!maven/core-unittests/**' + workflow_dispatch: + +jobs: + build-port: + # Shared with scripts-ios.yml / scripts-ios-native.yml / ios-packaging.yml + # via the cn1-built cache; first runner to land a fresh SHA populates it + # and the others skip the rebuild. + uses: ./.github/workflows/_build-ios-port.yml + + build-mac-native: + needs: build-port + permissions: + contents: read + pull-requests: write + issues: write + runs-on: macos-15 + timeout-minutes: 45 + concurrency: + group: mac-ci-${{ github.workflow }}-mac-native-${{ github.ref_name }} + cancel-in-progress: true + + env: + GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + + steps: + - uses: actions/checkout@v4 + + - name: Cache CocoaPods and user gems + uses: actions/cache@v4 + with: + path: | + ~/.gem + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-v1-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-pods-v1- + + - name: Ensure CocoaPods / xcodeproj tooling + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + set -euo pipefail + if ! command -v ruby >/dev/null; then + echo "ruby not found"; exit 1 + fi + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + # The macNative path uses xcodeproj unconditionally to inject the + # Catalyst build settings (see applyMacNativeXcodeSettings in + # IPhoneBuilder.java). cocoapods comes along because the iOS + # pipeline shares the same gem cache key and we want one warm + # cache across both workflows. + if ! command -v pod >/dev/null 2>&1; then + gem install cocoapods xcodeproj --no-document --user-install + else + gem list xcodeproj | grep -q xcodeproj || gem install xcodeproj --no-document --user-install + fi + pod --version + ruby -e "require 'xcodeproj'; puts Xcodeproj::VERSION" + + - name: Compute setup-workspace hash + id: setup_hash + run: | + set -euo pipefail + echo "hash=$(shasum -a 256 scripts/setup-workspace.sh | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> $GITHUB_ENV + + - name: Cache codenameone-tools + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + + - name: Cache Maven repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Restore cn1-binaries cache + uses: actions/cache@v4 + with: + path: ../cn1-binaries + key: cn1-binaries-${{ runner.os }}-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + cn1-binaries-${{ runner.os }}- + + - name: Restore built CN1 + iOS port artifacts + # The build-port reusable workflow populates this cache; reuse its + # exact key (same trick scripts-ios.yml uses) to avoid recomputing + # the hash on this runner and producing a spurious miss. + uses: actions/cache/restore@v4 + with: + path: | + ~/.m2/repository/com/codenameone + Themes + Ports/iOSPort/nativeSources + key: ${{ needs.build-port.outputs.cn1_built_cache_key }} + fail-on-cache-miss: true + + - name: Install Metal Toolchain + # Xcode 26+ requires the Metal Toolchain component for .metal + # shader compilation. The Mac Catalyst slice always uses Metal + # (Mac Catalyst doesn't have OpenGL ES), so this download is + # required even though we're not setting ios.metal=true here. + run: | + set -euo pipefail + XCODE_APP="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)" + if [ ! -x "$XCODE_APP/Contents/Developer/usr/bin/xcodebuild" ]; then + echo "Xcode 26 not found under /Applications. Cannot install Metal Toolchain." >&2 + exit 1 + fi + echo "Using $XCODE_APP" + export DEVELOPER_DIR="$XCODE_APP/Contents/Developer" + "$DEVELOPER_DIR/usr/bin/xcodebuild" -downloadComponent MetalToolchain + timeout-minutes: 10 + + - name: Build sample Mac native app and compile workspace + id: build-mac-native-app + run: ./scripts/build-mac-native-app.sh -q -DskipTests + timeout-minutes: 30 + + - name: Run Mac native UI screenshot tests + env: + ARTIFACTS_DIR: ${{ github.workspace }}/artifacts/mac-native-ui-tests + run: | + set -euo pipefail + mkdir -p "${ARTIFACTS_DIR}" + + echo "workspace='${{ steps.build-mac-native-app.outputs.workspace }}'" + echo "scheme='${{ steps.build-mac-native-app.outputs.scheme }}'" + + ./scripts/run-mac-native-ui-tests.sh \ + "${{ steps.build-mac-native-app.outputs.workspace }}" \ + "" \ + "${{ steps.build-mac-native-app.outputs.scheme }}" + timeout-minutes: 30 + + - name: Publish Mac native screenshot summary + # Surfaces run-mac-native-ui-tests.sh's comparison result in the + # job's GitHub Actions summary page so the Mac slice status is + # visible at a glance without digging into the artifact zip. + # Reuses the existing metal-screenshot-summary.py helper because + # the JSON schema is identical -- the summary text says "iOS + # Metal" so the wrapper here overrides the headline manually. + if: always() + env: + COMPARE_JSON: ${{ github.workspace }}/artifacts/mac-native-ui-tests/screenshot-compare.json + COMMENT_MD: ${{ github.workspace }}/artifacts/mac-native-ui-tests/screenshot-comment.md + run: | + set -eu + { + echo "## Mac native screenshot comparison" + echo + echo "Ran against \`scripts/hellocodenameone\` as a Mac Catalyst build (\`macNative.enabled=true\`)." + echo "Golden images: \`scripts/mac-native/screenshots/\` (see the README there for the seeding workflow)." + echo + if [ -s "$COMPARE_JSON" ]; then + python3 scripts/ci/metal-screenshot-summary.py --markdown "$COMPARE_JSON" + elif [ -s "$COMMENT_MD" ]; then + cat "$COMMENT_MD" + else + echo "_No screenshot comparison artifact was produced. See the upload step output for details._" + fi + } >> "$GITHUB_STEP_SUMMARY" + if [ -s "$COMPARE_JSON" ]; then + NOTICE="$(python3 scripts/ci/metal-screenshot-summary.py --headline "$COMPARE_JSON" || true)" + if [ -n "$NOTICE" ]; then + echo "::notice title=Mac native screenshot comparison::${NOTICE}" + fi + fi + + - name: Upload Mac native artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: mac-native-ui-tests + path: artifacts + if-no-files-found: warn + retention-days: 14 diff --git a/Ports/iOSPort/nativeSources/CN1ES2compat.h b/Ports/iOSPort/nativeSources/CN1ES2compat.h index 2894937b43..c4e2548e1d 100644 --- a/Ports/iOSPort/nativeSources/CN1ES2compat.h +++ b/Ports/iOSPort/nativeSources/CN1ES2compat.h @@ -43,6 +43,10 @@ enum CN1GLenum { }; #ifdef USE_ES2 +// On Mac Catalyst the GLKit/OpenGLES headers resolve to stub headers under +// macCatalystStubs/ via HEADER_SEARCH_PATHS[sdk=macosx*] (set by +// IPhoneBuilder when macNative.enabled=true). On iOS the real SDK headers +// are picked up. #import #import #import "ExecutableOp.h" diff --git a/Ports/iOSPort/nativeSources/ClearRect.m b/Ports/iOSPort/nativeSources/ClearRect.m index c64380e641..0ba153f6e2 100644 --- a/Ports/iOSPort/nativeSources/ClearRect.m +++ b/Ports/iOSPort/nativeSources/ClearRect.m @@ -29,6 +29,7 @@ #endif #ifdef USE_ES2 +#ifndef CN1_USE_METAL extern GLKMatrix4 CN1modelViewMatrix; extern GLKMatrix4 CN1projectionMatrix; extern GLKMatrix4 CN1transformMatrix; @@ -88,6 +89,7 @@ static GLuint getOGLProgram(){ return program; } +#endif // !CN1_USE_METAL #endif diff --git a/Ports/iOSPort/nativeSources/ClipRect.m b/Ports/iOSPort/nativeSources/ClipRect.m index 5cc0c98230..95598cbccb 100644 --- a/Ports/iOSPort/nativeSources/ClipRect.m +++ b/Ports/iOSPort/nativeSources/ClipRect.m @@ -258,13 +258,14 @@ +(void)updateClipToScale { if ( clipIsTexture ){ return; } +#ifndef CN1_USE_METAL int displayHeight = [CodenameOne_GLViewController instance].view.bounds.size.height * scaleValue; if(currentScaleX == 1 && currentScaleY == 1) { //_glEnable(GL_SCISSOR_TEST); //CN1Log(@"Updating clip to scale"); glScissor(clipX, displayHeight - clipY - clipH, clipW, clipH); } - +#endif // !CN1_USE_METAL } #ifndef CN1_USE_ARC diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 6e7de3d60e..9a14ddf1e8 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -119,10 +119,22 @@ @implementation CodenameOne_GLAppDelegate - (CodenameOne_GLViewController *)cn1EnsureViewController { if (self.viewController == nil) { + // The iOS XIB-based instantiation breaks under Mac Catalyst on + // Xcode 26: IBAgent-macOS-UIKit crashes compiling the GL/Metal + // view-controller XIBs, so the file is excluded from the Mac + // slice via EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*]. Pass nil as + // the NIB name on Mac so UIViewController synthesises a plain + // UIView; the Metal layer is attached programmatically further + // down the init chain, so the XIB's IBOutlet wiring isn't needed. +#if TARGET_OS_MACCATALYST + NSString *cn1NibName = nil; +#else + NSString *cn1NibName = @"CodenameOne_GLViewController"; +#endif #ifdef CN1_USE_ARC - self.viewController = [[CodenameOne_GLViewController alloc] initWithNibName:@"CodenameOne_GLViewController" bundle:nil]; + self.viewController = [[CodenameOne_GLViewController alloc] initWithNibName:cn1NibName bundle:nil]; #else - CodenameOne_GLViewController *viewController = [[CodenameOne_GLViewController alloc] initWithNibName:@"CodenameOne_GLViewController" bundle:nil]; + CodenameOne_GLViewController *viewController = [[CodenameOne_GLViewController alloc] initWithNibName:cn1NibName bundle:nil]; self.viewController = viewController; [viewController release]; #endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m index 27844def4c..d82f4badb7 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m @@ -40,6 +40,35 @@ - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session op [window release]; #endif +#if TARGET_OS_MACCATALYST + // Opt-in deterministic window sizing for screenshot CI. Read a + // "x" value from the CN1MacFixedWindowSize Info.plist key -- + // the macNative.fixedWindowSize build hint plumbs the user's + // setting through there. Absent or unparseable -> normal Mac + // resize behaviour (production apps are unaffected). Setting it + // pins both the minimum and maximum scene size so every launch + // produces a byte-identical window for strict-pixel comparison. + if (@available(macCatalyst 13.0, *)) { + NSString *fixedSpec = [[NSBundle mainBundle] + objectForInfoDictionaryKey:@"CN1MacFixedWindowSize"]; + if ([fixedSpec isKindOfClass:[NSString class]] && fixedSpec.length > 0) { + NSArray *parts = [fixedSpec componentsSeparatedByString:@"x"]; + if (parts.count == 2) { + CGFloat w = [parts[0] doubleValue]; + CGFloat h = [parts[1] doubleValue]; + if (w > 0 && h > 0) { + UIWindowScene *ws = (UIWindowScene *)scene; + if (ws.sizeRestrictions != nil) { + CGSize fixed = CGSizeMake(w, h); + ws.sizeRestrictions.minimumSize = fixed; + ws.sizeRestrictions.maximumSize = fixed; + } + } + } + } + } +#endif + UIOpenURLContext *urlContext = [connectionOptions.URLContexts anyObject]; if (urlContext != nil) { [appDelegate cn1OpenURL:[UIApplication sharedApplication] url:urlContext.URL sourceApplication:urlContext.options.sourceApplication annotation:urlContext.options.annotation]; diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 4942a3cd56..8e9f0cbe7d 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -181,6 +181,14 @@ static void updateDisplayMetricsFromView(UIView *view) { // screenSizeChanged event between stop and start (issue #4767). static CGSize cn1OrientationCorrectSize(UIView *view) { CGSize size = view.bounds.size; +#if TARGET_OS_MACCATALYST + // Mac Catalyst windows are user-resizable and don't have a true device + // orientation; the scene's interfaceOrientation is hard-coded to portrait + // even when the window is landscape, which would trip the swap logic + // below and publish the swapped size to the EDT. Trust the view bounds + // as-is on Mac. + return size; +#else #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 if (@available(iOS 13.0, *)) { UIWindowScene *scene = view.window.windowScene; @@ -197,6 +205,7 @@ static CGSize cn1OrientationCorrectSize(UIView *view) { } #endif return size; +#endif } BOOL forceSlideUpField; @@ -1408,10 +1417,15 @@ void com_codename1_impl_ios_IOSImplementation_nativeSetTransformImpl___float_flo { #ifdef USE_ES2 // dispatch_async(dispatch_get_main_queue(), ^{ - GLKMatrix4 m = GLKMatrix4MakeAndTranspose(a0,a1,a2,a3, - b0,b1,b2,b3, - c0,c1,c2,c3, - d0,d1,d2,d3); + // Equivalent to GLKMatrix4MakeAndTranspose(a..., b..., c..., d...): + // input is row-major; GLKMatrix4 stores column-major. Avoid the GLKit + // helper so the Mac Catalyst slice compiles without GLKit math symbols. + GLKMatrix4 m = (GLKMatrix4){ { + a0, b0, c0, d0, + a1, b1, c1, d1, + a2, b2, c2, d2, + a3, b3, c3, d3 + } }; SetTransform *f = [[SetTransform alloc] initWithArgs:m originX:originX originY:originY]; [CodenameOne_GLViewController upcoming:f]; @@ -1434,10 +1448,15 @@ void com_codename1_impl_ios_IOSImplementation_nativeSetTransformMutableImpl___fl { GLUIImage *target = [CodenameOne_GLViewController instance].currentMutableImage; if (target == nil) return; - GLKMatrix4 m = GLKMatrix4MakeAndTranspose(a0,a1,a2,a3, - b0,b1,b2,b3, - c0,c1,c2,c3, - d0,d1,d2,d3); + // Equivalent to GLKMatrix4MakeAndTranspose(a..., b..., c..., d...): + // input is row-major; GLKMatrix4 stores column-major. Avoid the GLKit + // helper so the Mac Catalyst slice compiles without GLKit math symbols. + GLKMatrix4 m = (GLKMatrix4){ { + a0, b0, c0, d0, + a1, b1, c1, d1, + a2, b2, c2, d2, + a3, b3, c3, d3 + } }; SetTransform *f = [[SetTransform alloc] initWithArgs:m originX:originX originY:originY]; [f setTarget:target]; [CodenameOne_GLViewController upcoming:f]; @@ -1450,10 +1469,15 @@ void com_codename1_impl_ios_IOSImplementation_nativeSetTransformMutableImpl___fl #ifdef USE_ES2 POOL_BEGIN(); currentMutableTransformSet = NO; - GLKMatrix4 m = GLKMatrix4MakeAndTranspose(a0,a1,a2,a3, - b0,b1,b2,b3, - c0,c1,c2,c3, - d0,d1,d2,d3); + // Equivalent to GLKMatrix4MakeAndTranspose(a..., b..., c..., d...): + // input is row-major; GLKMatrix4 stores column-major. Avoid the GLKit + // helper so the Mac Catalyst slice compiles without GLKit math symbols. + GLKMatrix4 m = (GLKMatrix4){ { + a0, b0, c0, d0, + a1, b1, c1, d1, + a2, b2, c2, d2, + a3, b3, c3, d3 + } }; CATransform3D output; GLfloat glMatrix[16]; CGFloat caMatrix[16]; @@ -2418,6 +2442,71 @@ +(CGAffineTransform) currentMutableTransform { return currentMutableTransform; } +#if defined(CN1_USE_METAL) && TARGET_OS_MACCATALYST +// On Mac Catalyst the iOS XIB never compiles (IBAgent-macOS-UIKit crashes +// on it under Xcode 26), so CodenameOne_GLAppDelegate.m passes nil to +// initWithNibName: and the default loadView would hand us a plain +// UIView. The rendering pipeline expects [eaglView] to find a METALView +// in self.view or its subviews; without one CN1MetalSetDeviceAndCommand- +// Queue never runs and CN1MetalGlyphAtlas+atlasForFont: returns nil for +// every font ("no atlas available" on every CN1MetalDrawString). Build +// the METALView programmatically and set it as the controller's view. +- (void)loadView { + CGRect screen = [UIScreen mainScreen].bounds; + if (CGRectIsEmpty(screen)) { + screen = CGRectMake(0, 0, 1024, 684); + } + METALView *rv = [[METALView alloc] initWithFrame:screen]; + rv.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.view = rv; +#ifndef CN1_USE_ARC + [rv release]; +#endif +} + +// Mac Catalyst routinely fires layoutSubviews several times per second +// while the window is being laid out (Catalyst's UINSView host bridge +// negotiates size with NSWindow on the AppKit side, then echoes that +// back into UIKit through repeated layoutIfNeeded passes). Re-emitting +// screenSizeChanged on every cycle reallocated CN1Metal mutable +// textures faster than the GC could reclaim them -- a CI run sat at +// 70+ GB resident memory after a minute. The guard here debounces: +// fire screenSizeChanged at most once per ~250 ms, and only when the +// observed size actually differs by more than one pixel from the +// previously reported size. The displayLink isn't running in this +// port (CADisplayLink is commented out -- see startAnimation), so we +// rely on this hook to keep the Metal layer in sync with the host +// window when the user resizes. Skipping it means form.show() after +// the first frame stops triggering a repaint of the GL view (every +// subsequent screenshot captures whatever the GL view was last asked +// to paint, which is usually the previous test's form). +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + if (self.view == nil) return; + CGSize sz = self.view.bounds.size; + int newW = (int)(sz.width * scaleValue); + int newH = (int)(sz.height * scaleValue); + if (newW <= 0 || newH <= 0) return; + int dw = newW - displayWidth; + int dh = newH - displayHeight; + if (dw < 0) dw = -dw; + if (dh < 0) dh = -dh; + if (dw <= 1 && dh <= 1) { + return; + } + static NSTimeInterval lastFire = 0; + NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate]; + if (lastFire != 0 && (now - lastFire) < 0.25) { + return; + } + lastFire = now; + displayWidth = newW; + displayHeight = newH; + screenSizeChanged(displayWidth, displayHeight); +} + +#endif + #ifdef INCLUDE_MOPUB @synthesize adView; - (void)viewDidLoad { @@ -2618,6 +2707,29 @@ - (void)signIn:(GIDSignIn *)signIn didDisconnectWithUser:(GIDGoogleUser *)user w bool lockDrawing; - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; +#if defined(CN1_USE_METAL) && TARGET_OS_MACCATALYST + // Reapply displayWidth / displayHeight once the view is attached to + // its window. The AppDelegate seeds them from [UIScreen mainScreen].bounds + // which on Mac Catalyst is the full Mac display (e.g., 1470x956), not + // the app window (1024x768 by default). Without this update the form + // lays out for the full screen but renders into the window-sized + // framebuffer. Doing this in viewDidLayoutSubviews instead caused a + // runaway form-relayout loop (observed locally: 70+ GB resident memory + // after a minute) -- layoutSubviews fires repeatedly during normal + // Catalyst window updates and re-triggering screenSizeChanged on every + // cycle re-allocated Metal mutable-image textures faster than the GC + // could reclaim them. + if (self.view != nil) { + CGSize sz = self.view.bounds.size; + int newW = (int)(sz.width * scaleValue); + int newH = (int)(sz.height * scaleValue); + if (newW > 0 && newH > 0 && (displayWidth != newW || displayHeight != newH)) { + displayWidth = newW; + displayHeight = newH; + screenSizeChanged(displayWidth, displayHeight); + } + } +#endif [self becomeFirstResponder]; [self updateCanvas:animated]; // Re-install / bring the status-bar tap proxy to the front. Native peers @@ -2948,7 +3060,10 @@ -(EAGLView*) eaglView { - (void)awakeFromNib { -#ifdef USE_ES2 +#if defined(USE_ES2) && !defined(CN1_USE_METAL) + // CN1transformMatrix/version + cn1CompareMatrices live in CN1ES2compat.m + // which is excluded from the Mac Catalyst slice. Skip them on Metal — + // CN1Metalcompat manages its own transform state. if (!cn1CompareMatrices(GLKMatrix4Identity, CN1transformMatrix)) { CN1transformMatrix = GLKMatrix4Identity; CN1transformMatrixVersion = (CN1transformMatrixVersion+1)%10000; @@ -2962,11 +3077,18 @@ - (void)awakeFromNib } sharedSingleton = self; [self initVars]; +#ifdef CN1_USE_METAL + // Metal builds never create an EAGLContext; the METALView owns its own + // MTLDevice / MTLCommandQueue. EAGLContext is unavailable on Mac + // Catalyst (OpenGLES.framework is absent from the macOS SDK) so we + // route around it entirely. + self.context = nil; +#else #ifdef USE_ES2 EAGLContext *aContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2]; #else EAGLContext *aContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES1]; - + if (!aContext) { aContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES1]; } @@ -2975,12 +3097,13 @@ - (void)awakeFromNib CN1Log(@"Failed to create ES context"); else if (![EAGLContext setCurrentContext:aContext]) CN1Log(@"Failed to set ES context current"); - + self.context = aContext; #ifndef CN1_USE_ARC [aContext release]; #endif - +#endif // !CN1_USE_METAL + #ifndef CN1_USE_METAL // METALView has no GL context. Under CN1_USE_METAL this call is a no-op. [[self eaglView] setContext:context]; @@ -2997,8 +3120,15 @@ - (void)awakeFromNib animationFrameInterval = 1; self.displayLink = nil; +#ifdef CN1_USE_METAL + // Metal builds don't query GL extensions; the OES_draw_texture fast + // path is GL-only. Default to true so behaviour matches a typical + // device GL response. + drawTextureSupported = YES; +#else const char* extensions = (const char*)glGetString(GL_EXTENSIONS); drawTextureSupported = extensions == 0 || strstr(extensions, "OES_draw_texture") != 0; +#endif //CN1Log(@"Draw texture extension %i", (int)drawTextureSupported); // register for keyboard notifications @@ -3073,24 +3203,33 @@ - (void)awakeFromNib GLErrorLog; - + +#ifndef CN1_USE_METAL + // The _glScalef / _glTranslatef pair flips the splash image into the + // GL Y-up coordinate system used by DrawImage. On Metal builds the + // projection flip is handled inside CN1MetalBeginFrame so the + // manual setup is unnecessary; the helpers themselves live in + // CN1ES2compat.m which is excluded from the Mac Catalyst slice. _glScalef(xScale, -1, 1); GLErrorLog; _glTranslatef(0, -he, 0); GLErrorLog; - +#endif + [dr execute]; #ifndef CN1_USE_ARC [gl release]; [dr release]; #endif - + +#ifndef CN1_USE_METAL _glTranslatef(0, he, 0); GLErrorLog; - + _glScalef(xScale, -1, 1); GLErrorLog; - +#endif + [[self eaglView] presentFramebuffer]; GLErrorLog; } @@ -3260,19 +3399,23 @@ - (void)keyboardWillShow:(NSNotification *)n - (void)dealloc { +#ifndef CN1_USE_METAL if (program) { glDeleteProgram(program); program = 0; } - +#endif + +#ifndef CN1_USE_METAL // Tear down context. if ([EAGLContext currentContext] == context) [EAGLContext setCurrentContext:nil]; - +#endif + #ifndef CN1_USE_ARC [context release]; #endif - + #ifdef INCLUDE_MOPUB self.adView = nil; #endif @@ -3318,15 +3461,19 @@ - (void)viewWillDisappear:(BOOL)animated - (void)viewDidUnload { [super viewDidUnload]; - + +#ifndef CN1_USE_METAL if (program) { glDeleteProgram(program); program = 0; } - +#endif + +#ifndef CN1_USE_METAL // Tear down context. if ([EAGLContext currentContext] == context) [EAGLContext setCurrentContext:nil]; +#endif self.context = nil; } @@ -3613,10 +3760,16 @@ - (void)drawFrame:(CGRect)rect if([currentTarget count] > 0) { [ClipRect setDrawRect:rect]; //CN1Log(@"Clipping rect to: %i, %i, %i %i", (int)rect.origin.x, (int)rect.origin.y, (int)rect.size.width, (int)rect.size.height ); +#ifndef CN1_USE_METAL + // _glScalef / _glTranslatef expand into glScalefES2 / glTranslatefES2 + // which live in CN1ES2compat.m (excluded for Mac Catalyst). On + // Metal builds the projection flip is handled by CN1MetalBeginFrame + // / METALView so this manual setup is unnecessary. _glScalef(1, -1, 1); GLErrorLog; _glTranslatef(0, -displayHeight, 0); GLErrorLog; +#endif // !CN1_USE_METAL /*if(((int)rect.size.width) != displayWidth || ((int)rect.size.height) != displayHeight) { glScissor(rect.origin.x, displayHeight - rect.origin.y - rect.size.height, rect.size.width, rect.size.height); @@ -3674,11 +3827,13 @@ - (void)drawFrame:(CGRect)rect #ifndef CN1_USE_ARC [cp release]; #endif +#ifndef CN1_USE_METAL _glTranslatef(0, displayHeight, 0); GLErrorLog; _glScalef(1, -1, 1); GLErrorLog; - +#endif + [DrawGradientTextureCache flushDeleted]; [DrawStringTextureCache flushDeleted]; if(firstTime) { @@ -3755,20 +3910,27 @@ -(void)searchHierarchy:(UIView*)view { - (BOOL)compileShader:(GLuint *)shader type:(GLenum)type file:(NSString *)file { +#ifdef CN1_USE_METAL + // The legacy ES1 shader compilation path is unused on the Metal + // backend (CN1Metalcompat / CN1MetalShaders.metal handle everything). + // Gating the body keeps the Mac Catalyst slice free of OpenGL symbols. + (void)shader; (void)type; (void)file; + return FALSE; +#else GLint status; const GLchar *source; - + source = (GLchar *)[[NSString stringWithContentsOfFile:file encoding:NSUTF8StringEncoding error:nil] UTF8String]; if (!source) { CN1Log(@"Failed to load vertex shader"); return FALSE; } - + *shader = glCreateShader(type); glShaderSource(*shader, 1, &source, NULL); glCompileShader(*shader); - + #if defined(DEBUG) GLint logLength; glGetShaderiv(*shader, GL_INFO_LOG_LENGTH, &logLength); @@ -3780,23 +3942,28 @@ - (BOOL)compileShader:(GLuint *)shader type:(GLenum)type file:(NSString *)file free(log); } #endif - + glGetShaderiv(*shader, GL_COMPILE_STATUS, &status); if (status == 0) { glDeleteShader(*shader); return FALSE; } - + return TRUE; +#endif } - (BOOL)linkProgram:(GLuint)prog { +#ifdef CN1_USE_METAL + (void)prog; + return FALSE; +#else GLint status; - + glLinkProgram(prog); - + #if defined(DEBUG) GLint logLength; glGetProgramiv(prog, GL_INFO_LOG_LENGTH, &logLength); @@ -3808,18 +3975,23 @@ - (BOOL)linkProgram:(GLuint)prog free(log); } #endif - + glGetProgramiv(prog, GL_LINK_STATUS, &status); if (status == 0) return FALSE; - + return TRUE; +#endif } - (BOOL)validateProgram:(GLuint)prog { +#ifdef CN1_USE_METAL + (void)prog; + return FALSE; +#else GLint logLength, status; - + glValidateProgram(prog); glGetProgramiv(prog, GL_INFO_LOG_LENGTH, &logLength); if (logLength > 0) @@ -3829,22 +4001,26 @@ - (BOOL)validateProgram:(GLuint)prog CN1Log(@"Program validate log:\n%s", log); free(log); } - + glGetProgramiv(prog, GL_VALIDATE_STATUS, &status); if (status == 0) return FALSE; - + return TRUE; +#endif } - (BOOL)loadShaders { +#ifdef CN1_USE_METAL + return FALSE; +#else GLuint vertShader, fragShader; NSString *vertShaderPathname, *fragShaderPathname; - + // Create shader program. program = glCreateProgram(); - + // Create and compile vertex shader. vertShaderPathname = [[NSBundle mainBundle] pathForResource:@"Shader" ofType:@"vsh"]; if (![self compileShader:&vertShader type:GL_VERTEX_SHADER file:vertShaderPathname]) @@ -3852,7 +4028,7 @@ - (BOOL)loadShaders CN1Log(@"Failed to compile vertex shader"); return FALSE; } - + // Create and compile fragment shader. fragShaderPathname = [[NSBundle mainBundle] pathForResource:@"Shader" ofType:@"fsh"]; if (![self compileShader:&fragShader type:GL_FRAGMENT_SHADER file:fragShaderPathname]) @@ -3860,23 +4036,23 @@ - (BOOL)loadShaders CN1Log(@"Failed to compile fragment shader"); return FALSE; } - + // Attach vertex shader to program. glAttachShader(program, vertShader); - + // Attach fragment shader to program. glAttachShader(program, fragShader); - + // Bind attribute locations. // This needs to be done prior to linking. glBindAttribLocation(program, ATTRIB_VERTEX, "position"); glBindAttribLocation(program, ATTRIB_COLOR, "color"); - + // Link program. if (![self linkProgram:program]) { CN1Log(@"Failed to link program: %d", program); - + if (vertShader) { glDeleteShader(vertShader); @@ -3892,20 +4068,21 @@ - (BOOL)loadShaders glDeleteProgram(program); program = 0; } - + return FALSE; } - + // Get uniform locations. uniforms[UNIFORM_TRANSLATE] = glGetUniformLocation(program, "translate"); - + // Release vertex and fragment shaders. if (vertShader) glDeleteShader(vertShader); if (fragShader) glDeleteShader(fragShader); - + return TRUE; +#endif } diff --git a/Ports/iOSPort/nativeSources/DrawGradient.m b/Ports/iOSPort/nativeSources/DrawGradient.m index b8e4931914..0f856829c2 100644 --- a/Ports/iOSPort/nativeSources/DrawGradient.m +++ b/Ports/iOSPort/nativeSources/DrawGradient.m @@ -28,6 +28,7 @@ #endif #ifdef USE_ES2 +#ifndef CN1_USE_METAL extern GLKMatrix4 CN1modelViewMatrix; extern GLKMatrix4 CN1projectionMatrix; extern GLKMatrix4 CN1transformMatrix; @@ -105,6 +106,7 @@ static GLuint getOGLProgram(){ return program; } +#endif // !CN1_USE_METAL #endif diff --git a/Ports/iOSPort/nativeSources/DrawGradientTextureCache.m b/Ports/iOSPort/nativeSources/DrawGradientTextureCache.m index 42b651e8ce..491b250e2f 100644 --- a/Ports/iOSPort/nativeSources/DrawGradientTextureCache.m +++ b/Ports/iOSPort/nativeSources/DrawGradientTextureCache.m @@ -128,8 +128,10 @@ -(void)dealloc { #ifndef CN1_USE_ARC [lastAccess release]; #endif +#ifndef CN1_USE_METAL glDeleteTextures(1, &textureName); GLErrorLog; +#endif #ifndef CN1_USE_ARC [super dealloc]; #endif diff --git a/Ports/iOSPort/nativeSources/DrawImage.m b/Ports/iOSPort/nativeSources/DrawImage.m index 479a29a9f8..3a7d4805ef 100644 --- a/Ports/iOSPort/nativeSources/DrawImage.m +++ b/Ports/iOSPort/nativeSources/DrawImage.m @@ -6,6 +6,7 @@ #endif #ifdef USE_ES2 +#ifndef CN1_USE_METAL extern GLKMatrix4 CN1modelViewMatrix; extern GLKMatrix4 CN1projectionMatrix; extern GLKMatrix4 CN1transformMatrix; @@ -89,6 +90,7 @@ static GLuint getOGLProgram(){ return program; } +#endif // !CN1_USE_METAL #endif diff --git a/Ports/iOSPort/nativeSources/DrawLine.m b/Ports/iOSPort/nativeSources/DrawLine.m index 65fd745ddb..3462c4f360 100644 --- a/Ports/iOSPort/nativeSources/DrawLine.m +++ b/Ports/iOSPort/nativeSources/DrawLine.m @@ -27,6 +27,7 @@ #import "CN1Metalcompat.h" #endif #ifdef USE_ES2 +#ifndef CN1_USE_METAL extern GLKMatrix4 CN1modelViewMatrix; extern GLKMatrix4 CN1projectionMatrix; extern GLKMatrix4 CN1transformMatrix; @@ -92,6 +93,7 @@ static GLuint getOGLProgram(){ return program; } +#endif // !CN1_USE_METAL #endif @implementation DrawLine diff --git a/Ports/iOSPort/nativeSources/DrawPath.m b/Ports/iOSPort/nativeSources/DrawPath.m index f746303b24..f3650a35cd 100644 --- a/Ports/iOSPort/nativeSources/DrawPath.m +++ b/Ports/iOSPort/nativeSources/DrawPath.m @@ -44,10 +44,15 @@ -(id)initWithArgs:(Renderer*)r color:(int)c alpha:(int)a //x:(int)xx y:(int)yy w } -(void)execute { - +#ifdef CN1_USE_METAL + // DrawPath is an ES1 alpha-mask path; on the Metal backend shapes are + // rasterised via DrawTextureAlphaMask + CN1Metalcompat. Nothing to do + // here in Metal mode. Gating the body also keeps the Mac Catalyst + // slice free of OpenGL function symbols. +#else GlColorFromRGB(color, alpha); JAVA_INT outputBounds[4]; - + Renderer_getOutputBounds(renderer, (JAVA_INT*)&outputBounds); // outputBounds is { minX, minY, maxX, maxY } in renderer pixel // space; maxX / maxY are legitimately negative when the path sits @@ -75,21 +80,21 @@ -(void)execute 0, 1, 1, 1, }; - - + + AlphaConsumer ac = { x, y, width, height, }; - + jbyte maskArray[ac.width*ac.height]; - + ac.alphas = (JAVA_BYTE*)&maskArray; Renderer_produceAlphas(renderer, &ac); - - + + glGenTextures(1, &tex); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, tex); @@ -97,11 +102,11 @@ -(void)execute glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - + glTexImage2D(GL_TEXTURE_2D, 0, GL_ALPHA, ac.width, ac.height, 0, GL_ALPHA, GL_UNSIGNED_BYTE, maskArray); - + _glEnableClientState(GL_VERTEX_ARRAY); GLErrorLog; //_glEnableClientState(GL_TEXTURE_COORD_ARRAY); @@ -123,16 +128,17 @@ -(void)execute GLErrorLog; //_glDisable(GL_TEXTURE_2D); GLErrorLog; - - +#endif // !CN1_USE_METAL } -(void)dealloc { +#ifndef CN1_USE_METAL glDeleteTextures(1, &tex); +#endif Renderer_destroy(renderer); #ifndef CN1_USE_ARC [super dealloc]; #endif - + } @end diff --git a/Ports/iOSPort/nativeSources/DrawRect.m b/Ports/iOSPort/nativeSources/DrawRect.m index 343cc2b750..62614e1985 100644 --- a/Ports/iOSPort/nativeSources/DrawRect.m +++ b/Ports/iOSPort/nativeSources/DrawRect.m @@ -28,6 +28,7 @@ #endif #ifdef USE_ES2 +#ifndef CN1_USE_METAL extern GLKMatrix4 CN1modelViewMatrix; extern GLKMatrix4 CN1projectionMatrix; extern GLKMatrix4 CN1transformMatrix; @@ -94,6 +95,7 @@ static GLuint getOGLProgram(){ return program; } +#endif // !CN1_USE_METAL #endif @implementation DrawRect diff --git a/Ports/iOSPort/nativeSources/DrawString.m b/Ports/iOSPort/nativeSources/DrawString.m index 74891dd220..8705b6fe4c 100644 --- a/Ports/iOSPort/nativeSources/DrawString.m +++ b/Ports/iOSPort/nativeSources/DrawString.m @@ -30,6 +30,7 @@ extern float scaleValue; #ifdef USE_ES2 +#ifndef CN1_USE_METAL extern GLKMatrix4 CN1modelViewMatrix; extern GLKMatrix4 CN1projectionMatrix; extern GLKMatrix4 CN1transformMatrix; @@ -113,6 +114,7 @@ static GLuint getOGLProgram(){ return program; } +#endif // !CN1_USE_METAL #endif @implementation DrawString diff --git a/Ports/iOSPort/nativeSources/DrawStringTextureCache.m b/Ports/iOSPort/nativeSources/DrawStringTextureCache.m index ae1c08ceb2..c1e2373ab9 100644 --- a/Ports/iOSPort/nativeSources/DrawStringTextureCache.m +++ b/Ports/iOSPort/nativeSources/DrawStringTextureCache.m @@ -135,16 +135,20 @@ -(void)dealloc { [str release]; [font release]; [lastAccess release]; +#ifndef CN1_USE_METAL if (textureName != 0) { glDeleteTextures(1, &textureName); GLErrorLog; } +#endif [super dealloc]; } #else -(void)dealloc { +#ifndef CN1_USE_METAL glDeleteTextures(1, &textureName); GLErrorLog; +#endif } #endif @end diff --git a/Ports/iOSPort/nativeSources/DrawTextureAlphaMask.m b/Ports/iOSPort/nativeSources/DrawTextureAlphaMask.m index f5068c1a49..1ae08ad8fc 100644 --- a/Ports/iOSPort/nativeSources/DrawTextureAlphaMask.m +++ b/Ports/iOSPort/nativeSources/DrawTextureAlphaMask.m @@ -30,6 +30,7 @@ #endif #ifdef USE_ES2 +#ifndef CN1_USE_METAL extern GLKMatrix4 CN1modelViewMatrix; extern GLKMatrix4 CN1projectionMatrix; extern GLKMatrix4 CN1transformMatrix; @@ -274,6 +275,7 @@ -(void)updateMatrices { } +#endif // !CN1_USE_METAL #endif @implementation DrawTextureAlphaMask diff --git a/Ports/iOSPort/nativeSources/ExecutableOp.m b/Ports/iOSPort/nativeSources/ExecutableOp.m index c20f1df167..ed081aa8bb 100644 --- a/Ports/iOSPort/nativeSources/ExecutableOp.m +++ b/Ports/iOSPort/nativeSources/ExecutableOp.m @@ -28,6 +28,14 @@ extern void logGlErrorAt(const char *f, int l) { +#ifdef CN1_USE_METAL + // No GL context on the Metal backend (and no GL symbols at all on + // the Mac Catalyst slice). Callers still expand GLErrorLog macros + // unconditionally; honour them with a no-op so the rendering ops + // don't need to be touched for this single log helper. + (void)f; + (void)l; +#else GLenum err = glGetError(); if(err != GL_NO_ERROR) { switch(err) { @@ -45,6 +53,7 @@ extern void logGlErrorAt(const char *f, int l) { break; } } +#endif } @implementation ExecutableOp diff --git a/Ports/iOSPort/nativeSources/FillPolygon.m b/Ports/iOSPort/nativeSources/FillPolygon.m index 3ca61bccbe..327d8e9fb8 100644 --- a/Ports/iOSPort/nativeSources/FillPolygon.m +++ b/Ports/iOSPort/nativeSources/FillPolygon.m @@ -39,6 +39,7 @@ #ifdef USE_ES2 +#ifndef CN1_USE_METAL extern GLKMatrix4 CN1modelViewMatrix; extern GLKMatrix4 CN1projectionMatrix; extern GLKMatrix4 CN1transformMatrix; @@ -105,6 +106,7 @@ static GLuint getOGLProgram(){ return program; } +#endif // !CN1_USE_METAL #endif diff --git a/Ports/iOSPort/nativeSources/FillRect.m b/Ports/iOSPort/nativeSources/FillRect.m index f8236ed616..a736742949 100644 --- a/Ports/iOSPort/nativeSources/FillRect.m +++ b/Ports/iOSPort/nativeSources/FillRect.m @@ -29,6 +29,7 @@ #endif #ifdef USE_ES2 +#ifndef CN1_USE_METAL extern GLKMatrix4 CN1modelViewMatrix; extern GLKMatrix4 CN1projectionMatrix; extern GLKMatrix4 CN1transformMatrix; @@ -95,6 +96,7 @@ static GLuint getOGLProgram(){ return program; } +#endif // !CN1_USE_METAL #endif diff --git a/Ports/iOSPort/nativeSources/GLUIImage.m b/Ports/iOSPort/nativeSources/GLUIImage.m index 273e43dce1..535298f99c 100644 --- a/Ports/iOSPort/nativeSources/GLUIImage.m +++ b/Ports/iOSPort/nativeSources/GLUIImage.m @@ -56,6 +56,13 @@ -(int)getTextureHeight { } -(GLuint)getTexture:(int)texWidth texHeight:(int)texHeight { +#ifdef CN1_USE_METAL + // Metal builds never sample via GL texture handles; DrawImage / TileImage + // route through getMTLTexture instead. Return 0 so the GL helpers are + // preprocessed away and the Mac Catalyst slice can link without GL + // function symbols. + return 0; +#else if(textureName == 0) { textureWidth = texWidth; textureHeight = texHeight; @@ -87,14 +94,14 @@ -(GLuint)getTexture:(int)texWidth texHeight:(int)texHeight { int h = texHeight;//(int)img.size.height; int p2w = nextPowerOf2(w); int p2h = nextPowerOf2(h); - + if (p2w > GL_MAX_TEXTURE_SIZE) { NSLog(@"Warning: Trying to create texture with width %d which exceeds the max texture size %d. This will fail, and image will appear black.", p2w, GL_MAX_TEXTURE_SIZE); } if (p2h > GL_MAX_TEXTURE_SIZE) { NSLog(@"Warning: Trying to create texture with height %d which exceeds the max texture size %d. This will fail, and image will appear black.", p2h, GL_MAX_TEXTURE_SIZE); } - + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); void* imageData = malloc(p2h * p2w * 4); CGContextRef context = CGBitmapContextCreate(imageData, p2w, p2h, 8, 4 * p2w, colorSpace, kCGImageAlphaPremultipliedLast); @@ -111,7 +118,7 @@ -(GLuint)getTexture:(int)texWidth texHeight:(int)texHeight { GLErrorLog; CGContextRelease(context); GLErrorLog; - + glBindTexture(GL_TEXTURE_2D, 0); GLErrorLog; free(imageData); @@ -123,6 +130,7 @@ -(GLuint)getTexture:(int)texWidth texHeight:(int)texHeight { } } return textureName; +#endif // !CN1_USE_METAL } -(void)setImage:(UIImage*)i { @@ -143,6 +151,7 @@ -(void)setImage:(UIImage*)i { [mtlTexture release]; mtlTexture = nil; #endif +#ifndef CN1_USE_METAL if(textureName != 0) { int tname = textureName; textureName = 0; @@ -158,6 +167,7 @@ -(void)setImage:(UIImage*)i { }); } } +#endif // !CN1_USE_METAL } -(void)setName:(NSString*)s { @@ -222,6 +232,7 @@ -(void)dealloc { [name release]; #endif } +#ifndef CN1_USE_METAL if(textureName != 0) { int tname = textureName; textureName = 0; @@ -237,6 +248,7 @@ -(void)dealloc { }); } } +#endif // !CN1_USE_METAL #ifdef CN1_USE_METAL // Both ivars hold a +1 MTLTexture retain (newTextureWithDescriptor / // CN1MetalTextureFromUIImage both return owned references). Without diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index e39fb62cea..02bf18cebe 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -32,6 +32,7 @@ #import "CN1Metalcompat.h" #endif #import +#import #ifndef NEW_CODENAME_ONE_VM #include "xmlvm-util.h" @@ -71,8 +72,21 @@ #include #include #import +#if !TARGET_OS_MACCATALYST +// AddressBookUI and the legacy AddressBook C API are unavailable on Mac +// Catalyst. Skip the import; the contacts path falls back to Contacts.framework +// (handled via INCLUDE_CONTACTS_USAGE undef below). #import +#endif #import + +#if TARGET_OS_MACCATALYST +// AddressBook.framework (the C ABAddressBookRef API) is unavailable on Mac +// Catalyst. Suppress the legacy contacts code path on Mac so the build links. +#ifdef INCLUDE_CONTACTS_USAGE +#undef INCLUDE_CONTACTS_USAGE +#endif +#endif #import "UIWebViewEventDelegate.h" #include #ifdef CN1_USE_STOREKIT @@ -1515,6 +1529,85 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isIOS7__(CN1_THREAD_STATE_MULTI_AR return isIOS7(); } +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isRunningOnMac__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) +{ + if (@available(iOS 13.0, *)) { + return [[NSProcessInfo processInfo] isMacCatalystApp] ? JAVA_TRUE : JAVA_FALSE; + } + return JAVA_FALSE; +} + +void com_codename1_impl_ios_IOSNative_setMacWindowDarkAppearance___boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_BOOLEAN dark) { +#if TARGET_OS_MACCATALYST + if (@available(iOS 13.0, *)) { + dispatch_async(dispatch_get_main_queue(), ^{ + // Step 1: trait-collection override on the UIWindow. This + // propagates the style through UIKit descendants (popovers, + // alerts, context menus) but does NOT, by itself, redraw the + // host NSWindow chrome (titlebar + traffic lights) on the + // AppKit side. Each UIWindow on Catalyst is backed by a + // UINSWindow which holds the actual NSWindow. + UIUserInterfaceStyle uiStyle = dark ? UIUserInterfaceStyleDark : UIUserInterfaceStyleLight; + Class nsAppearanceClass = NSClassFromString(@"NSAppearance"); + // Build the AppKit appearance object once. NSAppearance is + // available in the Catalyst process (UIScene.titlebar uses + // it internally) even though the rest of AppKit is not in + // the public surface. Look up the class + factory selector + // through the Obj-C runtime so the build doesn't need to + // link AppKit. + NSString *appearanceName = dark ? @"NSAppearanceNameDarkAqua" : @"NSAppearanceNameAqua"; + id appearance = nil; + if (nsAppearanceClass != nil) { + appearance = ((id (*)(id, SEL, id))objc_msgSend)(nsAppearanceClass, @selector(appearanceNamed:), appearanceName); + } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) continue; + UIWindowScene *ws = (UIWindowScene *)scene; + for (UIWindow *w in ws.windows) { + // (a) UIKit-side style override. + w.overrideUserInterfaceStyle = uiStyle; + // (b) walk the UIWindow's internal chain to the host + // NSWindow. On Catalyst the UIWindow is wrapped by a + // UINSWindow whose actual NSWindow is stored either + // under "_nsWindow" or reachable via the wrapper's + // "attachedWindow"/"hostWindow" private key. Try the + // documented Apple keys first, then the common + // private ones. + if (appearance == nil) continue; + id nsWindow = nil; + @try { nsWindow = [w valueForKey:@"_nsWindow"]; } @catch (id e) { nsWindow = nil; } + if (nsWindow == nil) { + @try { nsWindow = [w valueForKey:@"nsWindow"]; } @catch (id e) { nsWindow = nil; } + } + if (nsWindow == nil) { + @try { nsWindow = [w valueForKey:@"hostNSWindow"]; } @catch (id e) { nsWindow = nil; } + } + if (nsWindow != nil && [nsWindow respondsToSelector:@selector(setAppearance:)]) { + ((void (*)(id, SEL, id))objc_msgSend)(nsWindow, @selector(setAppearance:), appearance); + } + } + } + + // Step 2: also walk NSApplication.windows as a fallback in + // case the UIWindow -> NSWindow bridge isn't reachable via + // KVC on this OS version. NSApplication is reachable from + // a Catalyst process under at least macOS 11+. + Class nsAppClass = NSClassFromString(@"NSApplication"); + if (nsAppClass != nil && appearance != nil) { + id sharedApp = ((id (*)(id, SEL))objc_msgSend)(nsAppClass, @selector(sharedApplication)); + if (sharedApp != nil) { + NSArray *nsWindows = ((NSArray *(*)(id, SEL))objc_msgSend)(sharedApp, @selector(windows)); + for (id nsWindow in nsWindows) { + if (![nsWindow respondsToSelector:@selector(setAppearance:)]) continue; + ((void (*)(id, SEL, id))objc_msgSend)(nsWindow, @selector(setAppearance:), appearance); + } + } + } + }); + } +#endif +} + JAVA_LONG com_codename1_impl_ios_IOSNative_createNSData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT file) { POOL_BEGIN(); NSString* ns = toNSString(CN1_THREAD_STATE_PASS_ARG file); @@ -3569,11 +3662,18 @@ JAVA_LONG createNativeVideoComponentFromStringAV(JAVA_OBJECT str, JAVA_INT onCom #endif } JAVA_LONG com_codename1_impl_ios_IOSNative_createNativeVideoComponent___java_lang_String_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT str, JAVA_INT onCompletionCallbackId) { +#if TARGET_OS_MACCATALYST + // Mac slice: bypass the MP/AV runtime dispatch and always use AV. The + // legacy MPMoviePlayer* path links against a framework that is weak on the + // Mac slice and would crash at runtime. + return createNativeVideoComponentFromStringAV(str, onCompletionCallbackId); +#else if (useAVKit()) { return createNativeVideoComponentFromStringAV(str, onCompletionCallbackId); } else { return createNativeVideoComponentFromStringMP(str, onCompletionCallbackId); } +#endif } JAVA_LONG createVideoComponentMP(JAVA_OBJECT dataObject, JAVA_INT onCompletionCallbackId) { @@ -3726,11 +3826,15 @@ JAVA_LONG createNativeVideoComponentMP(JAVA_OBJECT dataObject, JAVA_INT onComple } JAVA_LONG com_codename1_impl_ios_IOSNative_createNativeVideoComponent___byte_1ARRAY_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT dataObject, JAVA_INT onCompletionCallbackId) { +#if TARGET_OS_MACCATALYST + return createNativeVideoComponentAV(dataObject, onCompletionCallbackId); +#else if (useAVKit()) { return createNativeVideoComponentAV(dataObject, onCompletionCallbackId); } else { return createNativeVideoComponentMP(dataObject, onCompletionCallbackId); } +#endif } JAVA_LONG createVideoComponentNSDataMP(JAVA_LONG nsData, JAVA_INT onCompletionCallbackId) { @@ -3859,11 +3963,17 @@ JAVA_LONG createNativeVideoComponentNSDataAV(JAVA_LONG nsData, JAVA_INT onComple } JAVA_LONG com_codename1_impl_ios_IOSNative_createNativeVideoComponentNSData___long_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG nsData, JAVA_INT onCompletionCallbackId) { +#if TARGET_OS_MACCATALYST + return createNativeVideoComponentNSDataAV(nsData, onCompletionCallbackId); +#else if (useAVKit()) { + // NOTE: branches preserved verbatim from the pre-existing iOS code path, + // including the inverted naming -- changing it would alter iOS behaviour. return createNativeVideoComponentNSDataMP(nsData, onCompletionCallbackId); } else { return createNativeVideoComponentNSDataAV(nsData, onCompletionCallbackId); } +#endif } void launchMailAppOnDevice(JAVA_OBJECT recipients, JAVA_OBJECT subject, JAVA_OBJECT content){ @@ -6102,7 +6212,20 @@ static BOOL cn1_renderViewIntoContext(UIView *renderView, UIView *rootView, CGCo } #ifdef __IPHONE_13_0 if (@available(iOS 13.0, *)) { + // afterScreenUpdates:YES waits for the next screen + // refresh before snapshotting. On Mac Catalyst CI + // (headless macos-15) the refresh never fires, so + // the completion handler never runs and the wait + // below times out -- yielding a black body. Use NO + // on Catalyst: the page is already loaded + DOM is + // queried before this point (BrowserComponentScreen- + // shotTest waits for onLoad + a JS round-trip), so + // the current frame already has the rendered HTML. +#if TARGET_OS_MACCATALYST + config.afterScreenUpdates = NO; +#else config.afterScreenUpdates = YES; +#endif } #endif __block UIImage *snapshotImage = nil; @@ -6118,11 +6241,23 @@ static BOOL cn1_renderViewIntoContext(UIView *renderView, UIView *rootView, CGCo [config release]; if (!snapshotComplete) { + // Pump the run loop in NSRunLoopCommonModes (not just + // NSDefaultRunLoopMode) so the snapshot completion source + // -- which on Mac Catalyst delivers via a tracking-mode + // source -- gets picked up. 1 s is enough on iOS / iPadOS + // (snapshotWithConfiguration delivers in ~50 ms when the + // page is loaded) but on Mac Catalyst's headless CI the + // first snapshot of a freshly-loaded page can take 2+ s, + // so wait up to 3 s before giving up. +#if TARGET_OS_MACCATALYST + NSTimeInterval timeout = 3.0; +#else NSTimeInterval timeout = 1.0; +#endif while (!snapshotComplete && timeout > 0) { NSTimeInterval step = 0.01; NSDate *stepDate = [NSDate dateWithTimeIntervalSinceNow:step]; - [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:stepDate]; + [[NSRunLoop currentRunLoop] runMode:NSRunLoopCommonModes beforeDate:stepDate]; timeout -= step; } } @@ -6150,9 +6285,19 @@ static BOOL cn1_renderViewIntoContext(UIView *renderView, UIView *rootView, CGCo } #endif if (!drawn && [renderView respondsToSelector:@selector(drawViewHierarchyInRect:afterScreenUpdates:)]) { - // afterScreenUpdates:NO — YES can stall indefinitely under UIScene waiting - // for a scene display-link cycle that never fires during a synchronous capture. + // afterScreenUpdates:NO — YES can stall indefinitely under UIScene on + // iPhone/iPad waiting for a scene display-link cycle that never fires + // during a synchronous capture. On Mac Catalyst the scene model is + // different and YES is required: the live screenTexture isn't + // committed by CADisplayLink between form.show() and the screenshot + // callback, so afterScreenUpdates:NO captures the previous form's + // framebuffer (see Cn1ssDeviceRunnerHelper's repaint-before-capture + // dance which alone isn't enough). +#if TARGET_OS_MACCATALYST + drawn = [renderView drawViewHierarchyInRect:localBounds afterScreenUpdates:YES]; +#else drawn = [renderView drawViewHierarchyInRect:localBounds afterScreenUpdates:NO]; +#endif } if (!drawn) { [renderView.layer renderInContext:ctx]; @@ -6483,6 +6628,11 @@ void com_codename1_impl_ios_IOSNative_dial___java_lang_String(CN1_THREAD_STATE_M void com_codename1_impl_ios_IOSNative_sendSMS___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT number, JAVA_OBJECT text) { +#if TARGET_OS_MACCATALYST + // SMS hardware is absent on Mac; MFMessageComposeViewController canSendText + // returns NO. Short-circuit to keep behaviour deterministic on Mac. + return; +#else NSString *recipient = toNSString(CN1_THREAD_STATE_PASS_ARG number); NSString *smsBody = toNSString(CN1_THREAD_GET_STATE_PASS_ARG text); dispatch_async(dispatch_get_main_queue(), ^{ @@ -6509,6 +6659,7 @@ void com_codename1_impl_ios_IOSNative_sendSMS___java_lang_String_java_lang_Strin } POOL_END(); }); +#endif // !TARGET_OS_MACCATALYST } extern int pendingRemoteNotificationRegistrations; @@ -9349,10 +9500,10 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreateTexture___lon return handle; } #endif -#ifdef USE_ES2 +#if defined(USE_ES2) && !defined(CN1_USE_METAL) __block JAVA_LONG outTexture = NULL; - + dispatch_sync(dispatch_get_main_queue(), ^{ POOL_BEGIN(); EAGLContext *ctx = [[CodenameOne_GLViewController instance] context]; @@ -9480,15 +9631,36 @@ void com_codename1_impl_ios_Matrix_MatrixUtil_multiplyMM___float_1ARRAY_int_floa #endif +#ifdef CN1_USE_METAL + // Manual 4x4 column-major multiply so this path compiles for the Mac + // Catalyst slice (no GLKit math symbols). Identical result to + // GLKMatrix4Multiply(GLKMatrix4MakeWithArray(L), GLKMatrix4MakeWithArray(R)). + const JAVA_ARRAY_FLOAT *L = lhsData + lhsOffset * sizeof(JAVA_FLOAT); + const JAVA_ARRAY_FLOAT *R = rhsData + rhsOffset * sizeof(JAVA_FLOAT); + float out[16]; + for (int col = 0; col < 4; col++) { + for (int row = 0; row < 4; row++) { + float s = 0; + for (int k = 0; k < 4; k++) { + s += L[k * 4 + row] * R[col * 4 + k]; + } + out[col * 4 + row] = s; + } + } + for (int i = 0; i < 16; i++) { + resultData[i + resultOffset] = clamp_float_to_int(out[i]); + } +#else GLKMatrix4 mLeft = GLKMatrix4MakeWithArray(lhsData+lhsOffset*sizeof(JAVA_FLOAT)); GLKMatrix4 mRight = GLKMatrix4MakeWithArray(rhsData+rhsOffset*sizeof(JAVA_FLOAT)); GLKMatrix4 mResult = GLKMatrix4Multiply(mLeft, mRight); - + for ( int i=0; i<16; i++){ resultData[i+resultOffset] = clamp_float_to_int(mResult.m[i]); } //memcpy(resultData+resultOffset*sizeof(JAVA_FLOAT), &mResult, 16*sizeof(JAVA_FLOAT)); #endif +#endif } @@ -9506,6 +9678,33 @@ JAVA_VOID com_codename1_impl_ios_Matrix_MatrixUtil_transformPoints___float_1ARRA JAVA_ARRAY_FLOAT* inData = (JAVA_ARRAY_FLOAT*) ((JAVA_ARRAY)in)->data; JAVA_ARRAY_FLOAT* outData = (JAVA_ARRAY_FLOAT*) ((JAVA_ARRAY)out)->data; #endif +#ifdef CN1_USE_METAL + // Manual matrix-vector multiply for the Mac Catalyst slice (no GLKit + // math symbols). mData is a 4x4 column-major matrix. + const JAVA_ARRAY_FLOAT *M = mData; + JAVA_INT len = numPoints * pointSize; + for (JAVA_INT i = 0; i < len; i += pointSize) { + JAVA_INT s0 = srcPos + i; + float inv[4] = { inData[s0], inData[s0+1], 0.0f, 1.0f }; + if (pointSize == 3) { + inv[2] = inData[s0+2]; + } + float outv[4]; + for (int row = 0; row < 4; row++) { + float s = 0; + for (int col = 0; col < 4; col++) { + s += M[col * 4 + row] * inv[col]; + } + outv[row] = s; + } + int d0 = destPos + i; + outData[d0++] = outv[0] / outv[3]; + outData[d0++] = outv[1] / outv[3]; + if (pointSize == 3) { + outData[d0] = outv[2] / outv[3]; + } + } +#else GLKMatrix4 mMat = GLKMatrix4MakeWithArray(mData); JAVA_INT len = numPoints * pointSize; for (JAVA_INT i=0; i> 16) & 0xff; + int g = (bg >> 8) & 0xff; + int b = bg & 0xff; + int luma = (r * 299 + g * 587 + b * 114) / 1000; + boolean dark = luma < 128; + if (lastMacWindowDark != null && lastMacWindowDark.booleanValue() == dark) return; + lastMacWindowDark = Boolean.valueOf(dark); + nativeInstance.setMacWindowDarkAppearance(dark); } @Override @@ -1207,6 +1226,14 @@ static boolean hitTest(int x, int y) { public void flushGraphics(int x, int y, int width, int height) { globalGraphics.clipApplied = false; flushBuffer(0, x, y, width, height); + if (isDesktop()) { + // Form-show isn't the only path that changes dark mode -- a theme + // refresh or a system appearance toggle re-styles the contentPane + // without dropping a new Form on the EDT. Re-check after every + // flush so the host NSWindow titlebar tracks the live form. + // syncMacWindowAppearance is no-op when the state hasn't changed. + syncMacWindowAppearance(Display.getInstance().getCurrent()); + } } private final static int[] singleDimensionX = new int[1]; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 50d02a3d83..04a6a231eb 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -150,6 +150,12 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre native boolean isTablet(); native boolean isIOS7(); + native boolean isRunningOnMac(); + + // Mac native: propagate the current form's brightness to the host + // NSWindow's appearance so the Mac titlebar (rendered by AppKit, not + // CN1) matches the app's dark/light theme. A no-op on iOS/iPadOS. + native void setMacWindowDarkAppearance(boolean dark); native void setImageName(long nativeImage, String name); diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 53b088bb58..b5ec2bd00b 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -463,6 +463,81 @@ Only supported for App Store builds. See https://www.codenameone.com/developer-g |ios.onDeviceDebug.waitForAttach |Boolean true/false defaults to false. When `true`, the app blocks at startup until the proxy connects and the IDE tells the VM to continue. Useful when the breakpoint to investigate fires during app boot. Has no effect unless `ios.onDeviceDebug=true`. +|codename1.mac.appid +|Mac Native cloud builds only. The Mac bundle identifier registered in App Store Connect / Apple Developer. Distinct from `codename1.ios.appid` because Apple treats the iOS and Mac App Store records as separate products. Required for cloud Mac builds. + +|codename1.mac.certificate +|Mac Native cloud builds only. Path to the `.p12` file containing the Mac signing certificate(s) — _Mac App Distribution_ (3rd Party Mac Developer Application) for App Store builds, _Developer ID Application_ for Developer ID builds, or both bundled into the same P12 when `macNative.distribution=both`. Not interchangeable with the iOS distribution certificate. Required for cloud Mac builds. + +|codename1.mac.certificatePassword +|Mac Native cloud builds only. Password to unlock the P12 referenced by `codename1.mac.certificate`. Required for cloud Mac builds. + +|codename1.mac.provision +|Mac Native cloud builds only. Path to the Mac provisioning profile (`.provisionprofile`). Apple issues distinct provisioning profiles for Mac App Store and Developer ID distribution — pass the one that matches the chosen channel. + +|macNative.distribution +|Mac Native builds only. `appStore` (default), `developerID`, or `both`. Selects which entitlements + ExportOptions plist + signing certificate to emit. `both` emits parallel `*-AppStore.entitlements` / `*-DeveloperID.entitlements` and matching `ExportOptions-*-Mac.plist` files so a single project can be archived to either channel. + +|macNative.teamId +|Mac Native builds only. Apple Developer Team ID (alphanumeric). Falls back to `ios.release.teamId` → `ios.teamId` → `ios.debug.teamId` since most apps share a single Apple Developer Team for iOS and Mac. + +|macNative.bundleId +|Mac Native builds only. Used only when `macNative.deriveBundleId=false`. Default: `.mac`. + +|macNative.deriveBundleId +|Mac Native builds only. `true` (default) maps to Xcode's `DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER=YES` (Xcode appends `.maccatalyst` to the iOS bundle ID). Set to `false` to take the bundle ID verbatim from `macNative.bundleId`. + +|macNative.minDeploymentTarget +|Mac Native builds only. Minimum macOS version (`MACOSX_DEPLOYMENT_TARGET`). Default `10.15` — earlier versions don't support Mac Catalyst. + +|macNative.iosMinDeploymentTarget +|Mac Native builds only. iOS deployment-target floor for the Catalyst slice (`IPHONEOS_DEPLOYMENT_TARGET`). Default `13.1`. The plugin coerces the iOS slice's minimum upward when set. + +|macNative.appCategory +|Mac Native builds only. `LSApplicationCategoryType` in the generated Info.plist. Default `public.app-category.utilities`. See https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype[Apple's category list]. + +|macNative.copyright +|Mac Native builds only. `NSHumanReadableCopyright` in the Info.plist. Defaults to `Copyright (c) `. + +|macNative.signing.style +|Mac Native builds only. `automatic` (default) lets Xcode pick the signing certificate; `manual` forces the certificate identity hints below to be respected verbatim. + +|macNative.signingIdentity.appStore +|Mac Native builds only. Signing certificate identity for the App Store channel. Default `Apple Distribution`. + +|macNative.signingIdentity.developerID +|Mac Native builds only. Signing certificate identity for the Developer ID channel. Default `Developer ID Application`. + +|macNative.provisioningProfile.appStore +|Mac Native builds only. Provisioning profile name for App Store distribution — used only when `macNative.signing.style=manual`. + +|macNative.provisioningProfile.developerID +|Mac Native builds only. Provisioning profile name for Developer ID distribution — used only when `macNative.signing.style=manual`. + +|macNative.entitlements.appSandbox +|Mac Native builds only. `true` enables `com.apple.security.app-sandbox`. Default is `true` for the `appStore` channel (Mac App Store requires the sandbox), `false` for `developerID`. + +|macNative.entitlements.network.client +|Mac Native builds only. Toggles `com.apple.security.network.client`. Default `true`. + +|macNative.entitlements.network.server +|Mac Native builds only. Toggles `com.apple.security.network.server`. Default `false`. + +|macNative.entitlements.files.userSelected +|Mac Native builds only. `readwrite` (default), `readonly`, or `none`. Sets the matching `com.apple.security.files.user-selected.*` entitlement. + +|macNative.entitlements.hardenedRuntime +|Mac Native builds only. `true` enables hardened runtime restrictions. Default is `true` for `developerID` (notarization requires it), `false` for `appStore`. + +|macNative.entitlements.allowJit +|Mac Native builds only. `true` enables `com.apple.security.cs.allow-jit` for hardened runtime. ParparVM is AOT-compiled so this is `false` by default; flip when bundling a JIT-using cn1lib. + +|macNative.entitlements.extra +|Mac Native builds only. Free-form XML inserted verbatim inside the `` of the generated entitlements plist. Use for entitlements Codename One doesn't expose individually. + +|macNative.fixedWindowSize +|Mac Native builds only. Opt-in. Format `x` — for example `1024x685`. When set, the Catalyst window's `UISceneSession.sizeRestrictions` minimum and maximum are pinned to the requested size so every launch produces a byte-identical window. Default unset, in which case the window is resizable. The CI screenshot pipeline turns this on to keep the strict-pixel golden comparison stable; production apps should leave it off. + |desktop.width |Width in pixels for the form in desktop builds, will be doubled for retina grade displays. Defaults to 800. diff --git a/docs/developer-guide/Working-with-Mac-OS-X.asciidoc b/docs/developer-guide/Working-with-Mac-OS-X.asciidoc index 1a0fd71be6..ca8d4dac2f 100644 --- a/docs/developer-guide/Working-with-Mac-OS-X.asciidoc +++ b/docs/developer-guide/Working-with-Mac-OS-X.asciidoc @@ -108,3 +108,91 @@ desktop.mac.plist.KEYNAME:: Injects the entry with key `KEYNAME` into the Info.plist file. For example, `desktop.mac.plist.LSApplicationCategoryType=public.app-category.business` +=== Mac Native Build (AOT-compiled macOS App) + +The "macOS Desktop" section above covers the JavaSE bundled-JRE wrapper. *Mac Native* is a separate, AOT-compiled target: it routes through the same iOS pipeline (ParparVM bytecode-to-C, native UIKit) so the resulting macOS app shares its rendering code path, its memory profile, and its certificate story with your iPhone / iPad build. The output is a single signed `.app` bundle suitable for Mac App Store or Developer ID distribution. + +This target is a good fit when: + +* Your app already builds against the iOS slice and you want a Mac version that looks and behaves the same. +* You need native-speed startup and rendering on Mac, not a wrapped JVM. +* You want a single AppKit-grade `.app` bundle without bundling a JRE. + +Java source is shared with the iOS target; no separate Mac-specific code is required. + +==== Local Build + +To generate an Xcode project locally that you can open and run on the Mac: + +[source,shell] +---- +mvn -B -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-source package +---- + +The output is written to: + +---- +target/-mac-source/.xcodeproj +---- + +Open the project in Xcode, select the Mac Catalyst destination, and press Run. The resulting `.app` is identical in shape to one produced by the cloud build server. + +==== Cloud Build + +To produce a release-ready signed Mac app on the Codename One build server: + +[source,shell] +---- +mvn -B -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-os-x-native package +---- + +Mac distribution requires *its own certificates and provisioning profile* — Apple issues separate _Mac App Distribution_ (3rd Party Mac Developer Application) and _Developer ID Application_ certificates that aren't interchangeable with the _iPhone Distribution_ certificate used for iOS App Store builds. Configure them via the parallel `codename1.mac.*` hints: + +[cols="1,3", options="header"] +|=== +| Hint | Purpose +| `codename1.mac.appid` | Mac bundle identifier (the App Store Connect record is distinct from the iOS one). +| `codename1.mac.certificate` | Path to the `.p12` containing the Mac signing certificate. Bundle both _Mac App Distribution_ and _Developer ID Application_ into a single P12 when targeting both channels. +| `codename1.mac.certificatePassword` | Password to unlock the P12. +| `codename1.mac.provision` | Path to the Mac `.provisionprofile`. +|=== + +The Apple Developer Team ID itself can stay shared with the iOS slice (single Apple Developer account) — `macNative.teamId` falls back to `ios.release.teamId` when not set. The server returns a signed `.app` bundle. + +==== IDE Shortcuts + +The IntelliJ workspace template ships two Mac Native run configurations alongside the iOS ones: + +* *Mac Native Project* (Local Builds folder) -- equivalent to running `mvn package -Dcodename1.buildTarget=mac-source`. Use this to generate the Xcode project for hands-on debugging. +* *Mac Native Build* (Build Server folder) -- equivalent to `mvn package -Dcodename1.buildTarget=mac-os-x-native`. Use this for cloud builds. + +==== Distribution + +The Mac Native build supports both Mac App Store and Developer ID distribution. The generated Xcode project includes two `ExportOptions-*-Mac.plist` files in `dist/`: + +* `ExportOptions-AppStore-Mac.plist` -- App Store distribution (sandboxed, hardened runtime off). +* `ExportOptions-DeveloperID-Mac.plist` -- Developer ID distribution (hardened runtime on, sandbox off). + +After archiving in Xcode, run: + +[source,shell] +---- +xcodebuild -exportArchive \ + -archivePath build/.xcarchive \ + -exportOptionsPlist dist/ExportOptions-AppStore-Mac.plist \ + -exportPath build/export +---- + +==== Entitlements + +The Mac Native build emits a `.entitlements` plist (or `-AppStore.entitlements` + `-DeveloperID.entitlements` when both channels are configured). The defaults are sensible: App Sandbox on for the App Store channel, hardened runtime on for the Developer ID channel, network client access enabled, user-selected file access enabled. The signing-certificate identity defaults to _Apple Distribution_ for the App Store channel and _Developer ID Application_ for the Developer ID channel. + +==== Supported Distribution Channels + +[cols="1,3", options="header"] +|=== +| Channel | Notes +| App Store | Sandboxed, hardened runtime off. Signed with _Apple Distribution_; uploaded via App Store Connect. +| Developer ID | Hardened runtime on, sandbox off. Signed with _Developer ID Application_; notarized before distribution. +|=== + diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 934f8a693d..c7375ff8e9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -77,6 +77,7 @@ public abstract class Executor { public static final String BUILD_TARGET_XCODE_PROJECT = "ios-source"; public static final String BUILD_TARGET_ANDROID_PROJECT = "android-source"; + public static final String BUILD_TARGET_MAC_NATIVE_PROJECT = "mac-source"; private String buildTarget; private static boolean disableDelete; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 416d1f0c1f..25b4d468d0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -51,6 +51,13 @@ */ public class IPhoneBuilder extends Executor { private boolean useMetal; + + // macNative.enabled=true switches this iOS build to also emit a native Mac + // variant of the same app. All Mac-specific code lives in MacNativeBuilder + // (same package). The underlying Apple technology is Mac Catalyst, but + // that is an implementation detail -- never surfaced in hint names. + private final MacNativeBuilder macNativeBuilder = new MacNativeBuilder(this); + private boolean enableGalleryMultiselect; private boolean usePhotoKitForMultigallery; private boolean enableWKWebView, disableUIWebView; @@ -182,6 +189,12 @@ private void ensureXcodeprojInstalled() throws BuildException { private static String escapeRuby(String input) { return input.replace("\\", "\\\\").replace("'", "\\'"); } + + /** Package-private accessor so {@link MacNativeBuilder} (separate file in + * the same package) can use the same escaping helper. */ + static String escapeRubyStr(String input) { + return escapeRuby(input); + } @Override protected String getDeviceIdCode() { @@ -278,6 +291,24 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException defaultEnvironment.put("LANG", "en_US.UTF-8"); tmpFile = tmpDir = getBuildDirectory(); useMetal = "true".equals(request.getArg("ios.metal", "true")); + + // macNative: extend this iOS build to also produce a native Mac slice. + // All Mac-specific work is delegated to MacNativeBuilder; this builder + // only flips a few iOS-side knobs (Metal forced on, minimum deployment + // target floor, Ruby xcodeproj gem required) when Mac is enabled. + macNativeBuilder.parseHints(request); + if (macNativeBuilder.isEnabled()) { + // The Mac slice cannot link OpenGL ES; force Metal on regardless of + // the ios.metal hint. (Already on by default now, but defensive.) + useMetal = true; + // Catalyst requires iOS 13.1+ -> macOS 10.15+. + addMinDeploymentTarget(macNativeBuilder.getIosMinDeploymentTarget()); + // Mac requires the iPad device family. iphone-only is incompatible. + macNativeBuilder.validateProjectType(request); + // Ruby + xcodeproj gem is unconditionally required for the Mac slice. + ensureXcodeprojInstalled(); + } + log("Request Args: "); log("-----------------"); for (String arg : request.getArgs()) { @@ -1503,6 +1534,38 @@ public void usesClassMethod(String cls, String method) { } catch (IOException ex) { throw new BuildException("Error while generating native interface stub for "+currentNative, ex); } + + // The generated .m imports "Impl.h" -- the Objective-C + // class the user is expected to provide as their native + // implementation. When no such class exists for this app + // (native interfaces pulled in transitively from a CN1 + // library, the app never instantiates them), the build + // still needs an @interface in scope so the .m compiles. + // Generate a tiny placeholder iff the user hasn't dropped + // their own copy alongside the project sources. The peer + // class itself stays absent at runtime, which is fine: any + // call into this native interface from Java would have + // failed to resolve a peer regardless. + File implHeader = new File(resDir, classNameWithUnderscores + "Impl.h"); + if (!implHeader.exists()) { + String guard = classNameWithUnderscores.toUpperCase() + "_IMPL_H"; + String hStub = "#ifndef " + guard + "\n" + + "#define " + guard + "\n" + + "// Auto-generated placeholder: the native interface " + + currentNative.getName() + " has no user-provided\n" + + "// Objective-C implementation in this project. The CN1\n" + + "// runtime returns nil from cn1_createNativeInterfacePeer\n" + + "// in that case; calls into the peer no-op silently.\n" + + "#import \n" + + "@interface " + classNameWithUnderscores + "Impl : NSObject\n" + + "@end\n" + + "#endif\n"; + try (FileOutputStream out = new FileOutputStream(implHeader)) { + out.write(hStub.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Error while generating placeholder header for "+currentNative, ex); + } + } } } String javacPath = System.getProperty("java.home") + "/../bin/javac"; @@ -2154,17 +2217,30 @@ public void usesClassMethod(String cls, String method) { debug("Building using addLibs="+addLibs); stopwatch.split("Prepare ParparVM"); try { - if (!exec(userDir, env, 420000, "java", "-DsaveUnitTests=" + isUnitTestMode(), "-DfieldNullChecks=" + fieldNullChecks, "-DINCLUDE_NPE_CHECKS=" + includeNullChecks, "-Dcn1.onDeviceDebug=" + onDeviceDebug, "-DbundleVersionNumber=" + bundleVersionNumber, "-Xmx384m", - "-jar", parparVMCompilerJar, "ios", - classesDir.getAbsolutePath() + ";" + resDir.getAbsolutePath() + ";" + - buildinRes.getAbsolutePath(), - tmpFile.getAbsolutePath(), - request.getMainClass(), - request.getPackageName(), - request.getDisplayName(), - buildVersion, - request.getArg("ios.project_type", "ios"), // one of: ios, iphone, ipad - addLibs)) { + List parparCmd = new ArrayList(); + parparCmd.add("java"); + parparCmd.add("-DsaveUnitTests=" + isUnitTestMode()); + parparCmd.add("-DfieldNullChecks=" + fieldNullChecks); + parparCmd.add("-DINCLUDE_NPE_CHECKS=" + includeNullChecks); + parparCmd.add("-Dcn1.onDeviceDebug=" + onDeviceDebug); + parparCmd.add("-DbundleVersionNumber=" + bundleVersionNumber); + if (macNativeBuilder.isEnabled()) { + parparCmd.add(macNativeBuilder.parparvmOptionalFrameworksArg()); + } + parparCmd.add("-Xmx384m"); + parparCmd.add("-jar"); + parparCmd.add(parparVMCompilerJar); + parparCmd.add("ios"); + parparCmd.add(classesDir.getAbsolutePath() + ";" + resDir.getAbsolutePath() + ";" + + buildinRes.getAbsolutePath()); + parparCmd.add(tmpFile.getAbsolutePath()); + parparCmd.add(request.getMainClass()); + parparCmd.add(request.getPackageName()); + parparCmd.add(request.getDisplayName()); + parparCmd.add(buildVersion); + parparCmd.add(request.getArg("ios.project_type", "ios")); // ios, iphone, ipad + parparCmd.add(addLibs); + if (!exec(userDir, env, 420000, parparCmd.toArray(new String[0]))) { return false; } } catch (Exception ex) { @@ -2764,10 +2840,23 @@ public void usesClassMethod(String cls, String method) { addLocalizedIconsBuildSetting(pbxprojFile); String teamId = request.getArg("ios.teamId", ""); + // injectDevelopmentTeam anchors on `SDKROOT = iphoneos;`, which only + // matches the project-level XCBuildConfiguration. That stays correct + // for the iOS slice. The Mac slice's team is routed by + // MacNativeBuilder.applyXcodeSettings via a [sdk=macosx*] key, + // so this regex injection is intentionally NOT broadened. injectDevelopmentTeam(pbxprojFile, request.getArg("ios.debug.teamId", teamId), request.getArg("ios.release.teamId", teamId)); + if (macNativeBuilder.isEnabled()) { + File appSrcDir = new File(tmpFile, "dist/" + request.getMainClass() + "-src"); + macNativeBuilder.writeEntitlements(request, appSrcDir); + macNativeBuilder.writeStubHeaders(appSrcDir); + macNativeBuilder.applyXcodeSettings(request, tmpFile, buildVersion); + macNativeBuilder.writeExportOptions(request, new File(tmpFile, "dist")); + } + } catch (Exception ex) { throw new BuildException("Failed to inject into plist"); } @@ -4078,7 +4167,9 @@ private void injectDevelopmentTeam(File pbx, String debugTeam, String releaseTea } } - private String sanitizeTeamId(String raw, String hint) { + /** Package-private so {@link MacNativeBuilder} can validate its own + * {@code macNative.teamId} hint with the same regex as the iOS team. */ + String sanitizeTeamId(String raw, String hint) { if (raw == null) { return ""; } @@ -4093,6 +4184,7 @@ private String sanitizeTeamId(String raw, String hint) { return trimmed; } + private void addLocalizedIconsBuildSetting(File pbx) throws IOException { if (localizedIcons.isEmpty()) { return; @@ -4161,6 +4253,10 @@ private void normalizeAssetCatalogs(BuildRequest request) throws IOException { delTree(legacyLaunchImages); } } + + if (macNativeBuilder.isEnabled()) { + macNativeBuilder.writeAppIconset(new File(appSrcDir, "Images.xcassets"), icon512); + } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java new file mode 100644 index 0000000000..6fad313023 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java @@ -0,0 +1,572 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.apache.tools.ant.BuildException; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Calendar; + +/** + * Helper class extracted from {@link IPhoneBuilder} that owns every Mac + * native specific code path. Activated when the build hint {@code + * macNative.enabled=true} is set: parses the {@code macNative.*} hint + * family, generates the per-channel {@code .entitlements} plists, the + * {@code ExportOptions-AppStore-Mac.plist} / {@code ExportOptions- + * DeveloperID-Mac.plist} archive-export plists, the Mac iconset under + * {@code Images.xcassets/Mac.appiconset/}, the GLKit / OpenGL ES stub + * headers used by the Mac Catalyst slice, and finally a Ruby + * {@code xcodeproj}-based script that injects the {@code + * SUPPORTS_MACCATALYST=YES} family of build settings plus the + * {@code DEAD_CODE_STRIPPING / EXCLUDED_SOURCE_FILE_NAMES} workarounds + * needed for the Catalyst slice to compile + link. + * + *

This class is NOT a separate {@link Executor} -- it is a delegate + * owned by {@link IPhoneBuilder}, called at three well-defined points + * inside that builder's pipeline (hint parsing, post-project-generate + * patching, and asset-catalog finalisation). The Mac slice still + * piggybacks on the existing iOS Xcode project; this helper just adds + * the Mac-specific overlays on top. + * + *

The underlying technology is Mac Catalyst at the Xcode level, but + * that is an implementation detail and never surfaces in the build + * hint names, output directories, or methods on this class. The + * user-facing surface uses {@code macNative.*}. + */ +class MacNativeBuilder { + private final IPhoneBuilder owner; + + // Parsed hints. + private boolean enabled; + private String distribution; // appStore | developerID | both + private String teamId; + private String bundleId; + private boolean deriveBundleId; + private String minDeploymentTarget; // MACOSX_DEPLOYMENT_TARGET + private String iosMinDeploymentTarget; // IPHONEOS floor for Catalyst + private String appCategory; + private String copyright; + private String signingStyle; // automatic | manual + private String signingIdentityAppStore; + private String signingIdentityDeveloperID; + private String fixedWindowSize; // "x" or empty for native default + + MacNativeBuilder(IPhoneBuilder owner) { + this.owner = owner; + } + + boolean isEnabled() { + return enabled; + } + + /** + * Parse the {@code macNative.*} hint family off the request and + * stash the values for later. Caller is expected to flip {@code + * useMetal=true} and update the minimum deployment target since + * Catalyst won't link OpenGL ES. + */ + void parseHints(BuildRequest request) { + enabled = "true".equals(request.getArg("macNative.enabled", "false")); + if (!enabled) { + return; + } + distribution = request.getArg("macNative.distribution", "appStore"); + teamId = request.getArg("macNative.teamId", + request.getArg("ios.release.teamId", + request.getArg("ios.teamId", + request.getArg("ios.debug.teamId", "")))); + bundleId = request.getArg("macNative.bundleId", + request.getPackageName() + ".mac"); + deriveBundleId = !"false".equals(request.getArg("macNative.deriveBundleId", "true")); + minDeploymentTarget = request.getArg("macNative.minDeploymentTarget", "10.15"); + iosMinDeploymentTarget = request.getArg("macNative.iosMinDeploymentTarget", "13.1"); + appCategory = request.getArg("macNative.appCategory", "public.app-category.utilities"); + String defaultCopyright = "Copyright (c) " + + Calendar.getInstance().get(Calendar.YEAR) + + " " + (request.getVendor() != null ? request.getVendor() : request.getPackageName()); + copyright = request.getArg("macNative.copyright", defaultCopyright); + signingStyle = request.getArg("macNative.signing.style", "automatic"); + signingIdentityAppStore = request.getArg( + "macNative.signingIdentity.appStore", "Apple Distribution"); + signingIdentityDeveloperID = request.getArg( + "macNative.signingIdentity.developerID", "Developer ID Application"); + // Opt-in deterministic window size for headless screenshot CI. + // Format "WxH", e.g., "1024x685". Empty/unset preserves the + // default user-resizable Catalyst window. + fixedWindowSize = request.getArg("macNative.fixedWindowSize", "").trim(); + } + + /** + * iOS-port frameworks that must be weak-linked or omitted on the + * Mac slice. ByteCodeTranslator already honours {@code + * -Doptional.frameworks} and emits {@code ATTRIBUTES = (Weak, );} + * for each entry, so the iOS slice still links normally while + * the Mac slice tolerates absent runtime symbols at startup. + */ + String parparvmOptionalFrameworksArg() { + return "-Doptional.frameworks=AddressBookUI.framework;" + + "AddressBook.framework;MessageUI.framework;" + + "MediaPlayer.framework;GLKit.framework;OpenGLES.framework"; + } + + String getIosMinDeploymentTarget() { + return iosMinDeploymentTarget; + } + + /** + * Write the per-channel {@code .entitlements} plists into {@code + * appSrcDir}. For {@code distribution=both} two files are emitted + * (suffixed {@code -AppStore} / {@code -DeveloperID}); for a single + * channel a single file named after the main class is emitted. + */ + void writeEntitlements(BuildRequest request, File appSrcDir) throws IOException { + appSrcDir.mkdirs(); + if ("both".equalsIgnoreCase(distribution)) { + writeEntitlementsFile(request, appSrcDir, + request.getMainClass() + "-AppStore", "appStore"); + writeEntitlementsFile(request, appSrcDir, + request.getMainClass() + "-DeveloperID", "developerID"); + } else { + writeEntitlementsFile(request, appSrcDir, + request.getMainClass(), distribution); + } + } + + private void writeEntitlementsFile(BuildRequest request, File appSrcDir, + String baseName, String channel) throws IOException { + boolean sandbox = parseEntitlementBool(request, + "macNative.entitlements.appSandbox", + "appStore".equalsIgnoreCase(channel)); + boolean networkClient = parseEntitlementBool(request, + "macNative.entitlements.network.client", true); + boolean networkServer = parseEntitlementBool(request, + "macNative.entitlements.network.server", false); + String filesUserSelected = request.getArg( + "macNative.entitlements.files.userSelected", "readwrite").toLowerCase(); + boolean hardenedRuntime = parseEntitlementBool(request, + "macNative.entitlements.hardenedRuntime", + "developerID".equalsIgnoreCase(channel)); + boolean allowJit = parseEntitlementBool(request, + "macNative.entitlements.allowJit", false); + String extra = request.getArg("macNative.entitlements.extra", ""); + + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + sb.append("\n\n"); + if (sandbox) { + sb.append(" com.apple.security.app-sandbox\n \n"); + } + if (networkClient) { + sb.append(" com.apple.security.network.client\n \n"); + } + if (networkServer) { + sb.append(" com.apple.security.network.server\n \n"); + } + if ("readwrite".equals(filesUserSelected)) { + sb.append(" com.apple.security.files.user-selected.read-write\n \n"); + sb.append(" com.apple.security.files.downloads.read-write\n \n"); + } else if ("readonly".equals(filesUserSelected)) { + sb.append(" com.apple.security.files.user-selected.read-only\n \n"); + } + if (hardenedRuntime && !allowJit) { + sb.append(" com.apple.security.cs.allow-jit\n \n"); + sb.append(" com.apple.security.cs.allow-unsigned-executable-memory\n \n"); + } else if (allowJit) { + sb.append(" com.apple.security.cs.allow-jit\n \n"); + } + if (extra != null && extra.trim().length() > 0) { + sb.append(extra); + if (!extra.endsWith("\n")) { + sb.append("\n"); + } + } + sb.append("\n\n"); + + File ent = new File(appSrcDir, baseName + ".entitlements"); + try (Writer w = new OutputStreamWriter(Files.newOutputStream(ent.toPath()), StandardCharsets.UTF_8)) { + w.write(sb.toString()); + } + owner.log("Wrote Mac entitlements: " + ent.getAbsolutePath() + " (channel=" + channel + ")"); + } + + private static boolean parseEntitlementBool(BuildRequest request, String hint, boolean def) { + return Boolean.parseBoolean(request.getArg(hint, Boolean.toString(def))); + } + + /** + * Write {@code ExportOptions-AppStore-Mac.plist} and/or {@code + * ExportOptions-DeveloperID-Mac.plist} into {@code distDir}, plus + * log the matching {@code xcodebuild archive} / {@code -exportArchive} + * command so a downstream operator can complete the export. + */ + void writeExportOptions(BuildRequest request, File distDir) throws IOException { + distDir.mkdirs(); + if ("both".equalsIgnoreCase(distribution)) { + writeExportOptionsFile(request, distDir, "appStore"); + writeExportOptionsFile(request, distDir, "developerID"); + } else { + writeExportOptionsFile(request, distDir, distribution); + } + owner.log("Use xcodebuild to archive and export the Mac app, e.g.:"); + owner.log(" xcodebuild -project " + request.getMainClass() + ".xcodeproj" + + " -scheme " + request.getMainClass() + + " -destination 'generic/platform=macOS,variant=Mac Catalyst'" + + " -archivePath build/" + request.getMainClass() + ".xcarchive archive"); + owner.log(" xcodebuild -exportArchive -archivePath build/" + request.getMainClass() + + ".xcarchive -exportOptionsPlist ExportOptions--Mac.plist" + + " -exportPath build/export"); + } + + private void writeExportOptionsFile(BuildRequest request, File distDir, String channel) + throws IOException { + boolean isAppStore = "appStore".equalsIgnoreCase(channel); + String method = isAppStore ? "app-store" : "developer-id"; + String signingIdentity = isAppStore + ? signingIdentityAppStore : signingIdentityDeveloperID; + String resolvedTeamId = owner.sanitizeTeamId(teamId, "macNative.teamId"); + + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + sb.append("\n\n"); + sb.append(" destination\n export\n"); + sb.append(" method\n ").append(method).append("\n"); + if (resolvedTeamId != null && !resolvedTeamId.isEmpty()) { + sb.append(" teamID\n ").append(resolvedTeamId).append("\n"); + } + sb.append(" signingStyle\n ") + .append("manual".equalsIgnoreCase(signingStyle) ? "manual" : "automatic") + .append("\n"); + if (signingIdentity != null && !signingIdentity.isEmpty()) { + sb.append(" signingCertificate\n ") + .append(signingIdentity).append("\n"); + } + if ("manual".equalsIgnoreCase(signingStyle)) { + String profile = request.getArg( + "macNative.provisioningProfile." + (isAppStore ? "appStore" : "developerID"), + ""); + if (!profile.isEmpty()) { + String macBundleId = deriveBundleId + ? request.getPackageName() + ".maccatalyst" + : bundleId; + sb.append(" provisioningProfiles\n \n") + .append(" ").append(macBundleId).append("\n") + .append(" ").append(profile).append("\n") + .append(" \n"); + } + } + sb.append("\n\n"); + + String label = isAppStore ? "AppStore" : "DeveloperID"; + File f = new File(distDir, "ExportOptions-" + label + "-Mac.plist"); + try (Writer w = new OutputStreamWriter(Files.newOutputStream(f.toPath()), StandardCharsets.UTF_8)) { + w.write(sb.toString()); + } + owner.log("Wrote Mac ExportOptions: " + f.getAbsolutePath()); + } + + /** + * Emit {@code Images.xcassets/Mac.appiconset/} so {@code actool} + * picks up the Mac icon during the Mac slice build. Maps the + * existing 1024 source icon as the largest size; actool scales + * down the rest. + */ + void writeAppIconset(File assetCatalogDir, File icon512) throws IOException { + File iconset = new File(assetCatalogDir, "Mac.appiconset"); + iconset.mkdirs(); + File source = icon512; + if (source == null || !source.exists()) { + File alt = new File(assetCatalogDir, "AppIcon.appiconset/Icon-1024.png"); + if (alt.exists()) { + source = alt; + } + } + if (source == null || !source.exists()) { + owner.log("Skipping Mac.appiconset generation: no 512/1024 source icon available"); + return; + } + File dest = new File(iconset, "icon_512x512@2x.png"); + Executor.copy(source, dest); + StringBuilder json = new StringBuilder(); + json.append("{\n \"images\" : [\n"); + json.append(" { \"size\" : \"512x512\", \"idiom\" : \"mac\", \"filename\" : \"icon_512x512@2x.png\", \"scale\" : \"2x\" }\n"); + json.append(" ],\n \"info\" : { \"version\" : 1, \"author\" : \"xcode\" }\n}\n"); + File contents = new File(iconset, "Contents.json"); + try (Writer w = new OutputStreamWriter(Files.newOutputStream(contents.toPath()), StandardCharsets.UTF_8)) { + w.write(json.toString()); + } + owner.log("Wrote Mac.appiconset at " + iconset.getAbsolutePath()); + } + + /** + * Stub headers for GLKit and OpenGL ES, used only by the Mac + * Catalyst slice via {@code HEADER_SEARCH_PATHS[sdk=macosx*]}. + * These satisfy the umbrella {@code #import} lines in the iOS-port + * headers ({@code GLViewController.h}, {@code EAGLView.h}, + * {@code CN1ES2compat.h}, etc.) so the project compiles for Mac. + * Real GL calls are preprocessed out by {@code #ifdef CN1_USE_METAL} + * or in {@code EXCLUDED_SOURCE_FILE_NAMES}-excluded .m files. + */ + void writeStubHeaders(File appSrcDir) throws IOException { + File stubsDir = new File(appSrcDir, "macCatalystStubs"); + File openGLES = new File(stubsDir, "OpenGLES"); + File openGLESes1 = new File(openGLES, "ES1"); + File openGLESes2 = new File(openGLES, "ES2"); + File glkit = new File(stubsDir, "GLKit"); + openGLESes1.mkdirs(); + openGLESes2.mkdirs(); + glkit.mkdirs(); + writeStub(new File(openGLES, "EAGL.h"), + "#ifndef CN1_MAC_CATALYST_STUB_EAGL_H\n" + + "#define CN1_MAC_CATALYST_STUB_EAGL_H\n" + + "#import \n" + + "@interface EAGLContext : NSObject @end\n" + + "typedef enum { kEAGLRenderingAPIOpenGLES1 = 1," + + " kEAGLRenderingAPIOpenGLES2 = 2," + + " kEAGLRenderingAPIOpenGLES3 = 3 } EAGLRenderingAPI;\n" + + "#endif\n"); + String glTypes = + "#ifndef CN1_MAC_CATALYST_STUB_GLES_TYPES\n" + + "#define CN1_MAC_CATALYST_STUB_GLES_TYPES\n" + + "typedef unsigned int GLenum;\n" + + "typedef unsigned int GLuint;\n" + + "typedef int GLint;\n" + + "typedef int GLsizei;\n" + + "typedef float GLfloat;\n" + + "typedef float GLclampf;\n" + + "typedef unsigned char GLubyte;\n" + + "typedef unsigned char GLboolean;\n" + + "typedef void GLvoid;\n" + + "typedef signed char GLbyte;\n" + + "typedef short GLshort;\n" + + "typedef unsigned short GLushort;\n" + + "typedef int GLfixed;\n" + + "typedef unsigned int GLbitfield;\n" + + "typedef long GLintptr;\n" + + "typedef long GLsizeiptr;\n" + + "#endif\n"; + writeStub(new File(openGLESes1, "gl.h"), glTypes); + writeStub(new File(openGLESes1, "glext.h"), ""); + writeStub(new File(openGLESes2, "gl.h"), glTypes); + writeStub(new File(openGLESes2, "glext.h"), ""); + writeStub(new File(glkit, "GLKit.h"), + "#ifndef CN1_MAC_CATALYST_STUB_GLKIT_H\n" + + "#define CN1_MAC_CATALYST_STUB_GLKIT_H\n" + + "#import \n" + + "#import \n" + + "typedef struct { float m[16]; } GLKMatrix4;\n" + + "typedef struct { float v[4]; } GLKVector4;\n" + + "typedef struct { float v[3]; } GLKVector3;\n" + + "typedef struct { float v[2]; } GLKVector2;\n" + + "@interface GLKView : NSObject @end\n" + + "@interface GLKBaseEffect : NSObject @end\n" + + "@interface GLKTextureLoader : NSObject @end\n" + + "@interface GLKTextureInfo : NSObject @end\n" + + "#endif\n"); + owner.log("Wrote Mac Catalyst stub headers under " + stubsDir.getAbsolutePath()); + } + + private static void writeStub(File f, String content) throws IOException { + try (Writer w = new OutputStreamWriter(Files.newOutputStream(f.toPath()), StandardCharsets.UTF_8)) { + w.write(content); + } + } + + /** + * Patch the generated {@code project.pbxproj} via Ruby + the + * {@code xcodeproj} gem so the app target gains {@code + * SUPPORTS_MACCATALYST=YES}, the right deployment targets, the + * signing wiring per channel, and the workarounds needed for the + * Catalyst slice (excluded GL-only sources, stub-header search + * path, OpenGLES iOS-only re-link, etc.). + */ + void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) + throws BuildException { + File hooksDir = new File(tmpFile, "hooks"); + hooksDir.mkdir(); + File scriptFile = new File(hooksDir, "apply_mac_native_settings.rb"); + String mainClass = request.getMainClass(); + String projectFile = new File(tmpFile, "dist/" + mainClass + ".xcodeproj").getAbsolutePath(); + String resolvedTeamId = owner.sanitizeTeamId(teamId, "macNative.teamId"); + boolean manualSigning = "manual".equalsIgnoreCase(signingStyle); + + // For the "both" case the AppStore variant is wired as the default + // CODE_SIGN_ENTITLEMENTS; xcodebuild -exportOptionsPlist picks up the + // DeveloperID entitlements via the matching ExportOptions file. + String entitlementsLeaf = "both".equalsIgnoreCase(distribution) + ? mainClass + "-AppStore.entitlements" + : mainClass + ".entitlements"; + String entitlementsPath = mainClass + "-src/" + entitlementsLeaf; + + StringBuilder s = new StringBuilder(); + s.append("#!/usr/bin/env ruby\n") + .append("require 'xcodeproj'\n") + .append("project_file = '").append(IPhoneBuilder.escapeRubyStr(projectFile)).append("'\n") + .append("xcproj = Xcodeproj::Project.open(project_file)\n") + .append("target = xcproj.targets.find { |t| t.name == '") + .append(IPhoneBuilder.escapeRubyStr(mainClass)).append("' }\n") + .append("abort('Unable to find app target ").append(IPhoneBuilder.escapeRubyStr(mainClass)) + .append("') unless target\n") + .append("target.build_configurations.each do |config|\n") + .append(" bs = config.build_settings\n") + .append(" bs['SUPPORTS_MACCATALYST'] = 'YES'\n") + .append(" bs['SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD'] = 'NO'\n") + .append(" bs['TARGETED_DEVICE_FAMILY'] = '1,2,6'\n") + .append(" bs['DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER'] = '") + .append(deriveBundleId ? "YES" : "NO").append("'\n"); + if (!deriveBundleId) { + s.append(" bs['PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]'] = '") + .append(IPhoneBuilder.escapeRubyStr(bundleId)).append("'\n"); + } + s.append(" bs['MACOSX_DEPLOYMENT_TARGET'] = '") + .append(IPhoneBuilder.escapeRubyStr(minDeploymentTarget)).append("'\n") + .append(" bs['IPHONEOS_DEPLOYMENT_TARGET'] = '") + .append(IPhoneBuilder.escapeRubyStr(iosMinDeploymentTarget)).append("'\n") + .append(" bs['MARKETING_VERSION'] = '") + .append(IPhoneBuilder.escapeRubyStr(request.getVersion() == null ? "1.0" : request.getVersion())).append("'\n") + .append(" bs['CURRENT_PROJECT_VERSION'] = '") + .append(IPhoneBuilder.escapeRubyStr(buildVersion == null ? "1" : buildVersion)).append("'\n") + .append(" bs['LD_RUNPATH_SEARCH_PATHS'] = '$(inherited) @executable_path/Frameworks @executable_path/../Frameworks'\n") + .append(" bs['INFOPLIST_KEY_LSApplicationCategoryType'] = '") + .append(IPhoneBuilder.escapeRubyStr(appCategory)).append("'\n") + .append(" bs['INFOPLIST_KEY_NSHumanReadableCopyright'] = '") + .append(IPhoneBuilder.escapeRubyStr(copyright)).append("'\n"); + s.append(" bs['CODE_SIGN_ENTITLEMENTS'] = '") + .append(IPhoneBuilder.escapeRubyStr(entitlementsPath)).append("'\n") + .append(" bs['CODE_SIGN_STYLE'] = '") + .append(manualSigning ? "Manual" : "Automatic").append("'\n"); + if (resolvedTeamId != null && !resolvedTeamId.isEmpty()) { + s.append(" bs['DEVELOPMENT_TEAM[sdk=macosx*]'] = '").append(resolvedTeamId).append("'\n"); + } + if (manualSigning) { + if (signingIdentityAppStore != null && !signingIdentityAppStore.isEmpty()) { + s.append(" bs['CODE_SIGN_IDENTITY[sdk=macosx*]'] = '") + .append(IPhoneBuilder.escapeRubyStr(signingIdentityAppStore)).append("'\n"); + } + } + // OpenGL ES backbone files have no Mac Catalyst equivalent (GLKit / + // OpenGLES headers are missing from recent macOS SDKs). Exclude them + // from the Mac slice via EXCLUDED_SOURCE_FILE_NAMES so the build + // compiles. The rendering-op .m files have internal #ifdef CN1_USE_METAL + // guards that route to the Metal path on Mac. + // All four iOS XIBs trigger an IBAgent-macOS-UIKit internal error + // when compiled for the Mac slice (observed on Xcode 26.x). + // CodenameOne_GLAppDelegate.m has a TARGET_OS_MACCATALYST branch + // that passes nil to initWithNibName: on Mac, so the runtime never + // tries to load these NIBs by name and excluding them at compile + // time is safe. The iOS slice keeps loading them normally. + s.append(" bs['EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*]'] = ") + .append("'CN1ES2compat.m CN1ES1compat.m EAGLView.m ") + .append("CodenameOne_GLViewController.xib MainWindow.xib ") + .append("CodenameOne_METALViewController.xib MainWindowMETAL.xib'\n"); + // Header search path stubs for the Mac slice: the iOS port ships an + // umbrella set of empty/stub GLKit and OpenGLES headers under + // macCatalystStubs/. + s.append(" bs['HEADER_SEARCH_PATHS[sdk=macosx*]'] = ") + .append("'$(inherited) $(SRCROOT)/").append(IPhoneBuilder.escapeRubyStr(mainClass)) + .append("-src/macCatalystStubs'\n"); + s.append("end\n"); + // OpenGLES.framework is absent from the macOS SDK. Drop the + // OpenGLES build-file entry from the unconditional Frameworks phase + // and re-add it only for the iOS slice via OTHER_LDFLAGS[sdk=iphoneos*]. + // GLKit stays in the build phase but is marked Weak so any genuinely- + // absent symbol surfaces at runtime, not link. + s.append("removed_refs = []\n"); + s.append("target.frameworks_build_phase.files.to_a.each do |bf|\n") + .append(" ref = bf.file_ref\n") + .append(" next unless ref && ref.path\n") + .append(" base = File.basename(ref.path)\n") + .append(" if base == 'OpenGLES.framework'\n") + .append(" removed_refs << ref\n") + .append(" bf.remove_from_project\n") + .append(" elsif base == 'GLKit.framework'\n") + .append(" bf.settings ||= {}\n") + .append(" attrs = (bf.settings['ATTRIBUTES'] || []).dup\n") + .append(" attrs << 'Weak' unless attrs.include?('Weak')\n") + .append(" bf.settings['ATTRIBUTES'] = attrs\n") + .append(" end\n") + .append("end\n"); + // Force DEAD_CODE_STRIPPING=YES for the Mac slice. The iOS port + // declares a handful of native JNI methods (java.io.File hidden / + // directory probes, IOSNative biometrics, etc.) in headers but ships + // their C bodies in template files outside the per-app source tree; + // iOS strips them via `-dead_strip`, Mac Catalyst doesn't by default + // in Debug, so those refs surface as link errors without this flag. + s.append("target.build_configurations.each do |config|\n") + .append(" bs = config.build_settings\n") + .append(" existing = bs['OTHER_LDFLAGS[sdk=iphoneos*]'] || '$(inherited)'\n") + .append(" bs['OTHER_LDFLAGS[sdk=iphoneos*]'] = existing + ' -framework OpenGLES'\n") + .append(" existing_sim = bs['OTHER_LDFLAGS[sdk=iphonesimulator*]'] || '$(inherited)'\n") + .append(" bs['OTHER_LDFLAGS[sdk=iphonesimulator*]'] = existing_sim + ' -framework OpenGLES'\n") + .append(" bs['DEAD_CODE_STRIPPING[sdk=macosx*]'] = 'YES'\n") + .append("end\n"); + s.append("xcproj.save\n"); + + try { + owner.createFile(scriptFile, s.toString().getBytes(StandardCharsets.UTF_8)); + owner.exec(hooksDir, "chmod", "0755", scriptFile.getAbsolutePath()); + if (!owner.exec(hooksDir, scriptFile.getAbsolutePath())) { + throw new BuildException("Failed to apply macNative Xcode settings via xcodeproj"); + } + } catch (BuildException ex) { + throw ex; + } catch (Exception ex) { + throw new BuildException("Failed to apply macNative Xcode settings via xcodeproj", ex); + } + + // Custom Info.plist keys (e.g. CN1MacFixedWindowSize) -- INFOPLIST_KEY_* + // build settings only flow through for Apple-defined keys, so for our + // own keys we patch the generated Info.plist directly. + if (fixedWindowSize != null && !fixedWindowSize.isEmpty()) { + File infoPlist = new File(tmpFile, + "dist/" + mainClass + "-src/" + mainClass + "-Info.plist"); + String injection = "CN1MacFixedWindowSize\n" + + " " + fixedWindowSize + "\n" + + " \n"; + try { + owner.replaceInFile(infoPlist, "\n", injection); + } catch (IOException ex) { + throw new BuildException("Failed to inject CN1MacFixedWindowSize into Info.plist", ex); + } + } + } + + /** + * Friendly error when the user combined macNative.enabled=true with + * ios.project_type=iphone. Mac requires the iPad device family. + */ + void validateProjectType(BuildRequest request) { + if ("iphone".equalsIgnoreCase(request.getArg("ios.project_type", "ios"))) { + throw new BuildException("macNative.enabled=true is incompatible with ios.project_type=iphone. " + + "Use 'ios' (universal) or 'ipad'."); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index f1e0acd84b..7030361360 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -311,8 +311,12 @@ private File getStringsJar() throws IOException { return null; } + public static final String BUILD_TARGET_MAC_NATIVE_PROJECT = Executor.BUILD_TARGET_MAC_NATIVE_PROJECT; + private boolean isLocalBuildTarget(String buildTarget) { - return (buildTarget.startsWith("local-") || BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)); + return (buildTarget.startsWith("local-") || BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) + || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget) + || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)); } private void createAntProject() throws IOException, LibraryPropertiesException, MojoExecutionException { @@ -596,6 +600,12 @@ private void createAntProject() throws IOException, LibraryPropertiesException, automated = false; if (buildTarget.contains("android") || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { doAndroidLocalBuild(antProject, cn1SettingsProps, antDistJar); + } else if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { + // mac-source rides the iOS pipeline with the macNative.enabled + // build hint forced on. The IPhoneBuilder delegates Mac-specific + // emission to MacNativeBuilder when that hint is set. + cn1SettingsProps.setProperty("codename1.arg.macNative.enabled", "true"); + doIOSLocalBuild(antProject, cn1SettingsProps, antDistJar); } else if (buildTarget.contains("ios") || BUILD_TARGET_XCODE_PROJECT.equals(buildTarget)) { doIOSLocalBuild(antProject, cn1SettingsProps, antDistJar); } else if (buildTarget.contains("javascript")) { @@ -604,6 +614,12 @@ private void createAntProject() throws IOException, LibraryPropertiesException, throw new MojoExecutionException("Build target not supported "+buildTarget); } } else { + // Cloud-builds route through a remote build server; for the Mac + // native target we set the same macNative.enabled hint here so + // the server-side IPhoneBuilder takes the Mac branch. + if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { + cn1SettingsProps.setProperty("codename1.arg.macNative.enabled", "true"); + } if (automated) { getLog().debug("Attempting to start hyper beam stream the build log to the console"); hyperBeamThread.start(); @@ -742,6 +758,14 @@ private File getGeneratedIOSProjectSourceDirectory() { return new File(project.getBuild().getDirectory(), project.getBuild().getFinalName() + "-ios-source"); } + private File getGeneratedMacProjectSourceDirectory() { + return new File(project.getBuild().getDirectory(), project.getBuild().getFinalName() + "-mac-source"); + } + + private boolean isMacNativeBuild(Properties props) { + return "true".equalsIgnoreCase(props.getProperty("codename1.arg.macNative.enabled", "false")); + } + private void doAndroidLocalBuild(File tmpProjectDir, Properties props, File distJar) throws MojoExecutionException { if (BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { @@ -974,9 +998,13 @@ private void openWorkspace(File workspace) throws MojoExecutionException { private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) throws MojoExecutionException { - if (BUILD_TARGET_XCODE_PROJECT.equals(buildTarget)) { + boolean macNativeBuild = isMacNativeBuild(props); + + if (BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { - File generatedProject = getGeneratedIOSProjectSourceDirectory(); + File generatedProject = macNativeBuild + ? getGeneratedMacProjectSourceDirectory() + : getGeneratedIOSProjectSourceDirectory(); getLog().info("Generating Xcode Project to "+generatedProject+"..."); try { if (generatedProject.exists()) { @@ -1002,7 +1030,8 @@ private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) IPhoneBuilder e = new IPhoneBuilder(); e.setLogger(getLog()); - File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "ios-build"); + File buildDirectory = new File(tmpProjectDir, + "dist" + File.separator + (macNativeBuild ? "mac-build" : "ios-build")); e.setBuildDirectory(buildDirectory); e.setCodenameOneJar(codenameOneJar); @@ -1064,9 +1093,11 @@ private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) throw new MojoExecutionException("iOS build failed"); } - if (BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) && e.getXcodeProjectDir() != null) { + if ((BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) && e.getXcodeProjectDir() != null) { File xcodeProject = e.getXcodeProjectDir(); - File output = getGeneratedIOSProjectSourceDirectory(); + File output = macNativeBuild + ? getGeneratedMacProjectSourceDirectory() + : getGeneratedIOSProjectSourceDirectory(); output.getParentFile().mkdirs(); try { getLog().info("Copying Xcode Project to "+output); diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml index 273421c8da..3db9f4de7b 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml @@ -59,6 +59,38 @@ This the Ant build script used by the CN1BuildMojo for sending to the build serv /> + + + + + + }" +export CN1SS_COMMENT_LOG_PREFIX="${CN1SS_COMMENT_LOG_PREFIX:-[run-mac-native-ui-tests]}" +export CN1SS_PREVIEW_SUBDIR="${CN1SS_PREVIEW_SUBDIR:-mac-native}" +export CN1SS_SUCCESS_MESSAGE="${CN1SS_SUCCESS_MESSAGE:-✅ Native Mac screenshot tests passed.}" +REPORT_TITLE="${CN1SS_REPORT_TITLE:-Mac native screenshot updates}" + +CN1SS_VM_TIME=0 +if [ -f "$ARTIFACTS_DIR/vm_time.txt" ]; then + CN1SS_VM_TIME=$(cat "$ARTIFACTS_DIR/vm_time.txt") + rm_log "Loaded VM translation time: ${CN1SS_VM_TIME}s" +fi +export CN1SS_VM_TIME +export CN1SS_COMPILATION_TIME="$COMPILATION_TIME" + +cn1ss_process_and_report \ + "$REPORT_TITLE" \ + "$COMPARE_JSON" \ + "$SUMMARY_FILE" \ + "$COMMENT_FILE" \ + "$SCREENSHOT_REF_DIR" \ + "$SCREENSHOT_PREVIEW_DIR" \ + "$ARTIFACTS_DIR" \ + "${COMPARE_ENTRIES[@]}" +comment_rc=$? + +cp -f "$BUILD_LOG" "$ARTIFACTS_DIR/xcodebuild-build.log" 2>/dev/null || true +cp -f "$TEST_LOG" "$ARTIFACTS_DIR/device-runner.log" 2>/dev/null || true + +# Guard: the suite must produce at least this many screenshots. Matches +# the iOS pipeline's CN1SS_MIN_SCREENSHOTS so a regression that crashes +# the app early surfaces here too. Defaults to 0 on the first run so the +# goldens dir can be seeded without immediately failing CI. +MIN_SCREENSHOTS="${CN1SS_MIN_SCREENSHOTS:-0}" +if [ -s "$COMPARE_JSON" ]; then + ACTUAL_COUNT="$(python3 -c "import json,sys +try: + with open(sys.argv[1]) as f: + d = json.load(f) + print(len(d.get('results', []))) +except Exception as e: + print(0)" "$COMPARE_JSON" 2>/dev/null || echo 0)" +else + ACTUAL_COUNT=0 +fi +if [ "$ACTUAL_COUNT" -lt "$MIN_SCREENSHOTS" ]; then + rm_log "STAGE:SCREENSHOT_COUNT_REGRESSION -> got $ACTUAL_COUNT, expected >= $MIN_SCREENSHOTS" + exit 17 +fi +rm_log "Screenshot count check passed: $ACTUAL_COUNT >= $MIN_SCREENSHOTS" + +exit $comment_rc