Add a Compose Multiplatform desktop app for Windows, Linux and macOS - #4
Open
solcott wants to merge 5 commits into
Open
Add a Compose Multiplatform desktop app for Windows, Linux and macOS#4solcott wants to merge 5 commits into
solcott wants to merge 5 commits into
Conversation
Skia has no system font manager, so it draws with whatever font it is handed — which means flag emoji resolve differently per platform, and one platform cannot fix it for the others. Android, iOS and macOS have a system font with the regional indicator ligatures; on web Compose Multiplatform downloads the Noto subsets itself. LocalFlagFontFamily defaults to null, which is Text's "inherit", so this changes nothing anywhere today. The desktop app supplies a real font through it in a later commit, for Windows and Linux. It is applied to the two composables that render nothing but the flag, so the family needs no fallback chain behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The third entry point, and the least eventful one to build: CountriesApp
was already extracted for :web, every library module already publishes a
jvm target, and :network already had a JVM platformConfiguration putting
the Apollo SQLite cache under ~/.apollo. This is a main(), a Window, and
the packaging around them.
A plain kotlin("jvm") module, not multiplatform. Desktop *is* the jvm
target, so kotlin { } would hold one target and src/jvmMain would differ
from src/main only in name; :web earns multiplatform because it serves
js and wasmJs from one module. Like :web it skips kmp-library, which is
a library convention, and declares the toolchain and kotlin("test")
itself.
compose.desktop.currentOs is the one dependency declared through a
plugin accessor rather than a catalog coordinate, because skiko's
runtime jar is classified by OS *and* architecture. Everything built
here therefore runs on the build host's OS only, uber jar included;
jpackage cannot cross-build either, so real installers need the task run
on each OS.
nativeDistributions declares four extra jlink modules. jpackage trims
the runtime, and the default set has neither java.sql/jdk.unsupported
for sqlite-jdbc nor java.naming/jdk.crypto.ec for OkHttp's TLS. `run`
uses the full JDK, so a missing module only surfaces in an installed
build, as a crash on the first query.
Icons are generated rather than sourced; icons/ is the source of truth
for jpackage, and icon.png is also on the runtime classpath for the
window and dock, so the two installer-only formats are excluded from the
jar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Desktop has no system back gesture, and the top app bar's arrow was the only way off the detail screen. Esc, Cmd+[ and Cmd+Left on macOS, and Alt+Left elsewhere, now pop the backstack. The rule is a pure isBackShortcut() for the same reason historyAction() is pure in :web: a decision welded to a KeyEvent cannot be tested without a window. Its tests cover the modifier combinations, that it fires on key down only, and that a bare arrow key is not a shortcut — the list screen's filter field needs that for cursor movement. canPop is part of the rule rather than something the caller remembers. It is also what keeps Esc away from the filter field: the only screen with one is the root, where there is nothing to pop. Driving it needs the backstack outside CountriesApp, so it is hoisted here exactly as :web hoists it for window.history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Skia has no system font manager, so on desktop it draws with whatever the OS provides. macOS is fine. Windows renders all 250 rows as letter pairs, because Segoe UI Emoji has no flag glyphs — Microsoft's policy, not a gap that will close — and a Linux install without Noto Color Emoji renders tofu. So the app carries its own, supplied through :ui's LocalFlagFontFamily. The file is NotoColorEmoji-flagsonly.ttf verbatim from googlefonts/noto-emoji, so updating it is a curl rather than a subsetting pipeline. It is the same typeface web fetches from gstatic, which keeps the platforms looking alike. OFL 1.1, notice committed beside it. Two rules FlagFontTest pins, both found by rendering rather than by reasoning: It must be the CBDT build, not COLRv1. COLRv1 needs FreeType 2.11+ or a Windows 11-era DirectWrite, and where unsupported it draws nothing at all rather than falling back. Blank is a worse failure than letters, and PNG glyph images are the most widely supported colour format there is. It must not reach macOS. Skia goes through CoreText there, which refuses a bitmap-only font outright — makeFromData returns null — so the flags would vanish on the one platform that never needed the font. The COLRv1 build is not the escape hatch either: CoreText loads it and then renders its layers as nothing. needsBundledFlagFont() is the guard. That last point is why the test states its invariant as "wherever Skia can load this font it must ligate and rasterise in colour, and where it cannot, the app must not be using it" — the strongest thing assertable from a Mac. Flags on Windows and Linux remain unverified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTS.md gains a `desktop` module section and a "Fonts on desktop" subsection beside the existing web one, since the two share a root cause and resolve differently. The traps worth writing down are the jlink module list, which fails only in an installed build, and the two font findings. DECISION_LOG.md records how the font was chosen, because the process is the interesting part: two candidates picked on size and provenance, both wrong, and the second one wrong in a way every signal short of the pixels called correct. A control render settled it. That is the same lesson the web fonts entry already paid for once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
:desktop, the JVM counterpart to:appand:web. It buildsComposeGraph, readscircuitoff it, and mounts the sameCountriesApp.Most of this was already paid for:
CountriesAppwas extracted for:web, every library module already publishes ajvmtarget, and:networkalready had a JVMplatformConfigurationpointing the Apollo SQLite cache at~/.apollo. What is genuinely new is amain(), aWindow, two platform affordances, and the packaging.Commits
c96678a:uigainsLocalFlagFontFamily— null by default, so a no-op everywhere todayb5fb926main(), window, iconsf29df6cCountriesAppf9c4f4c962cc07Each compiles and passes
ktfmtCheckon its own.Decisions worth a look in review
A plain
kotlin("jvm")module, not multiplatform. Desktop is the jvm target, sokotlin { }would hold exactly one target andsrc/jvmMainwould differ fromsrc/mainonly in name.:webearns multiplatform because it serves js and wasmJs from one module.compose.desktop.currentOsis the one dependency declared through a plugin accessor rather than a catalog coordinate. It has to be — skiko's runtime jar is classified by OS and architecture. The consequence is that everything built here runs on the build host's OS only, uber jar included. Real cross-platform installers need a CI matrix, which is deliberately not in this PR.nativeDistributions { modules(...) }is load-bearing and fails invisibly. jpackage jlinks a trimmed runtime that has neitherjava.sql/jdk.unsupported(sqlite-jdbc) norjava.naming/jdk.crypto.ec(OkHttp TLS).runuses the full JDK, so a missing entry only surfaces in an installed build, as a crash on the first query.onRootPopstays the default no-op. Android passesfinish()because that is what back-past-root means there; on desktop the close button is how you leave, and Esc on the list should not quit the app.The flag font, which is where the real work was
Skia has no system font manager, so desktop draws with whatever the OS provides:
So the app carries
NotoColorEmoji-flagsonly.ttf, upstream verbatim (OFL 1.1, notice committed beside it). Two constraints, both found by rendering rather than by reasoning:makeFromDatareturns null — so the flags would vanish on the one platform that never needed the font. The COLRv1 build is not the escape hatch: CoreText loads it and then renders its layers as nothing.needsBundledFlagFont()is the guard.The second one is the instructive failure. It loaded, it shaped the ligature, it reported a sensible advance — every signal short of the pixels said it worked. A control render through identical code (Apple Color Emoji: 697 distinct colours; the bundled font: 1) settled it.
Verification
test, andktfmtCheckall pass.assembleDebugand:web:jsBrowserDistributionstill build — the:uichange touches every platform.:desktop:runlaunches clean..dmgwas built and the installed app exercised. With~/.apollo/countries.dbmoved aside, the packaged build on the jlink-trimmed runtime fetched over HTTPS and wrote a fresh cache with 258 records. That is the only run that proves themodules(...)list.Not verified: Windows and Linux. I have neither, and the flag font is precisely the thing that only shows itself there.
FlagFontTeststates the invariant as far as a Mac can — "wherever Skia can load this font it must ligate and rasterise in colour, and where it cannot, the app must not be using it" — plus a structural check that the file is still the CBDT build. That is a real regression guard, and it is not the same as having seen a flag on Windows.Worth knowing for a follow-up: that test takes the trivial branch on macOS and becomes a real colour assertion on
windows-latestandubuntu-latest. A CI matrix would close this gap for free, and Skia's raster rendering needs no display.🤖 Generated with Claude Code