Skip to content
Open
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
96 changes: 96 additions & 0 deletions src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-cli
*/

package app.morphe.engine

import app.morphe.patcher.patch.Patch
import app.morphe.patcher.patch.loadPatchesFromJar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import java.io.File
import java.util.logging.Logger

/**
* Loads patches from one or more `.mpp` files in parallel and tags each loaded
* Patch instance with the source it came from. The union [Result.allPatches]
* is what [PatchEngine.patch] expects; the per-source breakdown is for
* GUI/CLI consumers that want to badge or filter by origin.
*
* Lives in the engine package so the CLI can consume the same multi-source
* loading path when it adopts multi-source. GUI-specific aggregation (display
* name resolution, icon color picking) lives in the GUI layer.
*/
object MultiSourceLoader {

private val logger = Logger.getLogger(this::class.java.name)

data class SourceInput(
val sourceId: String,
val sourceName: String,
val patchFile: File,
)

data class LoadedSource(
val sourceId: String,
val sourceName: String,
val patches: Set<Patch<*>>,
val error: Throwable? = null,
) {
val isSuccess: Boolean get() = error == null
}

data class Result(
val perSource: List<LoadedSource>,
val allPatches: Set<Patch<*>>,
val patchToSourceId: Map<Patch<*>, String>,
) {
val hasErrors: Boolean get() = perSource.any { !it.isSuccess }
}

/**
* Load patches from each input in parallel. Each .mpp is copied to a temp file
* before loading to work around Windows URLClassLoader file-locking (mirrors
* the same workaround in PatchService.kt).
*/
suspend fun load(inputs: List<SourceInput>): Result = coroutineScope {
val loaded = inputs.map { input ->
async(Dispatchers.IO) { loadOne(input) }
}.awaitAll()

val allPatches = loaded.flatMap { it.patches }.toSet()
val patchToSourceId = loaded.flatMap { src ->
src.patches.map { it to src.sourceId }
}.toMap()

Result(perSource = loaded, allPatches = allPatches, patchToSourceId = patchToSourceId)
}

private suspend fun loadOne(input: SourceInput): LoadedSource = withContext(Dispatchers.IO) {
val tempCopy = File.createTempFile("morphe-mp-${input.sourceId}-", ".mpp")
try {
input.patchFile.copyTo(tempCopy, overwrite = true)
val patches = loadPatchesFromJar(setOf(tempCopy))
logger.info("MultiSourceLoader: loaded ${patches.size} patches from '${input.sourceName}'")
LoadedSource(
sourceId = input.sourceId,
sourceName = input.sourceName,
patches = patches,
)
} catch (e: Exception) {
logger.warning("MultiSourceLoader: failed to load '${input.sourceName}': ${e.message}")
LoadedSource(
sourceId = input.sourceId,
sourceName = input.sourceName,
patches = emptySet(),
error = e,
)
} finally {
tempCopy.deleteOnExit()
}
}
}
22 changes: 21 additions & 1 deletion src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,20 @@ val DEFAULT_PATCH_SOURCE = PatchSource(
data class AppConfig(
val themePreference: String = ThemePreference.SYSTEM.name,
val lastCliVersion: String? = null,
/**
* LEGACY single-source version pin. Kept only for one-version migration into
* [lastPatchesVersionBySource] — read it on first load if the map is empty,
* then phase out. Do not read this directly anywhere new — go through
* [ConfigRepository.getLastPatchesVersionsBySource].
*/
val lastPatchesVersion: String? = null,
/**
* Per-source version pin: sourceId → release tag. Absence of a key means
* "no pin — use that source's latest stable". Replaces the legacy single
* [lastPatchesVersion] which silently contaminated other sources whose tag
* names happened to overlap.
*/
val lastPatchesVersionBySource: Map<String, String> = emptyMap(),
val preferredPatchChannel: String = PatchChannel.STABLE.name,
val defaultOutputDirectory: String? = null,
val autoCleanupTempFiles: Boolean = true, // Default ON
Expand Down Expand Up @@ -57,6 +70,10 @@ data class AppConfig(
// user who swaps from a stable build to a dev build sees the right default.
// Once they pick one in Settings, this flips to true and we respect their choice.
val userDidChooseUpdateChannel: Boolean = false,
// One-shot dismissal flag for the "multiple sources are now active" hint shown
// after upgrading to multi-source builds. Flips to true once the user dismisses
// the banner, never resets.
val multiSourceHintDismissed: Boolean = false,
) {

fun getUpdateChannelPreference(): UpdateChannelPreference? {
Expand Down Expand Up @@ -91,7 +108,10 @@ data class PatchSource (
val type: PatchSourceType,
val url: String? = null, // For DEFAULT (morphe) and GITHUB (other source) type
val filePath: String? = null, // For local files
val deletable: Boolean = true
val deletable: Boolean = true,
// Multi-source enablement. Default true so old configs migrate to "all enabled"
// on first load (per user choice — see project memory).
val enabled: Boolean = true,
)

@Serializable
Expand Down
3 changes: 2 additions & 1 deletion src/main/kotlin/app/morphe/gui/data/model/Patch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ enum class PatchOptionType {
data class PatchConfig(
val inputApkPath: String,
val outputApkPath: String,
val patchesFilePath: String,
/** One or more .mpp file paths. Multiple = union of patches across sources. */
val patchesFilePaths: List<String>,
val enabledPatches: List<String> = emptyList(),
val disabledPatches: List<String> = emptyList(),
val patchOptions: Map<String, String> = emptyMap(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,43 @@ class ConfigRepository {
}

/**
* Update last used patches version.
* LEGACY — kept so single-source callers don't break during the multi-source
* transition. New code should use [setLastPatchesVersionForSource].
*/
@Deprecated("Use setLastPatchesVersionForSource", ReplaceWith("setLastPatchesVersionForSource(sourceId, version)"))
suspend fun setLastPatchesVersion(version: String) {
val current = loadConfig()
saveConfig(current.copy(lastPatchesVersion = version))
}

/**
* Pin a specific release tag for [sourceId]. Used by PatchesScreen when the
* user picks a version. Per-source = no cross-contamination across sources
* with overlapping tag names.
*/
suspend fun setLastPatchesVersionForSource(sourceId: String, version: String) {
val current = loadConfig()
val updated = current.lastPatchesVersionBySource + (sourceId to version)
saveConfig(current.copy(lastPatchesVersionBySource = updated))
}

/**
* Returns the per-source version pin map, with one-time migration from the
* legacy [AppConfig.lastPatchesVersion] field: if the map is empty and the
* legacy field is set, it's mapped to the default source.
*/
suspend fun getLastPatchesVersionsBySource(): Map<String, String> {
val current = loadConfig()
if (current.lastPatchesVersionBySource.isNotEmpty()) {
return current.lastPatchesVersionBySource
}
val legacy = current.lastPatchesVersion ?: return emptyMap()
// Migrate: write the legacy pin onto the default source, return the new map.
val migrated = mapOf(DEFAULT_PATCH_SOURCE.id to legacy)
saveConfig(current.copy(lastPatchesVersionBySource = migrated))
return migrated
}

/**
* Mark the given CLI version as dismissed for the update banner. Pass null to
* clear (so the banner reappears for whatever the next-available version is).
Expand Down Expand Up @@ -261,6 +291,44 @@ class ConfigRepository {
saveConfig(current.copy(patchSource = updatedSources))
}

/**
* Mark the multi-source upgrade hint as dismissed. One-shot — never resets.
*/
suspend fun setMultiSourceHintDismissed() {
val current = loadConfig()
if (current.multiSourceHintDismissed) return
saveConfig(current.copy(multiSourceHintDismissed = true))
}

/**
* Toggle enablement of a patch source. Safety net: if disabling would leave zero
* enabled sources, the default source is force-enabled (mirrors morphe-manager
* SourceManagementSheet.kt:142-149 LaunchedEffect).
*/
suspend fun setPatchSourceEnabled(id: String, enabled: Boolean) {
val current = loadConfig()
val updatedSources = current.patchSource.map {
if (it.id == id) it.copy(enabled = enabled) else it
}
val anyEnabled = updatedSources.any { it.enabled }
val finalSources = if (!anyEnabled) {
// Safety net: force-enable the default
updatedSources.map {
if (it.id == DEFAULT_PATCH_SOURCE.id) it.copy(enabled = true) else it
}
} else {
updatedSources
}
saveConfig(current.copy(patchSource = finalSources))
}

/**
* Get the list of currently enabled patch sources (in config order).
*/
suspend fun getEnabledPatchSources(): List<PatchSource> {
return loadConfig().patchSource.filter { it.enabled }
}

/**
* Remove a patch source by ID. Cannot remove non-deletable sources.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,23 @@ class PatchSourceManager(
private var cachedActiveRepo: PatchRepository? = null
private var cachedActiveSource: PatchSource? = null

// Incremented on every source switch so Compose can key on it
// Snapshot of currently-enabled sources for sync access. Updated on initialize()
// and whenever setSourceEnabled / addSource / removeSource fires.
private var cachedEnabledSources: List<PatchSource> = emptyList()

// Incremented on every source switch / enable change so Compose can key on it
private val _sourceVersion = MutableStateFlow(0)
val sourceVersion: StateFlow<Int> = _sourceVersion.asStateFlow()

// Observable list of enabled sources for UI
private val _enabledSources = MutableStateFlow<List<PatchSource>>(emptyList())
val enabledSources: StateFlow<List<PatchSource>> = _enabledSources.asStateFlow()

// Observable list of ALL sources (enabled + disabled) — drives the
// SourceManagementSheet which needs to render every source with a toggle.
private val _allSources = MutableStateFlow<List<PatchSource>>(emptyList())
val allSources: StateFlow<List<PatchSource>> = _allSources.asStateFlow()

/**
* Load the active source from config and cache its PatchRepository.
* Call once at app startup (from a LaunchedEffect).
Expand All @@ -40,7 +53,9 @@ class PatchSourceManager(
val source = configRepository.getActivePatchSource()
cachedActiveSource = source
cachedActiveRepo = getRepositoryForSource(source)
refreshEnabledSources()
Logger.info("PatchSourceManager initialized with source '${source.name}' (type=${source.type})")
Logger.info("Enabled sources: ${cachedEnabledSources.joinToString { it.name }}")
}

/**
Expand Down Expand Up @@ -153,4 +168,69 @@ class PatchSourceManager(
cachedActiveRepo?.clearCache()
_sourceVersion.value++
}

// ── Multi-source API ──────────────────────────────────────────────────────

/**
* Snapshot of currently-enabled sources, in config order. Synchronous.
*/
fun getEnabledSourcesSync(): List<PatchSource> = cachedEnabledSources

/**
* Pair each enabled source with its [PatchRepository]. The repo is null for LOCAL
* sources — callers should use [PatchSource.filePath] directly in that case.
*/
fun getEnabledRepositories(): List<Pair<PatchSource, PatchRepository?>> =
cachedEnabledSources.map { it to getRepositoryForSource(it) }

/**
* Toggle enablement of a source. Persists, refreshes the cached snapshot, and
* bumps [sourceVersion] so consumers reload. Default-source safety net is
* applied at the [ConfigRepository] layer.
*/
suspend fun setSourceEnabled(id: String, enabled: Boolean) {
configRepository.setPatchSourceEnabled(id, enabled)
refreshEnabledSources()
_sourceVersion.value++
Logger.info("Source '$id' enabled=$enabled. Enabled now: ${cachedEnabledSources.joinToString { it.name }}")
}

/**
* Add a new source. Persists and refreshes the cached snapshot.
*/
suspend fun addSource(source: PatchSource) {
configRepository.addPatchSource(source)
refreshEnabledSources()
_sourceVersion.value++
}

/**
* Remove a source by id. Refuses non-deletable (default) sources. Drops the
* cached repo for that id so a re-add doesn't reuse stale state.
*/
suspend fun removeSource(id: String) {
configRepository.removePatchSource(id)
repositories.remove(id)
refreshEnabledSources()
_sourceVersion.value++
}

/**
* Update an existing source (e.g. rename). Refuses non-deletable sources.
*/
suspend fun updateSource(updated: PatchSource) {
configRepository.updatePatchSource(updated)
// Drop the cached repo so the new url/name is picked up on next access.
repositories.remove(updated.id)
refreshEnabledSources()
_sourceVersion.value++
}

private suspend fun refreshEnabledSources() {
val all = configRepository.loadConfig().patchSource
val enabled = all.filter { it.enabled }
cachedEnabledSources = enabled
_enabledSources.value = enabled
_allSources.value = all
}
}
4 changes: 3 additions & 1 deletion src/main/kotlin/app/morphe/gui/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ val appModule = module {
get(),
get(),
psm.getActiveSourceName(),
psm.getLocalFilePath()
psm.getLocalFilePath(),
params.get(),
params.get(),
)
}
factory { params ->
Expand Down
Loading
Loading