Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 102 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ The project **is Kotlin Multiplatform**. The migration ran one module at a time,
`model` → `network` → `repository` → `presenter` → `ui` → `shared` → `shared-compose`.

**Every library module is migrated.** The only Android-specific module left is `app`, which stays
an Android application module — it is the Android entry point, and the CMP iOS, desktop and web
apps get their own equivalents.
an Android application module — it is the Android entry point. `web` and `desktop` are its
equivalents for the browser and the JVM; a CMP iOS app would be the fourth.

Supported targets, declared once in the `kmp-library` convention plugin:

Expand Down Expand Up @@ -229,11 +229,12 @@ tier, the Metro provider itself — stays in `commonMain`. Add new per-platform

## Module structure

Nine modules, with dependencies flowing strictly downward:
Ten modules, with dependencies flowing strictly downward:

```
app → Android entry point: Activity, theme, manifest. Nothing else.
web → Browser entry point (js + wasmJs): main(), index.html, URL routing.
desktop → Desktop entry point (jvm): main(), Window, keyboard back, flag font.
shared-compose → ComposeGraph — the Metro graph every Compose app shares
shared → CoreGraph for non-Compose consumers, plus the root Logger
ui → Compose UI (Circuit Ui implementations), CircuitProviders
Expand All @@ -246,8 +247,8 @@ model → Kotlin domain types
There are **two graphs** because of how the platform apps differ:

- `shared-compose` declares `ComposeGraph`, which exposes `Circuit`. Every Compose consumer
shares it — the Android and browser apps today, and the Compose Multiplatform iOS and desktop
apps alongside them. None of them declares a graph of its own.
shares it — the Android, browser and desktop apps today, and a Compose Multiplatform iOS app
alongside them. None of them declares a graph of its own.
- `shared` declares `CoreGraph`, which exposes repositories and no Compose types at all. That
is what a SwiftUI/UIKit iOS app uses: it drives Circuit `Presenter`s directly (see Circuit's
counter sample) and needs neither a `Circuit` instance nor any `Ui.Factory`, so it must not
Expand All @@ -256,21 +257,25 @@ There are **two graphs** because of how the platform apps differ:
All packages live under `io.github.solcott.countries`, with each module using its
own name as the suffix — `…countries.model`, `…countries.network`,
`…countries.repository`, `…countries.presenter`, `…countries.ui`,
`…countries.shared`, `…countries.shared.compose`, `…countries.web`. The `app`
`…countries.shared`, `…countries.shared.compose`, `…countries.web`,
`…countries.desktop`. The `app`
module uses the root `io.github.solcott.countries`, which is also the
`applicationId`. Each module's Gradle `namespace` matches its package.

Rules:

- **An app module holds no dependency wiring.** `app` and `web` depend on `shared-compose`
and nothing else from this project for the graph. Adding a `@Provides` to an app module is
almost always wrong — it would not be available to the other platform apps.
- **An app module holds no dependency wiring.** `app`, `web` and `desktop` depend on
`shared-compose` and nothing else from this project for the graph. Adding a `@Provides` to an app
module is almost always wrong — it would not be available to the other platform apps.
- **The app itself is `CountriesApp` in `:ui`, not the entry point.** The theme, the backstack,
`CircuitCompositionLocals` and `NavigableCircuitContent` live there; `MainActivity` and the
browser `main()` each do two things only — read `circuit` off the graph, and call it. New
screen-agnostic wiring belongs in `CountriesApp`, not in an entry point.
`CircuitCompositionLocals` and `NavigableCircuitContent` live there; `MainActivity`, the browser
`main()` and the desktop `main()` each do two things only — read `circuit` off the graph, and
call it. New screen-agnostic wiring belongs in `CountriesApp`, not in an entry point.
`rememberCircuitNavigator`'s `onRootPop` is the exception: it is genuinely per-platform
(Android finishes the Activity, the browser no-ops) and is passed in.
(Android finishes the Activity; the browser and desktop no-op) and is passed in.
- **`:ui` has exactly one platform seam: `LocalFlagFontFamily`.** It is null everywhere but
desktop — see [Fonts on desktop](#fonts-on-desktop). Resist adding a second; the reason this one
earns its place is that the alternative was a wrong-looking list on two of the six platforms.
- A module contributes its own providers with `@ContributesTo(AppScope::class)`, next to the
code they construct: `NetworkProviders` in `network`, `CircuitProviders` in `ui`,
`LoggingProviders` in `shared`.
Expand Down Expand Up @@ -352,6 +357,82 @@ module — see `presenter/build.gradle.kts`. CMP 1.12 added
bundle reaches skiko without an executable binary to bundle it into. It fires off the target's
test task existing, not off there being test sources, and there is no opt-out property.

### The `desktop` module

The Windows/Linux/macOS app. It is the smallest of the three entry points, because everything that
made `:web` interesting — history, a service worker, npm — the JVM either has already or does not
need. Four things are worth knowing:

- **It is a plain `kotlin("jvm")` module, not multiplatform.** Desktop *is* the jvm target, so
`kotlin { }` would hold exactly one target and `src/jvmMain` would be a directory with nothing to
distinguish it from `src/main`. `:web` is multiplatform because it genuinely serves two targets
from one module. Like `:web` it does not apply `kmp-library`, and like `:web` it declares
`kotlin("test")` and the JVM toolchain itself, since no convention is doing it.
- **`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, and only the accessor picks the right one. **The consequence is that everything
built here runs on the build host's OS only** — including `packageUberJarForCurrentOS`. Real
cross-platform installers need the packaging task run on each OS, because jpackage cannot
cross-build either; that is a CI matrix, and this repo has no CI yet.
- **`nativeDistributions { modules(...) }` is load-bearing and fails invisibly.** jpackage jlinks a
trimmed JDK, and the default module set has neither `java.sql`/`jdk.unsupported` (sqlite-jdbc,
under the Apollo cache) nor `java.naming`/`jdk.crypto.ec` (OkHttp's TLS). `run` uses the full
JDK, so a missing module never shows up in development — only in an installed build, as a crash
on the first query. Test packaging changes with `packageDistributionForCurrentOS`, not `run`.
- **Keyboard back is `isBackShortcut()` in `BackShortcut.kt`**, pure and tested, for the same
reason `historyAction()` is: a rule welded to a `KeyEvent` cannot be tested without a window. The
backstack is hoisted out of `CountriesApp` so `Window`'s `onKeyEvent` can reach it. `onRootPop`
is deliberately left at its default no-op — the close button is how you leave a desktop app, and
Esc on the root screen should not quit it.

Icons live in `desktop/icons/` and are the source of truth for both consumers: jpackage reads all
three from disk, and `icon.png` is also on the runtime classpath for the window and dock icon.
`build.gradle.kts` adds that directory as a resource root and excludes `*.icns`/`*.ico` from the
jar, since only the PNG is useful at runtime.

### Fonts on desktop

Same root cause as [Fonts on web](#fonts-on-web) — Skia has no system font manager — but a
different outcome per platform, and the 1.12 web font downloader does not apply here.

| Platform | Flags | Non-Latin native names |
| --- | --- | --- |
| macOS | Apple Color Emoji, fine | fine |
| Windows | **Segoe UI Emoji has no flag glyphs** — renders as the letter pair, e.g. "FR" | fine |
| Linux | tofu without Noto Color Emoji | tofu without Noto CJK etc. |

Windows omitting flag glyphs is Microsoft's deliberate policy, not a gap that will close. So
`:desktop` bundles `NotoColorEmoji-flagsonly.ttf` and provides it through `:ui`'s
`LocalFlagFontFamily`. That covers the flags. **It does not cover the non-Latin names on Linux** —
that would mean committing several MB of Noto CJK, and a Linux desktop that renders no CJK at all
is a system that will fail on far more than this app.

The font is upstream, verbatim, so updating it is a download:

```
curl -LO https://github.com/googlefonts/noto-emoji/raw/main/fonts/NotoColorEmoji-flagsonly.ttf
```

It is SIL Open Font License 1.1; the notice is committed beside it as
`desktop/src/main/resources/font/OFL.txt`.

Two rules that `FlagFontTest` pins, both discovered the hard way:

- **It must be the CBDT build, not `Noto-COLRv1.ttf`.** COLRv1 needs FreeType 2.11+ on Linux or a
Windows 11-era DirectWrite to rasterise, and where it is unsupported it draws *nothing* rather
than falling back. Blank is a worse failure than letters. CBDT stores each glyph as a PNG, which
is the most widely supported colour format there is.
- **It must not be handed to macOS.** Skia goes through CoreText there, and CoreText refuses to
load a bitmap-only font outright — `makeFromData` returns null, and the flags would disappear on
the one platform that never needed the font. `needsBundledFlagFont()` is the guard, and
`flagFontFamily` is null on macOS. (The COLRv1 build is not the escape hatch: CoreText loads it
and then Skia's CoreText scaler renders its layers as nothing.)

The practical consequence for anyone changing this: **macOS cannot verify the font renders.** The
test states the invariant as "wherever Skia can load it, it must ligate and rasterise in colour;
where it cannot, the app must not be using it", which is the strongest thing a Mac can assert.
Flags on Windows and Linux need a real machine.

### Offline, and the service worker

`web/src/commonMain/resources/sw.js`, registered from `ServiceWorker.kt`. **This is the thing that
Expand Down Expand Up @@ -485,8 +566,16 @@ needs, so it is easy to fix one and forget the other.
./gradlew :web:jsBrowserDevelopmentRun
./gradlew :web:wasmJsBrowserDistribution # → web/build/dist/wasmJs/productionExecutable
./gradlew :web:jsBrowserDistribution # → web/build/dist/js/productionExecutable

# Desktop app
./gradlew :desktop:run
./gradlew :desktop:packageUberJarForCurrentOS # → desktop/build/compose/jars
./gradlew :desktop:packageDistributionForCurrentOS # → desktop/build/compose/binaries
```

Both desktop packaging tasks produce a build for the **host** OS only — see
[The `desktop` module](#the-desktop-module).

`ktfmtCheck` at the root does not cover `build-logic` — that is a separate included build.
Run it from inside `build-logic/` to check the convention plugins.

Expand Down
64 changes: 64 additions & 0 deletions DECISION_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,70 @@ never render.
Android was re-verified on an emulator rather than assumed, since the Compose jump from stable to
rc is the part of this change least exercised by the test suite and easiest to skip.

## How is the desktop app put together?

`:desktop` is the third entry point, and by far the least interesting one to build — which is the
point. `CountriesApp` was already extracted for `:web`, every library module already published a
`jvm` target, and `:network` already had a JVM `platformConfiguration` pointing the Apollo SQLite
cache at `~/.apollo`. The module is a `main()`, a `Window`, and two platform affordances.

**A plain `kotlin("jvm")` module, not multiplatform.** Desktop *is* the jvm target. A `kotlin { }`
block would hold exactly one target and `src/jvmMain` would be a directory distinguishable from
`src/main` only by name. `:web` earns multiplatform because it serves js and wasmJs from one
module; this does not.

**Two things are genuinely per-platform, and both are small.** A keyboard back binding, because
desktop has no back gesture and the top-bar arrow was the only way out of the detail screen; and a
window with a starting size and a floor under it. `onRootPop` stays the default no-op — Android
passes `finish()` because leaving the app is what back-past-root means there, but on desktop the
close button is how you leave, and Esc should not quit the app out from under you. The back rule
went into a pure `isBackShortcut()` for the same reason `historyAction()` is pure: a decision
welded to a `KeyEvent` cannot be tested without a window.

**Flags forced the one platform seam in `:ui`.** This is the same root cause as the web tofu —
Skia has no system font manager, so it draws with the font it is handed — but it does not resolve
the same way. macOS is fine. Windows renders every one of the 250 rows as a letter pair, because
Segoe UI Emoji has no flag glyphs *by Microsoft's policy*, and no amount of waiting fixes that.
Linux without Noto Color Emoji renders tofu. So `:ui` gained `LocalFlagFontFamily`, null
everywhere but desktop, applied to the two composables that render nothing but a flag.

**Choosing the font is where the real work was, and my first two answers were both wrong.** I
started from the plan's assumption — subset the flag block out of `Noto-COLRv1.ttf` with
`pyftsubset` — then found upstream ships `NotoColorEmoji-flagsonly.ttf` ready-made, at a third of
the size, which made the whole pipeline a `curl`. Both of those were decided on size and
provenance, neither on whether Skia could actually draw them. It cannot, in both cases, on the
machine I was building on:

- The CBDT build does not **load** on macOS at all. Skia goes through CoreText there, and CoreText
refuses a bitmap-only font; `makeFromData` returns null.
- The COLRv1 build loads, shapes the ligature correctly, reports a sensible advance — and
rasterises to a blank canvas, because Skia's CoreText scaler has no COLRv1 path.

The second one is the instructive failure. Every signal short of looking at the pixels said it
worked. What settled it was a control: rendering the same string with Apple Color Emoji through
the identical code produced 697 distinct colours, and the bundled font produced one. That is the
lesson the web fonts section already paid for once — an explanation that predicts the observation
is not one that has been checked — arriving in a form where the check was cheap and I nearly
skipped it anyway.

The resolution is that macOS never gets the font: it does not need one, and handing it either
build would *delete* the flags rather than leave them alone. CBDT is right for the two platforms
that do need it, because PNG glyph images are the most widely supported colour format there is,
where COLRv1 wants FreeType 2.11+ or a Windows 11-era DirectWrite. Between "letters" and "blank",
letters is the better failure.

**Packaging stops short of installers on purpose.** `run`, an uber jar, and
`packageDistributionForCurrentOS` are all wired, and the `.dmg` builds. But `compose.desktop
.currentOs` resolves skiko by OS *and* architecture, and jpackage cannot cross-build, so genuine
Windows and Linux artifacts need the task run on each OS. That is a CI matrix, and this repo has
none yet; adding one alongside signing and notarisation is a larger change than the module itself.

**What is not verified: Windows and Linux.** I have neither, and the flag font is precisely the
thing that only shows itself on those two. The test 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 guard against a regression, and it is not the same as having seen a flag on Windows.

## What tradeoffs did I make due to time constraints?

- Minimal error handling/presentation (generic messages, swallowed cache misses).
Expand Down
38 changes: 30 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ A small Kotlin Multiplatform app that lists world countries from the public
[Countries GraphQL API](https://countries.trevorblades.com/), lets you filter them by
name and continent, and drills into a detail screen for each one.

It started as an Android app and every library module is now multiplatform. There are two
entry points today — the Android app (`:app`) and a Compose Multiplatform browser app
(`:web`, targeting both Kotlin/JS and Kotlin/Wasm) — sharing all of their UI, presentation
and data code.
It started as an Android app and every library module is now multiplatform. There are three
entry points today — the Android app (`:app`), a Compose Multiplatform browser app (`:web`,
targeting both Kotlin/JS and Kotlin/Wasm), and a Compose Multiplatform desktop app
(`:desktop`, for Windows, Linux and macOS) — sharing all of their UI, presentation and data
code.

## API choice

Expand Down Expand Up @@ -70,6 +71,25 @@ Both produce a static bundle you can serve from anywhere:
Routes are hashes, so the bundle needs no server-side rewriting and links are shareable:
`#/` is the list, `#/country/FR` opens France. Browser back and forward work.

### Desktop

No Android SDK needed.

```bash
./gradlew :desktop:run
```

`Esc`, `Cmd+[` and `Alt+←` navigate back from the detail screen. To build something
installable:

```bash
./gradlew :desktop:packageUberJarForCurrentOS # → desktop/build/compose/jars
./gradlew :desktop:packageDistributionForCurrentOS # .dmg / .msi / .deb, host OS only
```

Both are **host-OS builds** — the Skia runtime is selected by OS and architecture, and
jpackage cannot cross-build, so a Windows installer has to be produced on Windows.

### Cleaner `git blame`

Bulk formatting commits are listed in [`.git-blame-ignore-revs`](.git-blame-ignore-revs) so
Expand All @@ -82,23 +102,25 @@ git config blame.ignoreRevsFile .git-blame-ignore-revs

## Architecture at a glance

Nine Gradle modules, dependencies flowing strictly downward:
Ten Gradle modules, dependencies flowing strictly downward:

```
app → Android entry point: Activity, theme, manifest
web → Browser entry point (js + wasmJs): main(), index.html, URL routing
desktop → Desktop entry point (jvm): main(), Window, keyboard back, flag font
shared-compose → ComposeGraph — the Metro graph every Compose app shares
shared → CoreGraph for non-Compose consumers, plus the root Logger
ui → Compose UI (Circuit Ui), and CountriesApp — the app both entry points mount
ui → Compose UI (Circuit Ui), and CountriesApp — the app every entry point mounts
presenter → Circuit Screens, presenters, state, events (the state holders)
repository → domain-facing data access, generated → model mapping
network → Apollo client, .graphql operations, generated code
model → plain Kotlin domain types + Response<T>
```

Everything from `shared-compose` down is Kotlin Multiplatform and builds for Android, JVM,
iOS, macOS, js and wasmJs. `app` and `web` are the only platform-specific modules, and each
does the same two things: build the Metro graph, and hand its `Circuit` to `CountriesApp`.
iOS, macOS, js and wasmJs. `app`, `web` and `desktop` are the only platform-specific modules,
and each does the same two things: build the Metro graph, and hand its `Circuit` to
`CountriesApp`.

- **UI:** Jetpack Compose throughout.
- **Architecture:** MVI via [Circuit](https://slackhq.github.io/circuit/) — presenters own
Expand Down
Loading