Cross-platform app store for GitHub + Codeberg + Forgejo releases. Kotlin Multiplatform + Compose Multiplatform. Android (min API 26) + Desktop (JVM: Win/macOS/Linux). Package zed.rainxch.githubstore. Version 1.8.3 (code 18). Target SDK 36.
./gradlew :composeApp:assembleDebug # Android
./gradlew :composeApp:run # Desktop dev
./gradlew :composeApp:packageExe :composeApp:packageMsi # Win installer
./gradlew :composeApp:packageDmg :composeApp:packagePkg # macOS
./gradlew :composeApp:packageDeb :composeApp:packageRpm # Linux
./gradlew build # fullJDK 21+. Android SDK for Android.
composeApp/ # entry points, navigation, DI wiring (commonMain / androidMain / jvmMain)
core/
domain/ # interfaces, models, use cases (no framework deps)
data/ # repos, Ktor, Room, Koin, platform impls
presentation/ # Material 3 theme + reusable components + 13-locale strings
feature/
apps auth details dev-profile favourites home profile recently-viewed search starred tweaks
build-logic/convention/ # convention plugins
Each feature: up to 3 sub-modules (domain/, data/, presentation/). favourites, starred, recently-viewed are presentation-only.
Clean Architecture + MVVM. Layers: Domain (contracts), Data (Ktor + Room + Koin DI), Presentation (ViewModels with StateFlow/Channel, Compose).
class XViewModel : ViewModel() {
private val _state = MutableStateFlow(XState())
val state = _state.asStateFlow() // or .stateIn(WhileSubscribed)
private val _events = Channel<XEvent>()
val events = _events.receiveAsFlow()
fun onAction(action: XAction) { ... }
}State = data class. Action = sealed (user input). Event = sealed (one-off effects).
@Serializable sealed interface GithubStoreGraph in composeApp/.../app/navigation/. Routes: HomeScreen, SearchScreen, AuthenticationScreen, ProfileScreen, TweaksScreen, FavouritesScreen, StarredReposScreen, RecentlyViewedScreen, AppsScreen, SponsorScreen, ExternalImportScreen, MirrorPickerScreen, StarredPickerScreen, SkippedUpdatesScreen, HiddenRepositoriesScreen, WhatsNewHistoryScreen, AnnouncementsScreen, HostTokensScreen, DetailsScreen(repositoryId, owner, repo, isComingFromUpdate, sourceHost), DeveloperProfileScreen(username). DetailsScreen.sourceHost is non-null for Codeberg / Forgejo / custom-forge repos β routes all DetailsRepository calls through ForgejoClientRegistry instead of the GitHub-backed default path.
Koin. Feature modules in data/di/SharedModule.kt. ViewModels in composeApp/.../app/di/ViewModelsModule.kt (viewModelOf(::X) or explicit viewModel { ... }). Wired in initKoin.kt.
FavouritesRepository, StarredRepository, InstalledAppsRepository, SeenReposRepository, HiddenReposRepository, SearchHistoryRepository, TweaksRepository, AuthenticationState, ThemesRepository, ProxyRepository, RateLimitRepository, ExternalImportRepository, TelemetryRepository, HostTokenRepository (per-host PATs, KSafe-encrypted). Network: ForgejoApiClient + ForgejoClientRegistry (per-host Ktor clients, thread-safe via Mutex, proxy-aware, closes cached engines on shutdown / proxy change). Util: AssetVariant (token/glob/stem fingerprinting), assetPlatformOf, RepoIdCodec (23-bit host fingerprint + 40-bit raw id packed into the existing 64-bit repoId slot β sign bit = foreign source), RepositoryUrlParser (recognises GitHub + Codeberg + gitea.com + git.disroot.org + user-added forge hosts). System interfaces: Installer, InstallerStatusProvider, PackageMonitor, SystemInstallSerializer.
Kotlin 2.3.10, Compose Multiplatform 1.10.3, Ktor 3.4.0, Room 2.8.4, Koin 4.1.1, kotlinx.serialization 1.10.0, DataStore 1.2.0, Landscapist 2.9.5, Kermit 2.0.8, MOKO Permissions 0.20.1, Navigation Compose 2.9.2, multiplatform-markdown-renderer 0.39.2, Shizuku 13.1.5, WorkManager 2.11.1, kotlinx.datetime 0.7.1. Versions in gradle/libs.versions.toml.
convention.kmp.library (domain/data), convention.cmp.library (core/presentation), convention.cmp.feature (feature presentation), convention.cmp.application (main app), convention.room, convention.buildkonfig.
feature/<name>/{domain,data,presentation}/with appropriate convention pluginincludeinsettings.gradle.kts- Domain interfaces β impl + Koin module in
data/di/SharedModule.ktβ ViewModel + Screen - Route in
GithubStoreGraph.kt+ wire inAppNavigation.kt+ register Koin ininitKoin.kt
- GitHub OAuth:
GITHUB_CLIENT_IDinlocal.properties. Deep links:githubstore://auth(web-OAuth handoff),githubstore://callback(legacy device-flow leftover),githubstore://repo,githubstore://apps. - Shizuku (Android): silent install via
ShizukuProviderβ AIDL βpm install -S. Fallback to standard installer on failure. - Desktop logs:
CrashReporter(first line ofDesktopApp.main) tees stdout/stderr to rotatingsession.log+ writescrash-<ts>.logon uncaught. Paths:~/Library/Logs/GitHub-Store/(macOS),%LOCALAPPDATA%/GitHub-Store/logs/(Win),$XDG_STATE_HOME/GitHub-Store/logs/(Linux). Android = Logcat. - macOS distribution: Homebrew cask in tap
openhub-store/tap(separate repohomebrew-tap).brew install --cask github-store. Unsigned at present β user mustxattr -dr com.apple.quarantine /Applications/GitHub-Store.appafter install. CI builds.dmg+.pkgon every push togenerate-installers; tap cask updates automatically on release. X-GitHub-Tokenheader: Client attaches whenTokenStore.currentToken()is non-null on/v1/search,/v1/search/explore,/v1/repo,/v1/releases,/v1/readme,/v1/user. Backend re-sends asAuthorization: token $tokento GitHub. Without it, backend round-robins a 4-token service pool. Upstream 401 remapped to backend502(handled like "GitHub unreachable" β fall back viashouldFallbackToGithubOrRethrow).429= no fallback (same wall), only backoff.UnauthorizedInterceptoronly on direct-GitHub client;AuthenticationStateImpldebounces consecutive 401s by token snapshot.- Auth flow (web-OAuth-first): Primary path is web OAuth with PKCE + handoff.
feature/auth/data/crypto/PkceGeneratormints(state, codeVerifier, codeChallenge);WebAuthApi.registerPOSTs verifier + challenge + state tohttps://github-store.org/auth/register(Cloudflare Worker stashes them in Workers KV) and returnsauthUrl. User opens it, authorizes ongithub.com, GitHub redirects togithub-store.org/auth/callback?code&statewhere the Worker exchanges the code viaapi.github-store.org(backend stores(handoffId β access_token)for 60s in Postgres with atomicDELETEβ¦RETURNING), then bounces back togithubstore://auth?h=<handoffId>. App reads handoff viaWebAuthApi.consumeHandoff(GETDEL semantics). Secondary path: device flow via backend/v1/auth/device/start+/poll,AuthPath(Backend|Direct) tracked inSavedStateHandle, only escalatesBackend β Directon infra errors. Tertiary: paste a Personal Access Token (signInWithPatβ validates against/user, persists optimistically when GitHub unreachable). Backend rate limits: 10 device-starts/hr, 200 device-polls/hr per IP. Endpoints incore/data/network/BackendEndpoints.kt(BACKEND_ORIGIN,WEB_ORIGIN). - Windows installer signing (SignPath Foundation): CI workflow
.github/workflows/build-desktop-platforms.ymljobsign-windowsafter every push togenerate-installersbranch. Action pinned to commit SHA (not@v2). Secrets:SIGNPATH_API_TOKEN,SIGNPATH_ORGANIZATION_ID(1ecf111e-...). VariableSIGNPATH_SIGNING_POLICY_SLUG=test-signinguntil prod cert issued; flip torelease-signing. Project slugGitHub-Store, artifact config sluginitial. Unsigned artifact deleted post-sign; onlywindows-installers-signedreaches the draft release. - WinGet publish:
.github/workflows/winget-publish.ymlfires onrelease: [released]. Actionvedantmgoyal9/winget-releaser@main. SecretWINGET_TOKEN= PAT withContents+Pull requests: writeonOpenHub-Store/winget-pkgs(fork ofmicrosoft/winget-pkgs). Pinfork-user: OpenHub-Storeexplicitly so the action doesn't infer from token owner. - Forges (Codeberg / Forgejo / Gitea):
ForgejoApiClientper host (60s req / 30s connect+socket timeouts, exponential retry on 5xx + IOException).ForgejoClientRegistry.clientFor(host)cached + Mutex-guarded. Direct-to-forge β no backend mediator.RepoIdCodecpacks host fingerprint intorepoIdso the existing GitHub-shaped schema survives. README via/contents/README.md?ref={branch}(Forgejo has NO/readmeendpoint). License sniffed from/contents/LICENSEregex against SPDX headers. Downloads aggregated by summingasset.download_countacross releases. - Per-host PATs:
HostTokenRepositorystores{host, token, label, createdAt}rows AES-256-GCM encrypted via KSafe.HostTokenInterceptor(Ktor plugin) injectsAuthorization: token $paton matched host.HostNames.apiHostToTokenHostmapsapi.github.com β github.comso the GitHub-direct client looks up the right PAT. UI atTweaks β Access Tokens(HostTokensScreen). - KSafe: AES-256-GCM with hardware-backed Keystore on Android. Wraps every persisted credential / pref via
core/data/secure/KSafeSafe.ktextension funcs (safeGet,safePut,safeDelete,safeGetFlow) β surface log + return null/false on transient failure instead of throwing through coroutine scopes. - Translation providers:
TranslationProviderenum =GOOGLE,YOUDAO,LIBRE_TRANSLATE,DEEPL,MICROSOFT. Each per-provider config persisted viaTweaksRepository(KSafe-encrypted).TranslationRepositoryImpl.resolveTranslator()picks the impl. LibreTranslate defaults to the bundledtranslate.disroot.orgmirror when user URL pref blank. DeepL auto-routes:fx-suffixed keys toapi-free.deepl.com. Microsoft uses No-Trace by default β text never stored, never used for training. - Gradle: Config + build cache enabled. 4GB Gradle heap, 3GB Kotlin daemon. Official Kotlin style.
- caveman β session default, terse output.
- karpathy-guidelines β anti-overcomplication, minimal diffs, surface assumptions, verifiable success criteria. Every coding task.
- one-skill-to-rule-them-all β watch for skill-capture opportunities during multi-step work.
- gsd-inbox - Triage open GitHub issues + PRs against templates. Our exact pattern β automate the "check issue #N, draft reply, ship fix" loop.
- gsd-ship - Create PR + review + prep for merge. Every task ends here.
- gsd-quick - Trivial task with atomic commits + state tracking. Matches our small-commit policy.
- gsd-debug - Systematic debugging with persistent state across context resets. For bug-hunt cycles.
- android- skills* (
~/.claude/skills/android/) β auto-fire by description match; apply when in matching domain:android-compose-uiβ composables, recomposition, animations, modifiers, design systemandroid-data-layerβ repos, DTOs, Room, Ktor, mappersandroid-di-koinβ Koin module setup, ViewModel injectionandroid-error-handlingβ Result wrapper, typed errorsandroid-module-structureβ feature-layered modules, convention pluginsandroid-navigationβ type-safe Compose navandroid-presentation-mviβ State/Action/Event, Root/Screen split, UiText, SavedStateHandleandroid-testingβ testing patterns
- Packages
zed.rainxch.{module}.{layer} - Private state fields prefix
_state - Sealed routes/actions/events
- Repository pattern: interface in
domain/, impl indata/ - Source sets:
commonMainshared,androidMain,jvmMain - No KDoc, no inline comments unless the user explicitly asks. No function/class docs. Inline only for non-obvious invariants, tricky concurrency, workarounds. Applies globally.
- Feature-specific guidance in each
feature/*/CLAUDE.md
- Read existing files before writing. Don't re-read unless changed.
- Thorough in reasoning, concise in output.
- Skip files over 100KB unless required.
- No sycophantic openers or closing fluff.
- No emojis or em-dashes.
- Do not guess APIs, versions, flags, commit SHAs, or package names. Verify by reading code or docs before asserting, researching if necessary.