What's new

How I Tested Amana Across Locations in the iOS Simulator

A

Amana

Guest
At ten in the morning in Tokyo, under 94% cloud cover, I needed my app's paywall to show a clear sunset.

Waiting was the obvious option. What I did instead took one command: I moved my iOS Simulator to Albuquerque, where it was 7 p.m. with no clouds at all, waited 22 seconds, and took the screenshot. No mock data, no code change, no image editing.

The same trick then carried my App Store screenshots — though the sunset in my final screenshot set came from somewhere much closer to home. This is how it works, where it stops working, and the script that grew out of it.

Why I couldn't just fake the sky​


Amana is an iOS app that draws the sky outside your window — from the time, your location and the current weather — and then tries to get you to go look at the real one. I wrote about why it draws the sky on-device instead of using satellite images earlier in this series.

The app does have debug flags that let me set the sun altitude and cloud cover by hand. The problem is honesty. One store screenshot's caption reads "Night, too — real moon phase, real weather." Put a hand-placed moon under that caption and the screenshot is lying. And the paywall's widget preview runs on live weather too: on a cloudy day it renders grey, and a grey preview on a paywall is not a great look.

So the sky had to be real. It just didn't have to be my sky.

The one command​


Code:
SIM=<simulator-udid>
xcrun simctl location "$SIM" set 35.0844,-106.6504   # Albuquerque
xcrun simctl terminate "$SIM" "$BUNDLE_ID"
xcrun simctl launch "$SIM" "$BUNDLE_ID"
sleep 22
xcrun simctl io "$SIM" screenshot home.png

The write-ups of simctl location I found use it to test location features: maps, geofences, permission prompts. The part I hadn't seen written down is what happens downstream. The app never asks "where am I?" for its own sake. It asks CoreLocation for coordinates, then hands those coordinates to WeatherKit, to reverse geocoding for the place name, and to its sun-position math. Move the coordinates and every one of those follows: cloud cover, the place-name line, and the one-line "the sky may catch fire soon" hint.

I checked it by counting pixels instead of trusting my eyes. In the paywall's widget preview, a grey preview measures only a few percent warm-coloured pixels. After the jump it measured 30–32%.

The 22 seconds matter. My first attempt waited 13 seconds and captured the app's default cloud cover (0.40) — the placeholder it shows before the first weather refresh finishes. It looks plausible, which is exactly the problem. Real data arrived later, so the script now waits 22.

Three knobs, and only one of them turns​


Once location worked, I assumed the simulator could produce any sky on demand. It can't, and the reason is worth spelling out, because it applies to any app whose UI depends on the world.

Every input to a screenshot comes from one of three places:

  • Place (latitude, longitude): sun altitude, weather, place name, the hint. Movable with simctl location.
  • Calendar (the date): moon phase, and the season used for naming skies. Not movable.
  • Clock (the device's actual time): the time-of-day band used for naming. Not movable.

Place is free because the Earth is round: at any moment, 24 time zones exist at once, and one of them is having a clear dusk. There is only one calendar and one clock.

You can see it in the code. The moon phase has no latitude or longitude in it at all:

Code:
static func currentMoonPhase(date: Date = Date()) -> Double {
    let synodic = 29.530588853          // days in a lunar cycle
    let refNewMoon = 947_182_440.0      // a new moon: 2000-01-06 18:14 UTC
    let days = (date.timeIntervalSince1970 - refNewMoon) / 86400.0
    let phase = (days / synodic).truncatingRemainder(dividingBy: 1.0)
    return phase < 0 ? phase + 1.0 : phase
}

Fly anywhere you like; the moon stays the same shape. And when you name a photo, the naming engine reads the hour from the device:

Code:
static func timeBand(forHour hour: Int) -> TimeBand {
    let h = ((hour % 24) + 24) % 24
    switch h {
    case 4..<7:   return .dawn
    case 7..<16:  return .day
    case 16..<19: return .dusk
    default:      return .night
    }
}

simctl status_bar override --time only overrides what the status bar displays. Its help text says a full ISO date "will also set the date on relevant devices," so I tested that too. With 2026-12-25T09:30:00.000+09:00 (the forms I tried without milliseconds were rejected as "non-ISO"), the status bar changed to 9:30. A small program running inside the simulator still read the real date and time, and the Calendar icon still showed the 14th. The clock the app actually reads doesn't move. So if I want a sunset photo to get a dusk name, I have to shoot between 16:00 and 19:00 on the real clock. No simulator setting gets around that.

Choosing where to go​


The sun's position depends only on the current UTC time and your coordinates. My simulator's clock stays on Japan time (UTC+9), so to find a place that is at a given local hour right now, you can solve for longitude:

longitude ≈ (target local hour − current JST hour + 9) × 15 degrees east

Each hour is 15 degrees. At 17:00 JST, a local time of 17:30–18:30 lands between roughly 142° and 157° east: eastern Australia. I measured Cairns (−16.92, 145.77) at 10% cloud, with sunset at 17:11 JST.

In the end, though, I didn't use a foreign city for the home screen. The place name shows up on screen, and "Cairns" on a Japanese store listing looks odd. The better first move turned out to be the boring one: find somewhere in Japan that is clear right now. Then the device clock, the local time and the sky all agree automatically. The final English home screenshot reads Kushiro, 5:18 PM, a city in Hokkaido, and the status bar says 5:18.

Picking the city is a small script. It asks Open-Meteo for cloud cover, precipitation and sunset at 18 spots from Okinawa to Hokkaido, and ranks them. The core of it:

Code:
# dusk shot: skip anywhere raining; prefer clear skies about 15 minutes before sunset
timing = abs(to_sunset - 15)
score = cloud * 1.5 + timing

Foreign cities stay as the fallback for days when the whole country is overcast.

Why my status bar does not say 9:41​


The standard advice for App Store screenshots is to set the status bar to 9:41, Apple's own keynote time. Tools exist just for that; NSHipster's simctl guide points to one that keeps the clock at 9:41 AM.

For a sky app, 9:41 is a lie. An earlier set of my screenshots had a sky that didn't match the clock. One showed a night sky under 17:20, another a midday blue sky under 17:19. Forcing the status bar to 9:41 on top would have given three different times on one screen. The comment that now sits in the app's capture code says it plainly: the clock was the only thing still floating.

My rule now: status bar = in-app clock = the sky's time band. Every sky screenshot uses the real time it was taken:

Code:
xcrun simctl status_bar "$SIM" override --time "17:18" \
  --batteryState charged --batteryLevel 100 --cellularBars 4 --wifiBars 3

The Collection screenshot does say 9:41, because nothing on that screen depends on the time. 9:41 isn't wrong. It's just a default, and it only makes sense when the screen has no clock of its own.

A script that refuses to run​


The unmovable clock caused real damage. Twice, naming shots taken after dark gave sunset photos night names. Then three work sessions in a row started after the window had already closed — one at 22:57, another at 21:58. Writing "shoot before 18:30" in a handoff note clearly wasn't working, so the rule moved into the script (simplified):

Code:
check_window(){
  local mins=$((10#$(date +%H)*60 + 10#$(date +%M)))
  if [ $mins -lt 960 ] || [ $mins -gt 1110 ]; then   # 16:00–18:30 JST
    [ "${AMANA_FORCE_WINDOW:-0}" = "1" ] || die "outside the shooting window"
  fi
}

The window ends at 18:30, not 19:00, so that a run started late doesn't cross the dusk/night boundary partway through and leave one collection with both kinds of names.

Two other checks came from getting fooled:

  • "Not white" is not "loaded." The script checks which screen it's on by scaling each capture down and counting white pixels. A half-open dark navy screen isn't white either, so one run "captured four photos" and saved zero. It now requires both a white ratio and a dark ratio.
  • Checking the size proves nothing. A capture of the wrong screen (the photo picker) is still 1206×2622. The size check stays, but it comes after a check that the right screen is actually showing.

Screens are composited to 1290×2796, one of the sizes Apple accepts for 6.9-inch displays.

What I couldn't fix​


Duplicate names. Two different photos sometimes get the same name in the Collection. I tried swapping photos, shifting the time and moving the location. Three attempts, three new sets of duplicates. The math explains why. The name index is

Code:
let raw = (h + c * 100 + hr * 7).rounded()   // hue, cloudiness, hour

taken modulo the size of the word list, W. Photos named within the same hour share hr, so changing the hour shifts every photo by the same amount, and two photos that collided still collide. What did work was more words, and you can predict how many before adding any: the number of distinct names is the number of distinct values of round(hue + 100 × cloudiness) mod W. It is not monotonic. In my measurements nine words gave more distinct names than eight, and ten gave fewer than nine. With eleven, all seven of my test photos got different English names.

Place names stuck in the previous language. I shot the Japanese and English home screens at the same coordinates, and an English screenshot came back reading 仙台市, the Japanese name for Sendai. Going the other way, re-querying Sendai with the simulator in Japanese still returned "Sendai." In every test, the first lookup of a coordinate came back in the right language. I ruled out two explanations by measuring. One was a missing preferredLocale: without it, the first lookup still follows the device language. The other was the app's own cache: clearing it didn't help. That leaves a cache outside the app I haven't identified. The workaround is simple: use different coordinates for each language, and have the script check the stored place name for the wrong script before it continues.

The widget's Pro badge. The simulator's widget gallery was empty, so I placed the widget by editing IconState.plist while the simulator was shut down. That part works. Getting the Pro-only widget to render its unlocked state worked exactly once. Six different approaches since then have failed to reproduce it.

The part that carries over​


If your app shows anything derived from a location — weather, tides, air quality, sunrise, prayer times, a local event list — you don't have to wait for good conditions to shoot it. Move the simulator to where the conditions already are, and wait long enough for the real data to arrive.

Before you do, sort every value on the screen by where it comes from. Place-derived values will follow you. Calendar and clock values won't, and those decide when you have to be at your desk.

Amana is on the App Store, if you want to see what a sky with a real clock looks like.
 

Thread statistics

Created
Amana,
Replies
0
Views
1
Back
Top