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
5 changes: 4 additions & 1 deletion src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

/**
* Lazy initialized HttpClient for CLI commands. One client per process is fine for short-lived
Expand All @@ -19,7 +20,9 @@ import io.ktor.serialization.kotlinx.json.json
object CliHttpClient {
val instance: HttpClient by lazy {
HttpClient(CIO) {
install(ContentNegotiation) { json() }
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
}
}
10 changes: 0 additions & 10 deletions src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
package app.morphe.cli.command

import app.morphe.engine.CompatibleVersionsMap
import app.morphe.engine.MorpheData
import app.morphe.engine.VersionMap
import app.morphe.engine.mostCommonCompatibleVersions
import app.morphe.patcher.patch.loadPatchesFromJar
Expand Down Expand Up @@ -68,12 +67,6 @@ internal class ListCompatibleVersions : Runnable {
)
private var includeExperimental: Boolean = false

@Option(
names = ["-t", "--temporary-files-path"],
description = ["Path to store temporary files."],
)
private var temporaryFilesPath: File? = null

@Spec
private lateinit var spec: CommandSpec

Expand All @@ -96,13 +89,10 @@ internal class ListCompatibleVersions : Runnable {
appendLine(versions.buildVersionsString().prependIndent("\t"))
}

val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir

try {
patchesFiles = PatchFileResolver.resolve(
patchesFiles,
prerelease,
temporaryFilesPath,
CliHttpClient.instance
)
} catch (e: IllegalArgumentException) {
Expand Down
10 changes: 0 additions & 10 deletions src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

package app.morphe.cli.command

import app.morphe.engine.MorpheData
import app.morphe.engine.compatibleVersionsForDisplay
import app.morphe.engine.isCompatibleWith
import app.morphe.patcher.patch.Patch
Expand Down Expand Up @@ -63,12 +62,6 @@ internal object ListPatchesCommand : Runnable {
)
private var withDescriptions: Boolean = true

@Option(
names = ["-t", "--temporary-files-path"],
description = ["Path to store temporary files."],
)
private var temporaryFilesPath: File? = null

@Option(
names = ["-p", "--with-packages"],
description = ["List the packages the patches are compatible with."],
Expand Down Expand Up @@ -190,13 +183,10 @@ internal object ListPatchesCommand : Runnable {
)


val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir

try {
patchesFiles = PatchFileResolver.resolve(
patchesFiles,
prerelease,
temporaryFilesPath,
CliHttpClient.instance
)
} catch (e: IllegalArgumentException) {
Expand Down
10 changes: 0 additions & 10 deletions src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
package app.morphe.cli.command

import app.morphe.cli.command.model.PatchBundle
import app.morphe.engine.MorpheData
import app.morphe.engine.patches.LoadedBundle
import app.morphe.engine.patches.PatchBundleLoader
import app.morphe.cli.command.model.findMatchingBundle
Expand Down Expand Up @@ -63,12 +62,6 @@ internal object OptionsCommand : Callable<Int> {
)
private var prerelease: Boolean = false

@Option(
names = ["-t", "--temporary-files-path"],
description = ["Path to store temporary files."],
)
private var temporaryFilesPath: File? = null

@Option(
names = ["-f", "--filter-package-name"],
description = ["Filter patches by compatible package name."],
Expand All @@ -78,15 +71,12 @@ internal object OptionsCommand : Callable<Int> {
private val json = Json { prettyPrint = true }

override fun call(): Int {
val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir

try {
// Since we could have many URLs, we resolve each of them separately
patchesFiles = patchesFiles.map { file ->
val resolved = PatchFileResolver.resolve(
setOf(file),
prerelease,
temporaryFilesPath,
CliHttpClient.instance
)
resolved.single()
Expand Down
16 changes: 8 additions & 8 deletions src/main/kotlin/app/morphe/cli/command/PatchCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,13 @@ internal object PatchCommand : Callable<Int> {
private var aaptBinaryPath: File? = null

@CommandLine.Option(
names = ["--purge"],
description = ["Delete THIS run's scratch files after patching. " +
"Does not affect cached patches, other sessions, or config."],
names = ["--disable-purge"],
description = ["Keep THIS run's scratch files instead of deleting them after patching. " +
"By default the scratch files are purged once patching finishes; this keeps them " +
"(e.g. for debugging a failed patch). Does not affect cached patches, other sessions, or config."],
showDefaultValue = ALWAYS,
)
private var purge: Boolean = false
private var disablePurge: Boolean = false

@CommandLine.Parameters(
description = ["APK file to patch."],
Expand Down Expand Up @@ -511,7 +512,6 @@ internal object PatchCommand : Callable<Int> {
val resolved = PatchFileResolver.resolve(
setOf(bundle.patchesFile),
prerelease,
temporaryFilesPath,
CliHttpClient.instance
)
bundle.patchesFile = resolved.single()
Expand All @@ -524,7 +524,7 @@ internal object PatchCommand : Callable<Int> {
}

// Per-session scratch dir. Hoisted out of the patching `try` block so
// the `finally` block can reference it for --purge scope (Phase 6).
// the `finally` block can reference it for the auto-purge (unless --disable-purge).
// Naming matches the GUI's FileUtils.createPatchingTempDir() so the
// tmp/ folder shows consistent siblings across CLI + GUI sessions.
val patcherTemporaryFilesPath =
Expand Down Expand Up @@ -617,7 +617,7 @@ internal object PatchCommand : Callable<Int> {
// endregion

// (patcherTemporaryFilesPath is declared above the outer try
// block so it's visible to --purge in the finally clause.)
// block so it's visible to the auto-purge in the finally clause.)

// We need to check for apkm (like reddit), xapk and apks formats here

Expand Down Expand Up @@ -976,7 +976,7 @@ internal object PatchCommand : Callable<Int> {
}
}

if (purge) {
if (!disablePurge) {
// Scope: only THIS session's tmp subfolder. Cached patches,
// logs, config, and other in-flight sessions (CLI or GUI) are
// never touched.
Expand Down
43 changes: 14 additions & 29 deletions src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

package app.morphe.cli.command

import app.morphe.engine.patches.PatchCache
import app.morphe.engine.patches.RemotePatchSourceFactory
import app.morphe.engine.patches.findPatchAsset
import io.ktor.client.HttpClient
Expand All @@ -21,12 +22,12 @@ object PatchFileResolver {
* Returns a new Set<File> with URLs replaced by downloaded/cached .mpp files.
*
* Provider detection (GitHub vs GitLab) + URL parsing + API talk lives in the engine.
* This function only owns the CLI's disk cache layout and the "which release do we pick" decision.
* This function owns the "which release do we pick" decision. The on-disk cache
* layout is shared with the GUI via [PatchCache] (one cache for both surfaces).
*/
fun resolve(
patchFiles: Set<File>,
prerelease: Boolean,
cacheDir: File,
httpClient: HttpClient
): Set<File> {
val urlEntry = patchFiles.firstOrNull {
Expand Down Expand Up @@ -72,37 +73,21 @@ object PatchFileResolver {
val asset = targetRelease.findPatchAsset()
?: throw IllegalArgumentException("No .mpp file found in release ${targetRelease.tagName}")

// Disk-cache check (same layout as before: {cacheDir}/download/{owner}-{repo}/).
val versionNumber = targetRelease.tagName.removePrefix("v")
val repoCacheDir =
cacheDir.resolve("download").resolve(parsed.repoPath.replace("/", "-"))
// Shared on-disk cache (via PatchCache): the same directory and tag-prefixed filename
// the GUI uses, so a .mpp downloaded by either side is reused by the other.
// Layout: morphe-data/patches/<owner>-<repo>/<tag>__<asset>.mpp
val repoCacheDir = PatchCache.sourceDir(parsed.repoPath)
val targetFile = File(repoCacheDir, PatchCache.cachedFileName(targetRelease, asset))

val cachedFile = repoCacheDir.listFiles()?.find {
it.name.endsWith(".mpp") && it.name.contains(versionNumber)
}

val resolvedFile = if (cachedFile != null) {
val rel = cachedFile.relativeTo(cacheDir.parentFile).path
logger.info("Using cached patch file at $rel")

cachedFile
val resolvedFile = if (targetFile.exists() && targetFile.length() > 0L) {
logger.info("Using cached patch file at ${targetFile.absolutePath}")
targetFile
} else {
// Different version cached -> wipe it before downloading (matches the existing behavior)
repoCacheDir.listFiles()
?.filter { it.name.endsWith(".mpp") }
?.forEach{ it.delete() }
repoCacheDir.mkdirs()

val targetFile = File(repoCacheDir, asset.name)
logger.info("Downloading patches from ${parsed.repoPath} $versionNumber...")

logger.info("Downloading patches from ${parsed.repoPath} ${targetRelease.tagName}...")
source.downloadAsset(asset, targetFile).getOrThrow()

val rel = targetFile.relativeTo(cacheDir.parentFile).path
logger.info("Patches mpp saved to $rel. This file will be used on your next run as long as it is not deleted!")

logger.info("Patches mpp saved to ${targetFile.absolutePath}. This file will be used on your next run as long as it is not deleted!")
targetFile
}
}
patchFiles - urlEntry + resolvedFile
} catch (e: Exception) {
throw IllegalArgumentException("Failed to download patches from URL: ${e.message}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-desktop
*/

package app.morphe.cli.command.utility

import app.morphe.engine.CacheManager
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import java.util.concurrent.Callable
import java.util.logging.Logger

@Command(
name = "clear-cache",
description = ["Delete cached patch files, logs, and temporary files."],
)
internal object ClearCacheCommand : Callable<Int> {
private val logger = Logger.getLogger(this::class.java.name)

private const val EXIT_CODE_SUCCESS = 0
private const val EXIT_CODE_ERROR = 1

@Option(
names = ["--info"],
description = ["Show a per-category breakdown of what was cleared and how much space was freed."],
)
private var info: Boolean = false

override fun call(): Int {
val result = CacheManager.clearCaches()

if (info) {
logger.info(
buildString {
appendLine("Cache cleared:")
result.perDirectory.forEach { dir ->
appendLine(
" ${dir.label.padEnd(8)} ${humanReadable(dir.bytesFreed).padStart(9)}" +
" (${dir.filesDeleted} file${if (dir.filesDeleted == 1) "" else "s"})"
)
}
append(" ${"Total".padEnd(8)} ${humanReadable(result.bytesFreed).padStart(9)}")
}
)
} else {
logger.info("Cache cleared.")
}

// Deletion failures always shown, even without --info.
return if (result.success) {
EXIT_CODE_SUCCESS
} else {
logger.warning("${result.failedFiles} file(s) could not be deleted (may be locked)")
EXIT_CODE_ERROR
}
}

private fun humanReadable(bytes: Long): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0)
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ import picocli.CommandLine
@CommandLine.Command(
name = "utility",
description = ["Commands for utility purposes."],
subcommands = [InstallCommand::class, UninstallCommand::class],
subcommands = [InstallCommand::class, UninstallCommand::class, ClearCacheCommand::class],
)
internal object UtilityCommand
68 changes: 68 additions & 0 deletions src/main/kotlin/app/morphe/engine/CacheManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-desktop
*/

package app.morphe.engine

import java.io.File

/**
* Clears Morphe's on-disk caches. Shared by the GUI's "Clear Cache" action and
* the CLI's `utility clear-cache` subcommand so both wipe the same set of
* directories: downloaded patches, logs, and temp scratch.
*/
object CacheManager {

/** What was cleared from a single directory. */
data class DirResult(
val label: String,
val bytesFreed: Long,
val filesDeleted: Int,
val failedFiles: Int,
)

/** Outcome of a [clearCaches] run, with a per-directory breakdown. */
data class ClearResult(
val perDirectory: List<DirResult>,
) {
val bytesFreed: Long get() = perDirectory.sumOf { it.bytesFreed }
val failedFiles: Int get() = perDirectory.sumOf { it.failedFiles }
val success: Boolean get() = failedFiles == 0
}

/**
* Deletes the contents of the patches cache, logs, and temp scratch
* directories (keeping the top-level directories themselves).
*
* Best-effort: a file that can't be deleted (e.g. a log still held open by
* the running process) is counted in [DirResult.failedFiles] rather than
* aborting the rest.
*/
fun clearCaches(): ClearResult = ClearResult(
listOf(
clearContents("Patches", MorpheData.patchesDir),
clearContents("Logs", MorpheData.logsDir),
clearContents("Temp", MorpheData.tmpDir),
),
)

private fun clearContents(label: String, dir: File): DirResult {
var bytesFreed = 0L
var filesDeleted = 0
var failedFiles = 0

dir.listFiles()?.forEach { entry ->
// Measure before deleting.
val files = entry.walkTopDown().filter { it.isFile }.toList()
val size = files.sumOf { it.length() }
if (entry.deleteRecursively()) {
bytesFreed += size
filesDeleted += files.size
} else {
failedFiles++
}
}
return DirResult(label, bytesFreed, filesDeleted, failedFiles)
}
}
Loading
Loading