mcpbeat Sign in

R8 Proguard Desktop Survival Agent Skill

Running a bytecode shrinker over a JVM desktop app — which optimization families must stay off and why, why obfuscation breaks the rendering and reflection layers, the keep-rule families a native-binding plus coroutines plus HTTP-client app needs, and how to feed the shrunk jars to the packager. Reach for it when the release build starts with a verification error, renders a see-through or blank surface, or fails only in the packaged installer while the development run is fine.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill r8-proguard-desktop-survival

The instruction itself

3 sections, as written by the author

Surviving a shrinker on desktop

Shrinking a desktop JVM app is not the same job as shrinking a mobile one. Three differences drive

everything below: the desktop JVM's class-file verifier is strict and runs at load time, the UI

stack reaches its rendering backend reflectively and by name, and the **packager builds its

classpath from your build output**, so a shrink step that nothing consumes is invisible.

The policy this skill assumes: shrinking and obfuscation stay on, permanently. Runtime breakage

is fixed with additional keep rules, never by turning a pass off. optimize = false as a "quick

test" is how a temporary workaround becomes the permanent configuration.

Traps

Three optimization families must be disabled; everything else stays on. Each was found by a

crash in a release build that the development run could not reproduce:

-optimizations !method/specialization/*,!code/allocation/variable,!method/inlining/*

-optimizations is honoured by only one of the two shrinkers that read this rule dialect — the one

this was mined on, and the one pinned here. The other parses the file and ignores the directive

silently, so confirm your shrinker actually consumes it before believing these families are off;

otherwise the rule reads as protection while the optimizations still run.

  • *Return-type specialization* narrows a method's declared return type from an interface down to its

single implementation. The call site's bytecode still pushes the interface-typed value, so the

verifier rejects it with a bad-return-type error — hit the first time any text renders.

  • *Local-variable re-allocation* and *method inlining* together re-pack local slots after inlining

and emit a stack-map frame that disagrees with the real frame on two-slot values (long,

double). Symptom: an inconsistent-stack-map error the moment a large generated UI class loads —

recorded here at startup on the Windows release build. Both are long-standing upstream shrinker

bugs; check whether they are fixed before re-enabling, do not assume.

Note the shape: all three fail at class load, not at build time, and only in the shrunk build.

Obfuscation breaks anything resolved by name — and it usually fails silently. Two distinct

symptoms seen here, neither of which throws:

  • Renaming the rendering backend and the UI/AWT interop classes breaks the window

interop-blending path: canvases and video surfaces render see-through, showing the desktop behind

them. Nothing is logged.

  • Renaming a vector-animation renderer's internals leaves it running but painting nothing — the

animation area stays blank, no crash, no warning.

Keep those packages un-obfuscated wholesale. "Runs but draws nothing" is the fingerprint of an

obfuscation problem; "throws at startup" is the fingerprint of an optimization problem.

Keep rules come in families, not one-offs. For a desktop app with a native binding, coroutines

and an HTTP client, budget for all of these:

# adapted — substitute your own packages for the <bracketed> names
# 1. native binding: the JNI/foreign-function layer and anything mapping a native struct
-keepclasseswithmembers class * { native <methods>; }
-keep class <native-binding>.** { *; }
-keep class * implements <native-binding>.** { *; }
-keepclassmembers class * extends <native-binding>.Structure { public *; }

# 2. coroutines runtime — whole package. The optimizer flattens the job type hierarchy and
#    emits an illegal special-invoke for a method reached through an indirect superinterface,
#    which the strict verifier rejects. Narrow rules do not hold here.
-keep class <coroutines-runtime>.** { *; }
-keepclassmembernames class <coroutines-runtime>.** { volatile <fields>; }

# 3. HTTP client + the engine you actually select, including its volatile state fields
-keep class <http-client>.** { *; }
-keepclassmembers class <http-client>.** { volatile <fields>; }

# 4. reflective serialization: companions and generated serializers
-if @<serialization>.Serializable class **
-keepclassmembers class <1> { static <1>$Companion Companion; }
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault

# 5. database runtime + driver, whose generated code delegates into coroutines
-keep class <db-runtime>.** { *; }
-keep class * extends <db-runtime>.RoomDatabase { <init>(); }

# 6. service-loader discovery — the interface name, the implementations, and the resource
-keepnames class <spi-interface>
-keep class * implements <spi-interface> { *; }
-adaptresourcefilecontents META-INF/services/**

# 7. anything reflecting on generic signatures
-keepattributes Signature, InnerClasses, EnclosingMethod

-dontwarn is a claim that the code path is unreachable — write down why. A library compiled

against a different version of a shared rendering or native backend leaves references the shrinker

cannot resolve, and it aborts rather than guessing. Suppressing that warning is correct *only* when

the path genuinely never runs on this platform (a UI effect that platform never renders, a fallback

engine you do not ship) — put that reason in the comment, or the next person's options are "suppress

it too" and "spend a day proving it is safe". If the path *is* reachable, the real fix is a version

alignment; see transitive-version-pinning.

Pin the shrinker version to one that can read your newest class file. A dependency compiled at a

newer Java release aborts an older shrinker outright — a version bump, not a missing keep rule.

Feed the shrunk jars to the packager, or you ship the unshrunk ones. The packaging plugin

auto-detects the classpath from the build, which is the *raw* dependency set. The shrink task's

output has to replace it explicitly, and the config-writing task has to depend on the shrink task:

// adapted — the packager's config-writing task, overriding its auto-detected classpath
tasks.named<WriteConveyorConfigTask>("writeConveyorConfig") {
    dependsOn(tasks.named("proguardReleaseJars"))
    val shrunkJars = layout.buildDirectory.dir("compose/tmp/main-release/proguard")
    doLast {
        destination.get().asFile.appendText(
            "\napp.inputs = [ \"${shrunkJars.get().asFile.absolutePath}\" ]\n",
        )
    }
}

The packager expands a directory entry to every file inside it, so one line replaces the whole raw

jar list. Measure the difference — if the installer size did not move, the override did not take.

Verifying it

The shrunk build only fails in the shrunk build, so a development run proves nothing. After any rule

change, package a release and exercise, in one session: text rendering (catches the verifier

issues), an animation (catches obfuscation of the animation renderer), a **transparent or video

surface (catches interop renaming), one network call, opening the database, and the

native binding**. Everything on that list has been broken by a shrinker at some point here, each

with its own silent signature.

When something does break, add the keep rule for the whole package of the offending library

rather than the single class named in the stack trace — optimization passes reshape neighbouring

classes, so a narrow rule usually just moves the failure one class over.

How to use it

Copy the folder

Take maxrave-dev/r8-proguard-desktop-survival from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.