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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -20,11 +21,16 @@ class MultiSourceDownloaderImpl(
githubUrl: String,
suggestedFileName: String?,
): Flow<DownloadProgress> {
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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> = 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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrafficKind>,
)

object ProxyManager {
private val flows: Map<ProxyScope, MutableStateFlow<ProxyConfig>> =
ProxyScope.entries.associateWith { MutableStateFlow<ProxyConfig>(ProxyConfig.System) }

private val mirrorTemplate = AtomicReference<String?>(null)
private val mirror = AtomicReference<MirrorActive?>(null)
private var mirrorCollectorJob: Job? = null

fun configFlow(scope: ProxyScope): StateFlow<ProxyConfig> = flows.getValue(scope).asStateFlow()
Expand All @@ -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,
Expand All @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ data class MirrorConfig(
val status: MirrorStatus,
val latencyMs: Int?,
val lastCheckedAt: Instant?,
val trafficKinds: Set<TrafficKind> = setOf(TrafficKind.RELEASE_ASSET, TrafficKind.RAW_FILE),
)
Original file line number Diff line number Diff line change
@@ -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
}
}
}