●BUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the output●NATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other builders●PLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screen●COMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to end●PRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that back●DEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verify●BUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the output●NATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other builders●PLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screen●COMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to end●PRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that back●DEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verify
Find the Code That Trips App Store 2.5.2 Before You Submit
If a remote value can change what your app does, Guideline 2.5.2 may apply. Here is where the line actually sits, plus a script that surfaces the risky paths in your repo before submission.
There is one moment that always makes me pause: adding a new key to remote config.
Changing a color from the server is fine. Swapping out an entire screen from the server is a different animal. The wallpaper apps I run ship their catalog and their copy from a backend, and the wider that remote surface gets, the smoother operations become — right up until review.
In March 2026, Apple blocked App Store updates for Replit and Vibecode. The cited basis was Guideline 2.5.2, and the reported issue was that both let users generate code and then run it inside the reviewed app (Apple Blocks Updates for AI Vibe Coding Apps in App Store).
If you ship apps built with Rork, that story is closer to home than it looks. Nothing stops you from letting an AI write your code. What can stop you is leaving a path where code gets executed inside the app — and that path can slip in without anyone deciding to add it.
What follows is a way to surface those paths mechanically, with the script I actually ran and what running it taught me about its own blind spots.
2.5.2 is about whether behavior changes, not where code comes from
Start with the text. Guideline 2.5.2 says apps should not "download, install, or execute code which introduces or changes features or functionality of the app, including other apps" (App Review Guidelines).
The easy misread is the subject. What is prohibited is not downloading code. What is prohibited is introducing or changing functionality by doing so.
You can confirm that reading by working backward from the carve-outs. JavaScript running inside WebKit is fine under this rule. Educational apps may, under conditions, download code for students to run. Neither exception would make sense if the rule were a blanket ban on remote code.
I had this wrong for a while myself. I designed as if anything fetched from a server was suspect. The real line is narrower and more specific.
What arrives from the server
What happens in the app
Through the 2.5.2 lens
Wallpaper catalog JSON
The list shows different items
Data swap. Behavior is unchanged
A Remote Config threshold
An existing branch is taken differently
Inside the reviewed feature set
An EAS Update JS bundle
The implementation is replaced wholesale
Interpreted-layer update. An accepted practice
An expression string passed to eval
New logic comes into existence at runtime
Introduces functionality
A dynamic import from a remote URL
Code nobody reviewed runs
Introduces functionality
Rows three and four are where the rule becomes legible. An EAS Update replaces your entire JS bundle. By volume, that dwarfs handing one line of text to eval. Yet the first is an established workflow and the second is the thing that gets apps pulled.
The boundary is not drawn around delivery. It is drawn around whether the app can exceed the feature set that was reviewed. OTA updates are treated as replacing the implementation of an app that keeps the same purpose and the same features. eval leaves an opening for logic that nobody had seen at review time. It is a question of scope, not of bytes.
Once you hold it that way, decisions get fast. Adding something new, ask yourself: am I delivering, or am I generating? The answer usually arrives on the spot.
Six implementations that cross the line
Here is the scan target. At indie scale, these are the six that realistically show up. The first three should be gone before you submit; the last three are fine to keep if you can explain what feeds them.
eval(...) — runs a string as code
new Function(...) — treated the same as eval. Hand-rolled expression evaluators love this one
import("https://...") — pulls a remote module at runtime
injectedJavaScript — JS injected into a WebView. A constant is fine; a string from your server is not
source={{ html: ... }} — a path for rendering arbitrary HTML
A map that picks a screen from a remote value, like components[remote.screen]
Items four through six are not on the "delete it" list because they are legitimate, widespread patterns. Rendering help content in a WebView is normal. Reordering tabs from remote config is normal. The problem only appears when the value flowing in comes from outside and its contents are not fixed ahead of time.
So treat these six as a list of places to trace an input back to its source, not a list of things to remove on sight.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can tell which parts of your own codebase could fall under 2.5.2, reading it straight from the source before you submit
✦You can draw the line in your own design between what a remote value may change (data, settings) and what it must not (the behavior itself)
✦You avoid the scramble of ripping out features after a rejection, so your launch date survives a period when review times are long
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is the script I actually ran, verified on Node.js 22. No dependencies.
#!/usr/bin/env node// scan-2-5-2.mjs — surface paths that can change app behavior after review// usage: node scan-2-5-2.mjs src appimport { readdirSync, readFileSync, statSync } from "node:fs";import { join, extname } from "node:path";const TARGET_EXT = new Set([".js", ".jsx", ".ts", ".tsx"]);const SKIP_DIR = new Set(["node_modules", ".git", "ios", "android", "dist", "build"]);// severity: block = remove before submitting / review = keep if you can justify the inputconst RULES = [ { id: "eval-call", severity: "block", re: /(^|[^.\w])eval\s*\(/, hint: "A string is being executed as code" }, { id: "function-ctor", severity: "block", re: /new\s+Function\s*\(/, hint: "The Function constructor is treated the same as eval" }, { id: "remote-import", severity: "block", re: /import\s*\(\s*[`'"]https?:\/\//, hint: "A remote module is loaded at runtime" }, { id: "webview-inject", severity: "review", re: /injectedJavaScript(BeforeContentLoaded)?\s*[=:]/, hint: "Check where this injected JS comes from" }, { id: "webview-html", severity: "review", re: /source\s*=\s*\{\{\s*html\s*:/, hint: "Check whether arbitrary HTML can reach this" }, { id: "remote-screen-map", severity: "review", re: /components?\s*\[\s*(remote|payload|res(ponse)?|json)\w*\./i, hint: "A remote value selects which implementation runs" },];function walk(dir, out = []) { for (const name of readdirSync(dir)) { if (SKIP_DIR.has(name)) continue; const p = join(dir, name); if (statSync(p).isDirectory()) walk(p, out); else if (TARGET_EXT.has(extname(p))) out.push(p); } return out;}// Only skip lines that start with // or *. Over-reporting beats missing somethingconst isComment = (line) => /^\s*(\/\/|\*|\/\*)/.test(line);const roots = process.argv.slice(2);if (roots.length === 0) { console.error("usage: node scan-2-5-2.mjs <dir> [dir...]"); process.exit(2);}const findings = [];let scanned = 0;for (const root of roots) { for (const file of walk(root)) { scanned++; const lines = readFileSync(file, "utf8").split("\n"); lines.forEach((line, i) => { if (isComment(line)) return; for (const rule of RULES) { if (rule.re.test(line)) { findings.push({ file, line: i + 1, ...rule, text: line.trim().slice(0, 100) }); } } }); }}const blocks = findings.filter((f) => f.severity === "block");const reviews = findings.filter((f) => f.severity === "review");for (const f of findings) { const mark = f.severity === "block" ? "BLOCK " : "REVIEW"; console.log(`${mark} ${f.file}:${f.line} [${f.id}] ${f.hint}`); console.log(` ${f.text}`);}console.log(`\nscanned ${scanned} files / block ${blocks.length} / review ${reviews.length}`);process.exit(blocks.length > 0 ? 1 : 0);
Run against a fixture tree holding one file per pattern plus a few deliberately safe files — seven files total — the output is this.
BLOCK src/lib/formula.ts:3 [function-ctor] The Function constructor is treated the same as eval const fn = new Function(...keys, `return (${expr});`);BLOCK src/lib/formula.ts:7 [eval-call] A string is being executed as code return eval(expr);BLOCK src/lib/plugins.ts:2 [remote-import] A remote module is loaded at runtime const mod = await import(`https://cdn.example.com/plugins/${name}.mjs`);REVIEW src/screens/Help.tsx:3 [webview-inject] Check where this injected JS comes from return <WebView source={{ html: html }} injectedJavaScript={boot} />;REVIEW src/screens/Help.tsx:3 [webview-html] Check whether arbitrary HTML can reach this return <WebView source={{ html: html }} injectedJavaScript={boot} />;REVIEW src/screens/Router.tsx:5 [remote-screen-map] A remote value selects which implementation runs return components[remote.screen];scanned 7 files / block 3 / review 3
The file that recolors the UI from remote config and the file that renders a catalog JSON both came back clean. Receiving values from a server does not trip it. Treating a received value as code does. That was the whole design intent, and it held.
A single BLOCK exits with status 1, so dropping it into CI turns it into a gate. I run it ahead of EAS Build.
# excerpt from .github/workflows/preflight.yml- name: Scan for 2.5.2 risks run: node scripts/scan-2-5-2.mjs src app
One caveat when you wire it up. If generated Expo Router output or build artifacts fall inside the scan path, you will get BLOCKs on code you never wrote. In production, add your project's generated directories to SKIP_DIR to work around it. Skip that step and nobody reads the log after the first week.
The false positives came from strings, not comments
Writing this, the thing I braced for was comments. A file with a note saying "never use eval here" firing a warning would be annoying, so isComment went in early.
Running it against deliberately tricky code, the miss landed somewhere else entirely.
const retrieval = (q: string) => q; // not flagged (as intended)const msg = "eval( is not allowed in this app"; // <- flagged as BLOCK// eval(dangerous) // not flagged (as intended)export const upsert = (db: any) => db.eval("noop"); // not flagged (as intended)
That file came back scanned 1 files / block 1 / review 0, and the only hit was a string literal. The comment fell out exactly as designed.
So the source of noise is string literals, not comments. Worse, the projects most likely to contain a sentence about banning eval are the ones already being careful about it. The teams paying attention get the false positive.
The same run exposed a place where the exclusion is too aggressive. db.eval("noop") is not flagged, because the [^.\w] guard drops any eval preceded by a dot.
Removing that guard is instructive. Plenty of unrelated libraries expose an .eval( method, and BLOCK counts explode. A check that produces a pile of BLOCKs is a check that gets ignored. I chose durability over coverage and kept the guard, reasoning that dot-qualified method calls are rarely your own code-generation path anyway.
Because it tolerates misses by design, this script does not certify anything. It is a way to know where to look during the read-through you do before submitting.
WebView, or hand it to the browser
The WebView findings land in REVIEW because no regex can settle them. That call is a design decision.
If you want one axis to decide on, use this: who changes the contents of that screen next?
What you are showing
Where it belongs
Why
Privacy policy, terms
In-app WebView, fixed URL
You update it, and it is a document. No new behavior
Help, announcements
In-app WebView, fixed URL
Same, and keeping the back path intact is worth a lot
Third-party service pages
Linking.openURL, external browser
You do not control the contents. Embedding blurs whose responsibility it is
Output generated by a user or an AI
Linking.openURL, external browser
Content that did not exist at review time would run as app functionality
That last row is the crux of the story this article opened with. Preview a generated artifact inside the app and you have built a structure where the app's capabilities keep growing at runtime. Open it in the browser and the thing doing the running is the browser, not your app.
The second option is a worse experience. You push the user out of your app. I still consider that the cheaper price compared with operating an app whose updates can be halted.
In the wallpaper apps, exactly three things are remotely changeable: catalog contents, display thresholds, and copy.
That limit was never about App Review. It was about reducing accidents. The wider your remote surface, the more easily you can alter production behavior from a laptop at midnight. It also fights with a staged rollout that starts at 5% and climbs while you watch crash-free numbers — if anything can be changed remotely, staging the rollout means less.
As it turned out, the constraint doubled as 2.5.2 insurance. A line drawn for operational safety happened to land on the safe side of review, which is the honest version of how it went.
Adding something new, the order of questions has not changed. First: does this need to be remotely changeable at all? Then: if it does, is what changes the data, or the logic? If it is the logic, it belongs to EAS Update, not to remote config. Both are "change it later," but the road determines how it is treated.
Roughly 560,000 new apps were added to the App Store in the first half of 2026 alone, and developers have reported waits of several weeks for approval.
In that climate, a 2.5.2 rejection hurts for a reason that has nothing to do with the finding itself. After you fix it and resubmit, you go back to the end of the same queue. Deleting one line still costs you the full wait a second time.
A few seconds of scanning before submission removes the need to gamble those weeks. As cost-benefit trades go, it is unusually clear.
What to do next
Run it once at the root of your project.
node scan-2-5-2.mjs src
Zero BLOCKs means this is not your problem today. One or more means the next step is deciding whether that line receives data or produces logic. The first is a refactor; the second means walking a design decision back.
As an indie developer, plenty of review problems can wait until you actually get rejected. 2.5.2 is not one of them — a rejection there usually means rebuilding the feature, not patching it. That makes it one of the few worth checking up front.
I am still working out exactly where my own line belongs. If this helps you find yours a little faster, I am glad.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.