Skip to content

Commit 9549972

Browse files
committed
test(docs): document v0.5 acceptance and exercise local mod operations
1 parent 076dfcd commit 9549972

6 files changed

Lines changed: 222 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,22 @@ occur on the first Fabric launch rather than when the profile settings are saved
5858
OptiFine remains a later graphics enhancement rather than the first loader: its installer and
5959
Forge-specific compatibility constraints are different from Fabric's public launcher-profile API.
6060

61+
## v0.5 — Local Mod Manager (development; acceptance pending)
62+
63+
Fabric instances now offer an **Instances → Mods** workspace for importing local JAR files,
64+
reviewing detectable `fabric.mod.json` id/name/version/dependencies, turning mods on/off
65+
(reversible `.jar.disabled` rename), and removing files to each instance's
66+
`game/.syntaxlauncher-mod-trash` for manual recovery. The original import source is preserved;
67+
SHA-256 and file names are tracked from disk, and enabled mod ID duplicates and basic
68+
missing/conflicting dependencies appear as warnings.
69+
70+
Operations are isolated to the instance's own `game/mods` folder and use the same
71+
cross-process activity lock as the launch/clone functions. This phase does not download
72+
dependencies or treat locally parsed metadata as a complete compatibility resolver.
73+
Only import JARs from trusted sources: Minecraft executes enabled mod code.
74+
See [the v0.5 verification checklist](docs/TESTING_V0.5.md).
75+
**Manual game startup and UX acceptance remain unverified**, including postponed v0.4 checks.
76+
6177
## Current implementation
6278

6379
The current v0.1 implementation includes:

‎docs/ROADMAP.md‎

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -402,37 +402,43 @@ offline account tests for this milestone.
402402

403403
Goal: manage local mod JARs safely before adding online repositories.
404404

405+
**Status:** 🚧 Implementation on main; CI/manual acceptance pending. v0.4 real Fabric
406+
boot tests were explicitly deferred by the maintainer on 20 October 2026 and remain open.
407+
See [v0.5 test checklist](TESTING_V0.5.md).
408+
405409
## Mod model
406410

407-
- [ ] Add local mod representation.
408-
- [ ] Track enabled/disabled state.
409-
- [ ] Track file path/hash.
410-
- [ ] Parse available mod metadata formats relevant to supported loaders.
411-
- [ ] Expose mod id/name/version when available.
412-
- [ ] Detect duplicate mod ids/files where practical.
411+
- [ ] 🚧 IN PROGRESS — Local JAR model includes file name, size, SHA-256, enabled flag and optional Fabric metadata; automated test acceptance pending.
412+
- [ ] 🚧 IN PROGRESS — Enabled/disabled state persisted via per-instance filesystem rename; manual testing pending.
413+
- [ ] 🚧 IN PROGRESS — Track file hash and filename from the instance's own mods directory; CI acceptance pending.
414+
- [ ] 🚧 IN PROGRESS — Read `fabric.mod.json`; dedicated Quilt/Forge metadata belongs to their later loader milestones.
415+
- [ ] 🚧 IN PROGRESS — Expose Fabric id/name/version, declared requirements/conflicts; client verification pending.
416+
- [ ] 🚧 IN PROGRESS — Detect duplicate enabled IDs and identical JAR SHA-256 at import; automated tests pending.
413417

414418
## Local mod operations
415419

416-
- [ ] Add/import mod JAR into instance.
417-
- [ ] Remove mod.
418-
- [ ] Enable/disable mod without deleting it.
419-
- [ ] Show installed mods.
420-
- [ ] Surface basic compatibility/dependency metadata.
421-
- [ ] Avoid modifying mods in other instances.
420+
- [ ] 🚧 IN PROGRESS — Import local JAR through the Fabric instance dialog and atomic staging; tests pending.
421+
- [ ] 🚧 IN PROGRESS — Remove JAR to a recoverable instance-local trash folder; manual acceptance pending.
422+
- [ ] 🚧 IN PROGRESS — Enable/disable with a reversible `.jar.disabled` rename; acceptance pending.
423+
- [ ] 🚧 IN PROGRESS — Show installed local mods and basic metadata in a scrollable dialog; UX check pending.
424+
- [ ] 🚧 IN PROGRESS — Warn about missing/conflicting declared Fabric dependencies; version ranges advisory only.
425+
- [ ] 🚧 IN PROGRESS — Use the instance repository's cross-process lock for mutations and never touch other profiles; acceptance pending.
422426

423427
## Dependency awareness
424428

425-
- [ ] Detect obviously missing declared dependencies where metadata provides them.
426-
- [ ] Detect obvious declared incompatibilities/conflicts where practical.
427-
- [ ] Present warnings before launch.
428-
- [ ] Do not pretend local-only dependency resolution is complete when repository data is unavailable.
429+
- [ ] 🚧 IN PROGRESS — Warn for missing enabled declared dependencies; no remote lookup.
430+
- [ ] 🚧 IN PROGRESS — Warn when enabled mods declare an obvious `breaks` conflict.
431+
- [ ] 🚧 IN PROGRESS — Warnings appear in the mods dialog before launch; no blocking pre-launch check yet.
432+
- [x] Modrinth-backed dependency resolution remains explicitly in v0.6; v0.5 does not claim it.
429433

430434
## v0.5 quality gate
431435

432-
- [ ] Mod enable/disable is reversible.
433-
- [ ] Mod state persists per instance.
434-
- [ ] Fabric instance can launch with locally managed mods.
435-
- [ ] Vanilla instance behavior remains unaffected.
436+
- [ ] Mod enable/disable is reversible (automated test added; CI pending).
437+
- [ ] Mod state persists per instance (automated test added; real client pending).
438+
- [ ] Fabric instance can launch with locally managed mods (real game boot deferred).
439+
- [ ] Vanilla instance behavior remains unaffected (automated coverage added; manual pending).
440+
- [ ] Per-instance mods mutations are protected against concurrent launcher/game use.
441+
- [ ] Manual tests in `docs/TESTING_V0.5.md` pass.
436442

437443
---
438444

‎docs/TESTING_V0.5.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# v0.5 — Local Mod Manager verification
2+
3+
The maintainer deferred manual v0.4 acceptance on 20 October 2026. This **does not mean Fabric boot was tested**.
4+
5+
## Automated
6+
- [ ] GitHub Actions Gradle core/desktop tests and build pass.
7+
- [ ] Instance-scoped import preserves source and other profiles.
8+
- [ ] Enable/disable is a reversible rename with identical SHA-256.
9+
- [ ] Removal moves a mod to game/.syntaxlauncher-mod-trash.
10+
- [ ] Missing dependencies, duplicate active IDs and disabled dependencies are reported.
11+
- [ ] Invalid archives and filename traversal are rejected.
12+
13+
## Manual — not yet executed
14+
- [ ] Create or select a Fabric profile; open Instances → Mods.
15+
- [ ] Import a local, trusted Fabric JAR with appropriate Minecraft/loader compatibility.
16+
- [ ] Confirm JAR appears under only that instance's game/mods directory.
17+
- [ ] Confirm enabling/disabling/removing updates the list, disk and UI without blocking.
18+
- [ ] Launch a real Fabric client with the mod and verify it loads.
19+
- [ ] Launch Vanilla and a second Fabric profile; neither inherits the first profile's mods.
20+
- [ ] Test duplicate filenames, duplicate IDs, missing dependencies, malformed JAR and a mod requiring a newer Fabric loader.
21+
- [ ] Try changing mods while the game runs and after detached launcher exit; mutations must be rejected.
22+
- [ ] Verify Windows/macOS/Linux dialogs, small displays and file chooser.
23+
24+
**Scope:** Fabric metadata is advisory only. Version-range evaluation, nested-JAR diagnostics,
25+
repository-driven dependency resolution, automatic downloads, update matching and dedicated Quilt/Forge
26+
metadata readers are deferred; do not assume a missing warning implies compatibility.
27+
A local JAR can execute arbitrary code once Minecraft loads it, so use trusted sources only.

‎docs/WORKLOG.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,42 @@ Every code/build/documentation/CI task must add an entry according to `AGENTS.md
88

99
---
1010

11+
## 2026-10-20 — Begin v0.5 instance-local Fabric mod management
12+
13+
**Milestone:** v0.5 — Local Mod Manager
14+
**Author/Agent:** GPT-6
15+
**Status:** Partial — source pushed to main; CI and real game acceptance pending
16+
17+
### Completed
18+
- Added a UI-independent local JAR manager reading optional Fabric mod metadata, computing SHA-256 and warning about duplicate active IDs, missing declared dependencies and conflicts.
19+
- Implemented atomic, non-overwriting import from a user-selected JAR, a reversible enable/disable filename change, and a remove-to-private-trash operation.
20+
- Kept writes scoped to one instance and synchronized with the repository's existing cross-process activity lock. Rejected symlinked mods locations and archive/filename traversal.
21+
- Connected a scrollable Compose mod dialog and local file picker to Fabric instance management; future online downloads remain in v0.6.
22+
- Added deterministic tests for imports, reversibility, metadata, missing dependencies, duplicates and Vanilla isolation.
23+
- Maintainer explicitly deferred v0.4 manual tests; none were marked passed.
24+
25+
### Changed files
26+
- `launcher-core/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/core/mods/LocalModManager.kt` — local mod service and metadata diagnostics.
27+
- `launcher-core/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/core/instance/MinecraftInstanceRepository.kt` — guarded instance mutation access.
28+
- `launcher-desktop/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/desktop/{DesktopLauncherSession.kt,ui/InstanceWorkspace.kt,ui/SyntaxLauncherApp.kt,ui/LocalModsDialog.kt}` — UI and wiring.
29+
- `launcher-core/src/test/kotlin/pl/syntaxdevteam/syntaxlauncher/core/mods/LocalModManagerTest.kt` — regression tests.
30+
- `README.md`, `docs/ROADMAP.md`, `docs/TESTING_V0.5.md`, `docs/WORKLOG.md` — documentation.
31+
32+
### Verification
33+
- `./gradlew test` — NOT RUN locally; GitHub Actions pending.
34+
- `./gradlew build` — NOT RUN locally; CI pending.
35+
- Manual Minecraft startup, Fabric mod load and cross-platform GUI checks — NOT RUN (deferred).
36+
37+
### Roadmap updates
38+
- `[ ]` v0.5 implementation items are marked 🚧 until tests and acceptance pass.
39+
- `[ ]` v0.4 real client and desktop verification stay outstanding.
40+
41+
### Follow-ups / known issues
42+
- Review CI, fix failures and reconcile passing automated checkpoints in roadmap.
43+
- Run live Fabric with enabled/disabled local mods; validate interaction with other Fabric instances and Vanilla.
44+
- Extend metadata matching/version range handling and pre-launch dependency warnings without hiding optional/unknown states.
45+
- Confirm user-supplied local mod JAR provenance; enabled mods run third-party code in Minecraft.
46+
1147
## 2026-10-19 — Verify Fabric v0.4 implementation across CI platforms
1248

1349
**Milestone:** v0.4 — Fabric Loader

‎launcher-core/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/core/mods/LocalModManager.kt‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,10 @@ class LocalModManager(private val instances: MinecraftInstanceRepository) {
111111
withContext(Dispatchers.IO) {
112112
instances.withInactiveInstance(instanceId) { instance ->
113113
val mods = modsDirectory(instance)
114-
val base = requireFileName(fileName)
115-
val from = mods.resolve(base).takeIf {
116-
Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS)
117-
} ?: mods.resolve("$base.disabled").also {
118-
require(Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS)) {
119-
"Mod no longer exists or is not a regular file."
120-
}
114+
requireFileName(fileName)
115+
val from = mods.resolve(fileName)
116+
require(Files.isRegularFile(from, LinkOption.NOFOLLOW_LINKS)) {
117+
"Mod no longer exists or is not a regular file."
121118
}
122119
val trash = instances.gameDirectory(instance).resolve(".syntaxlauncher-mod-trash")
123120
require(!Files.isSymbolicLink(trash)) { "Unsafe mod trash directory." }
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.mods
2+
3+
import java.nio.file.Files
4+
import java.nio.file.Path
5+
import java.util.zip.ZipEntry
6+
import java.util.zip.ZipOutputStream
7+
import kotlin.test.Test
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertFailsWith
10+
import kotlin.test.assertFalse
11+
import kotlin.test.assertTrue
12+
import kotlinx.coroutines.runBlocking
13+
import pl.syntaxdevteam.syntaxlauncher.core.instance.MinecraftInstanceRepository
14+
import pl.syntaxdevteam.syntaxlauncher.core.loader.ModLoaderSelection
15+
import pl.syntaxdevteam.syntaxlauncher.core.storage.LauncherPaths
16+
import pl.syntaxdevteam.syntaxlauncher.core.storage.MinecraftInstallationLayout
17+
18+
class LocalModManagerTest {
19+
private fun setup(): Pair<MinecraftInstanceRepository, LocalModManager> {
20+
val root = Files.createTempDirectory("syntax-local-mod-")
21+
val paths = LauncherPaths(root.resolve("data"), root.resolve("config"),
22+
root.resolve("cache"), root.resolve("logs"), root.resolve("runtimes"))
23+
val instances = MinecraftInstanceRepository(
24+
paths.dataDir.resolve("instances"),
25+
paths.dataDir.resolve("instance-trash"),
26+
MinecraftInstallationLayout.from(paths),
27+
)
28+
return instances to LocalModManager(instances)
29+
}
30+
31+
private fun jar(path: Path, id: String, requires: String = "{}"): Path {
32+
ZipOutputStream(Files.newOutputStream(path)).use { archive ->
33+
archive.putNextEntry(ZipEntry("fabric.mod.json"))
34+
archive.write(
35+
"""{"schemaVersion":1,"id":"$id","name":"Test $id","version":"1.2.3","depends":$requires}"""
36+
.toByteArray())
37+
archive.closeEntry()
38+
}
39+
return path
40+
}
41+
42+
@Test fun importingTogglingAndRemovingKeepsInstanceDataSafe() = runBlocking {
43+
val (repo, manager) = setup()
44+
val first = repo.create("Fabric A", "26.3", loader = ModLoaderSelection("fabric", "0.18.0"))
45+
val second = repo.create("Fabric B", "26.3", loader = ModLoaderSelection("fabric", "0.18.0"))
46+
val source = jar(Files.createTempFile("demo-mod", ".jar"), "demo")
47+
val initial = manager.importJar(first.id, source)
48+
assertEquals(1, initial.mods.size)
49+
val entry = initial.mods.single()
50+
assertTrue(entry.enabled)
51+
assertEquals("demo", entry.metadata?.id)
52+
assertEquals("1.2.3", entry.metadata?.version)
53+
assertEquals(64, entry.sha256.length)
54+
assertTrue(Files.exists(source))
55+
assertTrue(manager.list(second.id).mods.isEmpty())
56+
57+
val off = manager.setEnabled(first.id, entry.fileName, false).mods.single()
58+
assertFalse(off.enabled)
59+
assertTrue(off.fileName.endsWith(".jar.disabled"))
60+
assertTrue(Files.exists(repo.gameDirectory(first).resolve("mods").resolve(off.fileName)))
61+
val on = manager.setEnabled(first.id, off.fileName, true).mods.single()
62+
assertTrue(on.enabled)
63+
assertEquals(entry.sha256, on.sha256)
64+
65+
val removed = manager.remove(first.id, on.fileName)
66+
assertTrue(removed.mods.isEmpty())
67+
val trash = repo.gameDirectory(first).resolve(".syntaxlauncher-mod-trash")
68+
Files.newDirectoryStream(trash).use { assertEquals(1, it.count()) }
69+
assertTrue(Files.exists(source))
70+
assertTrue(manager.list(second.id).mods.isEmpty())
71+
}
72+
73+
@Test fun missingDependenciesAndDuplicateIdsProduceWarnings() = runBlocking {
74+
val (repo, manager) = setup()
75+
val profile = repo.create("Fabric", "26.3", loader = ModLoaderSelection("fabric", "0.18.0"))
76+
val a = jar(Files.createTempDirectory("mods").resolve("alpha.jar"), "alpha",
77+
"""{"fabricloader":">=0.14.0","missing-lib":"*"}""")
78+
val b = jar(Files.createTempDirectory("mods").resolve("another.jar"), "alpha")
79+
val first = manager.importJar(profile.id, a)
80+
assertTrue(first.warnings.any { "missing-lib" in it })
81+
val second = manager.importJar(profile.id, b)
82+
assertTrue(second.warnings.any { "Duplicate enabled mod id 'alpha'" in it })
83+
assertFailsWith<IllegalStateException> {
84+
manager.importJar(profile.id, b)
85+
}
86+
}
87+
88+
@Test fun refusesVanillaInvalidArchivesAndUntrustedNames() = runBlocking {
89+
val (repo, manager) = setup()
90+
val vanilla = repo.create("Vanilla", "26.3")
91+
val fabric = repo.create("Fabric", "26.3", loader = ModLoaderSelection("fabric", "0.18.0"))
92+
val jar = jar(Files.createTempDirectory("mod-jar").resolve("example.jar"), "example")
93+
assertFailsWith<IllegalArgumentException> { manager.importJar(vanilla.id, jar) }
94+
val fake = Files.createTempDirectory("mod-bad").resolve("broken.jar")
95+
Files.writeString(fake, "not a ZIP/JAR")
96+
assertFailsWith<Exception> { manager.importJar(fabric.id, fake) }
97+
assertTrue(manager.list(fabric.id).mods.isEmpty())
98+
assertFailsWith<IllegalArgumentException> { manager.setEnabled(fabric.id, "../path.jar", false) }
99+
}
100+
101+
@Test fun disabledModDoesNotSatisfyRequiredDependency() = runBlocking {
102+
val (repo, manager) = setup()
103+
val profile = repo.create("Fabric", "26.3", loader = ModLoaderSelection("fabric", "0.18.0"))
104+
val dependent = jar(Files.createTempDirectory("dependent").resolve("dependent.jar"),
105+
"dependent", """{"library":"*"}""")
106+
val library = jar(Files.createTempDirectory("library").resolve("library.jar"), "library")
107+
manager.importJar(profile.id, dependent)
108+
manager.importJar(profile.id, library)
109+
assertFalse(manager.list(profile.id).warnings.any { "requires 'library'" in it })
110+
manager.setEnabled(profile.id, "library.jar", false)
111+
assertTrue(manager.list(profile.id).warnings.any { "requires 'library'" in it })
112+
}
113+
}

0 commit comments

Comments
 (0)