Skip to content

Commit da5ec1c

Browse files
authored
feat: Add GUI third party patch sources, add experimental app patching (#98)
1 parent c58e923 commit da5ec1c

55 files changed

Lines changed: 10424 additions & 3859 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build_pull_request.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,19 @@ jobs:
1414
fail-fast: false
1515
matrix:
1616
os: [ubuntu-latest, windows-latest, macos-latest]
17+
1718
steps:
1819
- name: Checkout
1920
uses: actions/checkout@v4
2021
with:
2122
fetch-depth: 0
2223

24+
- name: Set up JDK 17
25+
uses: actions/setup-java@v4
26+
with:
27+
distribution: 'temurin'
28+
java-version: '17'
29+
2330
- name: Cache Gradle
2431
uses: burrunan/gradle-cache-action@v1
2532

@@ -35,4 +42,3 @@ jobs:
3542
with:
3643
archive: false
3744
path: build/libs/morphe-cli-*-all.jar
38-

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ build/
44

55
# Local configuration file (sdk path, etc)
66
local.properties
7+
old_build.gradle.kts
78

89
# Log/OS Files
910
*.log

build.gradle.kts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ group = "app.morphe"
1919
kotlin {
2020
jvmToolchain {
2121
languageVersion.set(JavaLanguageVersion.of(17))
22-
vendor.set(JvmVendorSpec.ADOPTIUM)
22+
vendor.set(JvmVendorSpec.JETBRAINS)
2323
}
2424
compilerOptions {
2525
jvmTarget.set(JvmTarget.JVM_17)
@@ -100,6 +100,10 @@ dependencies {
100100
implementation(libs.voyager.koin)
101101
implementation(libs.voyager.transitions)
102102

103+
// -- JNA (Windows DWM title bar tinting) -------------------------------
104+
implementation(libs.jna)
105+
implementation(libs.jna.platform)
106+
103107
// -- APK Parsing (GUI) -------------------------------------------------
104108
implementation(libs.apk.parser)
105109

@@ -154,6 +158,8 @@ tasks {
154158
exclude(dependency("io.insert-koin:.*"))
155159
// Coroutines Swing provides Dispatchers.Main via ServiceLoader
156160
exclude(dependency("org.jetbrains.kotlinx:kotlinx-coroutines-swing"))
161+
// JNA uses reflection + native loading for DWM title bar tinting
162+
exclude(dependency("net.java.dev.jna:.*"))
157163
}
158164

159165
mergeServiceFiles()

gradle/libs.versions.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ voyager = "1.1.0-beta03"
2626
coroutines = "1.10.2"
2727
kotlinx-serialization = "1.9.0"
2828

29+
# JNA (Windows DWM title bar tinting)
30+
jna = "5.14.0"
31+
2932
# APK
3033
apk-parser = "2.6.10"
3134

@@ -65,6 +68,10 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-
6568
# Serialization
6669
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
6770

71+
# JNA (Windows DWM title bar tinting)
72+
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
73+
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
74+
6875
# APK
6976
apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apk-parser" }
7077

settings.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
pluginManagement {
22
repositories {
3+
gradlePluginPortal()
34
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
45
google()
56
mavenCentral()
6-
gradlePluginPortal()
77
}
88
}
99

src/main/kotlin/app/morphe/cli/command/PatchCommand.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import app.morphe.cli.command.model.withUpdatedBundle
2222
import app.morphe.engine.PatchEngine
2323
import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS
2424
import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD
25+
import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME
2526
import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_ALIAS
2627
import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_PASSWORD
2728
import app.morphe.engine.UpdateChecker
@@ -219,7 +220,7 @@ internal object PatchCommand : Callable<Int> {
219220
description = ["The name of the signer to sign the patched APK file with."],
220221
showDefaultValue = ALWAYS,
221222
)
222-
private var signer = "Morphe"
223+
private var signer = DEFAULT_SIGNER_NAME
223224

224225
@CommandLine.Option(
225226
names = ["-t", "--temporary-files-path"],

src/main/kotlin/app/morphe/engine/PatchEngine.kt

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ object PatchEngine {
5050
val forceCompatibility: Boolean = false,
5151
val patchOptions: Map<String, Map<String, Any?>> = emptyMap(),
5252
val unsigned: Boolean = false,
53-
val signerName: String = "Morphe",
53+
val signerName: String = DEFAULT_SIGNER_NAME,
5454
val keystoreDetails: ApkUtils.KeyStoreDetails? = null,
5555
val architecturesToKeep: Set<CpuArchitecture> = emptySet(),
5656
val aaptBinaryPath: File? = null,
@@ -60,6 +60,7 @@ object PatchEngine {
6060
companion object {
6161
internal const val DEFAULT_KEYSTORE_ALIAS = "Morphe"
6262
internal const val DEFAULT_KEYSTORE_PASSWORD = "Morphe"
63+
internal const val DEFAULT_SIGNER_NAME = "Morphe"
6364
internal const val LEGACY_KEYSTORE_ALIAS = "Morphe Key"
6465
internal const val LEGACY_KEYSTORE_PASSWORD = ""
6566
}
@@ -222,7 +223,19 @@ object PatchEngine {
222223
// 7. Sign APK (unless unsigned)
223224
val tempOutput = File(tempDir, config.outputApk.name)
224225
if (!config.unsigned) {
225-
onProgress("Signing APK...")
226+
val keystoreDetails = config.keystoreDetails ?: ApkUtils.KeyStoreDetails(
227+
File(tempDir, "morphe.keystore"),
228+
null,
229+
Config.DEFAULT_KEYSTORE_ALIAS,
230+
Config.DEFAULT_KEYSTORE_PASSWORD,
231+
)
232+
233+
if (config.keystoreDetails != null) {
234+
onProgress("Signing APK with custom keystore: ${keystoreDetails.keyStore.name}")
235+
} else {
236+
onProgress("Signing APK...")
237+
}
238+
226239
try {
227240
fun signApk(details: ApkUtils.KeyStoreDetails) {
228241
ApkUtils.signApk(
@@ -233,16 +246,9 @@ object PatchEngine {
233246
)
234247
}
235248

236-
val keystoreDetails = config.keystoreDetails ?: ApkUtils.KeyStoreDetails(
237-
File(tempDir, "morphe.keystore"),
238-
null,
239-
Config.DEFAULT_KEYSTORE_ALIAS,
240-
Config.DEFAULT_KEYSTORE_PASSWORD,
241-
)
242-
243249
try {
244250
signApk(keystoreDetails)
245-
} catch (e: Exception){
251+
} catch (e: Exception) {
246252
// Retry with legacy keystore defaults.
247253
if (config.keystoreDetails == null && keystoreDetails.keyStore.exists()) {
248254
logger.info("Using legacy keystore credentials")

src/main/kotlin/app/morphe/gui/App.kt

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,32 @@
66
package app.morphe.gui
77

88
import androidx.compose.animation.Crossfade
9-
import androidx.compose.foundation.layout.fillMaxSize
9+
import androidx.compose.foundation.background
10+
import androidx.compose.foundation.layout.*
11+
import androidx.compose.foundation.window.WindowDraggableArea
12+
import androidx.compose.material3.MaterialTheme
1013
import androidx.compose.material3.Surface
1114
import androidx.compose.runtime.*
15+
import androidx.compose.ui.Alignment
1216
import androidx.compose.ui.Modifier
17+
import androidx.compose.ui.draw.clipToBounds
18+
import androidx.compose.ui.unit.dp
19+
import app.morphe.gui.ui.components.LocalFrameWindowScope
20+
import app.morphe.gui.ui.components.LottieAnimation
21+
import app.morphe.gui.ui.components.SakuraPetals
22+
import app.morphe.gui.util.applyTitleBarTint
1323
import cafe.adriel.voyager.navigator.Navigator
1424
import cafe.adriel.voyager.transitions.SlideTransition
1525
import app.morphe.gui.data.repository.ConfigRepository
16-
import app.morphe.gui.data.repository.PatchRepository
17-
import app.morphe.gui.util.PatchService
26+
import app.morphe.gui.data.repository.PatchSourceManager
1827
import app.morphe.gui.di.appModule
1928
import kotlinx.coroutines.launch
2029
import org.koin.compose.KoinApplication
2130
import org.koin.compose.koinInject
2231
import app.morphe.gui.ui.screens.home.HomeScreen
2332
import app.morphe.gui.ui.screens.quick.QuickPatchContent
2433
import app.morphe.gui.ui.screens.quick.QuickPatchViewModel
34+
import app.morphe.gui.util.PatchService
2535
import app.morphe.gui.ui.theme.LocalThemeState
2636
import app.morphe.gui.ui.theme.MorpheTheme
2737
import app.morphe.gui.ui.theme.ThemePreference
@@ -42,31 +52,35 @@ val LocalModeState = staticCompositionLocalOf<ModeState> {
4252
}
4353

4454
@Composable
45-
fun App(initialSimplifiedMode: Boolean = true) {
55+
fun App(
56+
initialSimplifiedMode: Boolean = true
57+
) {
4658
LaunchedEffect(Unit) {
4759
Logger.init()
4860
}
4961

5062
KoinApplication(application = {
5163
modules(appModule)
5264
}) {
53-
AppContent(initialSimplifiedMode)
65+
AppContent(initialSimplifiedMode = initialSimplifiedMode)
5466
}
5567
}
5668

5769
@Composable
58-
private fun AppContent(initialSimplifiedMode: Boolean) {
70+
private fun AppContent(
71+
initialSimplifiedMode: Boolean
72+
) {
5973
val configRepository: ConfigRepository = koinInject()
60-
val patchRepository: PatchRepository = koinInject()
61-
val patchService: PatchService = koinInject()
74+
val patchSourceManager: PatchSourceManager = koinInject()
6275
val scope = rememberCoroutineScope()
6376

6477
var themePreference by remember { mutableStateOf(ThemePreference.SYSTEM) }
6578
var isSimplifiedMode by remember { mutableStateOf(initialSimplifiedMode) }
6679
var isLoading by remember { mutableStateOf(true) }
6780

68-
// Load config on startup
81+
// Initialize PatchSourceManager and load config on startup
6982
LaunchedEffect(Unit) {
83+
patchSourceManager.initialize()
7084
val config = configRepository.loadConfig()
7185
themePreference = config.getThemePreference()
7286
isSimplifiedMode = config.useSimplifiedMode
@@ -114,25 +128,97 @@ private fun AppContent(initialSimplifiedMode: Boolean) {
114128
LocalThemeState provides themeState,
115129
LocalModeState provides modeState
116130
) {
131+
// Tint the OS title bar (Windows DWM caption color, macOS traffic
132+
// light contrast) to match the active theme's surface color.
133+
val titleBarColor = MaterialTheme.colorScheme.surface
134+
val frameScope = LocalFrameWindowScope.current
135+
LaunchedEffect(titleBarColor, frameScope) {
136+
frameScope?.window?.let { applyTitleBarTint(it, titleBarColor) }
137+
}
138+
139+
// macOS only: render a 28dp colored band at the very top of the
140+
// window, sitting underneath the (now-transparent) OS title bar.
141+
// The traffic lights overlay this band at their default position.
142+
// Wrapped in WindowDraggableArea so the band acts as a drag region.
143+
val isMac = remember {
144+
System.getProperty("os.name")?.lowercase()?.contains("mac") == true
145+
}
146+
117147
Surface(modifier = Modifier.fillMaxSize()) {
118-
if (!isLoading) {
119-
// Create QuickPatchViewModel outside Crossfade so it persists across mode switches.
120-
// Otherwise every expert→simplified switch creates a new VM that re-fetches from GitHub.
121-
val quickViewModel = remember {
122-
QuickPatchViewModel(patchRepository, patchService, configRepository)
148+
Column(modifier = Modifier.fillMaxSize()) {
149+
if (isMac && frameScope != null) {
150+
with(frameScope) {
151+
WindowDraggableArea {
152+
Box(
153+
modifier = Modifier
154+
.fillMaxWidth()
155+
.height(16.dp)
156+
.background(titleBarColor)
157+
)
158+
}
159+
}
123160
}
124161

125-
Crossfade(targetState = isSimplifiedMode) { simplified ->
126-
if (simplified) {
127-
// Quick/Simplified mode
128-
QuickPatchContent(quickViewModel)
129-
} else {
130-
// Full mode
131-
Navigator(HomeScreen()) { navigator ->
132-
SlideTransition(navigator)
162+
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
163+
if (!isLoading) {
164+
val patchService: PatchService = koinInject()
165+
val quickViewModel = remember {
166+
QuickPatchViewModel(patchSourceManager, patchService, configRepository)
167+
}
168+
169+
Crossfade(targetState = isSimplifiedMode) { simplified ->
170+
if (simplified) {
171+
QuickPatchContent(quickViewModel)
172+
} else {
173+
Navigator(HomeScreen()) { navigator ->
174+
SlideTransition(navigator)
175+
}
176+
}
177+
}
178+
}
179+
180+
// Falling petals — on top of everything (Sakura)
181+
SakuraPetals(
182+
enabled = themePreference == ThemePreference.SAKURA
183+
)
184+
185+
// Matcha cat — top-right corner
186+
if (themePreference == ThemePreference.MATCHA) {
187+
val catJson = remember {
188+
try {
189+
object {}.javaClass.getResourceAsStream("/cat2333s.json")
190+
?.bufferedReader()?.readText()
191+
} catch (e: Exception) {
192+
null
193+
}
194+
}
195+
catJson?.let { json ->
196+
// 1080px canvas, rendered at 350dp (1dp ≈ 3.086 canvas px).
197+
// Ears ~y385 → 125dp, bar bottom ~y576 → 187dp.
198+
// Body shrunk to 85% so it hides behind bar.
199+
// Clip from 120dp to 192dp (72dp visible) — ears to just past bar.
200+
val renderSize = 350.dp
201+
val clipTop = 120.dp // just above ears
202+
val clipHeight = 72.dp // ears → just past bar bottom
203+
Box(
204+
modifier = Modifier
205+
.align(Alignment.TopEnd)
206+
.padding(top = 24.dp, end = 16.dp)
207+
.requiredWidth(renderSize)
208+
.requiredHeight(clipHeight)
209+
.clipToBounds()
210+
) {
211+
LottieAnimation(
212+
jsonString = json,
213+
modifier = Modifier
214+
.requiredSize(renderSize)
215+
.offset(y = -clipTop),
216+
alpha = 0.28f
217+
)
133218
}
134219
}
135220
}
221+
}
136222
}
137223
}
138224
}

0 commit comments

Comments
 (0)