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
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ jobs:
run: cd backend && npm install
- name: Verificar sintaxis
run: node --check backend/server.js && echo "Sintaxis OK"
- name: Verificar sintaxis modulos
run: |
for f in backend/modules/extensions/*.js; do
node --check "$f" && echo "OK $f"
done
- name: Verificar archivos
run: |
test -f backend/server.js && echo "OK server.js"
Expand Down
50 changes: 50 additions & 0 deletions .github/workflows/extensions-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Extensions module (rama)

# Verificación del módulo backend de extensiones de Echo
# (backend/modules/extensions). Node viene preinstalado en los runners de
# GitHub, así que no hace falta setup-node.
#
# Complementa a deploy.yml (que solo corre en main/PR): aquí se valida en
# ramas de trabajo para no tocar main sin comprobar antes la sintaxis.
on:
push:
branches:
- 'feat/**'
- 'echo/**'
- main
paths:
- 'backend/modules/extensions/**'
- '.github/workflows/extensions-check.yml'
pull_request:
paths:
- 'backend/modules/extensions/**'
- '.github/workflows/extensions-check.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
check:
name: Verificar módulo de extensiones (JS)
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Sintaxis (node --check)
run: |
node -v
for f in backend/modules/extensions/*.js; do
node --check "$f" && echo "OK $f"
done

- name: Carga y exports (catalog.js no depende de npm)
run: |
node -e "const c=require('./backend/modules/extensions/catalog.js'); const need=['loadCatalog','toPublic','resolveAsset','hasUpdate','fetchBinary','baseName']; for (const k of need) { if (typeof c[k] !== 'function') throw new Error('falta export: ' + k); } console.log('catalog.js OK:', Object.keys(c).join(', '));"

- name: Carga y exports (config.js)
run: |
node -e "const c=require('./backend/modules/extensions/config.js'); const need=['SOURCE_URL','BACKEND_URL','CACHE_TTL_MS','ASSET_TTL_MS','MAX_BYTES','FETCH_TIMEOUT_MS']; for (const k of need) { if (c[k] === undefined) throw new Error('falta config: ' + k); } console.log('config.js OK:', c.BACKEND_URL);"
8 changes: 8 additions & 0 deletions apk/android/echo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization")
}

// Backend público de CodeHub. Se usa para el catálogo privado de extensiones:
// el dispositivo solo habla con CodeHub, nunca con el origen real.
// Se puede sobreescribir con -PCODEHUB_BACKEND=... o en gradle.properties.
val codehubBackend: String =
(findProperty("CODEHUB_BACKEND") as String?)?.trim()?.takeIf { it.isNotEmpty() }
?: "https://codehub-98s6.onrender.com"

android {
namespace = "dev.brahmkshatriya.echo"
compileSdk = 36
Expand All @@ -24,6 +31,7 @@ android {
// ContextUtils.appVersion() y en la versión de las extensiones built-in.
buildConfigField("String", "VERSION_NAME", "\"7.18\"")
buildConfigField("int", "VERSION_CODE", "10")
buildConfigField("String", "CODEHUB_BACKEND", "\"$codehubBackend\"")
}

buildFeatures {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package dev.brahmkshatriya.echo.extensions

import dev.brahmkshatriya.echo.BuildConfig
import dev.brahmkshatriya.echo.common.helpers.ContinuationCallback.Companion.await
import dev.brahmkshatriya.echo.utils.Serializer.toData
import kotlinx.serialization.Serializable
import okhttp3.OkHttpClient
import okhttp3.Request

/**
* Catálogo privado de extensiones alojado por CodeHub.
*
* El dispositivo SOLO habla con el backend de CodeHub: es el servidor quien
* resuelve y reenvía el `.eapk` real, así que la URL/repositorio de origen
* nunca viaja al cliente. Aquí no se guarda ni se muestra ese enlace.
*/
object CodeHubExtensions {

/** Ruta del proxy de descarga que sirve el backend. */
const val PKG_PATH = "/api/extensions/pkg/"

private const val TTL_MS = 30 * 60 * 1000L

val backend: String = BuildConfig.CODEHUB_BACKEND.trimEnd('/')

/** Lista de extensiones disponibles (el mismo JSON que ya consume Echo). */
fun catalogUrl(): String = "$backend/api/extensions"

/** ¿Esta URL la sirve el backend de CodeHub? */
fun isProxyUrl(url: String): Boolean = url.contains(PKG_PATH)

@Serializable
data class Entry(
val id: String,
val name: String,
val updateUrl: String,
)

@Volatile private var cache: Map<String, String>? = null
@Volatile private var cacheAt: Long = 0L

/** Mapa `id -> updateUrl` (proxy), cacheado en memoria. */
suspend fun catalog(client: OkHttpClient, force: Boolean = false): Map<String, String> {
val now = System.currentTimeMillis()
val current = cache
if (!force && current != null && now - cacheAt < TTL_MS) return current
val fresh = runCatching {
client.newCall(Request.Builder().url(catalogUrl()).build())
.await().body.string()
.toData<List<Entry>>().getOrThrow()
.associate { it.id to it.updateUrl }
}.getOrNull() ?: return current ?: emptyMap()
cache = fresh
cacheAt = now
return fresh
}

/** URL proxy del `id`, o `null` si la extensión no está en el catálogo. */
suspend fun updateUrlFor(id: String, client: OkHttpClient): String? = catalog(client)[id]
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import dev.brahmkshatriya.echo.di.App
import dev.brahmkshatriya.echo.extensions.ExtensionLoader
import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow
import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getOrThrow
import dev.brahmkshatriya.echo.extensions.CodeHubExtensions
import dev.brahmkshatriya.echo.extensions.InstallationUtils.installApp
import dev.brahmkshatriya.echo.extensions.InstallationUtils.installFile
import dev.brahmkshatriya.echo.extensions.InstallationUtils.uninstallApp
Expand Down Expand Up @@ -212,7 +213,10 @@ class ExtensionsViewModel(
show: Boolean = false
): File? {
val currentVersion = extension.version
val updateUrl = extension.metadata.updateUrl ?: return null
// Catálogo privado de CodeHub primero (el origen nunca se expone);
// si la extensión no está en él, se mantiene la URL de su manifiesto.
val updateUrl = CodeHubExtensions.updateUrlFor(extension.id, client)
?: extension.metadata.updateUrl ?: return null
val url = runCatching {
getUpdateFileUrl(currentVersion, updateUrl, client).getOrThrow()
}.recoverCatching {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dev.brahmkshatriya.echo.R
import dev.brahmkshatriya.echo.databinding.DialogExtensionAddBinding
import dev.brahmkshatriya.echo.extensions.CodeHubExtensions
import dev.brahmkshatriya.echo.extensions.InstallationUtils.openFileSelector
import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack
import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel
Expand Down Expand Up @@ -58,6 +59,12 @@ class ExtensionsAddBottomSheet : BottomSheetDialogFragment() {
}
}

// Catálogo privado de CodeHub: se carga solo al abrir, sin que el
// usuario pegue ningún enlace y sin mostrar nunca la URL de origen.
if (!viewModel.opened && viewModel.addingFlow.value is AddViewModel.AddState.Init) {
viewModel.addFromLinkOrCode(CodeHubExtensions.catalogUrl())
}

observe(viewModel.addingFlow) {
val loading = it != AddViewModel.AddState.Init
binding.loading.root.isVisible = loading
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okio.use
import java.io.File
import java.net.URLEncoder
import java.util.zip.ZipFile

object AppUpdater {
Expand Down Expand Up @@ -174,10 +175,36 @@ object AppUpdater {
client: OkHttpClient
) = runIOCatching {
if (updateUrl.isEmpty()) return@runIOCatching null
if (updateUrl.startsWith("https://api.github.com/repos/")) {
getGithubUpdateUrl(currentVersion, updateUrl, client)
} else {
throw Exception("Unsupported update url")
when {
updateUrl.startsWith("https://api.github.com/repos/") ->
getGithubUpdateUrl(currentVersion, updateUrl, client)

// Catálogo privado de CodeHub: el backend resuelve el origen y
// decide si hay actualización. El enlace real nunca llega aquí.
updateUrl.contains("/api/extensions/pkg/") ->
getCodeHubUpdateUrl(currentVersion, updateUrl, client)

else -> throw Exception("Unsupported update url")
}
}

private suspend fun getCodeHubUpdateUrl(
currentVersion: String,
updateUrl: String,
client: OkHttpClient
): String? {
val check = updateUrl + "?check=1&from=" +
URLEncoder.encode(currentVersion, "UTF-8")
val response = client.newCall(Request.Builder().url(check).build()).await()
return try {
when (response.code) {
200 -> updateUrl
204 -> null
404 -> throw Exception("Extensión no encontrada en el catálogo")
else -> throw Exception("CodeHub extensions: HTTP ${response.code}")
}
} finally {
response.close()
}
}

Expand Down
Loading
Loading