Files
amethyst/commons/plans/2026-10-17-platform-code-sharing-review.md

218 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Review: what is left in amethyst/ and desktopApp/, and what each platform can share with the other"
type: refactor
status: in-progress (§1, §2 and the §5 step-2 quick wins done)
date: 2026-10-17
owner: commons
consumers: amethyst, desktopApp, commonsUI, commons, nestsClient
depends-on: commons/plans/2026-09-27-one-ui-android-desktop.md
---
# What is left on each platform, and what it can share
The one-UI plan
([2026-09-27-one-ui-android-desktop.md](2026-09-27-one-ui-android-desktop.md)) has moved 231 of
252 screens, the navigation shell and the app root to `commonsUI`. This review covers what is
still platform-side:
- `amethyst/src/{main,play,fdroid}`: 350 Kotlin files in `main`;
- `desktopApp/src/jvmMain`: 85 files.
For each file, the question was whether it can move to `commons`/`commonsUI` so the other
platform gets the feature, and whether the other platform already has a copy that could be unified.
**Method.**
- The imports of every file were read, and every Android/Desktop slot implementation was compared
against the `AppPlatform`, `NotePlatform` and `RichTextPlatform` defaults.
- Every finding in "Bugs" was confirmed by reading the code. None was reproduced in a running app.
- Licences quoted below come from Maven POMs, not from LICENSE files. Re-check them against the
LICENSE file before adding a dependency, as AGENTS.md requires.
Paths are relative to `amethyst/src/main/java/com/vitorpamplona/amethyst/` (Android),
`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/` (Desktop) or the module root.
## 1. Desktop bugs found on the way
**Fixed 2026-10-18.**
1. GIFs take `MediaHttp.imageClient`, the image role's client.
2. `LoadCityName` defaults to the known place name, or else the geohash.
3. The meeting slots are gone; `NoteCompose` calls the shared renderers.
4. Desktop's `DesktopQrScannerDialog` scans a picture from a file or the clipboard, with zxing core.
5. A shared `CalendarReminderScanner` (commons) runs from Android's worker and from a once-a-minute
`DesktopCalendarReminders`. On Desktop, reminders also need OS notifications on, and pause during
do-not-disturb.
6. Desktop runs `syncIfStale()` for the logged-in account on login and then every hour.
7. Payment cards, `blossom:` resolution (`BlossomUriRenderer` over `blossomServerFinder`) and the
secret-emoji reveal (`SecretMessageContent`) are in the shared renderer. `BlossomFetcher` moved to
`commonsUI/jvmAndroid`; Desktop builds a `BlossomServerResolver` and registers the fetcher.
LaTeX stays Android's.
8. The Concord decryption moved to `commonsUI/jvmAndroid` (`rememberDecryptedConcordImageModel`);
Desktop keeps the plaintext in `concord-images/` under its data directory.
| # | Bug | Evidence | Fix |
|---|---|---|---|
| 1 | **Animated GIFs bypass Tor.** | `ui/media/AnimatedGifImage.kt:53` fetches through `DesktopHttpClient.currentClient()`. Nothing in the new app calls `DesktopHttpClient.setInstance`, so `currentClient()` returns `simpleClient`, a direct connection (`network/DesktopHttpClient.kt:184`). Notes (`app/DesktopNotePlatform.kt:215`) and the lightbox (`ui/media/ZoomableImage.kt:125`) both go through it. Coil itself is wired correctly (`app/AmethystDesktop.kt:160`). | Use `roleBasedHttpClientBuilder.okHttpClientForImage(url)`, as Coil does. Then delete `DesktopHttpClient` and drop the default argument in `DesktopImageLoaderSetup`. |
| 2 | **Geohash place names are blank.** | The `NotePlatform.LoadCityName` default is `{}` (`commonsUI/…/ui/note/platform/NotePlatform.kt:182-186`), so neither `content` nor `onLoading` runs. Desktop does not override it. Affected: the geohash feed and chat headers, `FeedFilterSpinner`, relay-group metadata, active subscriptions and geohash bubbles. | Change the shared default to `content(geohashStr)`, or to the cached place name when one exists. |
| 3 | **Meeting events (kinds 30312, 30313 and 10312) render blank.** | The renderers are already shared (`commonsUI/…/note/types/MeetingSpace.kt`). Android only forwards to them (`ui/note/platform/AndroidNotePlatform.kt:399-430`); the slot defaults are `{}` (`NotePlatform.kt:314-332`). | Call the shared renderers directly and delete the three slots. |
| 4 | **QR scanning is a dead end.** | The `AppPlatform.QrCodeScannerDialog` default is `{}` (`AppPlatform.kt:207-210`), so `onDismiss` never fires. Six shared flows open it unguarded: show-QR, the NWC and Clink wallet setup, the NIP-46 signer, the geocache log and the Cordn migration. | Short term: dismiss at once, or hide the scan button when no scanner exists. Proper fix: §3, A2. |
| 5 | **The calendar-reminder switch does nothing.** | The shared settings screen calls `AppServices.setCalendarRemindersScheduled`, whose default is a no-op (`commons/…/service/AppServices.kt:132`); Desktop does not override it. | §3, A4. |
| 6 | **The web-of-trust index is never refreshed.** | `TrustNetworkState.syncDue()` has a single caller, Android's `service/wot/TrustNetworkSyncWorker.kt:61`. A long-running Desktop app keeps the first download. | §3, A5. |
| 7 | **Payment and Blossom content shows as raw text.** | Desktop uses the base `DefaultRichTextSegmentRenderer`. Invoices, withdrawals, Cashu and Clink offers, and `blossom:` URIs render as text, and LaTeX and secret emoji stay unrendered. | §3, A1. |
| 8 | **Encrypted Concord community images fall back to robohash.** | `rememberConcordImageModel` uses the plain URL by default; only Android decrypts. | §3, A6. |
## 2. Dead code in `desktopApp` (delete)
**Done 2026-10-18.**
- Deleted, with their tests: `AccountManager`, `LoginProgress`, `DesktopHttpClient`, the four theme
files, `PlatformMaterialTheme`, `LocalWindowState` and `DesktopScheduleAtPickerTest`.
- `MediaHttp` and `DesktopImageLoaderSetup` no longer fall back to a direct client: a fetch before
`main` installs them fails instead of skipping Tor.
- `BlossomClientTest`, `MediaCompressorTest`, `MediaMetadataReaderTest` and
`UploadOrchestratorClientTest` (renamed: commons already has an `UploadOrchestratorTest`) moved
to `commons/jvmTest`, and `NwcRpcIntegrationTest` to `quartz/jvmTest`.
- The Compose UI tests and `DecodeImageWithSkiaTest` stay in `desktopApp`. They need Compose's UI
test library or Skia, and only the desktop smoke job runs them under Xvfb.
Reachability was checked with grep from `desktop/app/`:
- `account/AccountManager.kt` (1,076 lines) and `account/LoginProgress.kt`. The only references are
a KDoc mention in `DesktopAccountStorage.kt:87` and their own tests (9 `AccountManager*Test`
files).
- `network/DesktopHttpClient.kt` and its test, once bug 1 is fixed.
- `platform/PlatformColorScheme.kt`, `PlatformShapes.kt`, `PlatformTypography.kt` and
`ui/theme/AmethystSpacing.kt`. These are reached only from `PlatformMaterialTheme` in
`platform/PlatformTheme.kt`, which nothing calls. Keep `applyNativeWindowChrome` and
`titleBarInsetTop` from that file.
- `jvmTest/…/ui/scheduling/DesktopScheduleAtPickerTest`. It tests a copy of picker math whose class
no longer exists.
- **Later:** `account/DesktopAccountStorage.kt`, `EncryptedKeyBackupTest` and the four
`app/Legacy*Import.kt`, once the legacy-app migration window closes.
These Desktop tests exercise `commons`/`commonsUI` code and belong in those modules' `jvmTest`:
`service/upload/*`, `ui/BlockInteractionsUiTest`, `ui/chats/*`, `ui/search/*`, `ui/uploads/*`,
`nwc/NwcRpcIntegrationTest`, `app/DecodeImageWithSkiaTest` and `app/InlineImageSizeTest`.
## 3. Moves
Effort: XS is under an hour, S is a day, M is a few days, L is a project.
### A. Android code that would give Desktop a feature
| # | What | Blockers today | Move | Effort |
|---|---|---|---|---|
| A1 | **Rich-text leaves.** The `Payment` and `SecretMessage` branches of `ui/components/AmethystRichText.kt`, plus the Blossom renderers and secret emoji in `RichTextPlatformLeaves.kt`. | The `Payment` and `SecretMessage` branches have none: they call shared composables (`MayBeInvoicePreview`, `CashuPreview`, …). The Blossom renderers use `Amethyst.instance.blossomResolver` (`:429-474`). | Fold them into `DefaultRichTextSegmentRenderer`, and read Blossom through `LocalAppServices.blossomServerFinder`, which Desktop must wire. LaTeX stays a slot. | S |
| A2 | **QR from an image or the clipboard.** `QrScannerState`, `QrScannerOverlay`, `QrImageImport` and `ScanQrImageScreen`. | `QrScannerState` and `QrScannerOverlay` have none. `QrImageImport` uses `Bitmap` and zxing-cpp. `ScanQrImageScreen` uses `Intent`. | Move the state and the overlay as they are. Split the import behind a decoder interface: zxing core on the JVM, which `commonsUI/jvmAndroid` already links. Desktop's scanner slot opens the import path. The CameraX dialog stays native. | S–M |
| A3 | **Notification gate.** The filter rules in `service/notifications/EventNotificationConsumer.kt:188-260` (own events, chat toggles, muted threads, `curatedHidesByTrust`) and `NotificationDispatcher.NOTIFICATION_KINDS`. | `MainActivity.isResumed`, a wakelock and the renderers. | A commons `(account, event) → decision + draft`, with "in foreground" injected. Desktop's `ui/notifications/DesktopNotificationAutoDispatcher.kt:125-200`, which has no mute, WoT or chat-toggle checks, then calls it. Merge the three kind taxonomies: `NotificationTopic`, `NotifKind` and `NotificationCategory`. | M |
| A4 | **Calendar reminder scan.** The body of `service/calendar/CalendarReminderWorker.kt` (`acceptedRsvpsInCache`, `couldStillFire`). | WorkManager and the notifier only. | A commons `CalendarReminderScanner` with a notifier port. Desktop runs it on a timer through `DesktopNotifications`. This fixes bug 5. | S |
| A5 | **Daily WoT sync.** The 4-line body of `service/wot/TrustNetworkSyncWorker.kt:52-61`. | None. | A commons `syncAllDue(accounts)`, plus a Desktop ticker. This fixes bug 6. | XS |
| A6 | **`ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt`.** | `context.cacheDir` only. | Move to `commonsUI/jvmAndroid` with a cache-dir port. This fixes bug 8. | S |
| A7 | **Image loading.** `service/images/BlossomFetcher.kt` (`blossom:` URIs, read-auth), `ProfilePictureFetcher.kt`, and the assembly in `ImageLoaderSetup.kt`. | `BlossomFetcher` has none (Coil, OkHttp, commons). `ProfilePictureFetcher` sits on `ThumbnailDiskCache`, which uses `Bitmap`. `ImageLoaderSetup` uses `Context` and `Build.VERSION`. | Move `BlossomFetcher` to `commonsUI/jvmAndroid` as it is. Add one shared `buildAmethystImageLoader(decoders, diskCache, callFactory, readAuth)` that replaces `service/images/DesktopImageLoaderSetup.kt`, including its duplicate `TorAwareOkHttpFactory`. Put the thumbnail cache behind an expect. | S |
| A8 | **Blossom upload policy.** In `service/uploads/AndroidMediaUploader.kt`: `mirrorToOtherServers` (`:423`), `SharedUploadAuth` (`:292`) and `sanitizeForBuzzWorkspace` (`:273`). | Mixed into the Android uploader. | Lift them into the shared `UploadOrchestrator`. Fold `service/uploads/blossom/BlossomUploader.kt` (a second OkHttp BUD-02 client) into `commons/jvmAndroid/…/BlossomClient` as a streaming overload. Desktop gains BUD-04 mirroring and Buzz-workspace sanitising. | M |
| A9 | **Upload header and audio stripping.** `FileHeader.prepare(url)` (`service/uploads/FileHeaderPreparation.kt:35`) and `stripMp3Metadata` (`MetadataStripper.kt:386`), which is pure byte logic. | `Context` temp-file plumbing; `Bitmap` for the blurhash. | Move both to `commons/jvmAndroid`, with a `PreviewHasher` expect (Android uses `Bitmap`, the JVM uses the existing `MediaMetadata.compute`). Desktop's `stripMetadata` (`app/DesktopMediaUploader.kt:600-611`) leaves audio tags in place today. | S |
| A10 | **NIP-38 now playing.** `service/nowPlaying/InAppPlayback.kt` and `NowPlayingStatusCoordinator.kt`. | The registry map is keyed by media3 `Player`. | Key it by `Any`, move both to commons, and have Desktop's `service/media/GlobalMediaPlayer` feed it. | S |
| A11 | **Napplet logic with no blocker, or one trivial blocker.** `NappletManifestLookup`, `gateways/AccountIdentityReader`, `connectedApps/consent/Nip46ConsentInfoBuilder`, `SignerConsentDetails`, `NappletLaunchRegistry`, the DataStore permission and storage stores. | None, or DataStore (already used in commons commonMain). | Move them as they are. Desktop then replaces `InMemoryNappletPermissionStore` (`app/DesktopAppModules.kt:371-372`) with persistent grants. | S |
| A12 | **Napplet logic after a small cut.** `NostrSignerOpLabels` and `NappletConsentSummary` (an unused `Context` and `browserIcons`, which is on `AppServices`); `NappletLiveSubscriptions` and `NappletIncBus` (`Messenger` used only as an owner key); `gateways/NappletResourceFetcher` (one `android.util.Base64` call); the network and permission registries (`Amethyst.instance.appStores`); the consent dialogs inside the three consent Activities. The `nappletHost` blob cache, prefetcher and CSP response building are pure as well. | The cuts named in this row. | Move to commons/commonsUI. This is the groundwork for a Desktop napplet host (C6). | M |
| A13 | **Tor state machine.** `ui/tor/TorManager.kt` (591 lines) and `TorBackend.kt` have no Android import; `ArtiGuardState` uses Jackson only; `TorConnectionFailureDialog` is Compose plus `Res`. `ui/tor/TorServiceStatus.kt` duplicates `commons/tor/TorServiceStatus.kt`, and `AndroidAppServices.kt:82-88` maps one onto the other. | None. | Move to commons/commonsUI and merge the two status classes. `tor/DesktopTorManager.kt` (kmp-tor, the C tor binary) becomes a ~100-line `TorBackend`. Desktop gains the stuck-connecting watchdog, the one-hour session bypass, the self-heal cooldown, network-change resets and the failure dialog. Android keeps `TorService` and `ArtiNative`. | M |
| A14 | **Settings › Resource usage.** `ResourceUsageScreen` and `UsageTrendChart`. | Vico is already `api` in commonsUI. `Amethyst.instance.resourceUsage` (`:179`, `:215`), and one `ACTION_SEND` (`:700`) that `FileSharer` covers. | Move both, and expose the accountant (already in `commons/jvmAndroid`) on `AppServices`. Desktop also needs its meters wired. Plan step 9 says the screen stays; its Android ties are this thin. | S |
| A15 | **`ConnectedAppDetailScreen`** (914 lines). | `Amethyst.instance` ledgers that Desktop's `AppServices` already provides, a `Toast`, and `NappletBrokerService.revokeSessionGrants`. | Split it. The shared connected-apps lists already navigate to `Route.ConnectedAppDetail`, which Desktop does not register. | S–M |
| A16 | **Share note as image.** `ui/note/share/ShareNoteAsImageScreen.kt`. The capture already uses Compose's `rememberGraphicsLayer`. | `R.drawable.amethyst` (`:506`), `Bitmap.compress` + `FileProvider` (`:528-536`). | Move the logo to Compose resources and add an image-sharer port. Desktop saves a PNG or copies it to the clipboard. | M |
| A17 | **Audio rooms.** `NestFullScreen`, `NestActionBar`, `ParticipantHostActionsSheet` and `NestActivityContent`. `NestViewModel` is already in `commons/jvmAndroid` and talks to `AudioCapture`, `AudioPlayer` and Opus interfaces. | `RECORD_AUDIO` and the Settings intent, `payViaIntent` (the shared `WalletAppLauncher` exists), one share intent. | Move the UI. Add JVM `AudioCapture`/`AudioPlayer`/Opus actuals in `nestsClient/jvmMain` (javax.sound, plus the JNA Opus already in jvmTest; licence check needed) and a Desktop `openNestRoom`, which is a no-op today. Plan step 9 keeps the room UI native; most of it does not need to be. | L |
**Small leftovers.** These move with little or no change; the shared side gains their tests, and
there is little to gain beyond that:
- `service/playback/composable/RenderPlaybackError.kt` and `controls/SkipButton.kt` (no Android
imports; Desktop's `ui/media/VideoControls.kt:86-95` re-declares `SKIP_SECONDS` and its own skip
button);
- `playerPool/positions/VideoViewedPositionCache.kt` (androidx `LruCache` instead of the Android
one; it duplicates `savedPositions` in `GlobalMediaPlayer.kt:207`);
- `service/cast/{CastMediaSummary,CastRoutePlan}`;
- `service/resourceusage/MeteringNostrSigner`;
- `model/accountsCache/MarmotStreamTransport.kt`, which `DesktopAppModules.kt:436-437` repeats
inline: give `:marmotQuic` a default instead;
- the host-agnostic browser chrome state in `ui/screen/loggedIn/embed/` (`ConsoleBridge`,
`FindBridge`, `SelectionUiState`, `EmbeddedLoadStatus`, `EmbeddedAutoRecovery`,
`EmbeddedTabChrome`, …).
### B. Desktop code that would give Android (and iOS) a feature
| # | What | Move | Effort |
|---|---|---|---|
| B1 | **`ui/map/DesktopTileMap.kt`** (505 lines), `DesktopLocationPreviewMap` and `DesktopGeocacheMapTab`: pure Compose, Coil and `Res`. | Move them to `commonsUI/commonMain` as they are. Then rebuild Android's osmdroid screens on them: `LocationPickerMap`, `LocationPreviewMap`, `GeocacheMapTab` and the map part of `GeohashLocationPickerDialog` (788 lines; `GeoPoint`/`BoundingBox` and the forward geocoder go behind a port). Desktop gets the geohash picker and teleport screen, which are blank today. Android can drop osmdroid and iOS gets maps. | M |
| B2 | **`service/images/SkiaDownsamplingDecoder.kt`, `SkiaGifDecoder.kt`.** They import only Coil and Skia. | Move to `commonsUI/skikoMain`, so iOS gets downsampled decoding and GIFs. | XS |
| B3 | **`ui/media/NowPlayingBar.kt`.** Android has no persistent now-playing bar. | Move it with C2. | with C2 |
| B4 | **Scheduled posts.** Desktop's `DesktopScheduledPostScheduler.drainOnce` uses the shared `ScheduledPostPublisher.drainDue`. Android's `service/scheduledposts/ScheduledPostWorker.kt:132-212` re-implements it (`publishAndConfirmDetailed` at `:173`) without the publisher's pubkey and signature checks. | Call `drainDue` from the worker and keep only the WorkManager scheduling. | XS |
### C. Copies on both platforms to unify
| # | Android | Desktop | Unify as | Effort |
|---|---|---|---|---|
| C1 | `ui/tor/TorManager` (Arti) | `tor/DesktopTorManager` (kmp-tor) | A13 | M |
| C2 | The `service/playback/composable/controls/*` chrome | `ui/media/VideoControls`, `NowPlayingBar` | The `PlaybackController` port the plan defers ("do it when Desktop gets a player"). Desktop has one now: `GlobalMediaPlayer` on composemediaplayer, MIT per its POM. | L |
| C3 | `ui/components/ZoomableContentView` + `ZoomableContentDialog` (1,688 lines) | `ui/media/LightboxOverlay` + `ZoomableImage` (742 lines, hand-rolled gestures) | One shared image viewer on `net.engawapg` zoomable (already a commonsUI dependency). Video, save and share stay slots. | M |
| C4 | `service/playback/composable/mainVideo/VideoPlayerActiveMutex` | `ui/media/VideoVisibilityMutex` | A shared "nearest to centre wins" election, with a per-platform bounds source. | S |
| C5 | `service/images/ImageLoaderSetup` | `service/images/DesktopImageLoaderSetup` | A7 | S |
| C6 | Napplet and nsite host (WebView in the `:napplet` process) | `DesktopAppLauncher` does nothing for `FavoriteApp.NostrApp` (`app/DesktopAppPlatform.kt:115-122`); napplet cards render nothing on Desktop | After A11 and A12: a JCEF/KCEF host behind an `EmbeddedWebSurface` port. Chromium renderers run out of process; `CefResourceRequestHandler` plays the content server; `CefMessageRouter` reaches an in-process `NappletBroker`. Per their POMs, kcef and jcefmaven are Apache-2.0 and jcef-api is BSD. The licences of the Chromium binaries bundled in CEF are unverified, and ffmpeg may be LGPL (a WARN). | L |
| C7 | `app/AmethystLocalCacheHost.kt` | `app/DesktopLocalCacheHost.kt` | Nearly identical; they differ only in `usageCounter` and the NIP-95 directory. Not worth a seam on its own. | — |
| C8 | `AppModules.kt` (1,677 lines) | `app/DesktopAppModules.kt` (682 lines) | Later: a commons builder for the shared graph (relay client, HTTP builders, connectors), so new services stop being wired twice. | M |
**Slots that a shared implementation would make redundant:**
- `NotePlatform`: the five Git slots, the three meeting slots (bug 3) and the three URL-preview
slots;
- `AppPlatform.AppBottomBar`.
Both platforms pass the same shared composable through each of them, so call it directly.
## 4. What stays native (no action)
**Android:**
- media3 and PiP, CameraX, WebRTC calls (`stream-webrtc-android`), LightCompressor HLS and video
compression, Health Connect, `TextToSpeech`;
- notifications (`NotificationCompat`, channels, receivers, the tile, `ConversationShortcuts`), the
foreground services, WorkManager scheduling, push and FCM/UnifiedPush;
- Arti JNI, Android's Geocoder, `MediaRecorder` voice capture, MediaStore saving and `FileProvider`
sharing;
- the Messenger IPC between the main and `:napplet` processes, the consent and launcher Activities
and the `SurfaceControlViewHost` tab layer;
- the legacy `EncryptedSharedPreferences` migration, and the flavours (ML Kit, Chromecast,
AppFunctions).
**Desktop:**
- the window, menu bar and `platform/*` (UI scale, Wayland, fonts and window chrome);
- the keyring and the OS scheduler for scheduled posts;
- the auto-updater, the spell-check engines (the text field side is already shared) and the
composemediaplayer engine.
**The call screens** (`CallScreen`, `ConnectedCallUI`, `PipCallUI`, ~1,000 lines) are Compose over the
shared `CallManager`. They could move behind a video-renderer slot, but only pay off once a JVM
WebRTC engine is chosen (licence gate first).
## 5. Suggested order
1. **Bugs 1–4 and the dead code** (§1, §2). One small PR each, and no new ports needed.
2. **Quick wins that close Desktop gaps.** All done 2026-10-18:
- A5 (WoT sync), A1 (rich-text leaves), A4 (calendar reminders) and A6 (Concord images), with
the §1 bugs;
- B4 (scheduled-post publisher): Android's worker runs `ScheduledPostPublisher` per loaded
account; the publisher no longer marks a post FAILED when its drain is cancelled;
- A7/C5 (one image loader): `buildAmethystImageLoader` in `commonsUI/jvmAndroid`, used by both
apps. Desktop still lacks the avatar thumbnail cache (`ProfilePictureFetcher` sits on Android's
`Bitmap` cache);
- B2 (Skia decoders to `skikoMain`).
3. **One version of a feature where there are two today:**
- B1 (shared tile map, osmdroid retired);
- A13/C1 (one Tor state machine);
- A3 (one notification gate);
- A8/A9 (one Blossom upload policy);
- A2 (QR import);
- C3/C4 (image viewer and video mutex).
4. **Larger features:**
- A11/A12, then C6 (Desktop napplets);
- C2/B3 (`PlaybackController`, the now-playing bar on both platforms);
- A17 (audio rooms on Desktop);
- A14–A16 (resource usage, connected-app detail, share as image).
Each step follows the one-UI rules: check with `run-headless.sh` before calling a UI change done,
and gate every new library on its licence.