Skip to content

Add a Compose Multiplatform desktop app for Windows, Linux and macOS - #4

Open
solcott wants to merge 5 commits into
mainfrom
feature/desktop
Open

Add a Compose Multiplatform desktop app for Windows, Linux and macOS#4
solcott wants to merge 5 commits into
mainfrom
feature/desktop

Conversation

@solcott

@solcott solcott commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Adds :desktop, the JVM counterpart to :app and :web. It builds ComposeGraph, reads circuit off it, and mounts the same CountriesApp.

Most of this was already paid for: CountriesApp was extracted for :web, every library module already publishes a jvm target, and :network already had a JVM platformConfiguration pointing the Apollo SQLite cache at ~/.apollo. What is genuinely new is a main(), a Window, two platform affordances, and the packaging.

Commits

c96678a :ui gains LocalFlagFontFamily — null by default, so a no-op everywhere today
b5fb926 the module: build config, main(), window, icons
f29df6c keyboard back, which is what forces the backstack out of CountriesApp
f9c4f4c the bundled flag font and its OS guard
962cc07 AGENTS.md, DECISION_LOG.md, README.md

Each compiles and passes ktfmtCheck on its own.

Decisions worth a look in review

A plain kotlin("jvm") module, not multiplatform. Desktop is the jvm target, so kotlin { } would hold exactly 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.

compose.desktop.currentOs is 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 neither java.sql/jdk.unsupported (sqlite-jdbc) nor java.naming/jdk.crypto.ec (OkHttp TLS). run uses the full JDK, so a missing entry only surfaces in an installed build, as a crash on the first query.

onRootPop stays the default no-op. Android passes finish() 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:

Flags Non-Latin native names
macOS fine fine
Windows letter pairs — Segoe UI Emoji has no flag glyphs, by Microsoft's policy fine
Linux tofu without Noto Color Emoji tofu without Noto CJK

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:

  • 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 rather than falling back. Blank is a worse failure than letters.
  • 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: 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

  • 14 desktop tests, root test, and ktfmtCheck all pass.
  • assembleDebug and :web:jsBrowserDistribution still build — the :ui change touches every platform.
  • :desktop:run launches clean.
  • The .dmg was built and the installed app exercised. With ~/.apollo/countries.db moved 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 the modules(...) list.

Not verified: Windows and Linux. I have neither, and the flag font is precisely the thing that only shows itself there. FlagFontTest states 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-latest and ubuntu-latest. A CI matrix would close this gap for free, and Skia's raster rendering needs no display.

🤖 Generated with Claude Code

solcott and others added 5 commits August 8, 2026 17:46
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant