diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt index 5b68b3f4a..c991c32b6 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.launch import zed.rainxch.core.data.network.MirrorRewriter import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.domain.model.DownloadProgress +import zed.rainxch.core.domain.model.TrafficKind import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.system.MultiSourceDownloader @@ -20,11 +21,16 @@ class MultiSourceDownloaderImpl( githubUrl: String, suggestedFileName: String?, ): Flow { - val template = ProxyManager.currentMirrorTemplate() - if (template == null) { + val active = ProxyManager.currentMirror() + // The multi-source race targets release-asset downloads. A mirror that + // doesn't list RELEASE_ASSET (e.g. jsDelivr, raw-files only) can't + // serve these URLs — fall through to a Direct download. + if (active == null || TrafficKind.RELEASE_ASSET !in active.trafficKinds) { return downloader.download(githubUrl, suggestedFileName) } - val mirrorUrl = MirrorRewriter.applyTemplate(template, githubUrl) + val mirrorUrl = + MirrorRewriter.applyTemplate(active.template, githubUrl) + ?: return downloader.download(githubUrl, suggestedFileName) return raceDownloads(githubUrl, mirrorUrl, suggestedFileName) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt index b800b4731..e55a7846b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt @@ -15,6 +15,7 @@ import kotlin.time.Clock import zed.rainxch.core.data.mirror.MirrorPersistence import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.domain.model.DownloadProgress +import zed.rainxch.core.domain.model.TrafficKind import zed.rainxch.core.domain.network.SlowDownloadDetector class SlowDownloadDetectorImpl( @@ -69,7 +70,13 @@ class SlowDownloadDetectorImpl( } if (recentSlowEvents.size < triggerCount) return - if (ProxyManager.currentMirrorTemplate() != null) return + // A mirror that already handles release-asset traffic means the user + // is on a slow path despite mirroring — don't double-nag them with the + // pick-a-mirror suggestion. A raw-file-only mirror (e.g. jsDelivr) is + // bypassed for release downloads, so they're effectively Direct and + // still benefit from a release-capable mirror suggestion. + val active = ProxyManager.currentMirror() + if (active != null && TrafficKind.RELEASE_ASSET in active.trafficKinds) return val prefs = preferences.data.first() if (prefs[MirrorPersistence.AUTO_SUGGEST_DISMISSED_KEY] == true) return val snoozeUntil = prefs[MirrorPersistence.AUTO_SUGGEST_SNOOZE_UNTIL_KEY] ?: 0L diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/MirrorListResponse.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/MirrorListResponse.kt index 0ad3311c6..5b5979701 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/MirrorListResponse.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/MirrorListResponse.kt @@ -18,4 +18,5 @@ data class MirrorEntry( @SerialName("status") val status: String, @SerialName("latency_ms") val latencyMs: Int? = null, @SerialName("last_checked_at") val lastCheckedAt: String? = null, + @SerialName("traffic_kinds") val trafficKinds: List? = null, ) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt index 0c609443d..e875c39e7 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt @@ -23,6 +23,7 @@ import zed.rainxch.core.domain.model.MirrorConfig import zed.rainxch.core.domain.model.MirrorPreference import zed.rainxch.core.domain.model.MirrorStatus import zed.rainxch.core.domain.model.MirrorType +import zed.rainxch.core.domain.model.TrafficKind import zed.rainxch.core.domain.repository.MirrorRemoved import zed.rainxch.core.domain.repository.MirrorRepository @@ -162,5 +163,14 @@ class MirrorRepositoryImpl( }, latencyMs = latencyMs, lastCheckedAt = lastCheckedAt?.let { runCatching { Instant.parse(it) }.getOrNull() }, + // Pre-1.8.3 backend responses don't ship this field. Default keeps the + // legacy assumption that every mirror handles both traffic kinds, so + // older entries stay routable as before. + trafficKinds = + trafficKinds + ?.mapNotNull { TrafficKind.fromWire(it) } + ?.toSet() + ?.ifEmpty { null } + ?: setOf(TrafficKind.RELEASE_ASSET, TrafficKind.RAW_FILE), ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt index 6ad1eb635..ffa8f2011 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt @@ -8,36 +8,28 @@ import io.ktor.http.Url import io.ktor.http.takeFrom import io.ktor.util.AttributeKey -/** - * Marks a request to bypass [installMirrorRewrite] — used by the - * direct branch of the multi-source download race in Task 11. - */ val NO_MIRROR_REWRITE: AttributeKey = AttributeKey("NoMirrorRewrite") -/** - * Installs the mirror-rewrite hook on a Ktor [HttpClient]. Call after - * the client is built but before any request is fired. - * - * The hook checks (in order): - * 1. `NO_MIRROR_REWRITE` attribute — bypass if true. - * 2. [MirrorRewriter.shouldRewrite] — only rewrite GitHub-owned hosts. - * 3. [ProxyManager.currentMirrorTemplate] — only rewrite when a - * non-Direct preference resolves to a non-null template. - */ fun HttpClient.installMirrorRewrite() { plugin(HttpSend).intercept { request -> if (!request.attributes.contains(NO_MIRROR_REWRITE)) { val original = request.url.buildString() if (MirrorRewriter.shouldRewrite(original)) { - val template = ProxyManager.currentMirrorTemplate() - if (template != null) { - val originalHost = request.url.host - val rewritten = Url(MirrorRewriter.applyTemplate(template, original)) - request.url.takeFrom(rewritten) - // Host changed — strip the user's GitHub bearer token so we - // never send it to a community mirror. - if (rewritten.host != originalHost) { - request.headers.remove(HttpHeaders.Authorization) + val active = ProxyManager.currentMirror() + val kind = MirrorRewriter.classify(original) + if (active != null && kind != null && kind in active.trafficKinds) { + val rewritten = + MirrorRewriter + .applyTemplate(active.template, original) + ?.let { runCatching { Url(it) }.getOrNull() } + if (rewritten != null) { + val originalHost = request.url.host + request.url.takeFrom(rewritten) + // Host changed — strip the user's GitHub bearer token so we + // never send it to a community mirror. + if (rewritten.host != originalHost) { + request.headers.remove(HttpHeaders.Authorization) + } } } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriter.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriter.kt index ee1830fb8..e0cf94d96 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriter.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriter.kt @@ -1,6 +1,7 @@ package zed.rainxch.core.data.network import io.ktor.http.Url +import zed.rainxch.core.domain.model.TrafficKind object MirrorRewriter { private val rewriteHosts = @@ -10,27 +11,94 @@ object MirrorRewriter { "objects.githubusercontent.com", ) - /** - * True iff the URL host is one of the GitHub-owned hosts that should - * be routed through a community mirror. `api.github.com` is intentionally - * excluded — community mirrors are built for binary downloads, not API - * calls, and routing API traffic through them returns 403. `api.github-store.org` - * (our backend) is also excluded. - */ fun shouldRewrite(url: String): Boolean = runCatching { val host = Url(url).host.lowercase() host in rewriteHosts }.getOrDefault(false) + fun classify(url: String): TrafficKind? = + runCatching { + val parsed = Url(url) + val host = parsed.host.lowercase() + val path = parsed.encodedPath + when { + host == "objects.githubusercontent.com" -> TrafficKind.RELEASE_ASSET + host == "github.com" && "/releases/download/" in path -> TrafficKind.RELEASE_ASSET + host == "raw.githubusercontent.com" -> TrafficKind.RAW_FILE + host == "github.com" && ("/raw/" in path || "/blob/" in path) -> TrafficKind.RAW_FILE + else -> null + } + }.getOrNull() + /** - * Substitutes the literal `{url}` in the template with the full - * GitHub URL. Caller is responsible for ensuring the template - * contains exactly one `{url}` placeholder; that validation happens - * at custom-mirror entry time. + * Two template styles supported: + * + * - `{url}` (whole-URL proxy): substituted with the full GitHub URL. + * Used by ghfast.top, gh-proxy.com, etc. + * + * - `{owner}/{repo}@{ref}/{path}` (path-decomposed): used by jsDelivr's + * `/gh/` endpoint and similar CDN-style mirrors. Parses the source + * GitHub URL to extract the four placeholders. Returns null if the + * source URL doesn't decompose cleanly. */ fun applyTemplate( template: String, githubUrl: String, - ): String = template.replace("{url}", githubUrl) + ): String? = + when { + "{url}" in template -> template.replace("{url}", githubUrl) + "{owner}" in template -> applyDecomposedTemplate(template, githubUrl) + else -> null + } + + private fun applyDecomposedTemplate( + template: String, + githubUrl: String, + ): String? { + val parts = decompose(githubUrl) ?: return null + return template + .replace("{owner}", parts.owner) + .replace("{repo}", parts.repo) + .replace("{ref}", parts.ref) + .replace("{path}", parts.path) + } + + private data class Decomposed( + val owner: String, + val repo: String, + val ref: String, + val path: String, + ) + + private fun decompose(githubUrl: String): Decomposed? { + val parsed = runCatching { Url(githubUrl) }.getOrNull() ?: return null + val host = parsed.host.lowercase() + val segments = parsed.encodedPath.trimStart('/').split('/').filter { it.isNotEmpty() } + return when (host) { + "raw.githubusercontent.com" -> { + // /{owner}/{repo}/{ref}/{path...} + if (segments.size < 4) return null + Decomposed( + owner = segments[0], + repo = segments[1], + ref = segments[2], + path = segments.drop(3).joinToString("/"), + ) + } + "github.com" -> { + // /{owner}/{repo}/raw/{ref}/{path...} or /{owner}/{repo}/blob/{ref}/{path...} + if (segments.size < 5) return null + val verb = segments[2] + if (verb != "raw" && verb != "blob") return null + Decomposed( + owner = segments[0], + repo = segments[1], + ref = segments[3], + path = segments.drop(4).joinToString("/"), + ) + } + else -> null + } + } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt index e8fad30cb..eab4d0f7f 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt @@ -12,20 +12,19 @@ import kotlinx.coroutines.launch import zed.rainxch.core.domain.model.MirrorPreference import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.ProxyScope +import zed.rainxch.core.domain.model.TrafficKind import zed.rainxch.core.domain.repository.MirrorRepository -/** - * Live in-memory cache of the three per-scope proxy configurations - * **and** the resolved mirror URL template. Writers (the proxy - * repository and the mirror collector) push updates here; consumers - * (HTTP clients, the MirrorRewriteInterceptor) read synchronously - * via [configFlow] / [currentMirrorTemplate]. - */ +data class MirrorActive( + val template: String, + val trafficKinds: Set, +) + object ProxyManager { private val flows: Map> = ProxyScope.entries.associateWith { MutableStateFlow(ProxyConfig.System) } - private val mirrorTemplate = AtomicReference(null) + private val mirror = AtomicReference(null) private var mirrorCollectorJob: Job? = null fun configFlow(scope: ProxyScope): StateFlow = flows.getValue(scope).asStateFlow() @@ -40,22 +39,19 @@ object ProxyManager { } /** - * Effective mirror template for the current preference, or null - * when Direct. Read by [zed.rainxch.core.data.network.MirrorRewriteInterceptor] - * on every outbound GitHub request — must be hot-path safe (atomic, no I/O). + * Resolved active mirror — template plus the traffic kinds it can serve. + * Read on every outbound GitHub request; must be hot-path safe (atomic, no I/O). + * Null when preference is Direct or the catalog has no template for the + * selected mirror. */ - fun currentMirrorTemplate(): String? = mirrorTemplate.get() + fun currentMirror(): MirrorActive? = mirror.get() /** - * Starts a long-lived collector that mirrors [MirrorRepository.observePreference] - * into the atomic snapshot used by [currentMirrorTemplate]. Idempotent — - * subsequent calls are no-ops as long as the previous job is alive. - * - * Looks up the catalog via [MirrorRepository.observeCatalog] to resolve - * `Selected(id)` → template string. If the catalog is empty (cold start - * before bundled fallback emits) the template stays null until the - * first emission lands. + * Convenience accessor for callers that only need the template string and + * don't gate by traffic kind. Prefer [currentMirror] for new code. */ + fun currentMirrorTemplate(): String? = mirror.get()?.template + fun startMirrorCollector( repository: MirrorRepository, scope: CoroutineScope, @@ -69,12 +65,23 @@ object ProxyManager { ) { pref, catalog -> when (pref) { MirrorPreference.Direct -> null - is MirrorPreference.Custom -> pref.template - is MirrorPreference.Selected -> - catalog.firstOrNull { it.id == pref.id }?.urlTemplate + is MirrorPreference.Custom -> + MirrorActive( + template = pref.template, + trafficKinds = setOf(TrafficKind.RELEASE_ASSET, TrafficKind.RAW_FILE), + ) + is MirrorPreference.Selected -> { + val cfg = catalog.firstOrNull { it.id == pref.id } + val template = cfg?.urlTemplate + if (cfg == null || template == null) { + null + } else { + MirrorActive(template = template, trafficKinds = cfg.trafficKinds) + } + } } - }.collect { template -> - mirrorTemplate.set(template) + }.collect { active -> + mirror.set(active) } } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorConfig.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorConfig.kt index 96b421437..ea5aa11bb 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorConfig.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorConfig.kt @@ -10,4 +10,5 @@ data class MirrorConfig( val status: MirrorStatus, val latencyMs: Int?, val lastCheckedAt: Instant?, + val trafficKinds: Set = setOf(TrafficKind.RELEASE_ASSET, TrafficKind.RAW_FILE), ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TrafficKind.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TrafficKind.kt new file mode 100644 index 000000000..2915d3de3 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TrafficKind.kt @@ -0,0 +1,16 @@ +package zed.rainxch.core.domain.model + +enum class TrafficKind { + RELEASE_ASSET, + RAW_FILE, + ; + + companion object { + fun fromWire(value: String): TrafficKind? = + when (value.lowercase()) { + "release_asset" -> RELEASE_ASSET + "raw_file" -> RAW_FILE + else -> null + } + } +}