Skip to content

Commit 827afa2

Browse files
committed
feat(v0.6): discover popular Modrinth mods, read details and install reviewed dependencies
1 parent 1d09b33 commit 827afa2

4 files changed

Lines changed: 275 additions & 1 deletion

File tree

‎launcher-desktop/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/desktop/DesktopLauncherSession.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import kotlinx.coroutines.flow.StateFlow
2222
import java.time.Instant
2323
import pl.syntaxdevteam.syntaxlauncher.core.loader.ModLoaderRegistry
2424
import pl.syntaxdevteam.syntaxlauncher.core.mods.LocalModManager
25+
import pl.syntaxdevteam.syntaxlauncher.core.mods.ModrinthCatalog
26+
import pl.syntaxdevteam.syntaxlauncher.core.mods.ModrinthInstaller
27+
import pl.syntaxdevteam.syntaxlauncher.core.network.DownloadManager
2528
import pl.syntaxdevteam.syntaxlauncher.core.instance.MinecraftInstance
2629
import pl.syntaxdevteam.syntaxlauncher.core.instance.MinecraftInstanceRepository
2730
import pl.syntaxdevteam.syntaxlauncher.core.launch.LaunchOptions
@@ -53,6 +56,8 @@ class DesktopLauncherSession(
5356
private val microsoftSignIn: DesktopMicrosoftSignIn? = null,
5457
) {
5558
val modManager = LocalModManager(instances)
59+
val modrinthCatalog = ModrinthCatalog(httpClient)
60+
val modrinthInstaller = ModrinthInstaller(instances, modManager, DownloadManager(httpClient))
5661
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
5762
private val lock = Any()
5863
private val closing = AtomicBoolean(false)

‎launcher-desktop/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/desktop/ui/LocalModsDialog.kt‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,17 @@ import kotlinx.coroutines.launch
4040
import pl.syntaxdevteam.syntaxlauncher.core.instance.MinecraftInstance
4141
import pl.syntaxdevteam.syntaxlauncher.core.mods.LocalModInventory
4242
import pl.syntaxdevteam.syntaxlauncher.core.mods.LocalModManager
43+
import pl.syntaxdevteam.syntaxlauncher.core.mods.ModrinthCatalog
44+
import pl.syntaxdevteam.syntaxlauncher.core.mods.ModrinthInstaller
45+
import pl.syntaxdevteam.syntaxlauncher.core.mods.ModrinthInstallPlan
4346

4447
/** Local JARs only; online dependency resolution belongs to v0.6. */
4548
@Composable
4649
fun LocalModsDialog(
4750
instance: MinecraftInstance,
4851
manager: LocalModManager,
52+
catalog: ModrinthCatalog,
53+
installer: ModrinthInstaller,
4954
canModify: Boolean,
5055
onBusyChange: (Boolean) -> Unit,
5156
onDismiss: () -> Unit,
@@ -55,6 +60,7 @@ fun LocalModsDialog(
5560
var error by remember(instance.id) { mutableStateOf<String?>(null) }
5661
var busy by remember { mutableStateOf(false) }
5762
var removing by remember { mutableStateOf<String?>(null) }
63+
var browsing by remember { mutableStateOf(false) }
5864

5965
LaunchedEffect(instance.id) {
6066
try {
@@ -66,14 +72,15 @@ fun LocalModsDialog(
6672
}
6773
}
6874

69-
fun operate(operation: suspend () -> LocalModInventory) {
75+
fun operate(operation: suspend () -> LocalModInventory, afterSuccess: () -> Unit = {}) {
7076
if (busy || !canModify) return
7177
busy = true
7278
onBusyChange(true)
7379
scope.launch {
7480
try {
7581
inventory = operation()
7682
error = null
83+
afterSuccess()
7784
} catch (cancelled: CancellationException) {
7885
throw cancelled
7986
} catch (failure: Exception) {
@@ -125,6 +132,16 @@ fun LocalModsDialog(
125132
}
126133

127134

135+
if (browsing) {
136+
ModrinthBrowsePane(
137+
instance = instance, catalog = catalog,
138+
busy = busy, canModify = canModify,
139+
onInstall = { plan: ModrinthInstallPlan ->
140+
operate({ installer.install(instance.id, plan) }) { browsing = false }
141+
},
142+
onDismiss = { browsing = false },
143+
)
144+
} else {
128145
Row(
129146
horizontalArrangement = Arrangement.SpaceBetween,
130147
modifier = Modifier.fillMaxWidth(),
@@ -145,6 +162,8 @@ fun LocalModsDialog(
145162
horizontalArrangement = Arrangement.SpaceBetween) {
146163
Text("${inventory?.mods?.count { it.enabled } ?: 0} enabled / ${inventory?.mods?.size ?: 0} installed",
147164
style = MaterialTheme.typography.bodyMedium)
165+
TextButton(enabled = canModify && !busy,
166+
onClick = { browsing = true }) { Text("Discover on Modrinth") }
148167
Button(enabled = canModify && !busy, onClick = {
149168
val picker = JFileChooser().apply {
150169
dialogTitle = "Import a local Fabric mod"
@@ -229,6 +248,7 @@ fun LocalModsDialog(
229248
"game/.syntaxlauncher-mod-trash. External dependencies are not installed automatically.",
230249
style = MaterialTheme.typography.bodySmall,
231250
color = MaterialTheme.colorScheme.onSurfaceVariant)
251+
}
232252
}
233253
}
234254
}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package pl.syntaxdevteam.syntaxlauncher.desktop.ui
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.BoxWithConstraints
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Row
7+
import androidx.compose.foundation.layout.fillMaxWidth
8+
import androidx.compose.foundation.layout.fillMaxSize
9+
import androidx.compose.foundation.layout.heightIn
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.rememberScrollState
12+
import androidx.compose.foundation.verticalScroll
13+
import androidx.compose.material3.Button
14+
import androidx.compose.material3.CircularProgressIndicator
15+
import androidx.compose.material3.HorizontalDivider
16+
import androidx.compose.material3.MaterialTheme
17+
import androidx.compose.material3.OutlinedButton
18+
import androidx.compose.material3.OutlinedTextField
19+
import androidx.compose.material3.Text
20+
import androidx.compose.material3.TextButton
21+
import androidx.compose.runtime.Composable
22+
import androidx.compose.runtime.LaunchedEffect
23+
import androidx.compose.runtime.getValue
24+
import androidx.compose.runtime.mutableStateOf
25+
import androidx.compose.runtime.remember
26+
import androidx.compose.runtime.rememberCoroutineScope
27+
import androidx.compose.runtime.setValue
28+
import androidx.compose.ui.Modifier
29+
import androidx.compose.ui.text.font.FontWeight
30+
import androidx.compose.ui.unit.dp
31+
import java.awt.Desktop
32+
import java.net.URI
33+
import kotlinx.coroutines.CancellationException
34+
import kotlinx.coroutines.launch
35+
import pl.syntaxdevteam.syntaxlauncher.core.instance.MinecraftInstance
36+
import pl.syntaxdevteam.syntaxlauncher.core.mods.ModrinthCatalog
37+
import pl.syntaxdevteam.syntaxlauncher.core.mods.ModrinthInstallPlan
38+
import pl.syntaxdevteam.syntaxlauncher.core.mods.ModrinthProject
39+
import pl.syntaxdevteam.syntaxlauncher.core.mods.ModrinthProjectDetail
40+
41+
/** Search + popularity + details + user-approved verified installation, scoped to an instance. */
42+
@Composable
43+
fun ModrinthBrowsePane(
44+
instance: MinecraftInstance,
45+
catalog: ModrinthCatalog,
46+
busy: Boolean,
47+
canModify: Boolean,
48+
onInstall: (ModrinthInstallPlan) -> Unit,
49+
onDismiss: () -> Unit,
50+
) {
51+
val scope = rememberCoroutineScope()
52+
var text by remember(instance.id) { mutableStateOf("") }
53+
var submitted by remember(instance.id) { mutableStateOf("") }
54+
var offset by remember(instance.id) { mutableStateOf(0) }
55+
var found by remember(instance.id) { mutableStateOf<List<ModrinthProject>>(emptyList()) }
56+
var selected by remember(instance.id) { mutableStateOf<ModrinthProject?>(null) }
57+
var detail by remember(instance.id) { mutableStateOf<ModrinthProjectDetail?>(null) }
58+
var plan by remember(instance.id) { mutableStateOf<ModrinthInstallPlan?>(null) }
59+
var loading by remember { mutableStateOf(false) }
60+
var detailsLoading by remember { mutableStateOf(false) }
61+
var resolving by remember { mutableStateOf(false) }
62+
var error by remember { mutableStateOf<String?>(null) }
63+
64+
LaunchedEffect(instance.id, submitted, offset) {
65+
loading = true
66+
error = null
67+
try {
68+
found = catalog.search(instance, submitted, offset)
69+
selected = null
70+
detail = null
71+
plan = null
72+
} catch (cancelled: CancellationException) {
73+
throw cancelled
74+
} catch (failure: Exception) {
75+
found = emptyList()
76+
error = failure.message ?: "Could not load Modrinth mods."
77+
} finally { loading = false }
78+
}
79+
LaunchedEffect(selected?.id) {
80+
detail = null
81+
plan = null
82+
val item = selected ?: return@LaunchedEffect
83+
detailsLoading = true
84+
try {
85+
detail = catalog.detail(item)
86+
} catch (cancelled: CancellationException) {
87+
throw cancelled
88+
} catch (failure: Exception) {
89+
error = failure.message ?: "Could not read mod details."
90+
} finally { detailsLoading = false }
91+
}
92+
93+
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
94+
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
95+
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
96+
Text("DISCOVER MODS · MODRINTH", style = MaterialTheme.typography.labelMedium,
97+
color = MaterialTheme.colorScheme.primary)
98+
Text("Minecraft " + instance.minecraftVersionId + " · " +
99+
(instance.loader?.id ?: "Vanilla"),
100+
style = MaterialTheme.typography.bodySmall,
101+
color = MaterialTheme.colorScheme.onSurfaceVariant)
102+
}
103+
TextButton(enabled = !busy && !resolving, onClick = onDismiss) { Text("← Installed mods") }
104+
}
105+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
106+
OutlinedTextField(
107+
value = text,
108+
onValueChange = { if (it.length <= 100) text = it },
109+
label = { Text("Find a mod") },
110+
singleLine = true,
111+
modifier = Modifier.weight(1f),
112+
enabled = !busy,
113+
)
114+
Button(enabled = !loading && !busy, onClick = {
115+
submitted = text.trim()
116+
offset = 0
117+
}) { Text("Search") }
118+
}
119+
Text(if (submitted.isEmpty()) "Most downloaded compatible mods"
120+
else "Results for “$submitted”",
121+
style = MaterialTheme.typography.titleSmall)
122+
error?.let { Text(it, color = MaterialTheme.colorScheme.error,
123+
style = MaterialTheme.typography.bodySmall) }
124+
if (loading || detailsLoading || resolving) {
125+
CircularProgressIndicator()
126+
}
127+
BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) {
128+
val stacked = maxWidth < 620.dp
129+
val list: @Composable (Modifier) -> Unit = { modifier ->
130+
Column(modifier = modifier.verticalScroll(rememberScrollState()),
131+
verticalArrangement = Arrangement.spacedBy(6.dp)) {
132+
if (!loading && found.isEmpty()) Text(
133+
"No mods were found for this Minecraft version and loader.",
134+
style = MaterialTheme.typography.bodyMedium)
135+
found.forEach { project ->
136+
val isSelected = selected?.id == project.id
137+
OutlinedButton(
138+
onClick = { selected = project; error = null },
139+
modifier = Modifier.fillMaxWidth(), enabled = !busy,
140+
) {
141+
Column(modifier = Modifier.fillMaxWidth(),
142+
verticalArrangement = Arrangement.spacedBy(3.dp)) {
143+
Text(project.title + (if (isSelected) " · Selected" else ""),
144+
fontWeight = FontWeight.SemiBold)
145+
Text(project.summary.take(135),
146+
style = MaterialTheme.typography.bodySmall)
147+
Text("by " + project.author + " · " + project.downloads + " downloads",
148+
style = MaterialTheme.typography.labelSmall,
149+
color = MaterialTheme.colorScheme.onSurfaceVariant)
150+
}
151+
}
152+
}
153+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
154+
OutlinedButton(onClick = { offset = (offset - 20).coerceAtLeast(0) },
155+
enabled = offset > 0 && !loading && !busy) { Text("Previous") }
156+
Text("Page " + (offset / 20 + 1), modifier = Modifier.padding(top = 10.dp))
157+
OutlinedButton(onClick = { offset += 20 },
158+
enabled = found.size == 20 && !loading && !busy && offset < 1000) {
159+
Text("Next") }
160+
}
161+
}
162+
}
163+
val preview: @Composable (Modifier) -> Unit = { modifier ->
164+
Column(modifier = modifier.verticalScroll(rememberScrollState()),
165+
verticalArrangement = Arrangement.spacedBy(8.dp)) {
166+
val current = selected
167+
if (current == null) {
168+
Text("Select a mod to read its description and review a compatible download.",
169+
color = MaterialTheme.colorScheme.onSurfaceVariant)
170+
} else {
171+
Text(current.title, style = MaterialTheme.typography.titleLarge,
172+
fontWeight = FontWeight.SemiBold)
173+
Text(current.summary, style = MaterialTheme.typography.bodyMedium)
174+
detail?.let { info ->
175+
Text("License: " + (info.license ?: "Unknown"),
176+
style = MaterialTheme.typography.labelMedium)
177+
OutlinedButton(enabled = !busy, onClick = {
178+
runCatching {
179+
if (Desktop.isDesktopSupported()) {
180+
Desktop.getDesktop().browse(URI(info.projectUrl))
181+
}
182+
}.onFailure { error = it.message ?: "Could not open browser." }
183+
}) { Text("Read on Modrinth ↗") }
184+
HorizontalDivider()
185+
Text(info.description.ifBlank { "No further project description." }.take(3500),
186+
style = MaterialTheme.typography.bodySmall)
187+
}
188+
if (plan == null && !detailsLoading) {
189+
Button(enabled = canModify && !busy && !resolving,
190+
onClick = {
191+
resolving = true
192+
error = null
193+
scope.launch {
194+
try { plan = catalog.plan(instance, current) }
195+
catch (cancelled: CancellationException) { throw cancelled }
196+
catch (failure: Exception) {
197+
error = failure.message ?: "No compatible version found."
198+
} finally { resolving = false }
199+
}
200+
}) { Text("Review installation") }
201+
}
202+
plan?.let { proposal ->
203+
HorizontalDivider()
204+
Text("Ready to install", style = MaterialTheme.typography.titleMedium)
205+
proposal.entries.forEach { entry ->
206+
Text("• " + entry.title + " · " + entry.versionNumber +
207+
(if (entry.dependency) " (required)" else ""),
208+
style = MaterialTheme.typography.bodySmall)
209+
}
210+
proposal.notices.forEach { notice ->
211+
Text("Note: " + notice, style = MaterialTheme.typography.bodySmall,
212+
color = MaterialTheme.colorScheme.error)
213+
}
214+
Text("Downloads are checked against Modrinth SHA-1 and file size. " +
215+
"Mods execute third-party code when Minecraft starts.",
216+
style = MaterialTheme.typography.bodySmall)
217+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
218+
Button(enabled = canModify && !busy,
219+
onClick = { onInstall(proposal) }) { Text("Install to this instance") }
220+
TextButton(enabled = !busy, onClick = { plan = null }) {
221+
Text("Cancel") }
222+
}
223+
}
224+
}
225+
}
226+
}
227+
if (stacked) {
228+
Column(modifier = Modifier.fillMaxSize(),
229+
verticalArrangement = Arrangement.spacedBy(12.dp)) {
230+
list(Modifier.weight(1f).fillMaxWidth())
231+
HorizontalDivider()
232+
preview(Modifier.weight(1f).fillMaxWidth())
233+
}
234+
} else {
235+
Row(modifier = Modifier.fillMaxSize(),
236+
horizontalArrangement = Arrangement.spacedBy(14.dp)) {
237+
list(Modifier.weight(1f))
238+
preview(Modifier.weight(1f))
239+
}
240+
}
241+
}
242+
Text("Results are provided by Modrinth. Only listed compatible versions are offered; " +
243+
"the loader must already be installed in the selected instance.",
244+
style = MaterialTheme.typography.labelSmall,
245+
color = MaterialTheme.colorScheme.onSurfaceVariant)
246+
}
247+
}

‎launcher-desktop/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/desktop/ui/SyntaxLauncherApp.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,8 @@ private fun LauncherPanel(
552552
LocalModsDialog(
553553
instance = target,
554554
manager = session.modManager,
555+
catalog = session.modrinthCatalog,
556+
installer = session.modrinthInstaller,
555557
canModify = !isRunning && !launch.isPreparing && !mutationBusy,
556558
onBusyChange = { modsBusy = it },
557559
onDismiss = { if (!modsBusy) managingMods = null },

0 commit comments

Comments
 (0)