mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Security:
- Participants connect with a per-room key derived from a random per-install
secret; others only see an id derived from it, so nobody can reconnect as
someone else (including the host).
- "create" only works on a fresh code or for the room's own host, so empty
rooms can't be taken over.
- Per-socket message and queue budgets disconnect clients that flood; the
host throttles itself well under them.
- Bans also cover the device: a hashed digest of device traits combined with
the network, stored only as an opaque per-room value. Earlier members are
never caught by someone else's ban.
- Peer queue items and suggestions are sanitized (streaming tracks only, no
URLs, https covers); suggestions are credited to the server-attested sender;
local file paths go out as opaque ids. Names are cleaned server-side.
- Room codes and keys use a secure random source; the deploy workflow is
read-only.
Names:
- Participants get a stable generated name ("Mellow Otter") per install, and
can pick their own on the Jam screen or in Jam settings. The participant
list marks "(you)".
Also translate the Jam strings into all locales and add tests for the above.
14 KiB
14 KiB
AGENTS
Repo shape (KMP + modules)
- Gradle multi-module:
:composeApp(main app),:plugin_interfaces(plugin API contracts),:js_plugin_example(Zipline JS plugin template). :composeAppuses custom KMP source sets (mobileMain,androidJvmMain) with explicitdependsOn; Gradle prints "Default Kotlin Hierarchy Template Not Applied Correctly" warning. Treat as known/expected.
Real app entrypoints
- Desktop/JVM:
composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt(mainClass = org.spotube.spotube.MainKt). - Android:
composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt. - iOS bridge:
composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.ktandiosApp/iosApp/ContentView.swift.
High-value commands
- Use Gradle wrapper (
./gradlewor./gradlew.bat) only. - Run desktop:
:composeApp:run - Build Android debug:
:composeApp:assembleStableDebug(orassembleNightlyDebug). Android hasstable/nightlyproduct flavors (nightly = beta channel:.nightlyapplicationId suffix, own icons/name incomposeApp/src/nightly/res). Desktop icons come fromcomposeApp/icons/{stable,nightly}, chosen with-Pnightly=true. - Module checks:
:composeApp:check,:plugin_interfaces:check,:js_plugin_example:check - Focused tests:
:composeApp:jvmTest,:composeApp:iosSimulatorArm64Test,:plugin_interfaces:jvmTest,:plugin_interfaces:jsTest,:js_plugin_example:jsTest - No lint/typecheck/formatter tasks are configured;
:composeApp:checkis the only aggregated check.
Dependencies
gradle/libs.versions.tomlis the single source of truth for all version pins and library declarations.- Kotlin:
2.4.21, JVM target:11(compile/target compatibility in bothcomposeApp/build.gradle.ktsandplugin_interfaces/build.gradle.kts). - compose-webview (desktop) dispatcher hijack:
dev.nucleusframework:composewebviewtransitively pullsnucleus.decorated-window-tao, which registers aTaoMainDispatcherFactory(loadPriority = 100) viaMETA-INF/servicesthat hijacksDispatchers.Mainaway from the Swing EDT. This breaks lifecycle'senforceMainThreadIfNeeded(window fails with "Method addObserver must be called on the main thread"). The app overrides it withSwingMainDispatcherFactory(composeApp/src/jvmMain/.../core/coroutines/,loadPriority = Int.MAX_VALUE) which pinsDispatchers.Mainback to the Swing EDT. Keep that factory + itsMETA-INF/services/kotlinx.coroutines.internal.MainDispatcherFactoryresource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority).
Networking
- Never call
HttpClient(...)directly. InjectHttpClientFactory(commonMain/.../core/network/HttpClientFactory.kt, a Koin single) and use.default/.json(shared, never close them) or.create { ... }for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop). - Do not add
ktor-client-cio: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails.
Group Jam
- Room server:
jam-server/(Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol injam-server/src/protocol.ts; keep it in sync with the wire frames incore/jam/JamRoomClient.kt(bumpPROTOCOL_VERSIONon both sides for breaking changes). - App side:
core/jam/—JamRoomClient(Ktor WebSocket, reconnects, keep-alives, clock pings),JamClock/JamDriftCorrector(sync math),JamRoomService(host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off). - Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See
jam-server/README.md. - Security: the server enforces identity (per-install secret → per-room key → public id), device-level bans (
JamDevice.kttraits, only ever sent hashed), host-only powers and flood limits. The app treats everything from peers as untrusted and runs it throughJamMediaItem.sanitizedbefore loading it. See "Security" injam-server/README.md. - A network drop must never crash the app:
JamRoomClient.pumpturns socket failures into a reconnect.JamSessionSimulationTest.roomSurvivesNetworkLosscuts the network withTcpProxy. - Server URL setting:
UserSettings.jamServerUrl(defaultDEFAULT_JAM_SERVER_URLinSettingsModels.kt). - Tests:
JamSyncTest(pure), plusJamRoomClientIntegrationTestandJamSessionSimulationTest, which only run withJAM_TEST_URLset (startnpm run devinjam-server/).
GraalVM native image (desktop)
- Enabled via
graalvm { }insidenucleus.applicationincomposeApp/build.gradle.kts. Nucleus downloads GraalVM CE itself (or uses$GRAALVM_HOME). Linux needsgcc+patchelf. - Build:
:composeApp:createGraalvmNativeDistributable(outputcomposeApp/build/compose/binaries/main/graalvm-app/), installers viapackageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}. A build takes ~10 min and uses all cores and ~15 GB RAM. - Hand-maintained + agent-recorded metadata lives in
composeApp/graalvm/reachability-metadata.json. After adding features that use reflection/JNI/JNA (vlcj, Zipline, plugins), run:composeApp:runWithNativeAgent, exercise the feature, then close the window normally (the merge only happens on a clean exit). uk.co.caprica.vlcj.binding.lib.LibVlcmust stay registered (allDeclaredMethods+jniAccessible): JNA direct mapping finds itsnativemethods by reflection.- The
*UberJarForCurrentOStask excludes other OSes'/CPUs' native libs and stale CEF binaries, because native-image embeds every registered resource into the binary. On Linux,copyGraalvmJvmSois disabled andcopyGraalvmAwtSoLibsprefers native-image's own libs (10 KBlibjvm.so/libjava.soshims instead of the JDK's 28 MBlibjvm.so). isProductionBuild(top ofcomposeApp/build.gradle.kts) is true forpackage*/create*/notarize*/nativeImageCompiletasks, or with-Pproduction=true|false. It gates the Rust release profile (GobleyjvmVariant) so:composeApp:runkeeps fast debug Rust builds. Put new slow, ship-only optimizations behind it.- Full user-facing build docs are in
README.md("Building from source").
Codegen and plugin packaging
- JS plugin bundles:
:js_plugin_example:packageDevelopmentPluginand:js_plugin_example:packageProductionPlugin. Output is.smplugfiles injs_plugin_example/build/distributions/. - Zipline plugin entrypoint:
mainFunction = "org.spotube.js_plugin_example.main"injs_plugin_example/build.gradle.kts. Plugin metadata fromjs_plugin_example/plugin.json.
Plugin architecture
plugin_interfacesexportszipline.coreandsemveras API.composeAppdepends on it for the plugin system.plugin_interfacesalso has a JS target (browser()), used by the plugin system.
UI component patterns
- ALL user-facing strings MUST come from
composeApp/src/commonMain/composeResources/values/strings.xmlviastringResource(Res.string.xxx). Never hardcode a literal string in aText(...), dialog title, button label, content description, placeholder, or error message inside@Composablecode. If the string you need doesn't exist yet, add a new<string name="...">entry tostrings.xml(grouped near related keys, following the existingfeature_context_purposenaming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not justmodules/settings.strings.xmlis the single source of truth for copy, not a showcase file for one screen. - ViewModels own ALL state and logic. Composables are dumb renderers: they collect a single
StateFlow<UiState>from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, orLaunchedEffect-based state syncing belongs in a composable — it goes in the ViewModel. Thecombine/stateInflow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediaterememberderivations ormutableStateListOfmirrors. - AdaptiveDropdownBottomSheet (
commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt): switches betweenDropdownMenu(large screen) andModalBottomSheet(small screen) viacurrentWindowAdaptiveInfo(). Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. - AdaptiveDialogBottomSheet (
commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt): switches betweenThemedDialog(large screen) andModalBottomSheet(small screen) viacurrentWindowAdaptiveInfo(). Same rule — keep in commonMain unless platform-specific APIs are required. - Use
expect/actualonly when the component MUST use platform-specific APIs (e.g.WindowStatefor desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain. - JavaFX is required;
--add-opensflags incompose.desktop.application.jvmArgsmust be preserved:javafx.graphics/javafx.scene,javafx.graphics/com.sun.javafx.sg.prism,javafx.graphics/com.sun.javafx.scene,javafx.web/com.sun.webkit,javafx.media/com.sun.media.jfxmedia,javafx.media/com.sun.media.jfxmedia.events. - JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via
System.getProperty("os.name").
Tooling
- Gradle config cache enabled (
gradle.properties); prefer module-scoped tasks. - Gradle daemon JVM pinned to JetBrains JDK 21 via
gradle/gradle-daemon-jvm.properties. - Gradle 8.14.3 (from
gradle/wrapper/gradle-wrapper.properties). - Foojay toolchain resolver in use (
plugins { id("org.gradle.toolchains.foojay-resolver-convention") }).
Current testing reality
- No committed
*Test*.ktfiles; test tasks may run zero tests unless new tests are added.
Release workflow (.github/workflows/release.yml)
- Triggered by
workflow_dispatchwith arelease_typechoice input (stable|nightly). prepare-depsjob checks out & publisheskdroidFilter/ComposeNativeWebview(compose-webview) tomavenLocal. The spotube gradle plugin and vlcj-bundler now come from Maven Central.- Build jobs per platform (Android, Linux, Windows, macOS), all
needs: prepare-deps. Desktop jobs ship GraalVM native installers (packageGraalvm{Deb,Rpm,Exe,Dmg}, output incomposeApp/build/compose/binaries/main/graalvm-<format>/), not jpackage/JVM ones. - Stable: reads
versionNamefromcomposeApp/build.gradle.kts, tag =v{version}, draft release. - Nightly: builds with
-PversionName=nightly, tag =nightly(updates existing), prerelease. - Android signing: decodes
secrets.KEYSTORE(base64) →composeApp/upload-keystore.jks, writes signing config intolocal.propertiesfrom secrets. create-releasejob (depends on all builds) usessoftprops/action-gh-release@v2to create the GitHub release with all artifacts attached.
Package managers (packaging/, .github/workflows/spotube-publish-binary.yml)
packaging/<target>(linux,aur,flatpak,winget,chocolatey) holds JMustache templates (*.mustache) plus files copied as is.:composeApp:renderPackaging<Target>renders them intocomposeApp/build/packaging/<target>(RenderPackagingTaskin buildSrc documents the template data). Never hand-write versions, checksums or release notes into these files.- Checksums come from release assets in
-PpackagingArtifactsDir=<dir>; a template using a missing value fails the render. Release notes come fromgit-cliff --contextwithcliff.toml(GitCliffContextTask): needsgit-cliffon the PATH and full git history.gradle.propertiesdefaults tonightly=true, which leaves commits unreleased: pass-Pnightly=falsefor a stable render. packaging/package-linux-tarball.shpacksspotube-linux-<version>-<arch>.tar.xz(the AUR and Flathub source) from the GraalVM distributable and the renderedlinuxdir; the release workflow builds it before the AppImage step.- The publish workflow (
workflow_dispatch, inputtag) renders once, then pushes to Flathub (team-spotube/org.spotube.spotube), the AUR (spotube-bin), WinGet (PR fromteam-spotube/winget-pkgs) and Chocolatey.
OpenCode Agent MCP Execution Directives
1. Code Discovery & Context Search
- Use
android-studio-mcpsearch/symbol tools to locate declarations before making edits across:composeApp,:plugin_interfaces, or:js_plugin_example[cite: 1, 2]. - Keep
gradle/libs.versions.tomlas the single source of truth for dependencies and version updates.
2. UI Architecture Constraints
- Strict ViewModel Separation: UI composables in
commonMainmust only collectStateFlow<UiState>and emit event callbacks. Do NOT write business logic, filtering, ormutableStateListOfdirectly inside@Composablefunctions. - Adaptive Components: Use
AdaptiveDropdownBottomSheetorAdaptiveDialogBottomSheetinsidecommonMainfor adaptive layouts[cite: 2]. Do NOT create platform-specificexpect/actualvariants unless platform-native APIs (like windowing or native scrollbars) are explicitly required[cite: 2].
3. Verification & Diagnostic Loops
- Post-Edit Verification: After editing Kotlin code, validate compilation by running
./gradlew :composeApp:checkor./gradlew :composeApp:assembleStableDebugvia MCP[cite: 2]. - Dispatcher Integrity: Never remove or bypass
SwingMainDispatcherFactoryincomposeApp/src/jvmMain/.../core/coroutines/[cite: 2]. It is required to preventcompose-webviewfrom hijackingDispatchers.Mainaway from the Swing EDT[cite: 2]. - Live Preview Iteration: Use
compose-hot-reloadto evaluate UI layout changes headlessly without forcing full Gradle rebuilds.