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
7 changes: 7 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@ org.gradle.parallel = true
org.gradle.caching = true
kotlin.code.style = official
version = 1.9.2-dev.1

# Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than
# the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit
# exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each
# (~4g total) fits comfortably on a 16GB machine.
org.gradle.jvmargs = -Xmx2g
kotlin.daemon.jvmargs = -Xmx2g
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ kotlin = "2.3.21"
# CLI
picocli = "4.7.7"
arsclib = "a28c6fb2a7"
morphe-patcher = "1.5.2-dev.2" # TODO: Change to stable release
morphe-patcher = "1.5.2"
morphe-library = "1.3.0"

# Compose Desktop
Expand Down
37 changes: 34 additions & 3 deletions src/main/kotlin/app/morphe/cli/command/PatchCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,13 @@ internal object PatchCommand : Callable<Int> {
description = ["Alias of the private key and certificate pair keystore entry."],
showDefaultValue = ALWAYS,
)
private var keyStoreEntryAlias = PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS
private var keyStoreEntryAlias = DEFAULT_KEYSTORE_ALIAS

@CommandLine.Option(
names = ["--keystore-entry-password"],
description = ["Password of the keystore entry."],
)
private var keyStoreEntryPassword = PatchEngine.Config.DEFAULT_KEYSTORE_PASSWORD
private var keyStoreEntryPassword = DEFAULT_KEYSTORE_PASSWORD

@CommandLine.Option(
names = ["--signer"],
Expand Down Expand Up @@ -432,7 +432,38 @@ internal object PatchCommand : Callable<Int> {

val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir

val keystoreFilePath = keyStoreFilePath ?: MorpheData.defaultKeystoreFile
// Resolve and possibly convert the keystore. The patcher only loads BKS,
// but users frequently pass PKCS12 (Android Studio default) or JKS (older
// projects, URV exports). KeystoreImporter sniffs by magic bytes (not
// extension — URV ships the same bytes under multiple suffixes), short-
// circuits when already BKS so existing setups are zero-risk, and writes
// converted bytes to a separate file so the user's source is never mutated.
val keystoreFilePath = run {
val source = keyStoreFilePath ?: return@run MorpheData.defaultKeystoreFile
if (!source.exists()) return@run source // patcher will produce a clearer error
val importResult = app.morphe.engine.util.KeystoreImporter.ensureBks(
source = source,
convertedOutput = MorpheData.importedKeystoreFile,
alias = keyStoreEntryAlias,
password = keyStoreEntryPassword,
)
when (importResult) {
is app.morphe.engine.util.KeystoreImporter.Result.AlreadyBks -> importResult.file
is app.morphe.engine.util.KeystoreImporter.Result.Converted -> {
logger.info(
"Converted ${importResult.sourceFormat.displayName} keystore → BKS: ${importResult.file.absolutePath}"
)
importResult.file
}
is app.morphe.engine.util.KeystoreImporter.Result.Failed -> {
logger.severe("Keystore conversion failed: ${importResult.reason}")
importResult.cause?.let { logger.severe(it.stackTraceToString()) }
throw IllegalArgumentException(
"Could not use keystore '${source.absolutePath}': ${importResult.reason}"
)
}
}
}

val installer = if (deviceSerial != null) {
val deviceSerial = deviceSerial!!.ifEmpty { null }
Expand Down
10 changes: 10 additions & 0 deletions src/main/kotlin/app/morphe/engine/MorpheData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ object MorpheData {
*/
val defaultKeystoreFile: File get() = File(root, "morphe.keystore")

/**
* Destination for keystores the user imports in non-BKS formats
* (PKCS12 / JKS). The original source file is left untouched; we write
* the BKS-converted bytes here and point the user's keystore config
* at this path. Distinct from [defaultKeystoreFile] so the import flow
* doesn't clobber the auto-generated default — clearing the configured
* path reverts patching to that default.
*/
val importedKeystoreFile: File get() = File(root, "imported.keystore")

/**
* Reason the primary (JAR-adjacent) location was rejected. Drives the
* fallback log message so a user reporting "where's my cache?" can
Expand Down
154 changes: 154 additions & 0 deletions src/main/kotlin/app/morphe/engine/PatchedAppStore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-cli
*/

package app.morphe.engine

import app.morphe.engine.model.PatchedAppRecord
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.logging.Logger

/**
* Persistent store for [PatchedAppRecord]s — the patched-app history shared by
* the CLI and GUI (see `patched-app-recall-plan.md`).
*
* Lives in the engine layer (not GUI) so **both** pipelines can record into one
* file: `morphe-data/patched-apps.json`. Reads are cached in memory; writes go
* through a [Mutex] (in-process safety) and are atomic (temp file + move) so a
* crash mid-write can't corrupt the history.
*
* The [file] is injectable for testing; production code uses the default under
* [MorpheData.root].
*/
class PatchedAppStore(
private val file: File = File(MorpheData.root, FILE_NAME),
) {
private val logger = Logger.getLogger(PatchedAppStore::class.java.name)

private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}

private val mutex = Mutex()
private var cache: List<PatchedAppRecord>? = null

private val _changes = MutableSharedFlow<Unit>(extraBufferCapacity = 1)

/**
* Emits once whenever the history changes (upsert/delete). Observe this to
* refresh UI the moment a patch completes or a record is forgotten, instead
* of waiting for a screen to be re-entered.
*/
val changes: SharedFlow<Unit> = _changes.asSharedFlow()

/** All records, most-recently-patched first. */
suspend fun getAll(): List<PatchedAppRecord> = withContext(Dispatchers.IO) {
mutex.withLock { load() }
}

/** The record for [packageName], or null if the app was never patched. */
suspend fun get(packageName: String): PatchedAppRecord? = withContext(Dispatchers.IO) {
mutex.withLock { load().firstOrNull { it.packageName == packageName } }
}

/** Insert [record], replacing any existing record for the same package. */
suspend fun upsert(record: PatchedAppRecord): Unit = withContext(Dispatchers.IO) {
mutex.withLock {
val others = load().filterNot { it.packageName == record.packageName }
persist(listOf(record) + others)
}
_changes.tryEmit(Unit)
}

/** Remove the record for [packageName] if present. */
suspend fun delete(packageName: String): Unit = withContext(Dispatchers.IO) {
val changed = mutex.withLock {
val remaining = load().filterNot { it.packageName == packageName }
if (remaining.size != cache?.size) {
persist(remaining)
true
} else {
false
}
}
if (changed) _changes.tryEmit(Unit)
}

// --- internals (call only while holding [mutex]) ---

private fun load(): List<PatchedAppRecord> {
cache?.let { return it }
val records = if (file.exists()) {
try {
json.decodeFromString<StoreFile>(file.readText()).records
} catch (e: Exception) {
// Corrupt/incompatible file: don't lose the user's ability to keep
// patching — start fresh in memory and let the next write heal it.
logger.warning("Could not read patched-app history (${e.message}); starting empty")
emptyList()
}
} else {
emptyList()
}
cache = records
return records
}

private fun persist(records: List<PatchedAppRecord>) {
try {
file.parentFile?.mkdirs()
val content = json.encodeToString(StoreFile.serializer(), StoreFile(SCHEMA_VERSION, records))
// Atomic write: write a temp file, then move it over the target so a
// crash never leaves a half-written history behind.
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeText(content)
try {
Files.move(
tmp.toPath(), file.toPath(),
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
)
} catch (_: Exception) {
// ATOMIC_MOVE isn't supported on every filesystem — fall back.
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
cache = records
} catch (e: Exception) {
logger.warning("Failed to write patched-app history: ${e.message}")
}
}

@Serializable
private data class StoreFile(
val version: Int = SCHEMA_VERSION,
val records: List<PatchedAppRecord> = emptyList(),
)

companion object {
const val FILE_NAME = "patched-apps.json"

/** Bump when the on-disk shape changes incompatibly; add a migration in [load]. */
const val SCHEMA_VERSION = 1

/**
* Process-wide shared instance — use this everywhere in production so the
* in-memory cache is coherent (two instances in one process could race
* and drop each other's records). Tests construct their own with a
* custom [file].
*/
val shared: PatchedAppStore by lazy { PatchedAppStore() }
}
}
80 changes: 80 additions & 0 deletions src/main/kotlin/app/morphe/engine/model/PatchedAppRecord.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-cli
*/

package app.morphe.engine.model

import kotlinx.serialization.Serializable

/**
* A record of one app the user has patched — the "recall" data behind the
* patched-app history (see `patched-app-recall-plan.md`).
*
* Written by both the CLI and the GUI on a successful patch (the store lives in
* the shared engine layer so the two pipelines feed one history), and read back
* to surface "you've patched this before / an update is available" UX.
*
* Keyed by [packageName] — re-patching the same app overwrites its record.
* For apps whose package is renamed by a patch, this is the **original**
* (pre-patch) package name, for consistency with the rest of our schema.
*/
@Serializable
data class PatchedAppRecord(
/** Original (pre-patch) package name. Primary key; matches the supported-apps list. */
val packageName: String,
/**
* Post-patch package as it installs on a device — differs from [packageName]
* when a rename patch was applied (e.g. `com.google.android.youtube` →
* `app.morphe.android.youtube`). Read from the output APK's manifest at patch
* time. Null/blank = no rename (same as [packageName]). Mirrors Manager's
* `currentPackageName`.
*/
val currentPackageName: String? = null,
/** Human-readable app name shown in UI. */
val displayName: String,
/** APK version at patch time. */
val apkVersion: String,

/** Input APK path used. May no longer exist on disk. */
val inputApkPath: String,
/** Output APK path we wrote. Its existence is the "is it still here" signal. */
val outputApkPath: String,

/**
* Baseline integrity fingerprint of the output APK, captured at patch time.
* Lets us later detect when the patched APK was changed outside Morphe
* (hash mismatch) vs untouched. Null only for records written before this
* field existed, or if hashing failed. See `patched-app-recall-plan.md` (Phase 6).
*/
val outputApkSha256: String? = null,
/** Size in bytes of the output APK at patch time. */
val outputApkSize: Long = 0,

/** Bundle source id → set of enabled patch unique ids (same shape as the selection state). */
val patchSelectionByBundle: Map<String, Set<String>> = emptyMap(),
/** "patchName.optionKey" → raw value string (deserialized by patch type at apply time). */
val patchOptionValues: Map<String, String> = emptyMap(),

/**
* Which sources + versions were enabled at patch time. "Update available"
* detection compares current source versions against this snapshot.
*/
val sourcesSnapshot: List<PatchedSourceSnapshot> = emptyList(),

/** Epoch millis of when the patch completed. */
val patchedAt: Long,
/** The Morphe (CLI/GUI) version that produced the patch — handy for debugging. */
val patchedWithMorpheVersion: String,
) {
/** Package actually installed on a device (post-rename if applicable). */
val installedPackageName: String get() = currentPackageName?.takeIf { it.isNotBlank() } ?: packageName

@Serializable
data class PatchedSourceSnapshot(
val sourceId: String,
val sourceName: String,
/** `.mpp` release version, e.g. `v1.5.0`. */
val version: String,
)
}
43 changes: 43 additions & 0 deletions src/main/kotlin/app/morphe/engine/util/FileChecksum.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-cli
*/

package app.morphe.engine.util

import java.io.File
import java.security.MessageDigest

/**
* Canonical file-hashing used across the engine, CLI, and GUI. Lives in the
* engine layer so non-GUI callers (CLI patch recording, patched-app integrity
* checks) don't have to depend on `gui.util`. The GUI's `ChecksumUtils`
* delegates here so there's one implementation.
*/
object FileChecksum {

/** Lowercase-hex SHA-256 of [file]. Streams the file — safe for large APKs. */
fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(8192)
file.inputStream().use { stream ->
var read: Int
while (stream.read(buffer).also { read = it } != -1) {
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}

/**
* Baseline fingerprint `(sha256, sizeBytes)` of the file at [path], or
* `(null, 0)` if it doesn't exist or hashing fails. Blocking IO — call off
* the main thread. Used to record patched-APK integrity at patch time.
*/
fun fingerprintOrNull(path: String): Pair<String?, Long> = try {
val file = File(path)
if (file.exists()) sha256(file) to file.length() else null to 0L
} catch (_: Exception) {
null to 0L
}
}
Loading
Loading