▲ 1 r/nextjs

opengraph-image for an unknown slug was returning 500 and taking the Node process with it

Ten days of access logs on my Next.js site had 1,446 5xx responses. 1,378 of them were on /<route>/opengraph-image. Every HTML page, the feed and the sitemap were clean, which took a while to notice because the 5xx had been attributed to the feed and sitemap.

Known slugs were fine. They come from generateStaticParams and get a prerendered PNG. The problem was the fallback branch for slugs that are not in that list:

export default async function Image({ params }) {
  const { slug } = await params;
  const issue = getIssueBySlug(slug);
  const fonts = getOGFonts();          // runs either way

  if (!issue) {
    return new ImageResponse(
      <div>Issue not found</div>,
      { ...size, fonts },
    );
  }
  ...
}

getOGFonts() ran before the not-found check, so an unknown slug still loaded fonts and built an ImageResponse at runtime. On a process that had already served an uncached /_next/image optimization, that font read crashed the worker instead of returning an image. 1,364 of the 1,378 were 502s, which is the proxy failing to reach Node at all. PM2 had 80 restarts on that process.

The fix is ordering:

const issue = getIssueBySlug(slug);
if (!issue) notFound();

const fonts = getOGFonts();

notFound() throws before anything touches the font buffer, so an unknown slug returns a zero-byte 404 and the process stays up. Since the deploy: 895 requests, zero 5xx, unknown OG slugs 404, known ones still 200 image/png.

Two things worth checking if you have dynamic OG routes:

  1. Request an OG image for a slug that does not exist. If you get anything other than a 404, you have a runtime ImageResponse path a crawler can reach.
  2. Group your access log's 5xx by request path before you trust which route is failing. Mine pointed at two routes that had never returned a 5xx.
reddit.com
u/VictorBuildsApps — 2 days ago

launchctl submit kept reopening an app after every quit

A local macOS app came back as soon as I quit it. The binary was not the cause.

Two leftover jobs from earlier repair sessions were still registered with launchd. Both had been created with `launchctl submit`. Apple's man page says that also tells launchd to keep the program alive in the event of failure.

Recorded run counts: 8,429 and 9,169. After both labels were unloaded, the same quit left the app absent for twenty seconds of half-second samples. The app source was not changed.

launchctl print gui/$(id -u)/<label>

Print the job before you rewrite the app.

https://thechosenvictor.com/newsletter/archive/launchctl-submit-kept-the-app-alive

u/VictorBuildsApps — 3 days ago

Twelve App Store versions were READY_FOR_SALE. That still did not prove which icons customers saw.

On August 15, all twelve portfolio versions were READY_FOR_SALE. That settled release state, not the public icon pixels.

A fresh US iTunes Lookup was run today for every Apple ID. Each artworkUrl512 file was downloaded and compared against the selected 1024-pixel master after both were normalized to 256 by 256 pixels.

The public versions were Gloam 1.0.8, Hushlayer 1.0.8, Ignis 1.0.6, Kitwise 1.0.4, QRift 1.1.5, ReceiptCrate 1.2.5, Scope Binder 1.0.5, SipStreak 1.3.4, Sortes 1.0.6, Swipe Clean 2.0.5, UnitFlip 1.1.4, and VaultSnap 1.3.6.

All twelve public icons matched the selected candidates. Normalized RMSE ranged from 0.0044 to 0.0137, which is consistent with App Store resizing and compression rather than an asset mismatch.

The release gate now has two checks: version state in App Store Connect, then public artwork through the storefront lookup. A green version alone cannot prove what a customer sees.

reddit.com
u/VictorBuildsApps — 5 days ago

Apple recommends a minimum 3-week lead for featuring nominations. I had six drafts, and five versions did not meet it.

I wrote the nominations while a batch of updates was ready to submit. All six were saved as drafts. None had been submitted.

Five related versions were set to release automatically after approval. My measured median App Review time across the account was 11.4 hours. That setting could make them public about 20 days before Apple's recommended lead had elapsed.

The release option was the control that mattered. App Store Connect can hold an approved version for manual release, or release it automatically no earlier than a specified date. Either option separates App Review from the public launch.

If featuring is part of the release plan, the order is: submit the nomination first, choose a publication date at least three weeks out, and make the version's release option match that date. Review can finish whenever it finishes.

I left those five on automatic release. The updates shipped, and the drafts never became submissions for that release.

reddit.com
u/VictorBuildsApps — 9 days ago
▲ 1 r/iosdev

I found 11 stale builds after four App Store feedback feeds came back empty

I audited twelve iOS apps before a release wave. Customer reviews, TestFlight feedback, TestFlight crashes, and the latest-build performance diagnostics were all empty.

That looked reassuring until I compared each uploaded binary to its source. Eleven repositories contained meaningful fixes newer than the builds in App Store Connect. The gaps included persistence rollback, notification ordering, wall-clock timers, larger text, and data provenance.

The check that exposed them was simple. For each app I recorded the App Store version, build number, build identifier, and source commit together. Then I compared that commit with the current source and reviewed only changes that affected released behavior.

I now keep three release questions separate:

- Did a review, tester report, crash, or diagnostic reach its collection?

- Which source commit produced the uploaded binary?

- Which version can a customer install from the public storefront?

An empty feed answers only the first question. It does not make the shipped build current or establish that the app is crash-free.

After the audit and rebuilds, ten new versions are live and two are still in review. The useful change was not another dashboard. It was pinning every uploaded artifact to the source that produced it, then finishing the check at the storefront.

reddit.com
u/VictorBuildsApps — 10 days ago

Three iOS apps use the same feedback endpoint. Their privacy manifests are not the same.

A dependency and network audit across twelve iOS codebases found no third-party

SDK or code package linked into any of the app targets. Eight of the twelve make

no direct network call.

The difference appeared inside three apps that send feedback to the same

first-party endpoint. Two request payloads include diagnostics, and their

checked-in privacy manifests include Diagnostics. The third uses the same

server without diagnostic data in its payload, and its manifest does not

include Diagnostics.

The host name was shared, and the target-level dependency scan returned the

same result: no linked third-party SDK or code package. The request fields were

not the same.

The check that separated them was reading the request model and encoder, listing

the fields that can leave the device, and matching those fields against

PrivacyInfo.xcprivacy. The dependency scan stayed as a separate check for

third-party code.

A shared endpoint does not prove that two apps collect the same data. Even

with no third-party code linked into the targets, the privacy result can differ

because the apps send different payloads.

reddit.com
u/VictorBuildsApps — 12 days ago
▲ 3 r/iosdev

Finished taking AdMob out of all twelve of my apps. The ads were paying pennies and costing a clean privacy label.

I put Google's ad SDK into some of my apps a while back for the usual reason. The app is free, the SDK is free, a banner in the corner makes a little money while I sleep. All twelve of my apps are ad-free now, pulled one app at a time.

The revenue was pennies. Not a side income, not an amount I would notice if it vanished. A banner pays when a lot of people see it a lot of times, and quiet utility apps are nowhere near that threshold.

The cost is the part that does not show up on day one.

On QRift, taking out the AdMob and UMP stack meant deleting four services, two ad views, a privacy settings row and their tests, both SPM packages, the ADMOB build settings, GADApplicationIdentifier, the ATT usage string, and four ad unit ID keys out of Info.plist. PrivacyInfo.xcprivacy went back to tracking false with no domains. A clean build now takes about five seconds, because there is nothing left to link.

The App Privacy label is the one worth checking on your own apps. QRift was declaring six data types purely because of the ad stack: Advertising Data, Device ID, Coarse Location, Performance, Crash, and Product Interaction. With the ad code gone I set it to Data Not Collected. That is the actual trade the SDK makes.

Two things that bite on the way out, if you are doing this to an app that is already live.

You cannot set the privacy label to the truth until the ad-free build is live. The label describes the shipping app, and the shipping app still serves ads until the new version is out. So it is ship first, then fix the label, then fix the same claim on your website.

That rule cuts both ways, and QRift is the example. It does not read Data Not Collected today. A later version added an in-app bug report form, so the live label is four types under Data Linked to You: Contact Info, User Content, Identifiers, and Diagnostics. None of them is advertising. The label tracks what the app does now, not what you took out of it.

And read your in-app purchase text. ReceiptCrate's lifetime unlock said "No ads and unlimited receipt records", which stopped being true the moment the ads left. An approved in-app purchase localization cannot be PATCHed. It is a new inAppPurchaseLocalizations draft plus a separate submission through inAppPurchaseSubmissions to replace the live string.

reddit.com
u/VictorBuildsApps — 13 days ago

If your Age Ratings button says View instead of Edit, that is not a bug. It cost me five new app versions.

Apple added social-media questions to the age rating questionnaire. They are mandatory from September 2026 and App Store Connect carries its own banner about it on the Apps list. On five of my twelve apps I could not answer them at all.

The API refused the write with this:

This age rating declaration is not editable. Is the corresponding AppInfos editable?

Each of those five apps has exactly one App Info record, sitting in READY_FOR_DISTRIBUTION, which is locked. An App Info only becomes editable alongside a new version.

I checked the web UI too, signed in as the account holder. It cannot do it either. On SipStreak the Age Ratings control says View rather than Edit, and opening the questionnaire shows all 16 radios disabled, including the two social-media questions that were the only unanswered ones. On QRift, which had an editable version in progress, the same control says Edit and none of the 16 are disabled. Same lock, not a separate wall.

So the only route is a new App Store version per app. Creating the version produces a second App Info in PREPARE_FOR_SUBMISSION, and that one takes the answers.

The part that will catch you if you script it: write to the new App Info by its id. Targeting the app instead silently writes at the live record again, which is the one that was locked in the first place.

If you want to see where you stand without changing anything, this is read only:

GET /v1/apps/{id}/appInfos?include=ageRatingDeclaration

It shows you which App Info record you have, what state it is in, and what is already stored in the declaration.

All twelve of mine answer No to both questions. Every one is a single-user utility with no messaging, no user-generated content, and no unrestricted web access, and those three fields were already false before any of this started. Five apps still needed a new version created to record an answer the API had already shown was No.

reddit.com
u/VictorBuildsApps — 14 days ago
▲ 4 r/iosdev

I measured App Store review time across 12 of my own apps. Median 11.4 hours, slowest 89.7, a 64x spread on one account.

Review time comes up here constantly and almost every answer is one person's anecdote, mine included. So I pulled the actual numbers off my own account.

Method, because it decides whether any of this is worth reading. ASC does not expose an approval timestamp. lastUpdatedDate came back null on every submission I checked, and appStoreVersions only carries createdDate. So I used the public release date as a stand-in for approval, which makes every number below an upper bound. It includes Apple's own release propagation after approval.

That stand-in only holds when the version releases automatically after approval, so I dropped the four apps set to manual release. Their submit to live gap measures me, not Apple.

Eight versions left.

App Version Submitted (UTC) Hours
SipStreak 1.2.0 2026-06-07 17:35 1.4
Kitwise 1.0.1 2026-07-18 17:27 2.8
VaultSnap 1.3.1 2026-07-13 19:28 10.1
Gloam 1.0.4 2026-07-29 10:49 11.2
SwipeClean 2.0.0 2026-05-31 06:47 11.6
UnitFlip 1.1.0 2026-06-14 07:08 24.1
QRift 1.1.0 2026-07-02 15:26 35.7
ScopeBinder 1.0 2026-06-20 20:37 89.7

Min 1.4, median 11.4, max 89.7.

The slowest row is the only brand new app in the set, so "the first submission takes longer" holds there. It does not explain the rest. SipStreak 1.2.0 took 1.4 hours and UnitFlip 1.1.0 took 24.1 hours. Both updates, same account, one week apart, and one sat seventeen times longer than the other.

I read every app from both the US and German storefronts, because the iTunes lookup is edge cached per storefront and a single request can be stale. Zero disagreements between the two, and all 12 ASC version strings matched what iTunes reported, so I know the version I timed is the version that is live.

If you want to run it on your own account: take the newest state: COMPLETE entry from asc review submissions-list --app &lt;id&gt; for submittedDate, take releaseType and versionString from the READY_FOR_SALE entry in asc versions list --app &lt;id&gt;, and take currentVersionReleaseDate from the iTunes lookup with a cache busting parameter. Keep only the AFTER_APPROVAL rows and confirm the two version strings match before you difference the dates.

reddit.com
u/VictorBuildsApps — 15 days ago
▲ 1 r/iosdev

An editor backup was inside the Release app bundle because XcodeGen copies any file it cannot compile into Resources

A file called SettingsView.swift.pre-gemini, a 14,557-byte editor backup from April 10, was sitting in the Release bundle of a live project. XcodeGen adds any file under a target's sources path that it cannot compile to the Resources build phase, and it prints no warning when it does. project.yml excluded only .DS_Store and Info.plist, so the backup was wired in as a resource, with four references to it in the tracked project.pbxproj. .gitignore had kept it out of the repository the entire time. Git-ignored is not build-ignored.

Excluding that one suffix fixed that one file. A denylist only covers extensions someone already thought of, so the next step was planting decoys and rebuilding on XcodeGen 2.45.4. Four reached the Resources phase and were then found physically inside the built .app, not merely inferred from the project file: SettingsView.swift.rej, Something.swift~, Config.json.save, and a temp file from a separate tool. Config.json.save is the useful case, because the existing exclude only matched names ending in .swift.save. The tool temp file matched no exclude pattern in any project in the portfolio.

The check that holds is an allowlist. A script resolves every file in the Resources build phase to a full source path and asserts the complete set per target, so an unrecognized extension fails the build instead of shipping. Two things mattered while writing it. Comparing basenames, or piping both sides through sort -u, merges targets and hides a duplicate copy of an already-allowed resource sitting in an unexpected directory, so the comparison has to be a multiset with exact counts. And four consecutive regex-based versions of this gate each reported a false pass, which is invisible, because the script prints passed either way.

reddit.com
u/VictorBuildsApps — 16 days ago
▲ 1 r/Xcode

Symlinking all of ~/Library/Developer/Xcode to an external volume broke asset catalog builds with Operation not permitted

On a machine where ~/Library/Developer/Xcode had been replaced by a symlink to a directory on an external volume, asset catalog processing failed in three separate app projects. The CoreSimulator logs reported NSPOSIXErrorDomain Code=1, Operation not permitted, while Interface Builder simulator services copied sample device content into UserData/IB Support/Simulator Devices under the external path. The external volume is APFS, writable, with ownership enabled.

Setting HOME and CFFIXED_USER_HOME for the individual build command did not change the result, because ibtoold was already running with the original environment. Inspecting ibtool and actool did not turn up a supported per-command override for the simulator device set.

The fix was to stop symlinking the whole directory. ~/Library/Developer/Xcode is now a real directory on the internal disk, UserData and the two index mapping plists stay internal, and only DerivedData and iOS DeviceSupport are child symlinks to the external volume. On that machine now, UserData is 44 MB while the two externalized directories are 22 GB and 11 GB.

After the cutover, actool and CompileAssetCatalog ran for all three projects with no Operation not permitted failure, using fresh DerivedData paths that still resolved to the external volume. Their test suites passed at 49, 25, and 276 tests on an iPhone 17 Pro simulator running iOS 26.4. Those measurements are from 2026-07-20 and the split is still in place.

reddit.com
u/VictorBuildsApps — 17 days ago
▲ 0 r/swift

In a Debug build the guarded literals were only in the .debug.dylib, so scanning the app executable alone returned zero

A #if DEBUG guard was added around two string literals and then verified by scanning the built product instead of reading the source.

In an XcodeGen-generated project whose project.yml never mentions SWIFT_ACTIVE_COMPILATION_CONDITIONS, xcodebuild -showBuildSettings reports that setting as DEBUG under Debug and prints no line for it at all under Release. The guard holds in Release because the generator supplies that default, not because project.yml states it.

In the Debug simulator build the app bundle carries the app executable, a .debug.dylib, and __preview.dylib. A recursive scan of the whole bundle found both literals only in the .debug.dylib, and the app executable contained zero occurrences. A scan limited to the executable would have returned zero under Debug, which is the opposite of the actual state.

The Release bundle contained zero occurrences anywhere. On its own that zero is not evidence. The identical recursive scan against a Release build produced before the guard existed returned both literals in the app executable, and that control is what makes the Release zero meaningful.

reddit.com
u/VictorBuildsApps — 20 days ago
▲ 1 r/iosdev

An App Store Connect app can report as needing submission while it is live. Its latest review submission had zero items.

An app that is live on the store reported as needing a review submission.

The app store version resource says READY_FOR_SALE. A review-status query against the same app returned READY_FOR_REVIEW, with a next action to submit the prepared review submission.

That app has five review submissions. Four are COMPLETE. One sits in READY_FOR_REVIEW with a null submittedDate and zero review items attached, so it is an empty shell that was never sent. It is also the newest of the five, so a tool that resolves "the latest review submission" by recency selects it, and then reports the app as unsubmitted.

The submission that actually shipped the live version is a different record: COMPLETE, with one item in APPROVED.

The zero-item count was checked against a submission known to be real, which returned one item. So the zero is a real zero and not a failed call.

Bounded to what was actually observed: this is one tool against one account. The general point is only that resolving "latest" by recency can select an unsent shell. If you automate anything off review submission state, read the app store version resource to decide whether a version is live. The submission list tells you what happened to a submission, not what state the app is in.

reddit.com
u/VictorBuildsApps — 21 days ago

I set a 250-view floor before judging an iOS launch kit. The page got 4.

I ran a two-week traffic test on a launch-video kit I made for iOS developers. Before it started, I wrote down one rule: do not call the offer good or bad until at least 250 qualified product-page views.

Both planned placements ran. Gumroad recorded 9 UTM clicks from X and 1 from my newsletter, but only 4 product views in the window. One of those came from an older Product Hunt listing, not either test placement. Sales were 0. Revenue was $0.

Zero sales looks like an offer failure until you put it beside four views. The click counts do not reconcile with product views, and the offer never got enough qualified traffic to be tested.

I closed it as a distribution failure. The $29 listing stays untouched, and the web version stays closed.

The 250-view floor was useful because I wrote it down before any traffic arrived. This test says nothing conclusive about the offer. It says those two channels did not put it in front of enough people.

reddit.com
u/VictorBuildsApps — 22 days ago
▲ 0 r/swift

A test in my suite expired at 02:00 today. The other half of the same assertion goes at 21:00.

Opened the repo this morning. Suite red, nothing committed since yesterday afternoon.

The test injects a fake now of July 27. The service takes an injectable clock, that is the whole point of it. Then the assertion calls nextTriggerDate() on the UNCalendarNotificationTrigger that came back, and that one ignores my fake clock. It reads the device.

The reminder lands on 09:00 July 29, and pinned to Asia/Tokyo that is 02:00 here. So at 02:00 the date the assertion leans on slid into the past, and it has failed every run since. Not flaky. Permanent.

Then I looked at the line above. Same assertion, other trigger, pinned to Pacific/Honolulu, which puts it nineteen hours behind Tokyo. Still green while I type this. Goes at 21:00 tonight.

What stings is I already fixed this yesterday. Same file, different test, and the commit message literally says assert the scheduled components instead of the system clock. Fixed the one directly below and walked right past this.

Fix is the same. Resolve the dateComponents the service produced and assert on those.

reddit.com
u/VictorBuildsApps — 23 days ago
▲ 0 r/iosdev

My unit converter displayed 0 for 1 mm to km. That was also the number it copied.

UnitFlip formats results at a fixed decimal precision, and the default is 4. 1 mm to km is 0.000001, so at four decimals it rounded to an exact 0.

The result field showed 0, the equation caption read "1 mm = 0 km", and the copy button handed over 0. -1 mm showed -0. Nothing signaled the truncation, so the app just returned a wrong answer.

Fixed precision alone is not safe for a converter. Ordinary results keep it. A nonzero result that would round away now falls back to significant digits, and to scientific notation when the decimal form gets too long for the single-line display.

reddit.com
u/VictorBuildsApps — 24 days ago

I left my paywall alone after 6 downloads in 30 days

I have 12 apps live. Swipe Clean got six first-time downloads in the 30 days through July 8.

I left the paywall copy alone. At that volume, a conversion change would be noise. The useful problem was distribution: getting enough people into the app for a paywall test to mean anything.

reddit.com
u/VictorBuildsApps — 26 days ago
▲ 1 r/iosdev

I audited the Support URL on 12 live apps. Three were pointing at the wrong page

I audited the public App Store pages for all 12 of my live apps yesterday. Ignis, Gloam, and VaultSnap all had working Support URLs, but they went to a product or company page instead of the dedicated support page that already existed.

The live versions were locked. I had to prepare Ignis 1.0.2 (6), Gloam 1.0.4 (7), and VaultSnap 1.3.2 (8) just to make those three URL corrections. Zero behavior changes.

Every URL returned 200. Three still sent users to the wrong place.

reddit.com
u/VictorBuildsApps — 28 days ago
▲ 22 r/SwiftUI

The one architecture rule that keeps my solo iOS apps from rotting

I ship iOS apps solo. No code reviewer, no teammate catching me cut corners at 1am. The codebase only stays sane if I enforce structure on myself, because nobody else is going to.

The rule that saves me: views do not touch data.

My basic split:

  • Views handle layout and taps. If a view is deciding how data gets saved, I moved too much into the view.
  • ViewModels own screen state. I keep them @Observable and @MainActor, because SwiftUI already gives me enough ways to hurt myself.
  • Services do the work: SwiftData, network calls, file access, anything that changes the outside world.
  • Models stay boring. Data only.

I use it as a fence. Future me needs fewer places to look.

When a screen breaks, I know where to start. When I change storage, the UI does not need surgery. And when I let an AI agent work on a service, it has less room to wander into seven other files.

I learned this by doing the opposite. I let views query SwiftData directly in an early app and it turned into spaghetti. Worked fine until it didn't. Then every small change felt like pulling a wire out of a wall and hoping the lights stayed on.

Curious how other solo SwiftUI devs handle this. Do you keep the layers this strict, go heavier with something like TCA, or let small apps stay messy.

reddit.com
u/VictorBuildsApps — 2 months ago