Skip to content

Document E5 mirror system design and implementation plan#469

Merged
rainxchzed merged 32 commits intomainfrom
feat/e5-mirror-system
Apr 30, 2026
Merged

Document E5 mirror system design and implementation plan#469
rainxchzed merged 32 commits intomainfrom
feat/e5-mirror-system

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented Apr 30, 2026

Summary by CodeRabbit

  • New Features
    • Mirror picker in Tweaks with Official/Community lists, custom-template dialog, test-connection and health indicators
    • Mirror catalog fetched & cached (24h) with bundled fallback and removal notices; user preference (direct/selected/custom)
    • Parallel racing downloads (direct vs mirror) with winner selection and reporting to a slow-download detector that triggers an auto-suggest sheet (snooze/dismiss)
    • Automatic SHA‑256 verification of downloaded assets; platform verifiers added
  • Documentation
    • Added comprehensive mirror system design specification

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 30, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 28ab1671-ba6c-4ca2-afe1-6d3e556ba3bb

📥 Commits

Reviewing files that changed from the base of the PR and between e3d6b3d and 2726f8d.

📒 Files selected for processing (11)
  • .claude/settings.local.json
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt
  • core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml
  • core/presentation/src/commonMain/composeResources/values-it/strings-it.xml
  • core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml
  • core/presentation/src/commonMain/composeResources/values/strings.xml
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt

Walkthrough

Adds a mirror/proxy system: mirror catalog fetching & persistence, mirror picker UI and navigation, proxy rewrite interceptor, multi-source racing downloader, slow-download detector with auto-suggest UI, SHA‑256 digest verification, and DI wiring including platform digest verifiers and a test HttpClient.

Changes

Cohort / File(s) Summary
Navigation & ViewModel DI
composeApp/src/.../AppNavigation.kt, composeApp/src/.../GithubStoreGraph.kt, composeApp/src/.../ViewModelsModule.kt
Added MirrorPickerScreen, navigation callback wiring from Tweaks, registered AutoSuggestMirrorViewModel and MirrorPickerViewModel (injects mirrorRepository and named("test") HttpClient).
Domain models & contracts
core/domain/src/.../MirrorConfig.kt, .../MirrorPreference.kt, .../MirrorStatus.kt, .../MirrorType.kt, .../GithubAsset.kt, .../network/DigestVerifier.kt, .../network/SlowDownloadDetector.kt, .../system/MultiSourceDownloader.kt, .../network/Downloader.kt
Introduced mirror types/preferences/status, added optional digest to GithubAsset, new interfaces: DigestVerifier, SlowDownloadDetector, MultiSourceDownloader; updated Downloader.download signature to add bypassMirror.
Data: Mirror catalog & persistence
core/data/src/.../BundledMirrors.kt, .../MirrorPersistence.kt, .../MirrorRepositoryImpl.kt, .../MirrorListResponse.kt, .../MirrorApiClient.kt
Bundled fallback list, DataStore keys, MirrorRepositoryImpl with cached load/24h refresh, persistence of catalog JSON/timestamp, removed-notice stream, DTOs and API client for mirror list.
Data: Networking & rewrite
core/data/src/.../MirrorRewriter.kt, .../MirrorRewriteInterceptor.kt, .../ProxyManager.kt, .../HttpClientFactory.kt
Added MirrorRewriter, Ktor installMirrorRewrite() interceptor with NO_MIRROR_REWRITE attribute and Authorization removal on host change, ProxyManager in-memory template collector, and redirect plugin config with mirror rewrite installed.
Data: Download orchestration & detectors
core/data/src/.../MultiSourceDownloaderImpl.kt, .../SlowDownloadDetectorImpl.kt, services/DefaultDownloadOrchestrator.kt
Added MultiSourceDownloaderImpl (racing direct vs mirror, winner coordination), SlowDownloadDetectorImpl (sustained throughput detection, suggestMirror flow with snooze/dismiss checks), orchestrator now uses multi-source downloader, reports progress to detector, and performs SHA‑256 verification (delete+fail on mismatch).
Platform digest verifiers & downloaders
core/data/src/androidMain/.../AndroidDigestVerifier.kt, core/data/src/androidMain/.../PlatformModule.android.kt, core/data/src/jvmMain/.../DesktopDigestVerifier.kt, core/data/src/jvmMain/.../PlatformModule.jvm.kt, core/data/src/.../AndroidDownloader.kt, core/data/src/.../DesktopDownloader.kt
Added platform DigestVerifier implementations and DI registrations; updated platform Downloader signatures to accept bypassMirror (documented unused for OkHttp implementations).
DI: shared wiring & test client
core/data/src/commonMain/.../SharedModule.kt
Wired MirrorApiClient, MirrorRepositoryImpl (starts ProxyManager collector), MultiSourceDownloader, SlowDownloadDetector, DigestVerifier, and added a named("test") HttpClient with 5s timeouts and default User-Agent.
Presentation: Tweaks & Mirror UI
feature/tweaks/presentation/.../TweaksRoot.kt, TweaksViewModel.kt, TweaksAction.kt, components/sections/Network.kt, build.gradle.kts
Tweaks entry added for mirror picker, new TweaksAction variant, TweaksRoot navigation callback, dependency on core.data and Ktor client core.
Presentation: Mirror picker & components
feature/tweaks/presentation/.../MirrorPickerViewModel.kt, MirrorPickerRoot.kt, MirrorPickerState.kt, MirrorPickerAction.kt, MirrorPickerEvent.kt, components/*
New MirrorPicker screen and VM (uses testHttpClient), state model and test connectivity flow, custom-mirror dialog, MirrorRow/StatusDot/DeployYourOwnHint components, refresh and events/snackbars handling.
Presentation: Auto-suggest
feature/tweaks/presentation/.../AutoSuggestMirrorViewModel.kt, components/AutoSuggestMirrorSheet.kt
Auto-suggest ViewModel observes detector and exposes isVisible; sheet UI with pick/maybe-later/don't-ask actions wired to repository snooze/dismiss.
Resources & docs
core/presentation/.../strings.xml, core/presentation/.../values-*/strings-*.xml, docs/superpowers/specs/2026-04-29-e5-mirror-system-design.md
Added/updated i18n strings for mirror flows and apostrophe fixes; added detailed design spec documenting catalog, rewrite behavior, racing downloads, SHA‑256 verification, slow-detector, and UI.
Small config
.claude/settings.local.json
Expanded local command permissions (non-functional config change).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant MirrorPickerVM
    participant MirrorRepo
    participant ProxyManager
    participant TestHttpClient
    participant Remote

    User->>MirrorPickerVM: open screen
    MirrorPickerVM->>MirrorRepo: observeCatalog() & observePreference()
    MirrorRepo-->>MirrorPickerVM: catalog + preference
    User->>MirrorPickerVM: OnSelectMirror(mirror)
    MirrorPickerVM->>MirrorRepo: setPreference(Selected(id))
    MirrorRepo->>ProxyManager: preference change
    ProxyManager->>ProxyManager: update current template
    User->>MirrorPickerVM: OnTestConnection
    MirrorPickerVM->>TestHttpClient: GET probe (5s, NO_MIRROR_REWRITE absent)
    TestHttpClient->>Remote: request
    alt 2xx
        Remote-->>TestHttpClient: success
        MirrorPickerVM->>User: show Success(latency)
    else timeout / DNS / HTTP error
        MirrorPickerVM->>User: show corresponding result
    end
Loading
sequenceDiagram
    participant DownloadFlow
    participant MultiSourceDownloader
    participant DirectDownloader
    participant MirrorDownloader
    participant WinnerDeferred
    participant SlowDetector
    participant FileSystem

    DownloadFlow->>MultiSourceDownloader: download(githubUrl)
    alt template available
        par direct
            MultiSourceDownloader->>DirectDownloader: download(directUrl, bypassMirror=true)
            DirectDownloader->>WinnerDeferred: tryCompleteOnSuccess()
            DirectDownloader->>SlowDetector: emit progress
            DirectDownloader->>FileSystem: write chunks
        and mirror
            MultiSourceDownloader->>MirrorDownloader: download(rewrittenUrl)
            MirrorDownloader->>WinnerDeferred: tryCompleteOnSuccess()
            MirrorDownloader->>SlowDetector: emit progress
            MirrorDownloader->>FileSystem: write chunks
        end
        WinnerDeferred-->>MultiSourceDownloader: winner chosen
        MultiSourceDownloader->>Loser: cancelAndJoin()
    else no template
        MultiSourceDownloader->>DirectDownloader: download(directUrl, bypassMirror=true)
    end
    SlowDetector->>DownloadFlow: suggestMirror (maybe)
    FileSystem-->>DownloadFlow: complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I hopped through code to plant a bright mirror,
Racing streams, checksum guard — nothing sorrier.
When slow winds blow, I nudged a gentle sheet,
Pick, snooze, or hush — your downloads stay neat.
Hop on, choose fast paths — the carrots taste sweeter!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The pull request title describes only documentation and planning, but the changeset includes substantial implementation: new domain models, DI wiring, UI components, download orchestration changes, and mirror system logic across multiple modules. Update the title to reflect the primary implementation work, such as 'Implement E5 mirror system with multi-source downloads and digest verification' or 'Add mirror selection, auto-suggest, and digest verification for downloads'.
Docstring Coverage ⚠️ Warning Docstring coverage is 15.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/e5-mirror-system

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

# Conflicts:
#	composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt
#	core/presentation/src/commonMain/composeResources/values/strings.xml
#	feature/tweaks/presentation/build.gradle.kts
#	feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt
#	feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (5)
feature/tweaks/presentation/build.gradle.kts (1)

19-19: Change api() to implementation() for Ktor dependency.

The testHttpClient: HttpClient dependency in MirrorPickerViewModel.kt is private and used only internally. Since no public declarations in this module expose Ktor types, the api(libs.ktor.client.core) unnecessarily leaks Ktor to downstream modules. Switch to implementation(...) to limit transitive exposure.

Suggested change
-                api(libs.ktor.client.core)
+                implementation(libs.ktor.client.core)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@feature/tweaks/presentation/build.gradle.kts` at line 19, The buildscript
currently declares the Ktor client as api(libs.ktor.client.core) which
unnecessarily exposes Ktor types to downstream modules; change that to
implementation(libs.ktor.client.core). Locate the dependency declaration in
build.gradle.kts and replace api(...) with implementation(...); this aligns with
how the Ktor HttpClient is used privately in MirrorPickerViewModel.kt
(testHttpClient: HttpClient) and prevents leaking Ktor types transitively.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt (1)

63-64: 💤 Low value

Non-atomic check-then-act on mirrorCollectorJob.

The idempotency check mirrorCollectorJob?.isActive == true followed by assignment creates a race window. If startMirrorCollector is called concurrently, both calls could pass the check and launch duplicate collectors.

Given the relevant code snippet shows this is called once during DI initialization (SharedModule.kt:143-157), this is likely safe in practice, but consider using a Mutex or synchronized for defensive correctness.

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

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt`
around lines 63 - 64, The idempotency check in startMirrorCollector on the
mirrorCollectorJob field is non-atomic and can race; wrap the check-and-assign
in a mutual-exclusion block (e.g., add a Mutex like mirrorCollectorMutex and
perform the if (mirrorCollectorJob?.isActive == true) return and subsequent
mirrorCollectorJob = ... inside mirrorCollectorMutex.withLock { ... } or use a
synchronized(this) block) so only one coroutine/thread can observe-and-create
the job; update the startMirrorCollector method to acquire the mutex before
checking mirrorCollectorJob and releasing it after assignment to prevent
duplicate collectors.
feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt (2)

16-16: ⚖️ Poor tradeoff

Presentation layer imports data layer class.

MirrorRewriter is imported from core.data.network, which violates clean architecture's dependency rule (presentation should only depend on domain). Consider moving MirrorRewriter to domain or exposing a use case that encapsulates the template application logic.

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

In
`@feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt`
at line 16, MirrorPickerViewModel imports the data-layer class MirrorRewriter,
violating the presentation→domain dependency rule; remove the direct dependency
by either (A) moving MirrorRewriter (or its template-application logic) into the
domain module as a domain service, or (B) create a domain use-case (e.g.,
ApplyMirrorTemplateUseCase) that exposes a method used by MirrorPickerViewModel;
update MirrorPickerViewModel to depend only on the new domain use-case or domain
service (referencing MirrorPickerViewModel and MirrorRewriter by name) and
adjust DI/constructors to inject the domain abstraction instead of the
data-layer class.

139-145: ⚡ Quick win

refresh() silently swallows errors from refreshCatalog().

If mirrorRepository.refreshCatalog() throws, isRefreshing remains true and the user sees no feedback. Consider wrapping in try-catch to reset state and optionally emit an error event.

♻️ Suggested fix
     private fun refresh() {
         viewModelScope.launch {
             _state.update { it.copy(isRefreshing = true) }
-            mirrorRepository.refreshCatalog()
-            _state.update { it.copy(isRefreshing = false) }
+            try {
+                mirrorRepository.refreshCatalog()
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Exception) {
+                _events.send(MirrorPickerEvent.RefreshFailed(e.message ?: "Unknown error"))
+            } finally {
+                _state.update { it.copy(isRefreshing = false) }
+            }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt`
around lines 139 - 145, The refresh() coroutine currently calls
mirrorRepository.refreshCatalog() without error handling so exceptions can leave
_state.isRefreshing stuck true and provide no user feedback; wrap the call
inside a try-catch inside viewModelScope.launch, set _state.update {
it.copy(isRefreshing = false) } in a finally block to ensure the flag is
cleared, and in the catch block emit an error event or update state with an
error message (via the same _state or an event channel) so callers of
MirrorPickerViewModel see the failure; reference refresh(),
viewModelScope.launch, mirrorRepository.refreshCatalog(), _state.update and
ensure the finally always clears isRefreshing.
core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/DesktopDigestVerifier.kt (1)

23-26: 💤 Low value

Prefer idiomatic EOF check < 0 instead of <= 0.

While functionally equivalent here (buffer size is non-zero), the standard idiom for InputStream.read() EOF detection is read < 0 or read == -1. This improves readability and aligns with Java conventions.

♻️ Suggested fix
                         while (true) {
                             val read = stream.read(buf)
-                            if (read <= 0) break
+                            if (read < 0) break
                             digest.update(buf, 0, read)
                         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/DesktopDigestVerifier.kt`
around lines 23 - 26, Update the EOF check in DesktopDigestVerifier's read loop
to use the idiomatic InputStream EOF detection (read < 0 or read == -1) instead
of using `read <= 0`; locate the loop where `val read = stream.read(buf)` and
replace the `if (read <= 0) break` check with `if (read < 0) break` to follow
Java/Kotlin conventions and improve readability.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt`:
- Around line 312-314: The current navigation calls to
navController.navigate(GithubStoreGraph.MirrorPickerScreen) can push duplicate
MirrorPickerScreen entries; update both call sites (the one using
onNavigateToMirrorPicker around the navController.navigate call and the second
occurrence mentioned at lines 414-417) to pass launchSingleTop = true in the
navigate options so repeated taps will not create duplicate destinations in the
back stack; locate the two
navController.navigate(GithubStoreGraph.MirrorPickerScreen) invocations and add
the launchSingleTop flag in their navigation options.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt`:
- Around line 41-56: The flow builder in MultiSourceDownloaderImpl launches
child coroutines (e.g., the directJob launched around downloader.download and
using emit(progress)) which violates Flow's context preservation; replace the
outer flow { ... } with channelFlow { ... } and inside the launched coroutines
use send(progress) instead of emit(progress), keep the winnerSignal checks and
the same exception handling logic (e.g., in the directJob/cachedJob catch blocks
rethrow only if winnerSignal reports that source) and add the
kotlinx.coroutines.flow.channelFlow import; this ensures concurrent emissions
from the child coroutines (directJob, cachedJob) are safe and preserves the
original winnerSignal behavior.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt`:
- Around line 44-59: The mutex.withLock in SlowDownloadDetectorImpl currently
holds the lock while potentially calling recordSlowEvent which does I/O
(preferences.data.first()), causing contention; change the critical section to
only update and trim the in-memory samples and compute the decision variables
(e.g., first, last, elapsedSec, deltaBytes, bytesPerSec, windowFull) inside
mutex.withLock, then capture a simple boolean or necessary snapshot (e.g., now
and decision flag) and release the lock; after the lock, if the captured flag
indicates a slow event, call recordSlowEvent(now) (and any preference/proxy
checks) outside the lock so preference.data.first() is not invoked while holding
the mutex.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt`:
- Around line 64-70: Before overwriting _catalog, capture its current value into
a local variable (e.g., val previousCatalog = _catalog.value) and use
previousCatalog when resolving removed-mirror display names (instead of using
configs/new value); then assign _catalog.value = configs and proceed to
preferences update and checkSelectedMirrorStillExists(configs). Make the same
change at the other update site that sets _catalog (the block that currently
updates and immediately handles removed mirrors around
checkSelectedMirrorStillExists) so removed-mirror handling can look up names
from previousCatalog rather than the new configs.
- Around line 64-65: MirrorRepositoryImpl currently persists remote mirror
entries without rejecting reserved sentinel IDs ("direct", "__custom__"), which
can collide with observePreference's control-flow decoding; update the code so
that when building configs from response.mirrors (used to set _catalog.value)
you first map each entry via toDomain() and then filter out any with id in the
reserved set, and apply the same filtering when decoding/consuming mirror IDs
inside observePreference so preference decoding ignores any remote entries whose
id equals "direct" or "__custom__"; reference MirrorRepositoryImpl, the _catalog
assignment, and the observePreference logic when making these changes.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt`:
- Line 103: The mirror-rewrite interceptor enabled by installMirrorRewrite() can
rewrite request URLs to non-GitHub hosts but the client still attaches
Authorization; update the interceptor implementation used by HttpClientFactory
so it detects when the request host changes during rewrite (compare
originalUrl.host vs rewrittenUrl.host in the installMirrorRewrite interceptor)
and remove the Authorization header (or clear
call.request.headers[HttpHeaders.Authorization]) before proceeding if the hosts
differ; ensure this logic runs immediately after URL rewrite and before the
request is dispatched so tokens are never sent to rewritten/non-GitHub hosts.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt`:
- Line 3: The import of java.util.concurrent.atomic.AtomicReference in
ProxyManager is JVM-only; replace it with a KMP-safe atomic from
kotlinx.atomicfu: remove the java import, add kotlinx-atomicfu as a dependency,
and change the AtomicReference<T> usage in ProxyManager (the atomic reference
variable(s) and any compareAndSet/get/set calls) to use atomicfu's
atomic(initial) and .value (or atomicfu's compareAndSet) APIs so the logic
(reads, writes, CAS) behaves the same across targets; alternatively, if you
prefer no library, make the field `@Volatile` and implement a small synchronized
wrapper around get/set/CAS in the ProxyManager class.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt`:
- Line 261: In DefaultDownloadOrchestrator (the runCatching call that currently
uses java.io.File(filePath).delete()), replace the JVM-only java.io.File usage
with a KMP-safe abstraction: either call an injected platform
FileDeleter/FileSystem service or an expect/actual function (e.g., expect fun
deleteFile(path: String): Boolean) and call that inside runCatching, or use
okio.FileSystem if available; update the constructor or class to accept the
injected file utility and replace references to java.io.File(filePath).delete()
with the platform-agnostic deleteFile/fileSystem.delete call so compilation
succeeds on non-JVM targets.

In `@core/presentation/src/commonMain/composeResources/values/strings.xml`:
- Line 868: The current mirror_picker_description string incorrectly states the
mirror proxies GitHub API calls; update the string (mirror_picker_description)
to accurately describe that the mirror only rewrites download hosts for release
assets and explicitly does not proxy GitHub API calls (i.e., it affects
downloads only, not API requests). Make the wording concise and user-facing,
e.g. explain it's used for downloading release assets and that API calls remain
direct to GitHub.

In `@docs/superpowers/specs/2026-04-29-e5-mirror-system-design.md`:
- Line 41: There are four unlabeled fenced code blocks in the spec that trigger
markdownlint MD040; locate each triple-backtick block used for examples and add
an appropriate language identifier (e.g., text, kotlin) immediately after the
opening ``` so each block becomes ```text or ```kotlin, ensuring all unlabeled
fenced blocks are tagged to satisfy MD040.

In
`@feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/AutoSuggestMirrorViewModel.kt`:
- Around line 11-47: Refactor AutoSuggestMirrorViewModel to follow the
State/Action/Event pattern: replace the raw _isVisible/isVisible with a single
MutableStateFlow<State> where State is a data class (e.g., AutoSuggestState(val
isVisible: Boolean)), add a sealed interface Action (e.g., MaybeLater,
DontAskAgain, PickOneClicked, Dismiss) and expose a public handleAction(action:
Action) method that performs what the current public methods do (call
mirrorRepository.snoozeAutoSuggest or dismissAutoSuggestPermanently and update
state), and add a Channel<Event> (or MutableSharedFlow) for one-off outcomes
(e.g., ShowSnoozedConfirmation, ShowPickedMirror) which you emit instead of
returning via methods; also wire the detector.suggestMirror collector to update
the State.isVisible inside viewModelScope.launch rather than setting _isVisible
directly. Ensure to keep existing calls to mirrorRepository.snoozeAutoSuggest
and dismissAutoSuggestPermanently inside actions handling.

In
`@feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt`:
- Around line 98-101: In MirrorPickerRoot update the Icon call that currently
sets contentDescription = null (Icons.AutoMirrored.Filled.ArrowBack) so it
provides an accessible, localized label; replace the null with a call to
stringResource (e.g., stringResource(R.string.back) or a suitable key like
R.string.navigate_back) and add the matching string resource entry if missing so
the back button is announced to assistive technologies.

---

Nitpick comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt`:
- Around line 63-64: The idempotency check in startMirrorCollector on the
mirrorCollectorJob field is non-atomic and can race; wrap the check-and-assign
in a mutual-exclusion block (e.g., add a Mutex like mirrorCollectorMutex and
perform the if (mirrorCollectorJob?.isActive == true) return and subsequent
mirrorCollectorJob = ... inside mirrorCollectorMutex.withLock { ... } or use a
synchronized(this) block) so only one coroutine/thread can observe-and-create
the job; update the startMirrorCollector method to acquire the mutex before
checking mirrorCollectorJob and releasing it after assignment to prevent
duplicate collectors.

In
`@core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/DesktopDigestVerifier.kt`:
- Around line 23-26: Update the EOF check in DesktopDigestVerifier's read loop
to use the idiomatic InputStream EOF detection (read < 0 or read == -1) instead
of using `read <= 0`; locate the loop where `val read = stream.read(buf)` and
replace the `if (read <= 0) break` check with `if (read < 0) break` to follow
Java/Kotlin conventions and improve readability.

In `@feature/tweaks/presentation/build.gradle.kts`:
- Line 19: The buildscript currently declares the Ktor client as
api(libs.ktor.client.core) which unnecessarily exposes Ktor types to downstream
modules; change that to implementation(libs.ktor.client.core). Locate the
dependency declaration in build.gradle.kts and replace api(...) with
implementation(...); this aligns with how the Ktor HttpClient is used privately
in MirrorPickerViewModel.kt (testHttpClient: HttpClient) and prevents leaking
Ktor types transitively.

In
`@feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt`:
- Line 16: MirrorPickerViewModel imports the data-layer class MirrorRewriter,
violating the presentation→domain dependency rule; remove the direct dependency
by either (A) moving MirrorRewriter (or its template-application logic) into the
domain module as a domain service, or (B) create a domain use-case (e.g.,
ApplyMirrorTemplateUseCase) that exposes a method used by MirrorPickerViewModel;
update MirrorPickerViewModel to depend only on the new domain use-case or domain
service (referencing MirrorPickerViewModel and MirrorRewriter by name) and
adjust DI/constructors to inject the domain abstraction instead of the
data-layer class.
- Around line 139-145: The refresh() coroutine currently calls
mirrorRepository.refreshCatalog() without error handling so exceptions can leave
_state.isRefreshing stuck true and provide no user feedback; wrap the call
inside a try-catch inside viewModelScope.launch, set _state.update {
it.copy(isRefreshing = false) } in a finally block to ensure the flag is
cleared, and in the catch block emit an error event or update state with an
error message (via the same _state or an event channel) so callers of
MirrorPickerViewModel see the failure; reference refresh(),
viewModelScope.launch, mirrorRepository.refreshCatalog(), _state.update and
ensure the finally always clears isRefreshing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dc126eb7-c15e-44a1-9596-e667d01a42bf

📥 Commits

Reviewing files that changed from the base of the PR and between be8fe60 and cd9901a.

📒 Files selected for processing (54)
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/AndroidDigestVerifier.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/AssetNetwork.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/MirrorListResponse.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/AssetNetwork.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/BundledMirrors.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorPersistence.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorApiClient.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriter.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/DesktopDigestVerifier.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubAsset.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorConfig.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorPreference.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorStatus.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorType.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/DigestVerifier.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/Downloader.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/SlowDownloadDetector.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/MirrorRepository.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/MultiSourceDownloader.kt
  • core/presentation/src/commonMain/composeResources/values/strings.xml
  • docs/superpowers/plans/2026-04-29-e5-mirror-system.md
  • docs/superpowers/specs/2026-04-29-e5-mirror-system-design.md
  • feature/tweaks/presentation/build.gradle.kts
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Network.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/AutoSuggestMirrorViewModel.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerAction.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerEvent.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerState.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/AutoSuggestMirrorSheet.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/CustomMirrorDialog.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/DeployYourOwnHint.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/MirrorRow.kt
  • feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/StatusDot.kt

Comment on lines +44 to +59
mutex.withLock {
val now = Clock.System.now().toEpochMilliseconds()
samples.addLast(now to progress.bytesDownloaded)
while (samples.isNotEmpty() && samples.first().first < now - sustainedMs) {
samples.removeFirst()
}
if (samples.size >= 2) {
val first = samples.first()
val last = samples.last()
val elapsedSec = (last.first - first.first).coerceAtLeast(1L) / 1000.0
val deltaBytes = (last.second - first.second).coerceAtLeast(0L)
val bytesPerSec = (deltaBytes / elapsedSec).toLong()
val windowFull = (last.first - first.first) >= sustainedMs - 500
if (windowFull && bytesPerSec < thresholdBytesPerSec) {
recordSlowEvent(now)
}
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 | 🟠 Major | ⚡ Quick win

Avoid suspending I/O while holding the hot-path mutex.

At Line 44, mutex.withLock wraps logic that can reach preferences.data.first() via recordSlowEvent (Lines 58-59), which serializes progress handling and can backlog onProgress jobs during fast updates. Keep the lock scope to in-memory queue math only, then perform preference/proxy checks outside the critical section.

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

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt`
around lines 44 - 59, The mutex.withLock in SlowDownloadDetectorImpl currently
holds the lock while potentially calling recordSlowEvent which does I/O
(preferences.data.first()), causing contention; change the critical section to
only update and trim the in-memory samples and compute the decision variables
(e.g., first, last, elapsedSec, deltaBytes, bytesPerSec, windowFull) inside
mutex.withLock, then capture a simple boolean or necessary snapshot (e.g., now
and decision flag) and release the lock; after the lock, if the captured flag
indicates a slow event, call recordSlowEvent(now) (and any preference/proxy
checks) outside the lock so preference.data.first() is not invoked while holding
the mutex.

Comment on lines +64 to +65
val configs = response.mirrors.map { it.toDomain() }
_catalog.value = configs
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 | 🟠 Major | ⚡ Quick win

Reject reserved sentinel IDs from remote mirror entries.

Backend mirror IDs are persisted directly (Line 64), but observePreference reserves special IDs (direct, __custom__) for control flow (Lines 75-83). If a remote entry collides with a sentinel, preference decoding becomes ambiguous and can switch behavior incorrectly.

Also applies to: 75-83

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

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt`
around lines 64 - 65, MirrorRepositoryImpl currently persists remote mirror
entries without rejecting reserved sentinel IDs ("direct", "__custom__"), which
can collide with observePreference's control-flow decoding; update the code so
that when building configs from response.mirrors (used to set _catalog.value)
you first map each entry via toDomain() and then filter out any with id in the
reserved set, and apply the same filtering when decoding/consuming mirror IDs
inside observePreference so preference decoding ignores any remote entries whose
id equals "direct" or "__custom__"; reference MirrorRepositoryImpl, the _catalog
assignment, and the observePreference logic when making these changes.

val mismatch = digestVerifier.verify(filePath, expectedDigest)
if (mismatch != null) {
Logger.w { "Orchestrator: digest mismatch for ${spec.asset.name}: $mismatch" }
runCatching { java.io.File(filePath).delete() }
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 | 🟠 Major | ⚡ Quick win

Platform-specific java.io.File used in commonMain code.

java.io.File is JVM-specific and will fail compilation on non-JVM targets (iOS, JS, etc.). Since this is in commonMain, use an expect/actual pattern or a KMP-compatible file deletion abstraction.

🔧 Suggested approach

Either inject a platform-agnostic file utility or use expect/actual:

-                runCatching { java.io.File(filePath).delete() }
+                runCatching { fileSystem.delete(filePath) }

Or use okio.FileSystem which is KMP-compatible if already a dependency.

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

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt`
at line 261, In DefaultDownloadOrchestrator (the runCatching call that currently
uses java.io.File(filePath).delete()), replace the JVM-only java.io.File usage
with a KMP-safe abstraction: either call an injected platform
FileDeleter/FileSystem service or an expect/actual function (e.g., expect fun
deleteFile(path: String): Boolean) and call that inside runCatching, or use
okio.FileSystem if available; update the constructor or class to accept the
injected file utility and replace references to java.io.File(filePath).delete()
with the platform-agnostic deleteFile/fileSystem.delete call so compilation
succeeds on non-JVM targets.

Comment thread core/presentation/src/commonMain/composeResources/values/strings.xml Outdated

**New files:**

```
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 language identifiers to fenced code blocks (MD040).

Lines 41, 90, 216, and 355 use unlabeled fenced blocks. Please tag them (for example text or kotlin) to satisfy markdownlint and keep docs CI clean.

Also applies to: 90-90, 216-216, 355-355

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 41-41: 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 `@docs/superpowers/specs/2026-04-29-e5-mirror-system-design.md` at line 41,
There are four unlabeled fenced code blocks in the spec that trigger
markdownlint MD040; locate each triple-backtick block used for examples and add
an appropriate language identifier (e.g., text, kotlin) immediately after the
opening ``` so each block becomes ```text or ```kotlin, ensuring all unlabeled
fenced blocks are tagged to satisfy MD040.

Comment on lines +11 to +47
class AutoSuggestMirrorViewModel(
private val detector: SlowDownloadDetector,
private val mirrorRepository: MirrorRepository,
) : ViewModel() {
private val _isVisible = MutableStateFlow(false)
val isVisible = _isVisible.asStateFlow()

init {
viewModelScope.launch {
detector.suggestMirror.collect {
_isVisible.value = true
}
}
}

fun onMaybeLater() {
_isVisible.value = false
viewModelScope.launch {
mirrorRepository.snoozeAutoSuggest(24L * 60 * 60 * 1000)
}
}

fun onDontAskAgain() {
_isVisible.value = false
viewModelScope.launch {
mirrorRepository.dismissAutoSuggestPermanently()
}
}

fun onPickOneClicked() {
_isVisible.value = false
}

fun dismiss() {
_isVisible.value = false
}
}
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.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Align this ViewModel with the required State/Action/Event contract.

This ViewModel uses direct public methods instead of the project-standard Action/Event flow pattern. Please expose a state model, handle input via a sealed action type, and emit one-off outcomes through an event channel.

♻️ Refactor sketch
+data class AutoSuggestMirrorState(
+    val isVisible: Boolean = false,
+)
+
+sealed interface AutoSuggestMirrorAction {
+    data object MaybeLater : AutoSuggestMirrorAction
+    data object DontAskAgain : AutoSuggestMirrorAction
+    data object PickOne : AutoSuggestMirrorAction
+    data object Dismiss : AutoSuggestMirrorAction
+}
+
+sealed interface AutoSuggestMirrorEvent {
+    data object NavigateToMirrorPicker : AutoSuggestMirrorEvent
+}

As per coding guidelines, "Implement State/Action/Event pattern for all ViewModels: state in MutableStateFlow, actions as sealed interface, events in Channel".

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

In
`@feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/AutoSuggestMirrorViewModel.kt`
around lines 11 - 47, Refactor AutoSuggestMirrorViewModel to follow the
State/Action/Event pattern: replace the raw _isVisible/isVisible with a single
MutableStateFlow<State> where State is a data class (e.g., AutoSuggestState(val
isVisible: Boolean)), add a sealed interface Action (e.g., MaybeLater,
DontAskAgain, PickOneClicked, Dismiss) and expose a public handleAction(action:
Action) method that performs what the current public methods do (call
mirrorRepository.snoozeAutoSuggest or dismissAutoSuggestPermanently and update
state), and add a Channel<Event> (or MutableSharedFlow) for one-off outcomes
(e.g., ShowSnoozedConfirmation, ShowPickedMirror) which you emit instead of
returning via methods; also wire the detector.suggestMirror collector to update
the State.isVisible inside viewModelScope.launch rather than setting _isVisible
directly. Ensure to keep existing calls to mirrorRepository.snoozeAutoSuggest
and dismissAutoSuggestPermanently inside actions handling.

Comment on lines +98 to +101
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
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 | 🟠 Major | ⚡ Quick win

Provide an accessible label for the back button icon.

At Line 100, contentDescription = null leaves the navigation action unlabeled for assistive tech. Add a localized back/navigation description.

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

In
`@feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt`
around lines 98 - 101, In MirrorPickerRoot update the Icon call that currently
sets contentDescription = null (Icons.AutoMirrored.Filled.ArrowBack) so it
provides an accessible, localized label; replace the null with a call to
stringResource (e.g., stringResource(R.string.back) or a suitable key like
R.string.navigate_back) and add the matching string resource entry if missing so
the back button is announced to assistive technologies.

…ources

Standardizes string syntax across `strings.xml` and localized versions for Turkish, French, and Italian. Replaces escaped single quotes (`\'`) with unescaped apostrophes (`'`) in various UI strings, including language picker descriptions, Personal Access Token instructions, and telemetry settings. This is a syntax-only cleanup that does not change the translated text.
@rainxchzed rainxchzed merged commit 83acf68 into main Apr 30, 2026
1 check was pending
@rainxchzed rainxchzed deleted the feat/e5-mirror-system branch April 30, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant