Compose Multiplatform desktop client for OSPChat. Speaks the same wire
protocol as ospchat-android and discovers Android
peers on the same LAN via mDNS.
gradle runThe shared Kotlin module ospchat-shared
is consumed from the GitHub Packages Maven registry — same as
ospchat-android. Even for public packages GitHub
requires an authenticated GET, so before the first build add a
Personal Access Token with the
read:packages scope to your user-level ~/.gradle/gradle.properties:
gprUser=your-github-username
gprToken=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAlternatively export GITHUB_ACTOR and GITHUB_TOKEN in your shell —
the build reads either source.
jpackage (which Compose Desktop uses under the hood) only produces installers
for the host OS — you can't cross-compile a .dmg from Linux or an .msi
from macOS. To ship all three you need a CI matrix or three machines.
The provided Makefile wraps the canonical Gradle tasks and routes to the
right output path for the detected host:
| Command | Output |
|---|---|
make help |
List all targets (and show the detected host) |
make run |
Run from sources |
make dist |
Portable directory with bundled JRE (build/compose/binaries/main/app/OSPChat/) |
make package |
Native installer — .deb (Linux) / .dmg (mac) / .msi (win) |
make uber-jar |
Self-contained fat JAR (build/compose/jars/) |
make release |
package — the canonical release flow |
make install-deb |
sudo dpkg -i the produced .deb (Linux only) |
make clean |
gradle clean |
Raw gradle:
gradle packageDistributionForCurrentOS
# outputs:
# Linux: build/compose/binaries/main/deb/*.deb
# macOS: build/compose/binaries/main/dmg/*.dmg
# Win : build/compose/binaries/main/msi/*.msiThe GitHub-Released .dmg is currently not code-signed. With macOS'
application firewall enabled (System Settings → Network → Firewall → On),
the system has no stable Designated Requirement to anchor your "Allow
incoming connections" answer to, so it re-asks on every launch and the
Ktor listener stays blocked until you click through. Messaging silently
breaks if the prompt is dismissed.
Two workarounds while the build is unsigned:
-
Turn the firewall off for testing — System Settings → Network → Firewall → Off.
-
Allowlist the binary explicitly (one-shot, survives relaunches even without a signed app):
sudo /usr/libexec/ApplicationFirewall/socketfilterfw \ --add /Applications/OSPChat.app/Contents/MacOS/OSPChat sudo /usr/libexec/ApplicationFirewall/socketfilterfw \ --unblockapp /Applications/OSPChat.app/Contents/MacOS/OSPChat
The proper fix is a Developer ID-signed .dmg; the signing path is
already scaffolded in build.gradle.kts and macos/entitlements.plist
— pass -PmacSigningIdentity="Developer ID Application: …" (and
optionally -PmacSigningKeychain=...) to gradle packageDistributionForCurrentOS once a cert is provisioned and ALF will
remember its answer.
ospchat-desktop/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/libs.versions.toml
└── src/desktopMain/kotlin/com/ospchat/desktop/
├── Main.kt MainKt entry point + AppRoot composition
├── AppContainer.kt Manual DI for every shared service
├── AppController.kt App lifecycle owner (start server + discovery)
└── ui/
├── Screens.kt Sealed Screen state
├── NicknameScreen.kt First-run nickname prompt
├── PeersScreen.kt Peer list (online dot, unread count)
└── ChatScreen.kt Per-peer chat (text only)
All shared logic — Room data layer, mDNS discovery, embedded Ktor server +
client, identity store, image compressor, file stores — lives in
com.ospchat:ospchat-shared (Kotlin Multiplatform). The desktop module adds:
- Compose Multiplatform 1.7.3
- Ktor client CIO engine + content negotiation
- kotlinx-coroutines-swing (so
Dispatchers.Mainresolves to the AWT thread) - slf4j-nop 2.0.x (silences Ktor's SLF4J binding lookup)
- kotlinx.datetime (for
Clock.Systemtime stamps in chat UI)
- ✅ Launches; first run prompts for nickname (persisted via DataStore)
- ✅ Starts embedded HTTP server on an ephemeral port
- ✅ Advertises
_ospchat._tcp.via JmDNS - ✅ Three-tab shell (NavigationRail): Contacts / Groups / About
- ✅ Contacts tab: live peer list (split into saved contacts + visible peers), online dot, unread count, host:port
- ✅ Long-press peer row → menu: Add/Remove from contacts, Info
- ✅ Peer Info dialog: UUID, status, first/last seen, full address + nickname history
- ✅ Tap a peer → chat screen; send + receive text
- ✅ Attach image (📎 in composer) → OS file picker → JPEG-compressed + sent
- ✅ Inbound image attachments render inline (Skia-decoded from local file)
- ✅ Long-press chat bubble → emoji picker; reaction chips under bubble; tap chip to toggle
- ✅ Groups tab: live group list, split chat vs broadcast
- ✅ "New group" FAB → dialog with name + kind toggle + member multi-select
- ✅ Group chat screen: per-bubble sender name, broadcast-channel send guard for non-creators
- ✅ About tab: editable nickname, version, bound port, project link, exit (with confirm)
- ✅ Avatars — deterministic per-UUID initials avatar in peer rows + chat header
- ✅ Custom avatar picker in About — file dialog → SHA-256 hashed → peers notified via
/v1/notify-refresh - ✅ System tray icon (where the DE supports it) — Show / Hide / Exit
- ✅ Sub-second shutdown (was ~5 s) — see Shutdown below
Exit goes through a deliberate two-thread shutdown so the UI doesn't hang on JmDNS:
- A daemon
ospchat-shutdownthread runsMessageServer.stop,JmDnsPeerDiscovery.stop,HttpClient.close, andOspChatDatabase.closebest-effort. - A non-daemon
ospchat-shutdown-killerthread joins it with an 800 ms deadline and then callsexitProcess(0).
Why: JmDNS' close() blocks ~5 s flushing mDNS goodbye records, and its background threads aren't daemon-marked, so without intervention the JVM stays alive for several seconds after the window has been dismissed. With this fix the perceived shutdown is ≤ 800 ms (typically much less). Peers will notice us drop off via their next mDNS query timeout regardless of whether the goodbye packet was flushed.
Two workflows under .github/workflows/:
ci.yml— every push / PR. Runs on a singleubuntu-latestrunner: compilesospchat-desktop, builds the distributable (:createDistributable) as a smoke check. Native installers are skipped on branch / PR runs.release.yml— on tag push matchingv*(e.g.git tag v0.1.0 && git push origin v0.1.0). Matrix-builds installers onubuntu-latest(.deb),macos-latest(.dmg), andwindows-latest(.msi). Each runner uploads its installer as an artifact; a final job downloads all three and publishes a GitHub Release named after the tag with all three attached.generate_release_notes: truebuilds the changelog from commit messages since the previous tag.
Both workflows authenticate to GitHub Packages with the workflow-provided
GITHUB_TOKEN (surfaced as Gradle properties gprUser / gprToken) so
the build can resolve com.ospchat:ospchat-shared from the registry. The
job's permissions: block grants packages: read for this.
- Tray supported (KDE, Windows, macOS, GNOME-with-AppIndicator extension, etc.): the window's X button hides to tray; explicit Exit (tray menu / File → Exit / About → Exit) tears the backend down.
- No system tray (GNOME-Wayland default, plain stumpwm, etc.): the X button is wired to full exit, since "hide to tray" with no tray would strand the user.