Screen Mercy
Account Sign in
All articles

Releases

Screen Mercy 1.3.0: I Shipped You an 8.4 MB Text File

A new Scheduled timing mode, native Intel support, and a download 26% smaller than 1.2.0. Plus the 8.4 MB text file I shipped by accident, and why "Napoli" used to return Indianapolis.


Everyone who downloaded Screen Mercy 1.2.0 also downloaded an 8.4 megabyte tab-separated text file, uncompressed, most of it never read, re-parsed from scratch every time the app launched. I did not know this. The build pipeline was dutifully reporting a size metric the whole time. It was measuring the wrong thing.

So 1.3.0 is two releases wearing one version number. There is the feature release: a second timing engine, so you can drive warmth by the clock instead of the sun. And there is the reckoning: the app is now smaller than it was in 1.2.0 while carrying an entire second CPU architecture it did not ship before.

Solar mode, or your own clock

Screen Mercy has always followed the real sun, which is the correct default and the reason I built it. It is also, for some people, the wrong answer. If you work a fixed shift, or you live somewhere the sun sets at 3:30 p.m. in December and refuses to set at all in June, "follow the sky" is not a schedule. It is a rollercoaster.

So 1.3.0 adds Scheduled mode alongside Solar mode. You pick one. Solar mode warms and clears against your actual solar altitude, tuned by the Twilight control (1° to 40°). Scheduled mode takes a morning time, an evening time, and a fade duration (5 to 180 minutes), and does exactly what you told it to, every day, forever.

Your day and night Kelvin targets are shared between the two modes. Only the thing driving the interpolation changes. Switch modes and your warmth endpoints survive.

Three details I care about more than you probably do:

  • Schedules that cross midnight just work. Everything is minutes-of-day in modular arithmetic, so a schedule of "day starts 23:30, night starts 01:00" is not a special case, it is the same case. There is a test that sets exactly that and asserts midnight is full daytime.
  • The fade is smoothstep, not linear. t * t * (3 - 2 * t). Zero derivative at both ends, so the transition eases in and eases out instead of lurching into motion at the start time and slamming to a halt at the end.
  • Fades cannot overlap, ever. The requested duration is clamped to the shorter of your day and night spans. Ask for a 180 minute fade on a 20 minute night and you get a 20 minute fade, silently and correctly, instead of two transitions fighting over the same clock.

Scheduled mode also ticks differently. Solar mode polls every 10 seconds inside a transition and every 60 outside one. Scheduled mode knows exactly when its next boundary is, so outside a fade it just sleeps until then rather than waking up to confirm that nothing has happened.

The 8.4 MB text file

Now the embarrassing part.

1.0.0 was a 1.7 MB download. 1.1.0 was 1.9 MB. Then 1.2.0 was 6.3 MB. Nothing in the app got 3.4 times more capable in that step. What happened is that I added offline city search, and I implemented offline city search by taking the GeoNames cities15000.txt dump, dropping it in Resources/, and shipping it.

There is no one else here to have caught it. Screen Mercy is one developer, which means the review process is me, and the only thing standing between me and a bad decision is whatever I bothered to measure. Here is what I had bothered to measure: the only recorded size metric in the entire benchmark harness was the size of the git working tree, not the shipped bundle. And by then the working tree was roughly 89% that one data file. I had welded the dial to the thing it was supposed to be watching.

Lesson, filed permanently: a metric that cannot go up when the problem gets worse is not a metric. It is decoration.

Fixing it, in four parts

1. The city database: 8,385,345 bytes down to 2,152,532 (-74%). I went looking for what was actually in that file. 56.5% of it was the alternateNames column, an average of 10.3 aliases per city, overwhelmingly transliteration noise. Another 15.3% was columns Screen Mercy never reads at all. So the TSV moved out of the app and became a build-time input to a packer, which emits a binary index: a 32 byte header, 34,002 fixed-stride 16 byte records sorted by latitude, a shared region table (country and admin codes stored once instead of repeating 2,799 distinct values 34,002 times), and a blob of names pre-folded at build time.

All 34,002 cities are still there. Nothing was trimmed to hit a number.

The memory story is better than the disk story. The old path materialized four Swift Strings per city and parked them in a static let for the lifetime of the process, roughly 20 MB resident, paid on every launch. The new index is memory-mapped and evictable, loads in about 2 ms, and matching is done as UTF-8 byte substring search. UTF-8 is self-synchronizing, so a byte-level match cannot produce a false positive mid-character, which means no String is ever constructed for the ~34,000 records that fail to match.

2. The executable had a symbol table nobody needs. swift build -c release leaves a full symbol table in __LINKEDIT. For this binary that was 3.34 MB of a 5.01 MB arm64 slice. Two thirds of the executable, none of it used at runtime. A strip -x in the build script removes local symbols while keeping global and Objective-C runtime symbols, so NSClassFromString, Swift reflection, and dynamic casts all still resolve. The .dSYM is archived alongside the build, so crash reports from the field are still symbolicable.

3. Sparkle was shipping its own header files. 224 KB of .h files and a modulemap, compile-time inputs that no installed app has ever opened, riding along inside the framework bundle. Gone. Its 36 localizations stayed: that is 308 KB, and it is the only localized surface a non-English user ever sees, because Screen Mercy itself is English-only. Removing it to win a size argument would have been vandalism.

4. A build-cache bug was hiding the win. SwiftPM copies declared resources into a target bundle but never prunes files a later commit stopped declaring. So on any machine with an existing .build directory, swapping the 8.4 MB TSV for the 2.1 MB index left the TSV sitting in the bundle anyway, and the publish script does not build clean. The fix removes stale resource bundles before assembling. This is the one that would have quietly eaten the entire improvement.

The scoreboard

Measured 1.2.0 1.3.0 Change
Website DMG 6,328,186 4,699,421 -25.7%
In-app update zip 5,558,821 3,885,427 -30.1%
Offline city data 8,385,345 2,152,532 -74.3%
City index resident memory ~20 MB mapped evictable
Architectures arm64 arm64 + x86_64 +1
Bytes, measured on the shipped artifacts. The installed bundle is now 8.5 MB: a 4.0 MB universal executable, 2.7 MB of Sparkle, and 2.2 MB of resources.

I want to be precise about the headline, because "half the size" is the kind of claim that is fun to make and annoying to defend. The download is 26% smaller. The installed bundle is down roughly 45%. Along the way 1.3.0 spent about 2.0 MB of uncompressed executable on an Intel slice that 1.2.0 did not carry, and the download still came out 1,628,765 bytes lighter than 1.2.0's. That is the version of "less is more" I can put receipts behind.

Intel Macs, because the requirement was a lie

Screen Mercy's Info.plist has always declared LSMinimumSystemVersion 13.0. Ventura runs on Intel Macs from 2017 onward. The binary was arm64-only, which means on those machines it could not launch at all. The stated requirement was simply false, and had been since 1.0.0.

It is now a universal binary, x86_64 and arm64, which costs about 2.0 MB and makes the sentence on the download page true. I considered separate per-architecture downloads and rejected it: under 3 MB of savings on a sub-10 MB download, in exchange for doubling the release pipeline (two builds, two notarizations, two DMGs, two Sparkle zips, and two appcast feeds, since the feed URL is baked into Info.plist at build time), and introducing a failure mode where Rosetta silently runs the Intel build on Apple Silicon. That is the worst imaginable bug for an app whose entire pitch is efficiency.

"Napoli" used to return Indianapolis

This is my favorite bug of the release.

City search scored on the primary name and population. Aliases contributed nothing. So typing Napoli, the actual name of the city, in the actual language of the city, returned Indianapolis, Anápolis, and Kannapolis, because those contain the substring. Naples fell off the end of the list entirely. München, Москва, 東京, and Warszawa were similarly unreachable by the names their residents use.

Two things were wrong. First, alias selection: an earlier revision kept the first three aliases in file order, which is effectively arbitrary, because GeoNames tends to lead with airport codes and obscure historical names. The packer now keeps every qualifying alias for cities above 200,000 people, filtered to at most 2 words and 30 characters (longer entries are descriptive phrases like "lungsod ng muenchen", not names anyone types). The population threshold is not a size budget: English has exonyms for large cities and almost never for small towns, where the GeoNames primary name is already the local name.

Deduplication runs longest-first, which sounds like a micro-detail and is not. Storing "firenze" answers both "firenz" and "firenze". Storing "firenz" answers only the first. Aliases are ~790 KB of the index and this is why that is a good trade.

Second, ranking. An exact alias match now scores above a name prefix match, deliberately:

exactName    100_000
exactAlias    60_000   <- above namePrefix, on purpose
namePrefix    50_000
nameContains  20_000
aliasPrefix   10_000

+ populationFloor = min(population / 1000, 10_000)

"Wien" is all of what Vienna is called, while "Wiener Neustadt" is a separate town 46 km away that merely starts with those letters. "Milano" is Milan, not Milanówek, 1,118 km away in Poland. And the population contribution is floored at 10,000 points so a big city can never outrank a better textual match on size alone.

Folding is done with an explicitly nil locale, which is load-bearing twice: locale-dependent folding would make the shipped bytes depend on whichever region the build machine happened to be set to, and a user in a Turkish locale searching a Latin-script world index should not have dotless-i rules applied to "Istanbul".

There is now a test pinning 22 endonyms across five scripts (München, Köln, Wien, Praha, Warszawa, Firenze, Milano, Torino, Napoli, Lisboa, Sevilla, København, Göteborg, Antwerpen, Genève, Den Haag, Москва, 東京, 北京, 서울, Αθήνα, القاهرة) and asserting the first result lands within 25 km of the right coordinates. It asserts on coordinates rather than names on purpose, because GeoNames sometimes uses the endonym as the primary name ("Köln", "Sevilla") and sometimes the English exonym ("Naples", "Warsaw"). Asserting a string would be testing GeoNames editorial policy instead of my search.

Paris is not "Paris 04 Hôtel-de-Ville"

The Location row now shows the name of your city instead of two decimal numbers. Getting there required admitting that "nearest city" is the wrong question.

GeoNames lists administrative subdivisions as cities in their own right whenever they clear 15,000 people, and a subdivision's centroid is frequently closer to the metropolitan center than the parent city's own record is. Straight nearest-city lookup therefore labelled Paris as "Paris 04 Hôtel-de-Ville", Rome as "Esquilino", and Istanbul as "Eminönü".

The fix is a lookup policy, not a data patch: gather every city within 5 km, take the most populous, break ties deterministically by name, and fall back to nearest-within-150 km so rural coordinates still resolve to a real town. The 5 km radius was tuned against a sample of 14 places and is the interesting part, because widening it does not help, it actively hurts. At 10 km, Berkeley becomes "Oakland" and Yonkers becomes "The Bronx". At 15 km, Oakland itself becomes "San Francisco". Distinct neighbouring cities sit 10 to 15 km apart. Subdivisions of one city sit within about 5.

No city name is ever written to disk. Only your coordinates are stored, exactly as before, and the label is re-derived on demand. A persisted name would go stale the instant you dragged the map, and this way one source of truth serves all three ways coordinates can arrive: picking a city, dragging the map, and auto-detect. Resolution costs about 100 microseconds and is memoized behind a lock, because the label is a computed property evaluated inside a SwiftUI view body and therefore runs on every render of the popover.

1,200 disk writes per second, per slider

Dragging a slider used to call save() on every frame, and save() unconditionally rewrote all twenty scalar preference keys whether they had changed or not. Twenty keys, sixty frames per second. One slider drag issued roughly 1,200 UserDefaults writes per second, 1,140 of them describing values that had not moved. A drag across the location map wrote latitude and longitude separately, 40 writes per frame.

Three fixes:

  • Dirty-key diffing. save() now diffs a snapshot against what was last written and sets only the keys that actually differ. One property change issues one write instead of twenty. Each set is main-thread work plus KVO notification, so this is not a theoretical saving.
  • Gesture-scoped deferral. Controls declare the start and end of a gesture through a SwiftUI environment value whose default is a no-op, so previews and any host that does not inject it behave exactly as before. Inside a gesture, writes are marked dirty and a coalescing 250 ms flush is armed, re-armed on each change and cancelled by a generation token. A continuous drag persists about four times a second instead of sixty. A gesture that never formally ends still lands. Scopes are depth-counted, so overlapping gestures cannot end each other early, and everything pending is flushed on app termination. Writes outside an explicit gesture scope stay synchronous, because a mutation should be readable from a freshly constructed state object.
  • Per-tick values left the observable object. Live solar altitude and resolved Kelvin used to be published from app state, which invalidated every view observing app state on every single tick. They now live on per-display runtime state instead.

One more, because it was pure waste: SolarEngine.nextHorizonCrossing is a 2,880-step search, the full NOAA position algorithm evaluated once per simulated minute across a 48 hour window. On macOS, Menu content is built along with the enclosing body rather than deferred until the menu opens, so that search ran on every body pass, plus once per step of the Twilight slider. It is now memoized by a cache that floors the query instant to its minute and passes that floored value through, so a cached answer is byte-identical to what an uncached call for the same minute would have returned. The quantization is a documented property of the query rather than a hidden side effect of caching, and flooring makes clock changes self-invalidating. SolarEngine itself stays pure.

Performance

While chasing the size problem I found two hot paths doing more work than they needed to. gammaRamp was calling pow() 256 times even when gamma was exactly 1.0, which is the default; a fast path collapses that to a multiply, and a test pins the output bit-identical to the general path. SolarEngine.position was going through Calendar.dateComponents to get minutes past midnight UTC, which is now plain arithmetic on the Unix timestamp. That is exact rather than approximate, because Unix time has no leap seconds, and there is a test comparing it against Calendar across a full day.

Benchmark 1.2.0 1.3.0 Change
ColorTemperature.gammaRamp(256) 2546.71 ns 588.66 ns -76.9%
SolarEngine.position 511.63 ns 151.77 ns -70.3%
SchedulerCore.decide(3 displays) 813.10 ns 575.93 ns -29.2%
Scheduler.targetKelvin 0.75 ns 0.74 ns flat
Nanoseconds per operation on an Apple M3 Max, arm64, macOS 26.5.2. Faster horizon crossing search as a bonus: 1.39 ms down to 0.31 ms for a 48 hour crossing search.

City search is now measured too. These rows are new in 1.3.0 and have no 1.2.0 counterpart, because the old TSV path was never benchmarked, which is of course how it got this bad.

PackedCityIndex.nearest(150km)        65030.91 ns/op
PackedCityIndex.search("München")   1447424.58 ns/op
PackedCityIndex.search("s")         2288886.88 ns/op
PackedCityIndex.search("san")       1864283.12 ns/op
PackedCityIndex.search("san fr")    1835647.71 ns/op

A worst-case single-character query, which scores all 34,002 cities, comes in around 2.3 ms. You are typing. You will not notice it.

Gates, because I cannot review my own pull requests

An efficiency doc for this app already existed when 1.2.0 shipped. I wrote it. It tracked CPU obsessively and said nothing at all about artifact size, which is exactly how a 3.4x download regression walks through my own front door while I hold it open.

Working alone means the only reviewer I will ever have is the build, so the build gets teeth. make test-release now fails if the raw TSV reappears in the bundle, if either architecture slice goes missing, if the executable looks unstripped (over 5,000,000 bytes), if framework headers come back, or if the total payload exceeds 11 MB. On failure it prints the ten largest files, so the cause is immediate rather than a mystery. make benchmark now records the actual bundle breakdown per release, not the size of the source tree.

For context, and without inflating it: f.lux is around 12 MB, BetterDisplay around 45 MB, Iris around 83 MB. I am not claiming 8.5 MB is a record. Almost all of what remains is the offline city database and Sparkle, and I am keeping both. One is why picking your location needs no network. The other is why you are getting this update at all.

How to get it

If you already have Screen Mercy installed, the app will offer the update automatically if you have auto-check enabled. You can also check for updates manually from the menu bar, or grab the DMG directly from the download page.

All users get this at no charge. The 1.3.0 build is included in every active license. Enjoy the 1.6 MB you did not have to download.