Skip to content
Merged
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
124 changes: 124 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# AGENTS.md

This file provides guidance to WARP (warp.dev) when working with code in this repository.

## Project Overview

GitHub Store is a cross-platform app store for GitHub releases built with **Kotlin Multiplatform (KMP)** and **Compose Multiplatform**. It targets **Android** (min API 26, target 36) and **Desktop** (Windows, macOS, Linux via JVM).

Package: `zed.rainxch.githubstore`

## Build & Run Commands

```bash
# Android debug build
./gradlew :composeApp:assembleDebug

# Desktop (run in dev mode)
./gradlew :composeApp:run

# Full build check (both platforms)
./gradlew build

# Lint (ktlint auto-formats on preBuild/compileKotlin* tasks automatically)
./gradlew ktlintFormat # manual format all modules
./gradlew ktlintCheck # check without fixing

# Desktop installers
./gradlew :composeApp:packageDmg # macOS
./gradlew :composeApp:packageExe # Windows
./gradlew :composeApp:packageDeb # Linux
```

**Requirements:** JDK 21+ (Temurin recommended), Android SDK for Android builds.

**Setup:** Create a GitHub OAuth App and put `GITHUB_CLIENT_ID=<your_id>` in `local.properties` (root). Callback URL: `githubstore://callback`.

## Architecture

**Clean Architecture + MVVM** with strict layer separation:

- **Domain** — Repository interfaces, models, use cases. No framework dependencies.
- **Data** — Repository implementations, Ktor API clients, Room DAOs, DTOs, mappers. Each feature's DI module lives in `data/di/SharedModule.kt`.
- **Presentation** — ViewModels with `StateFlow`/`Channel`, Compose screens.

### State Management Pattern (every screen)

Every ViewModel follows the same State/Action/Event pattern:
- `State` — data class holding all UI state, exposed via `StateFlow`
- `Action` — sealed interface for user input (clicks, refreshes)
- `Event` — sealed interface for one-off effects (navigation, toasts), sent via `Channel.receiveAsFlow()`

### Module Layout

```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language identifier to the fenced block

The code fence at Line 54 is missing a language tag (MD040). Use something like ```text for the module tree block to clear lint warnings.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 54-54: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` at line 54, The markdown fenced code block that shows the
module/tree block is missing a language identifier (triggering MD040); update
the opening fence from ``` to include a language tag such as ```text (or
```md/```bash as appropriate) so the block is explicitly tagged and the linter
warning is resolved.

composeApp/ # App entry points, navigation, DI wiring
src/commonMain/ # Shared UI & wiring
src/androidMain/ # Android entry (MainActivity)
src/jvmMain/ # Desktop entry (DesktopApp.kt)
core/
domain/ # Shared interfaces, models, use cases
data/ # Networking (Ktor), database (Room), DI, platform impls
presentation/ # Material 3 theming, reusable UI components, localized strings (13 languages)
feature/<name>/
domain/ # Feature-specific interfaces & models
data/ # Feature-specific implementations & Koin DI module
presentation/ # Feature ViewModel + Compose screens
build-logic/convention/ # Custom Gradle convention plugins
```

Some features (favourites, starred, recently-viewed, tweaks) are **presentation-only** — they use core repositories directly and register ViewModels in `composeApp/.../di/ViewModelsModule.kt` instead of having a `data/di/` layer.

### Convention Plugins (build-logic)

| Plugin ID | Use For |
|-----------|---------|
| `convention.kmp.library` | KMP shared library modules (domain, data) |
| `convention.cmp.library` | Compose Multiplatform library modules |
| `convention.cmp.feature` | Feature presentation modules (auto-adds Compose + Koin + core:presentation) |
| `convention.cmp.application` | Main app module |
| `convention.room` | Room database modules |
| `convention.buildkonfig` | Build-time config (reads from local.properties) |

### Navigation

Type-safe navigation using `@Serializable` sealed interface `GithubStoreGraph` in `composeApp/.../navigation/GithubStoreGraph.kt`. Routes are wired in `AppNavigation.kt`. Parameterized routes: `DetailsScreen(repositoryId, owner, repo, isComingFromUpdate)`, `DeveloperProfileScreen(username)`.

### Dependency Injection

**Koin** — each feature's data layer defines a module in `data/di/SharedModule.kt`. All modules are registered in `composeApp/.../di/initKoin.kt`. ViewModels injected via `koinViewModel()`. `DetailsViewModel` and `MirrorPickerViewModel` use manual Koin `viewModel { }` with `parametersOf()` for constructor args; all others use `viewModelOf(::ClassName)`.

### Key Cross-Cutting Concerns

- **Auth flow:** GitHub device-flow OAuth. Primary path goes through backend proxy (`/v1/auth/device/start`, `/v1/auth/device/poll`); falls back to direct GitHub only on infrastructure errors (5xx, timeouts). HTTP 4xx and GitHub's negative 200-bodies never trigger fallback. Backend rate limits (10 starts/hr, 200 polls/hr per IP) are hard — do not add retry loops.
- **`X-GitHub-Token` header:** Only sent on `/v1/search` and `/v1/search/explore`. Never on other endpoints, never logged.
- **Platform branching:** Source sets are `commonMain` (shared), `androidMain` (Android), `jvmMain` (Desktop). Some features (apps, installation, Shizuku) are Android-only.
- **Shizuku (Android):** Optional silent install via AIDL service. Falls back to standard installer on failure.

## Coding Conventions

- Packages: `zed.rainxch.{module}.{layer}` (e.g. `zed.rainxch.home.data.repository`)
- Private state: underscore prefix `_state`, `_events`
- Sealed classes/interfaces for type-safe routes, actions, events
- Repository pattern: interface in `domain/`, implementation in `data/`
- Ktlint auto-runs on `preBuild`/`compileKotlin*` tasks; `ignoreFailures = true`
- Ktlint rules: wildcard imports allowed, filename rule disabled, `@Composable` functions exempt from function naming rule (see `.editorconfig`)

## Adding a New Feature

1. Create `feature/<name>/domain/`, `feature/<name>/data/`, `feature/<name>/presentation/`
2. Add `build.gradle.kts` in each using the appropriate convention plugin
3. Add `include` entries in `settings.gradle.kts`
4. Define domain interfaces/models in `domain/`
5. Implement repository + Koin DI module in `data/di/SharedModule.kt`
6. Create ViewModel (State/Action/Event pattern) and Screen in `presentation/`
7. Add navigation route to `GithubStoreGraph.kt` and wire in `AppNavigation.kt`
8. Register the Koin module in `initKoin.kt`

## Feature-Level Documentation

Each `feature/` directory contains its own `CLAUDE.md` with module structure, key interfaces, navigation routes, and implementation notes. Read those for feature-specific guidance.

## Versions

All library versions managed in `gradle/libs.versions.toml`. Key versions: Kotlin 2.3.10, Compose Multiplatform 1.10.3, Ktor 3.4.0, Room 2.8.4, Koin 4.1.1.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ interface InstalledAppDao {
@Query("SELECT * FROM installed_apps WHERE repoId = :repoId")
fun getAppByRepoIdAsFlow(repoId: Long): Flow<InstalledAppEntity?>

`@Query`("SELECT * FROM installed_apps WHERE repoId = :repoId ORDER BY installedAt DESC")
suspend fun getAppsByRepoId(repoId: Long): List<InstalledAppEntity>

`@Query`("SELECT * FROM installed_apps WHERE repoId = :repoId ORDER BY installedAt DESC")
fun getAppsByRepoIdAsFlow(repoId: Long): Flow<List<InstalledAppEntity>>

@Query("SELECT COUNT(*) FROM installed_apps WHERE isUpdateAvailable = 1")
fun getUpdateCount(): Flow<Int>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ class FavouritesRepositoryImpl(
override suspend fun isFavoriteSync(repoId: Long): Boolean = favoriteRepoDao.isFavoriteSync(repoId)

suspend fun addFavorite(repo: FavoriteRepo) {
val installedApp = installedAppsDao.getAppByRepoId(repo.repoId)
val installedApps = installedAppsDao.getAppsByRepoId(repo.repoId)
val firstInstalled = installedApps.firstOrNull { !it.isPendingInstall }
favoriteRepoDao.insertFavorite(
repo
.toEntity()
.copy(
isInstalled = installedApp != null,
installedPackageName = installedApp?.packageName,
isInstalled = firstInstalled != null,
installedPackageName = firstInstalled?.packageName,
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ class InstalledAppsRepositoryImpl(
override fun getAppByRepoIdAsFlow(repoId: Long): Flow<InstalledApp?> =
installedAppsDao.getAppByRepoIdAsFlow(repoId).map { it?.toDomain() }

override suspend fun getAppsByRepoId(repoId: Long): List<InstalledApp> =
installedAppsDao.getAppsByRepoId(repoId).map { it.toDomain() }

override fun getAppsByRepoIdAsFlow(repoId: Long): Flow<List<InstalledApp>> =
installedAppsDao.getAppsByRepoIdAsFlow(repoId).map { list -> list.map { it.toDomain() } }

override suspend fun isAppInstalled(repoId: Long): Boolean = installedAppsDao.getAppByRepoId(repoId) != null

override suspend fun saveInstalledApp(app: InstalledApp) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ class StarredRepositoryImpl(
val hasValidAssets =
checkForValidAssets(repo.owner.login, repo.name)
if (hasValidAssets) {
val installedApp = installedAppsDao.getAppByRepoId(repo.id)
val installedApps = installedAppsDao.getAppsByRepoId(repo.id)
val firstInstalled = installedApps.firstOrNull { !it.isPendingInstall }
zed.rainxch.core.domain.model.StarredRepository(
repoId = repo.id,
repoName = repo.name,
Expand All @@ -124,8 +125,8 @@ class StarredRepositoryImpl(
stargazersCount = repo.stargazersCount,
forksCount = repo.forksCount,
openIssuesCount = repo.openIssuesCount,
isInstalled = installedApp != null,
installedPackageName = installedApp?.packageName,
isInstalled = firstInstalled != null,
installedPackageName = firstInstalled?.packageName,
latestVersion = null,
latestReleaseUrl = null,
starredAt =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ interface InstalledAppsRepository {

fun getAppByRepoIdAsFlow(repoId: Long): Flow<InstalledApp?>

suspend fun getAppsByRepoId(repoId: Long): List<InstalledApp>

fun getAppsByRepoIdAsFlow(repoId: Long): Flow<List<InstalledApp>>

suspend fun isAppInstalled(repoId: Long): Boolean

suspend fun saveInstalledApp(app: InstalledApp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ data class DetailsState(
val isAppManagerAvailable: Boolean = false,
val isAppManagerEnabled: Boolean = false,
val installedApp: InstalledApp? = null,
/**
* All apps tracked for this repository. For single-app repos this
* contains at most one element (same as [installedApp]). For
* monorepos it may contain multiple entries with different package
* names. [installedApp] is the "primary" — the one whose asset
* filter matches the currently selected asset, or the first.
*/
val installedApps: List<InstalledApp> = emptyList(),
val isFavourite: Boolean = false,
val isStarred: Boolean = false,
val isTrackingApp: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -821,17 +821,28 @@ class DetailsViewModel(
private fun observeInstalledApp(repoId: Long) {
viewModelScope.launch {
installedAppsRepository
.getAppByRepoIdAsFlow(repoId)
.getAppsByRepoIdAsFlow(repoId)
.distinctUntilChanged()
.collect { app ->
.collect { apps ->
// Pick the "primary" tracked app: the one whose
// package name matches the currently selected
// asset's prior install, or just the first.
val primary = apps.firstOrNull { existing ->
_state.value.primaryAsset?.name?.let { assetName ->
val filter = existing.assetFilterRegex
filter != null && Regex(filter).containsMatchIn(assetName)
} == true
} ?: apps.firstOrNull()

// Recompute merged changelog + stalled signals
// against the new installed version — if the
// user just updated externally, the installed
// tag flips and what they've "missed" changes.
val insights = computeReleaseInsights(_state.value.allReleases, app)
val insights = computeReleaseInsights(_state.value.allReleases, primary)
_state.update {
it.copy(
installedApp = app,
installedApp = primary,
installedApps = apps,
mergedChangelog = insights.mergedChangelog,
mergedChangelogBaseTag = insights.mergedChangelogBaseTag,
stalledStableSinceDays = insights.stalledStableSinceDays,
Expand Down Expand Up @@ -2294,12 +2305,13 @@ class DetailsViewModel(
}
}

val installedAppDeferred =
val installedAppsDeferred =
async {
try {
val dbApp = installedAppsRepository.getAppByRepoId(repo.id)
val dbApps = installedAppsRepository.getAppsByRepoId(repo.id)

if (dbApp != null) {
// Reconcile pending-install status for each tracked app
dbApps.map { dbApp ->
if (dbApp.isPendingInstall &&
packageMonitor.isPackageInstalled(dbApp.packageName)
) {
Expand All @@ -2308,18 +2320,17 @@ class DetailsViewModel(
false,
)
installedAppsRepository.getAppByPackage(dbApp.packageName)
?: dbApp
} else {
dbApp
}
} else {
null
}
} catch (_: RateLimitException) {
rateLimited.set(true)
null
emptyList()
} catch (t: Throwable) {
logger.error("Failed to load installed app: ${t.message}")
null
logger.error("Failed to load installed apps: ${t.message}")
emptyList()
}
}

Expand All @@ -2330,7 +2341,8 @@ class DetailsViewModel(
val stats = statsDeferred.await()
val readme = readmeDeferred.await()
val userProfile = userProfileDeferred.await()
val installedApp = installedAppDeferred.await()
val allInstalledApps = installedAppsDeferred.await()
val installedApp = allInstalledApps.firstOrNull()

if (rateLimited.get()) {
// Any deferred tripping the rate-limit flag leaves the UI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ class DeveloperProfileRepositoryImpl(
repo: GitHubRepoResponse,
favoriteIds: Set<Long>,
): DeveloperRepository {
val installedApp = installedAppsDao.getAppByRepoId(repo.id)
val installedApps = installedAppsDao.getAppsByRepoId(repo.id)
val isFavorite = favoriteIds.contains(repo.id)

val (hasReleases, hasInstallableAssets, latestVersion) =
Expand All @@ -140,12 +140,11 @@ class DeveloperProfileRepositoryImpl(
return repo.toDomain(
hasReleases = hasReleases,
hasInstallableAssets = hasInstallableAssets,
// Treat a row as installed only if the actual install
// completed; a parked-download row left by a failed install
// would otherwise leak "Installed" into the UI (see
// `InstalledApp.isReallyInstalled` for the domain-model
// equivalent of this check).
isInstalled = installedApp != null && !installedApp.isPendingInstall,
// Treat a repo as installed if any tracked app has
// completed install; a parked-download row left by a
// failed install would otherwise leak "Installed" into
// the UI (see `InstalledApp.isReallyInstalled`).
isInstalled = installedApps.any { !it.isPendingInstall },
isFavorite = isFavorite,
latestVersion = latestVersion,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,19 @@ class HomeViewModel(
private fun observeInstalledApps() {
viewModelScope.launch {
installedAppsRepository.getAllInstalledApps().collect { installedApps ->
val installedMap = installedApps.associateBy { it.repoId }
val installedMap = installedApps.groupBy { it.repoId }
_state.update { current ->
current.copy(
repos =
current.repos
.map { homeRepo ->
val app = installedMap[homeRepo.repository.id]
val apps = installedMap[homeRepo.repository.id].orEmpty()
homeRepo.copy(
isInstalled = app.isReallyInstalled(),
isUpdateAvailable = app.hasActualUpdate(),
isInstalled = apps.any { it.isReallyInstalled() },
isUpdateAvailable = apps.any { it.hasActualUpdate() },
)
}.toImmutableList(),
isUpdateAvailable = installedMap.values.any { it.hasActualUpdate() },
isUpdateAvailable = installedMap.values.flatten().any { it.hasActualUpdate() },
)
}
}
Expand Down Expand Up @@ -341,7 +341,7 @@ class HomeViewModel(
installedAppsRepository
.getAllInstalledApps()
.first()
.associateBy { it.repoId }
.groupBy { it.repoId }

val favoritesMap =
favouritesRepository
Expand All @@ -358,16 +358,16 @@ class HomeViewModel(
val seenIds = _state.value.seenRepoIds

return repos.map { repo ->
val app = installedAppsMap[repo.id]
val apps = installedAppsMap[repo.id].orEmpty()
val favourite = favoritesMap[repo.id]
val starred = starredReposMap[repo.id]

DiscoveryRepositoryUi(
isInstalled = app.isReallyInstalled(),
isInstalled = apps.any { it.isReallyInstalled() },
isFavourite = favourite != null,
isStarred = starred != null,
isSeen = repo.id in seenIds,
isUpdateAvailable = app.hasActualUpdate(),
isUpdateAvailable = apps.any { it.hasActualUpdate() },
repository = repo.toUi(),
)
}
Expand Down
Loading