●SDK58 — The Expo SDK 58 beta is open. It ships the React Native 0.88 release candidate, and the beta period is stated as three to four weeks●11/01 — For anyone who requested an extension, Google Play's target API deadline lands on November 1. Forty-four days out●EASENV — A long-open report: secrets handed to a local build arrive as the literal variable name rather than its value, and the damage surfaces much later●NEW — The replacement the table recommended had already shut down. A record of reconciling all 74 rows of the deprecation list●UISCENE — iOS 27 requires the new scene lifecycle. SDK 57 makes it something you opt into; it only becomes the default in 58●CREDIT — What "AI errors don't cost credits" actually covers becomes clear once you record a day of asking for the same fix more than once●SDK58 — The Expo SDK 58 beta is open. It ships the React Native 0.88 release candidate, and the beta period is stated as three to four weeks●11/01 — For anyone who requested an extension, Google Play's target API deadline lands on November 1. Forty-four days out●EASENV — A long-open report: secrets handed to a local build arrive as the literal variable name rather than its value, and the damage surfaces much later●NEW — The replacement the table recommended had already shut down. A record of reconciling all 74 rows of the deprecation list●UISCENE — iOS 27 requires the new scene lifecycle. SDK 57 makes it something you opt into; it only becomes the default in 58●CREDIT — What "AI errors don't cost credits" actually covers becomes clear once you record a day of asking for the same fix more than once
Building an iMessage Extension with Rork Max to Bring Your App's World into Messages — Notes on Distribution as Code
A walkthrough of adding an iMessage extension to the native Swift project Rork Max generates, sharing your app's assets right inside Messages. Covers compact/expanded presentation, sending messages, and diagnosing why the extension won't appear in the drawer — from an indie developer's distribution lens.
Run an indie wallpaper app long enough and you notice the moment users most naturally recommend it to a friend isn't when they send an App Store link — it's when they drop a favorite image straight into a conversation. If that's true, could I bring the app's world into Messages itself, the very place sharing happens? That question is what led me to build an iMessage extension.
An iMessage extension is a native mechanism that depends on the Messages framework. It's effectively out of reach from React Native, but because Rork Max generates a native Swift project, you can add an extension target and subclass MSMessagesAppViewController. Here I'll work through presentation switching, sending messages, and the pitfalls — all through the lens of distribution.
Why a Messages extension instead of the main app
Send an app pitch as a store link and the recipient has to leave the conversation to open the App Store. An asset sent from an iMessage extension, on the other hand, stays in the conversation, and the recipient can engage with it right there. In my experience, the psychological distance to "maybe I'll try this" is far shorter with the latter. For a free, AdMob-centric app, adding one more entry point is far from trivial.
That said, the extension is a separate target from the main app, and its memory limit is stricter. Loading assets wholesale hits the ceiling fast. Designing for lightness is the starting point.
Step 1: Add the extension target and share assets with the main app
Add an iMessage extension target to the project Rork Max generated. To share assets (like images) with the main app, enable an App Group and read them through the shared container. Carrying images twice inside the extension bloats both the distribution size and memory.
// Resolve an asset URL from the shared containerfunc sharedAssetURL(_ name: String) -> URL? { let groupID = "group.net.rorklab.sample" let base = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: groupID) return base?.appendingPathComponent("assets/\(name)")}
The App Group identifier must match exactly between the main app and the extension. A mismatch leaves the asset always nil on the extension side, and you lose time tracking down the cause.
✦
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
✦Concrete presentationStyle transition code for switching MSMessagesAppViewController between compact and expanded layouts
✦How to assemble an MSMessage that attaches an asset like a wallpaper, and design how it persists in the recipient's conversation
✦How to diagnose the production symptom of an extension never showing in the Messages drawer, down to target settings and bundle structure
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.
Decoded bytes, not file size, decide how many you can show
Saying "extensions are tight on memory" as a generality settles no design question. What actually decides things is the decoded byte count, not the file size. A JPEG that occupies 1.2MB on disk becomes a raw bitmap of width × height × 4 bytes the instant it reaches the screen.
So I counted.
// imessage-memory.mjs — how much room a decoded bitmap really takesconst MB = 1024 * 1024;const BUDGET_MB = 40; // a budget I impose on the extension processconst source = { w: 1290, h: 2796 }; // full-resolution wallpaperconst cellPt = 110, scale = 3; // a 110pt grid cell drawn @3xconst thumb = { w: cellPt * scale, h: Math.round(cellPt * scale * (source.h / source.w)) };const bytes = (s) => s.w * s.h * 4; // RGBA8const fits = (s) => Math.floor((BUDGET_MB * MB) / bytes(s));console.log(`per-image full=${(bytes(source) / MB).toFixed(2)}MB thumb=${(bytes(thumb) / MB).toFixed(2)}MB`);console.log(`fits full=${fits(source)} thumb=${fits(thumb)}`);for (const n of [6, 12, 24, 48]) { console.log(`${n} | ${((n * bytes(source)) / MB).toFixed(1)} | ${((n * bytes(thumb)) / MB).toFixed(1)}`);}
Here is the output from a run on Node v22.22.3, laid out as a table. The thumbnail came out at 330 × 715 pixels.
Images shown
Full resolution (MB)
Thumbnail (MB)
Against a 40MB budget
1
13.76
0.90
full res breaks it on the third
6
82.6
5.4
full res only
12
165.1
10.8
full res only
24
330.2
21.6
full res only
48
660.4
43.2
both over
The gap between full resolution and thumbnail is 15.3x. Within a 40MB budget you fit 2 full-resolution images, or 44 thumbnails. Wanting merely twelve tiles in the compact presentation would demand 165MB if you load them at source size.
There is a second, easier-to-miss line at the bottom. Even thumbnails cross the budget at 48 images, 43.2MB. Downsampling alone is not safety — it buys you room only if you also release cells that scroll off screen. I misread exactly this and ended up with a crash that reproduced only on the devices of people who scroll fast.
When you downsample matters as much as whether you do. Loading with UIImage(contentsOfFile:) and resizing afterward is already too late; the full-size bitmap has landed in memory by then. Let ImageIO perform a smaller decode instead.
import ImageIOimport UIKit// Don't load then shrink — decode small in the first placefunc downsample(_ url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? { let srcOptions = [kCGImageSourceShouldCache: false] as CFDictionary guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions) else { return nil } let maxPixel = max(pointSize.width, pointSize.height) * scale let options = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: maxPixel, ] as CFDictionary guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, options) else { return nil } return UIImage(cgImage: cg, scale: scale, orientation: .up)}
Step 2: Switch between compact and expanded presentation
An iMessage extension has two presentations: "compact," which fits at the keyboard position, and "expanded," which uses most of the screen. A natural flow is to show an asset list quickly in compact, then expand after selection to confirm the result.
import Messagesfinal class MessagesViewController: MSMessagesAppViewController { override func willBecomeActive(with conversation: MSConversation) { super.willBecomeActive(with: conversation) presentList() // Show the list first } func didSelectAsset(_ name: String) { // Once selected, expand and present the confirmation screen requestPresentationStyle(.expanded) presentDetail(for: name) }}
requestPresentationStyle(_:) is a request, not an instant switch. To rebuild the UI after the actual transition, combine it with didTransition(to:). Confuse the two and you render the old layout before the view expands, causing a brief flicker.
Step 3: Assemble the asset as a message and send it
The core of sharing is assembling MSMessage. The bubble that stays in the conversation is defined by MSMessageTemplateLayout. You design here what remains on the recipient's screen.
func send(asset name: String, in conversation: MSConversation) { let layout = MSMessageTemplateLayout() // Don't load the bubble image at source size either — route it through downsample layout.image = sharedAssetURL(name).flatMap { downsample($0, to: CGSize(width: 110, height: 238), scale: 3) } layout.caption = "Sent you a favorite of mine" let message = MSMessage() message.layout = layout conversation.insert(message) { error in if let error = error { print("insert failed: \(error)") // Don't swallow insertion failures } }}
conversation.insert only "inserts the message into the input field" — actual sending is left to the user's send button. Mistake this for "sent" and you misread the by-design behavior (nothing is sent automatically) as a bug. That tripped me up at first, and it clicked once I understood insertion and sending are deliberately separate.
Step 4: Diagnose why it won't appear in the drawer
You built the extension and installed it on a device, yet it doesn't appear in the Messages app drawer — a frequent iMessage extension symptom. The cause is usually in configuration, not code.
The order to check is this. First, whether the extension target's Bundle Identifier correctly nests under the main app's identifier. Second, whether the extension's Info.plist sets the NSExtensionPointIdentifier for a Messages extension. Third, whether the app is enabled in the device's Messages settings. In my case, a mistaken extension point (the second item) left it building fine yet never appearing in the drawer.
Step 5: Keep the distribution effect measurable
You will inevitably want to know later how many new users the extension's sharing drove. Carry a URL with an identifying query in MSMessage.url, so the main app can measure the path when a recipient opens the app from there. I track these share-driven launches simply, alongside AdMob revenue. Once the numbers are visible, you can judge calmly whether the extension is worth the effort.
Count first whether the share loop amplifies or merely adds
Whenever I hesitate over investing time in an extension, I make myself put it into numbers once. Does sharing compound, or does it simply add up? Getting that backwards misplaces the expectation, and the disappointment arrives later.
The strength of a share loop is captured by k. k = share rate × recipients per share × open rate × install rate. When k is below 1, cumulative installs plateau at 1 / (1 - k) times the seed.
// share-loop.mjs — reading the Messages-extension share loop through kconst scenarios = [ { name: 'cautious', shareRate: 0.02, recipients: 1.0, openRate: 0.20, installRate: 0.15 }, { name: 'realistic', shareRate: 0.05, recipients: 1.3, openRate: 0.30, installRate: 0.20 }, { name: 'optimistic', shareRate: 0.12, recipients: 1.8, openRate: 0.40, installRate: 0.30 },];const seed = 1000;for (const s of scenarios) { const k = s.shareRate * s.recipients * s.openRate * s.installRate; const mult = 1 / (1 - k); console.log(`${s.name} | k=${k.toFixed(4)} | ${mult.toFixed(3)}x | ${Math.round(seed * mult)}`);}const r = scenarios[1]; // hold the realistic funnel fixedconst tail = r.recipients * r.openRate * r.installRate; // and solve for the share rate k demandsfor (const target of [0.2, 0.5, 1.0]) { console.log(`k=${target} needs share rate ${((target / tail) * 100).toFixed(1)}%`);}
The output, again from Node v22.22.3.
Scenario
Share rate
Recipients
Open rate
Install rate
k
Multiplier
Cumulative from a 1,000 seed
Cautious
2%
1.0
20%
15%
0.0006
1.001x
1,001 (+1)
Realistic
5%
1.3
30%
20%
0.0039
1.004x
1,004 (+4)
Optimistic
12%
1.8
40%
30%
0.0259
1.027x
1,027 (+27)
Even the optimistic row amplifies by only 2.7%. Holding the back half of the realistic funnel fixed and solving for the share rate that k = 0.2 would require returns 256.4% — a number you cannot reach.
I will admit I once held a compounding expectation for this feature. What the numbers showed me is that the thing I was hoping for did not have the shape of k at all. What works here is linear accumulation.
Scenario
Installs per 1,000 shares
Cautious
30
Realistic
78
Optimistic
216
That table is refreshingly plain. Entry points grow in proportion to shares. Which means the numbers worth tracking are not the multiplier but two others: the share of users who share at all, and installs per share. Those two map directly onto concrete work, like removing one tap from the extension's flow.
Every rate above is a placeholder. Your real values come from the identified URL in Step 5, so substitute your own before drawing conclusions. It is also worth keeping the purpose distinct from mechanisms built to amplify deliberately, such as invite codes (Designing a Referral System Without a Heavy Backend).
Deciding whether to invest in the extension as an indie developer
An iMessage extension is a domain you could polish endlessly. But viewed as a distribution channel, getting to "drop a favorite asset into a conversation in one tap" already delivers real effect. The approach I took as an indie developer was to concentrate on making one round-trip of sharing light and fast, leaving decorative features for later.
Messages is the place where users picture someone in their own words. Being able to quietly offer your app's world there carries meaning beyond promotion. Now that Rork Max puts a native extension within reach, whether you can think of distribution as something you design in code will, quietly, make a difference.
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.