appstore-precheck 1.1.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,7 +134,9 @@ fi
134
134
 
135
135
  # Locales: explicit config array, else the directory names under metadata/.
136
136
  declare -a LOCALES=()
137
+ LOCALES_FROM_CONFIG=""
137
138
  if [[ -f "$CONFIG" ]] && have_jq && [[ "$(jq -r '.locales | type' "$CONFIG" 2>/dev/null)" == "array" ]]; then
139
+ LOCALES_FROM_CONFIG=1
138
140
  while IFS= read -r l; do LOCALES+=("$l"); done < <(jq -r '.locales[]' "$CONFIG" 2>/dev/null)
139
141
  elif [[ -d "$META_DIR" ]]; then
140
142
  while IFS= read -r d; do
@@ -151,24 +153,28 @@ CHECK_FAMILY="$(cfg_bool '.optionalChecks.familyControls')"
151
153
  echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='${XCSTRINGS:-?}' locales=${#LOCALES[@]}"
152
154
 
153
155
  # ===================================================================
154
- # §1 — 5.1.1(v) Privacy Manifest Required Reason API parity
156
+ # §1 — 5.1.1 Privacy Manifest / Required Reason API parity
157
+ # Apple documents the Required Reason API rules under 5.1.1 (Data Collection and
158
+ # Storage) + the privacy-manifest developer docs; it is NOT roman sub-item (v).
159
+ # (v) "Account Sign-In" is a different rule — its account-deletion requirement is
160
+ # checked separately in §38.
155
161
  # ===================================================================
156
162
  check_required_reason_api() {
157
163
  local cat="$1" pattern="$2" hits declared
158
164
  hits=$(grep -rEl "$pattern" "$IOS_DIR" --include="*.swift" 2>/dev/null | head -3)
159
165
  declared=$(grep -c "NSPrivacyAccessedAPICategory${cat}" "$PRIVACY_FILE" 2>/dev/null)
160
166
  if [[ -n "$hits" && "${declared:-0}" -eq 0 ]]; then
161
- fail "5.1.1(v) Required Reason API — '$cat' used in code (e.g. $(echo "$hits" | head -1)) but not declared in PrivacyInfo.xcprivacy"
167
+ fail "5.1.1 Required Reason API — '$cat' used in code (e.g. $(echo "$hits" | head -1)) but not declared in PrivacyInfo.xcprivacy"
162
168
  elif [[ -z "$hits" && "${declared:-0}" -gt 0 ]]; then
163
- warn "5.1.1(v) PrivacyInfo — '$cat' declared but no code usage grepped (may be a false positive, verify manually)"
169
+ warn "5.1.1 PrivacyInfo — '$cat' declared but no code usage grepped (may be a false positive, verify manually)"
164
170
  elif [[ -n "$hits" && "${declared:-0}" -gt 0 ]]; then
165
- pass "5.1.1(v) Required Reason API — '$cat' parity OK"
171
+ pass "5.1.1 Required Reason API — '$cat' parity OK"
166
172
  fi
167
173
  }
168
174
  if [[ -z "$IOS_DIR" ]]; then
169
175
  warn "layout — could not auto-detect iOS source dir; set .iosSourceDir in $CONFIG"
170
176
  elif [[ -z "$PRIVACY_FILE" ]]; then
171
- fail "5.1.1(v) PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)"
177
+ fail "5.1.1 Required Reason API — PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)"
172
178
  else
173
179
  check_required_reason_api "UserDefaults" 'UserDefaults|@AppStorage'
174
180
  check_required_reason_api "FileTimestamp" 'attributesOfItem|creationDate|modificationDate|\.fileCreationDate|\.fileModificationDate'
@@ -266,7 +272,18 @@ if (( ${#LOCALES[@]} > 0 )); then
266
272
  expected_files=(name.txt subtitle.txt description.txt keywords.txt)
267
273
  for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
268
274
  d="$META_DIR/$loc"
269
- if [[ ! -d "$d" ]]; then fail "2.3.7 Locale missing — $d does not exist"; continue; fi
275
+ if [[ ! -d "$d" ]]; then
276
+ # A locale listed in .appstore-precheck.json but absent on disk is a
277
+ # config/reality mismatch, not a build fault: the locale was simply never
278
+ # submitted. Warn (don't block) so an approved set isn't falsely RED.
279
+ # Auto-detected locales always exist, so this only fires in config mode.
280
+ if [[ -n "$LOCALES_FROM_CONFIG" ]]; then
281
+ warn "2.3.7 Locale '$loc' is in .appstore-precheck.json but has no metadata folder ($d) — add it or remove '$loc' from the config 'locales' list"
282
+ else
283
+ fail "2.3.7 Locale missing — $d does not exist"
284
+ fi
285
+ continue
286
+ fi
270
287
  for f in "${expected_files[@]}"; do
271
288
  [[ -s "$d/$f" ]] || fail "2.3.7 Metadata missing — $d/$f is empty or absent"
272
289
  done
@@ -370,13 +387,13 @@ else
370
387
  fi
371
388
 
372
389
  # ===================================================================
373
- # §12 — 4.0 Minimum functionality — navigation hubs
390
+ # §12 — 4.2 Minimum functionality — navigation hubs
374
391
  # ===================================================================
375
392
  tab_count=$(grep -rcE 'TabView|NavigationStack|NavigationSplitView' "$IOS_DIR" --include="*.swift" 2>/dev/null | awk -F: '{sum+=$2} END {print sum+0}')
376
393
  if (( tab_count < 1 )); then
377
- warn "4.0 Minimum functionality — no TabView/NavigationStack found (heuristic, may be a false positive)"
394
+ warn "4.2 Minimum functionality — no TabView/NavigationStack found (heuristic, may be a false positive)"
378
395
  else
379
- pass "4.0 Minimum functionality — $tab_count navigation hub(s) found"
396
+ pass "4.2 Minimum functionality — $tab_count navigation hub(s) found"
380
397
  fi
381
398
 
382
399
  # ===================================================================
@@ -460,10 +477,10 @@ if [[ -d "$META_DIR" ]]; then
460
477
  [[ -s "$META_DIR/$loc/privacy_url.txt" ]] && privacy_found=1
461
478
  done
462
479
  if (( ${#LOCALES[@]} > 0 )); then
463
- [[ -z "$support_found" ]] && warn "2.3 Support URL — no non-empty support_url.txt in any locale under fastlane metadata; Apple requires a working support URL"
464
- [[ -z "$privacy_found" ]] && warn "2.3 Privacy URL — no non-empty privacy_url.txt in any locale under fastlane metadata; required for apps with accounts or in-app purchases"
480
+ [[ -z "$support_found" ]] && warn "2.3 Support URL — no non-empty support_url.txt in any locale under fastlane metadata; Apple requires a working support URL with developer contact info (1.5 / 2.3)"
481
+ [[ -z "$privacy_found" ]] && warn "2.3 Privacy URL — no non-empty privacy_url.txt in any locale under fastlane metadata; a privacy policy link is required for every app, and especially apps with accounts or in-app purchases (5.1.1(i))"
465
482
  fi
466
- url_ph=$(grep -rEnI 'example\.com|localhost|\bTODO\b|changeme' "$META_DIR" --include='*_url.txt' 2>/dev/null | grep -v "^Binary file" | head -10)
483
+ url_ph=$(grep -rEnI 'example\.com|localhost|\bTODO\b|\bchangeme\b' "$META_DIR" --include='*_url.txt' 2>/dev/null | grep -v "^Binary file" | head -10)
467
484
  if [[ -n "$url_ph" ]]; then
468
485
  warn "2.3 Metadata URL — placeholder URL in fastlane metadata (replace before submitting):"
469
486
  echo "$url_ph" | sed 's/^/ /'
@@ -502,7 +519,7 @@ fi
502
519
  # avoid flagging legitimate words. Overlaps the Phase 2 precheck "No placeholder
503
520
  # text" rule, but this one is local and runs before any network call.
504
521
  if [[ -d "$META_DIR" ]]; then
505
- ph_re='lorem ipsum|Lorem ipsum|\bTODO\b|\bFIXME\b|example\.com|placeholder|insert .* here|changeme'
522
+ ph_re='lorem ipsum|Lorem ipsum|\bTODO\b|\bFIXME\b|example\.com|placeholder|insert .* here|\bchangeme\b'
506
523
  ph_hits=$(grep -rEnI "$ph_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -20)
507
524
  if [[ -n "$ph_hits" ]]; then
508
525
  warn "2.1 Metadata content — placeholder/dummy text in store metadata (looks unfinished; rejected under 2.1):"
@@ -510,4 +527,309 @@ if [[ -d "$META_DIR" ]]; then
510
527
  fi
511
528
  fi
512
529
 
530
+ # ===================================================================
531
+ # §21 — 3.1.1 Third-party payment SDK for digital goods
532
+ # ===================================================================
533
+ # Apple requires in-app purchase to unlock digital content/functionality. A
534
+ # third-party payment SDK (Stripe, Braintree, PayPal, …) is legitimate for
535
+ # PHYSICAL goods/services but a frequent rejection when used to sell digital
536
+ # content. We can't tell digital from physical statically, so this is advisory.
537
+ if [[ -n "$IOS_DIR" ]]; then
538
+ payment_sdk=$(grep -rlE 'import Stripe|StripePaymentSheet|StripeApplePay|import Braintree|BTPaymentFlow|import PayPal|PayPalCheckout|PayPalNativeCheckout|import Square|SquareInAppPayments|import Adyen|AdyenComponents|RazorpaySDK|import Paddle' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
539
+ if [[ -n "$payment_sdk" ]]; then
540
+ warn "3.1.1 Third-party payment SDK — '$(basename "$payment_sdk")' detected; selling digital content/functionality must use in-app purchase, not an external processor (3.1.1). Allowed only for physical goods/services — verify your offering."
541
+ fi
542
+ fi
543
+
544
+ # ===================================================================
545
+ # §22 — 1.2 User-generated content without moderation affordances
546
+ # ===================================================================
547
+ # Apps with UGC must provide: a content filter, a report mechanism, the ability
548
+ # to block abusive users, and published contact info. We detect a UGC signal and
549
+ # warn when no report/block/moderation affordance is found anywhere in the source.
550
+ if [[ -n "$IOS_DIR" ]]; then
551
+ ugc_signal=$(grep -rlE 'userGeneratedContent|\bUGC\b|StreamChat|MessageKit|SendbirdSDK|PubNub|postComment|submitComment|createPost|publishPost|uploadUserPhoto|uploadVideo' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
552
+ if [[ -n "$ugc_signal" ]]; then
553
+ if grep -rqiE 'report(Content|User|Abuse|Post|Comment|Message|Reason|ed)|block(ed)?User|unblockUser|moderat(e|ion|or)|flag(Content|Post|User|Comment|Message)|content.?filter' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
554
+ pass "1.2 UGC — user-generated content with report/block/moderation affordances present"
555
+ else
556
+ warn "1.2 UGC — user-generated content detected (e.g. $(basename "$ugc_signal")) but no report/block/moderation mechanism found; UGC apps must offer content filtering, a report mechanism, user blocking, and published contact info (1.2)"
557
+ fi
558
+ fi
559
+ fi
560
+
561
+ # ===================================================================
562
+ # §23 — 1.6 App Transport Security disabled globally
563
+ # ===================================================================
564
+ # NSAllowsArbitraryLoads=true turns off ATS for the whole app, weakening
565
+ # data-in-transit security and inviting a 1.6 / data-security question at review.
566
+ if [[ -f "$INFO_PLIST" ]]; then
567
+ if awk '/NSAllowsArbitraryLoads/{getline; if ($0 ~ /<true/) print "ON"}' "$INFO_PLIST" 2>/dev/null | grep -q ON; then
568
+ warn "1.6 App Transport Security — NSAllowsArbitraryLoads=true in Info.plist disables ATS app-wide; prefer per-domain exceptions, and expect a data-security justification request at review (1.6)"
569
+ fi
570
+ fi
571
+
572
+ # ===================================================================
573
+ # §24 — 4.9 Apple Pay recurring-payment disclosure
574
+ # ===================================================================
575
+ # Apps using Apple Pay for recurring payments must disclose the renewal term,
576
+ # what's provided, the charges, and how to cancel. Gated strictly on the Apple
577
+ # Pay recurring API (PKRecurringPaymentRequest) so it does NOT conflate StoreKit
578
+ # auto-renew copy or a one-time PassKit payment with recurring Apple Pay. We don't
579
+ # try to detect the disclosure text (too noisy) — we flag it for manual verify.
580
+ if [[ -n "$IOS_DIR" ]] && grep -rqE 'PKRecurringPaymentRequest' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
581
+ warn "4.9 Apple Pay — recurring Apple Pay (PKRecurringPaymentRequest) detected; verify you disclose the renewal term, what's provided, the charges, and how to cancel before purchase (4.9)"
582
+ fi
583
+
584
+ # ===================================================================
585
+ # §25 — 5.6.1 Custom App Store review prompt
586
+ # ===================================================================
587
+ # Apple disallows custom review prompts and direct write-review links — apps must
588
+ # use the system SKStoreReviewController / requestReview API.
589
+ if [[ -n "$IOS_DIR" ]]; then
590
+ review_link=$(grep -rlE 'write-review|action=write-review|itms-apps[^"]*review' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
591
+ if [[ -n "$review_link" ]]; then
592
+ if grep -rqE 'requestReview|SKStoreReviewController|\.requestReview' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
593
+ pass "5.6.1 App reviews — uses the system requestReview API alongside the review link"
594
+ else
595
+ warn "5.6.1 App reviews — a direct App Store review link/prompt was found (e.g. $(basename "$review_link")) but no system requestReview (SKStoreReviewController) call; Apple disallows custom review prompts (5.6.1)"
596
+ fi
597
+ fi
598
+ fi
599
+
600
+ # ===================================================================
601
+ # §26 — 2.3.1 Misleading marketing claims in metadata
602
+ # ===================================================================
603
+ # Marketing the app for things iOS apps can't actually do (virus/malware
604
+ # scanners, fake speed boosters) is a 2.3.1 removal vector.
605
+ if [[ -d "$META_DIR" ]]; then
606
+ mislead_re='virus scan|virus scanner|antivirus|anti-virus|malware (scan|remov|clean)|spyware remov|clean your (iphone|device)|speed booster|boost.*(speed|ram)|free money|guaranteed.*(win|prize)'
607
+ mislead_hits=$(grep -rEniI "$mislead_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
608
+ if [[ -n "$mislead_hits" ]]; then
609
+ warn "2.3.1 Misleading marketing — claims that often violate 2.3.1 (e.g. iOS virus/malware scanners, fake speed boosters) in metadata; verify the app truly delivers them or remove the claim:"
610
+ echo "$mislead_hits" | sed 's/^/ /'
611
+ fi
612
+ fi
613
+
614
+ # ===================================================================
615
+ # §27 — 2.3.8 "For Kids/Children" wording outside the Kids Category
616
+ # ===================================================================
617
+ # Terms implying a child audience in name/subtitle/keywords/description are
618
+ # reserved for the Kids Category (2.3.8 / 5.1.4).
619
+ if [[ -d "$META_DIR" ]]; then
620
+ kids_re='for kids|for children|for your (kid|child)|kids[[:space:]]?app|für kinder|para niños|pour enfants'
621
+ kids_hits=$(grep -rEniI "$kids_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
622
+ if [[ -n "$kids_hits" ]]; then
623
+ warn "2.3.8 'For Kids/Children' wording — terms implying a child audience are reserved for the Kids Category (2.3.8); if not enrolled, remove them from name/subtitle/keywords/description:"
624
+ echo "$kids_hits" | sed 's/^/ /'
625
+ fi
626
+ fi
627
+
628
+ # ===================================================================
629
+ # §28 — 4.4.1 Keyboard extension requiring full access
630
+ # ===================================================================
631
+ # Keyboards must stay functional without full network access / "full access",
632
+ # and may only collect data to enhance the keyboard.
633
+ kb_plist=$(grep -rlE 'com\.apple\.keyboard-service' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
634
+ if [[ -n "$kb_plist" ]]; then
635
+ if grep -A1 'RequestsOpenAccess' "$kb_plist" 2>/dev/null | grep -q '<true'; then
636
+ warn "4.4.1 Keyboard extension — RequestsOpenAccess=true in $kb_plist; keyboards must remain functional without full access and may only collect data to enhance the keyboard (4.4.1). Justify full access in your review notes."
637
+ else
638
+ pass "4.4.1 Keyboard extension — present without requiring full access"
639
+ fi
640
+ fi
641
+
642
+ # ===================================================================
643
+ # §29 — 5.1.3 Health data with an iCloud sync path
644
+ # ===================================================================
645
+ # Personal health information must not be stored in iCloud, and HealthKit data
646
+ # may not be used for advertising/marketing.
647
+ if [[ -n "$IOS_DIR" ]] && grep -rqE 'import HealthKit|HKHealthStore' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
648
+ if grep -rqE 'import CloudKit|CKRecord|CKContainer|NSUbiquitousKeyValueStore|NSUbiquitousContainer' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
649
+ warn "5.1.3 Health data — HealthKit and iCloud/CloudKit are both used; personal health information must not be stored in iCloud (5.1.3). Verify HealthKit data is not synced to iCloud."
650
+ else
651
+ pass "5.1.3 Health data — HealthKit used without an obvious iCloud sync path"
652
+ fi
653
+ fi
654
+
655
+ # ===================================================================
656
+ # §30 — 5.4 VPN apps (NetworkExtension)
657
+ # ===================================================================
658
+ # VPN apps must be offered by an organization account, declare data collection
659
+ # on-screen before use, and may not sell/share data.
660
+ if [[ -n "$IOS_DIR" ]]; then
661
+ vpn_use=$(grep -rlE 'NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider|NEVPNProtocol' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
662
+ if [[ -n "$vpn_use" ]]; then
663
+ warn "5.4 VPN — NetworkExtension/NEVPNManager usage detected (e.g. $(basename "$vpn_use")); VPN apps must be offered by an organization account, disclose data collection on-screen before use, and not sell/share data (5.4). Verify compliance."
664
+ fi
665
+ fi
666
+
667
+ # ===================================================================
668
+ # §31 — 2.1 Demo account for a credential login
669
+ # ===================================================================
670
+ # Apps behind a login must give App Review working credentials (a demo account
671
+ # or notes). We fire only on a credential-login signal (a password field or a
672
+ # Login/SignIn view), then look for demo creds in fastlane review_information or
673
+ # the reviewer-prep notes. Social-only logins are not gated here (too noisy).
674
+ if [[ -n "$IOS_DIR" ]]; then
675
+ auth_signal=$(grep -rlE 'SecureField' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
676
+ [[ -z "$auth_signal" ]] && auth_signal=$(find "$IOS_DIR" "${PRUNE[@]}" \( -name '*Login*View*.swift' -o -name '*SignIn*View*.swift' \) 2>/dev/null | head -1)
677
+ if [[ -n "$auth_signal" ]]; then
678
+ demo_present=""
679
+ ri="$META_DIR/review_information"
680
+ if [[ -d "$ri" ]]; then
681
+ for f in demo_user.txt demo_password.txt notes.txt; do [[ -s "$ri/$f" ]] && demo_present=1; done
682
+ fi
683
+ if [[ -z "$demo_present" && -n "$REVIEW_PREP" && -f "$REVIEW_PREP" ]]; then
684
+ grep -qiE 'demo|review.*(account|credential)|test.*account' "$REVIEW_PREP" 2>/dev/null && demo_present=1
685
+ fi
686
+ if [[ -n "$demo_present" ]]; then
687
+ pass "2.1 Demo account — credential login present and reviewer demo credentials/notes found"
688
+ else
689
+ warn "2.1 Demo account — a credential login was detected (e.g. $(basename "$auth_signal")) but no demo account/credentials for App Review found (fastlane review_information or .reviewPrepNotes); apps behind a login must give reviewers working credentials (2.1)"
690
+ fi
691
+ fi
692
+ fi
693
+
694
+ # ===================================================================
695
+ # §32 — 2.5.2 Executable code download / hot-patch
696
+ # ===================================================================
697
+ # Apps must be self-contained; native hot-patching frameworks (JSPatch, Rollout,
698
+ # DynamicCocoa) download code that changes features and are a removal vector.
699
+ # JS-bundle OTA for React Native (CodePush) is allowed, so we do NOT flag it.
700
+ if [[ -n "$IOS_DIR" ]]; then
701
+ hotcode=$(grep -rlE 'import JSPatch|JSPatch\.|[Jj][Ss][Pp]atch|import Rollout|Rollout\.|rollout\.io|DynamicCocoa|import SwiftPatch' "$IOS_DIR" --include="*.swift" --include="*.m" --include="*.h" 2>/dev/null | head -1)
702
+ if [[ -n "$hotcode" ]]; then
703
+ warn "2.5.2 Executable code — a hot-patch / remote-code framework was detected (e.g. $(basename "$hotcode")); apps may not download or run code that changes features (JSPatch/Rollout-style native hot-patching). Allowed JS-bundle OTA (e.g. React Native CodePush) is fine — verify this is not native hot-patching (2.5.2)"
704
+ fi
705
+ fi
706
+
707
+ # ===================================================================
708
+ # §33 — 2.5.4 Background modes declared but unused
709
+ # ===================================================================
710
+ # Declare only the UIBackgroundModes the app actually uses; a mode declared with
711
+ # no matching API is a frequent rejection. We parse the array and check each
712
+ # declared mode against its framework/API in Swift.
713
+ if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
714
+ modes=$(awk '/<key>UIBackgroundModes<\/key>/{f=1;next} f&&/<\/array>/{f=0} f&&/<string>/{gsub(/.*<string>|<\/string>.*/,""); print}' "$INFO_PLIST" 2>/dev/null)
715
+ if [[ -n "$modes" ]]; then
716
+ unused=""
717
+ while IFS= read -r m; do
718
+ [[ -z "$m" ]] && continue
719
+ case "$m" in
720
+ location) grep -rqE 'CLLocationManager|CoreLocation' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused location" ;;
721
+ audio) grep -rqE 'AVAudioSession|AVPlayer|AVAudioPlayer|AVQueuePlayer|AVAudioEngine' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused audio" ;;
722
+ voip) grep -rqE 'PushKit|PKPushRegistry|CallKit|CXProvider' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused voip" ;;
723
+ fetch) grep -rqE 'BGAppRefreshTask|BackgroundTasks|setMinimumBackgroundFetchInterval|performFetchWithCompletionHandler' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused fetch" ;;
724
+ processing) grep -rqE 'BGProcessingTask|BackgroundTasks' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused processing" ;;
725
+ bluetooth-central|bluetooth-peripheral) grep -rqE 'CoreBluetooth|CBCentralManager|CBPeripheralManager' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused $m" ;;
726
+ remote-notification) grep -rqE 'didReceiveRemoteNotification|UNUserNotificationCenter|registerForRemoteNotifications' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused remote-notification" ;;
727
+ esac
728
+ done <<< "$modes"
729
+ if [[ -n "$unused" ]]; then
730
+ warn "2.5.4 Background modes —$unused declared in UIBackgroundModes but no matching API usage found in Swift; declare only the background modes the app actually uses (2.5.4)"
731
+ else
732
+ pass "2.5.4 Background modes — declared modes have matching API usage"
733
+ fi
734
+ fi
735
+ fi
736
+
737
+ # ===================================================================
738
+ # §34 — 3.1.5(a) Cryptocurrency wallet / exchange / mining
739
+ # ===================================================================
740
+ if [[ -n "$IOS_DIR" ]]; then
741
+ crypto_sdk=$(grep -rlE 'import Web3|web3swift|Web3Swift|WalletConnect|TrustWalletCore|CoinbaseWalletSDK|SolanaSwift|CryptoMining|coinhive|MoneroMiner' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
742
+ if [[ -n "$crypto_sdk" ]]; then
743
+ warn "3.1.5(a) Cryptocurrency — a crypto wallet/exchange/mining signal was detected (e.g. $(basename "$crypto_sdk")); wallets & exchanges have entity and licensing requirements, and on-device mining is not permitted (3.1.5(a)). Verify eligibility."
744
+ fi
745
+ fi
746
+
747
+ # ===================================================================
748
+ # §35 — 4.2.3 Web-wrapper / thin app
749
+ # ===================================================================
750
+ # A thin WKWebView wrapper around a website is rejected under minimum
751
+ # functionality. Heuristic: WKWebView present in a project with very few Swift
752
+ # files. WARN (verify) — this is the most false-positive-prone of the batch.
753
+ if [[ -n "$IOS_DIR" ]]; then
754
+ if grep -rqE 'WKWebView' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
755
+ swift_n=$(find "$IOS_DIR" "${PRUNE[@]}" -name '*.swift' 2>/dev/null | wc -l | tr -d ' ')
756
+ if (( swift_n > 0 && swift_n <= 4 )); then
757
+ warn "4.2.3 Minimum functionality — the app appears to be a WKWebView wrapper with only $swift_n Swift file(s); a thin wrapper around a website is rejected under 4.2.3. Add native value, or verify this is a real app rather than a repackaged site."
758
+ fi
759
+ fi
760
+ fi
761
+
762
+ # ===================================================================
763
+ # §36 — 4.2.7 Remote desktop / host-mirroring
764
+ # ===================================================================
765
+ if [[ -n "$IOS_DIR" ]]; then
766
+ remote_desktop=$(grep -rlE 'import libvncclient|LibVNC|VNCClient|RDPSession|RDPKit|RemoteDesktopClient|import FreeRDP|JumpDesktop' "$IOS_DIR" --include="*.swift" --include="*.m" 2>/dev/null | head -1)
767
+ if [[ -n "$remote_desktop" ]]; then
768
+ warn "4.2.7 Remote desktop — a remote-desktop/mirroring signal was detected (e.g. $(basename "$remote_desktop")); host-mirroring apps must only show/control the owner's host, display host content (not App Store content), and be free or use IAP (4.2.7). Verify."
769
+ fi
770
+ fi
771
+
772
+ # ===================================================================
773
+ # §37 — 4.4.2 Safari extension / content blocker
774
+ # ===================================================================
775
+ safari_ext=$(grep -rlE 'com\.apple\.Safari\.(content-blocker|web-extension|extension)' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
776
+ if [[ -n "$safari_ext" ]]; then
777
+ warn "4.4.2 Safari extension — a Safari content-blocker / web extension was detected ($safari_ext); it must use the extension APIs as intended, do only what it declares, and not include hidden analytics/ads or track without consent (4.4.2). Verify."
778
+ fi
779
+
780
+ # ===================================================================
781
+ # §38 — 5.1.1(v) Account Sign-In: account creation without in-app deletion
782
+ # ===================================================================
783
+ # Apple 5.1.1(v): apps that support account creation must also let users delete
784
+ # their account from within the app. Detect account-creation signals, then look
785
+ # for an in-app deletion path. Deletion via a web page is missed → WARN, not FAIL.
786
+ if [[ -n "$IOS_DIR" ]]; then
787
+ signup=$(grep -rlE 'createUser|signUp|signup|createAccount|registerUser|registerNewUser|Auth\.auth\(\)\.createUser' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
788
+ if [[ -n "$signup" ]]; then
789
+ if grep -rqiE 'delete.?account|account.?deletion|closeAccount|deleteUser|removeAccount|deleteMyAccount' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
790
+ pass "5.1.1(v) Account deletion — account creation present and an in-app account-deletion path was found"
791
+ else
792
+ warn "5.1.1(v) Account deletion — account creation was detected (e.g. $(basename "$signup")) but no in-app account-deletion path found; apps that support account creation must let users delete their account from within the app (5.1.1(v))"
793
+ fi
794
+ fi
795
+ fi
796
+
797
+ # ===================================================================
798
+ # §39 — 5.1.4 Kids audience with third-party ads / analytics
799
+ # ===================================================================
800
+ # Kids Category apps may not include third-party advertising or analytics and
801
+ # must include a parental gate. We fire when the metadata targets a child
802
+ # audience AND an ad/analytics SDK is linked. tracking_sdk/analytics_sdk come
803
+ # from §3/§19.
804
+ if [[ -d "$META_DIR" && -n "$IOS_DIR" ]]; then
805
+ if grep -rqEiI 'for kids|for children|kids[[:space:]]?app|für kinder|para niños|pour enfants' "$META_DIR" 2>/dev/null; then
806
+ if [[ -n "$tracking_sdk" || -n "$analytics_sdk" ]]; then
807
+ warn "5.1.4 Kids — the metadata targets a child audience and a third-party ads/analytics SDK is linked (e.g. $(basename "${tracking_sdk:-$analytics_sdk}")); Kids Category apps may not include third-party advertising or analytics and must offer a parental gate (5.1.4)"
808
+ fi
809
+ fi
810
+ fi
811
+
812
+ # ===================================================================
813
+ # §40 — 5.3.4 Real-money gambling
814
+ # ===================================================================
815
+ if [[ -d "$META_DIR" ]]; then
816
+ gamble_re='real[ -]?money|gambling|casino|sportsbook|sports[ -]?betting|place[ -].*bets|wager(ing)?|roulette.*real'
817
+ gamble_hits=$(grep -rEniI "$gamble_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
818
+ if [[ -n "$gamble_hits" ]]; then
819
+ warn "5.3.4 Gambling — real-money gaming language in metadata; real-money gambling/lotteries need the proper licenses, must be geo-restricted to permitted regions, and must be free on the App Store (5.3.4):"
820
+ echo "$gamble_hits" | sed 's/^/ /'
821
+ fi
822
+ fi
823
+
824
+ # ===================================================================
825
+ # §41 — 5.5 Mobile Device Management
826
+ # ===================================================================
827
+ if [[ -n "$IOS_DIR" ]]; then
828
+ mdm_sig=$(grep -rlE 'import DeviceManagement|MDMConfiguration|ManagedAppConfiguration|com\.apple\.mdm' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
829
+ [[ -z "$mdm_sig" ]] && mdm_sig=$(grep -rlE 'com\.apple\.configuration\.managed' --include='*.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
830
+ if [[ -n "$mdm_sig" ]]; then
831
+ warn "5.5 MDM — a Mobile Device Management signal was detected (e.g. $(basename "$mdm_sig")); MDM apps require a commercial enterprise/education entity, may request the MDM capability only for that purpose, and must not sell or use the data for other ends (5.5). Verify eligibility."
832
+ fi
833
+ fi
834
+
513
835
  echo "---END-OF-SCAN---"
@@ -5,8 +5,8 @@
5
5
  # the .precheck-pass token action — so the verdict is machine-testable, not just an
6
6
  # agent judgement.
7
7
  #
8
- # GREEN 0 FAIL and <=2 WARN -> token: write
9
- # YELLOW 0 FAIL and >=3 WARN -> token: hold (needs explicit human confirmation)
8
+ # GREEN 0 FAIL and <=4 WARN -> token: write
9
+ # YELLOW 0 FAIL and >=5 WARN -> token: hold (needs explicit human confirmation)
10
10
  # RED >=1 FAIL -> token: remove
11
11
  #
12
12
  # Usage:
@@ -49,7 +49,7 @@ passes=$(count '^PASS:')
49
49
 
50
50
  if (( fails >= 1 )); then
51
51
  verdict="RED"; token="remove"; code=1
52
- elif (( warns >= 3 )); then
52
+ elif (( warns >= 5 )); then
53
53
  verdict="YELLOW"; token="hold"; code=2
54
54
  else
55
55
  verdict="GREEN"; token="write"; code=0