Skip to content

Commit a52bc84

Browse files
committed
more fixes
1 parent c650c13 commit a52bc84

File tree

3 files changed

+282
-10
lines changed

3 files changed

+282
-10
lines changed

src/main/kotlin/com/jonnyzzz/intellij/hotreload/PluginHotReloadService.kt

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,16 @@ class PluginHotReloadService {
8585
progress.report(HotReloadBundle.message("progress.plugin.id", pluginId))
8686
log.info("Extracted plugin ID: $pluginId")
8787

88-
// Step 2: Save zip to temp file
88+
// Step 2: Check for self-reload attempt
89+
val selfPluginId = getSelfPluginId()
90+
if (pluginId == selfPluginId) {
91+
val errorMsg = HotReloadBundle.message("error.self.reload")
92+
log.warn("Attempted to reload the hot-reload plugin itself (ID: $selfPluginId)")
93+
progress.reportError(errorMsg)
94+
return ReloadResult(false, errorMsg, pluginId)
95+
}
96+
97+
// Step 3: Save zip to temp file
8998
val tempZipFile = try {
9099
val tempFile = Files.createTempFile("plugin-hot-reload-", ".zip")
91100
Files.write(tempFile, zipBytes)
@@ -118,7 +127,7 @@ class PluginHotReloadService {
118127
): ReloadResult {
119128
val pluginId = PluginId.getId(pluginIdString)
120129

121-
// Step 3: Find existing plugin
130+
// Step 4: Find existing plugin
122131
progress.report(HotReloadBundle.message("progress.looking.for.existing"))
123132
val existingPlugin = PluginManagerCore.getPlugin(pluginId)
124133
val existingPluginPath = existingPlugin?.pluginPath
@@ -127,11 +136,11 @@ class PluginHotReloadService {
127136
progress.report(HotReloadBundle.message("progress.existing.plugin", existingPlugin.name, existingPluginPath.toString()))
128137
}
129138

130-
// Step 4: Check if dynamic reload is possible and get reason if not
139+
// Step 5: Check if dynamic reload is possible and get reason if not
131140
var unloadBlockedReason: String? = null
132141
if (existingPlugin != null) {
133142
val descriptor = existingPlugin as? IdeaPluginDescriptorImpl
134-
if (descriptor != null) {
143+
if (descriptor !== null) {
135144
@Suppress("UnstableApiUsage")
136145
unloadBlockedReason = DynamicPlugins.checkCanUnloadWithoutRestart(descriptor)
137146
if (unloadBlockedReason != null) {
@@ -142,11 +151,11 @@ class PluginHotReloadService {
142151
}
143152
}
144153

145-
// Step 5: Unload existing plugin if it exists and is enabled
154+
// Step 6: Unload existing plugin if it exists and is enabled
146155
var memoryDumpPath: String? = null
147156
if (existingPlugin != null && !PluginManagerCore.isDisabled(pluginId)) {
148157
val descriptor = existingPlugin as? IdeaPluginDescriptorImpl
149-
if (descriptor != null) {
158+
if (descriptor !== null) {
150159
progress.report(HotReloadBundle.message("progress.unloading", existingPlugin.name))
151160

152161
@Suppress("UnstableApiUsage")
@@ -170,7 +179,7 @@ class PluginHotReloadService {
170179
}
171180
}
172181

173-
// Step 6: Delete old plugin folder (rename first for safety)
182+
// Step 7: Delete old plugin folder (rename first for safety)
174183
if (existingPluginPath != null && Files.exists(existingPluginPath)) {
175184
progress.report(HotReloadBundle.message("progress.removing.old", existingPluginPath))
176185
try {
@@ -190,7 +199,7 @@ class PluginHotReloadService {
190199
}
191200
}
192201

193-
// Step 7: Load descriptor from the zip file
202+
// Step 8: Load descriptor from the zip file
194203
progress.report(HotReloadBundle.message("progress.loading.descriptor"))
195204
@Suppress("UnstableApiUsage")
196205
val newDescriptor = try {
@@ -202,7 +211,7 @@ class PluginHotReloadService {
202211
return ReloadResult(false, errorMsg, pluginIdString, memoryDumpPath = memoryDumpPath, unloadBlockedReason = unloadBlockedReason)
203212
}
204213

205-
if (newDescriptor == null) {
214+
if (newDescriptor === null) {
206215
val errorMsg = HotReloadBundle.message("error.descriptor.load.failed")
207216
progress.reportError(errorMsg)
208217
return ReloadResult(false, errorMsg, pluginIdString, memoryDumpPath = memoryDumpPath, unloadBlockedReason = unloadBlockedReason)
@@ -213,7 +222,7 @@ class PluginHotReloadService {
213222

214223
progress.report(HotReloadBundle.message("progress.installing", pluginName, pluginVersion ?: "unknown"))
215224

216-
// Step 8: Install and load the plugin dynamically
225+
// Step 9: Install and load the plugin dynamically
217226
@Suppress("UnstableApiUsage")
218227
val loaded = try {
219228
PluginInstaller.installAndLoadDynamicPlugin(zipFile, null, newDescriptor as IdeaPluginDescriptorImpl)
@@ -260,22 +269,50 @@ class PluginHotReloadService {
260269

261270
/**
262271
* Extract the plugin ID from plugin.xml inside the zip file.
272+
* Handles both flat structure (META-INF/plugin.xml) and nested jar structure
273+
* (plugin-name/lib/plugin-name.jar containing META-INF/plugin.xml).
263274
*/
264275
internal fun extractPluginId(zipBytes: ByteArray): String? {
265276
ZipInputStream(ByteArrayInputStream(zipBytes)).use { zis ->
266277
var entry = zis.nextEntry
267278
while (entry != null) {
268279
val name = entry.name
280+
// Check for direct plugin.xml (flat structure)
269281
if (name.endsWith("/META-INF/plugin.xml") || name == "META-INF/plugin.xml") {
270282
val xmlBytes = zis.readBytes()
271283
return parsePluginIdFromXml(xmlBytes)
272284
}
285+
// Check for jars in lib folder (nested structure)
286+
if (name.contains("/lib/") && name.endsWith(".jar") && !entry.isDirectory) {
287+
val jarBytes = zis.readBytes()
288+
val pluginId = extractPluginIdFromJar(jarBytes)
289+
if (pluginId != null) {
290+
return pluginId
291+
}
292+
}
273293
entry = zis.nextEntry
274294
}
275295
}
276296
return null
277297
}
278298

299+
/**
300+
* Extract plugin ID from a jar file that may contain META-INF/plugin.xml.
301+
*/
302+
private fun extractPluginIdFromJar(jarBytes: ByteArray): String? {
303+
ZipInputStream(ByteArrayInputStream(jarBytes)).use { jis ->
304+
var entry = jis.nextEntry
305+
while (entry != null) {
306+
if (entry.name == "META-INF/plugin.xml") {
307+
val xmlBytes = jis.readBytes()
308+
return parsePluginIdFromXml(xmlBytes)
309+
}
310+
entry = jis.nextEntry
311+
}
312+
}
313+
return null
314+
}
315+
279316
/**
280317
* Parse the plugin ID from plugin.xml content.
281318
*/
@@ -298,3 +335,17 @@ class PluginHotReloadService {
298335
}
299336
}
300337
}
338+
339+
340+
/**
341+
* Gets the plugin ID of this hot-reload plugin dynamically.
342+
* We cannot reload ourselves - the reload code would be unloaded mid-execution.
343+
*
344+
* Falls back to EXPECTED_PLUGIN_ID if dynamic lookup fails (e.g., during tests
345+
* or if the plugin classloader isn't properly set up).
346+
*/
347+
fun getSelfPluginId(): String {
348+
return PluginManager.getPluginByClass(PluginHotReloadService::class.java)
349+
?.pluginId?.idString
350+
?: "com.jonnyzzz.intellij.hot-reload"
351+
}

src/main/resources/messages/HotReloadBundle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ progress.reloaded=Plugin {0} reloaded successfully
2929

3030
# Error messages
3131
error.no.plugin.id=Could not determine plugin ID from zip
32+
error.self.reload=Cannot hot-reload the hot-reload plugin itself. Please restart the IDE to update this plugin.
3233
error.descriptor.load.failed=Failed to load plugin descriptor
3334
error.install.failed=Failed to install plugin
3435
error.unload.failed=Failed to unload existing plugin

src/test/kotlin/com/jonnyzzz/intellij/hotreload/PluginHotReloadServiceTest.kt

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,226 @@ class PluginHotReloadServiceTest : BasePlatformTestCase() {
117117
assertNull("Should return null when id element is missing", pluginId)
118118
}
119119

120+
/**
121+
* Tests extraction of plugin ID from IntelliJ's actual plugin zip structure:
122+
* plugin-name/lib/plugin-name.jar containing META-INF/plugin.xml
123+
*
124+
* This is the format produced by `./gradlew buildPlugin`.
125+
*/
126+
fun testExtractPluginIdFromJarInsideZip() {
127+
val pluginXml = """
128+
<idea-plugin>
129+
<id>com.example.jar-nested-plugin</id>
130+
<name>Jar Nested Plugin</name>
131+
</idea-plugin>
132+
""".trimIndent()
133+
134+
// Create the inner JAR with META-INF/plugin.xml
135+
val innerJarBytes = ByteArrayOutputStream().use { jarBaos ->
136+
ZipOutputStream(jarBaos).use { jarZos ->
137+
jarZos.putNextEntry(ZipEntry("META-INF/"))
138+
jarZos.closeEntry()
139+
jarZos.putNextEntry(ZipEntry("META-INF/plugin.xml"))
140+
jarZos.write(pluginXml.toByteArray(StandardCharsets.UTF_8))
141+
jarZos.closeEntry()
142+
}
143+
jarBaos.toByteArray()
144+
}
145+
146+
// Create the outer ZIP with the plugin structure
147+
val zipBytes = ByteArrayOutputStream().use { baos ->
148+
ZipOutputStream(baos).use { zos ->
149+
zos.putNextEntry(ZipEntry("jar-nested-plugin/"))
150+
zos.closeEntry()
151+
zos.putNextEntry(ZipEntry("jar-nested-plugin/lib/"))
152+
zos.closeEntry()
153+
zos.putNextEntry(ZipEntry("jar-nested-plugin/lib/jar-nested-plugin.jar"))
154+
zos.write(innerJarBytes)
155+
zos.closeEntry()
156+
}
157+
baos.toByteArray()
158+
}
159+
160+
val service = PluginHotReloadService()
161+
val pluginId = service.extractPluginId(zipBytes)
162+
163+
assertEquals("com.example.jar-nested-plugin", pluginId)
164+
}
165+
166+
/**
167+
* Tests that we can extract plugin ID when there are multiple jars,
168+
* and the plugin.xml is in the second jar (not the first).
169+
*/
170+
fun testExtractPluginIdFromSecondJarInZip() {
171+
val pluginXml = """
172+
<idea-plugin>
173+
<id>com.example.multi-jar-plugin</id>
174+
<name>Multi Jar Plugin</name>
175+
</idea-plugin>
176+
""".trimIndent()
177+
178+
// Create a jar WITHOUT plugin.xml (like a dependency)
179+
val depJarBytes = ByteArrayOutputStream().use { jarBaos ->
180+
ZipOutputStream(jarBaos).use { jarZos ->
181+
jarZos.putNextEntry(ZipEntry("com/example/Dep.class"))
182+
jarZos.write("fake class bytes".toByteArray())
183+
jarZos.closeEntry()
184+
}
185+
jarBaos.toByteArray()
186+
}
187+
188+
// Create the plugin jar WITH plugin.xml
189+
val pluginJarBytes = ByteArrayOutputStream().use { jarBaos ->
190+
ZipOutputStream(jarBaos).use { jarZos ->
191+
jarZos.putNextEntry(ZipEntry("META-INF/"))
192+
jarZos.closeEntry()
193+
jarZos.putNextEntry(ZipEntry("META-INF/plugin.xml"))
194+
jarZos.write(pluginXml.toByteArray(StandardCharsets.UTF_8))
195+
jarZos.closeEntry()
196+
}
197+
jarBaos.toByteArray()
198+
}
199+
200+
// Create the outer ZIP with multiple jars
201+
val zipBytes = ByteArrayOutputStream().use { baos ->
202+
ZipOutputStream(baos).use { zos ->
203+
zos.putNextEntry(ZipEntry("multi-jar-plugin/"))
204+
zos.closeEntry()
205+
zos.putNextEntry(ZipEntry("multi-jar-plugin/lib/"))
206+
zos.closeEntry()
207+
// Add dependency jar first (no plugin.xml)
208+
zos.putNextEntry(ZipEntry("multi-jar-plugin/lib/dependency.jar"))
209+
zos.write(depJarBytes)
210+
zos.closeEntry()
211+
// Add plugin jar second (has plugin.xml)
212+
zos.putNextEntry(ZipEntry("multi-jar-plugin/lib/plugin.jar"))
213+
zos.write(pluginJarBytes)
214+
zos.closeEntry()
215+
}
216+
baos.toByteArray()
217+
}
218+
219+
val service = PluginHotReloadService()
220+
val pluginId = service.extractPluginId(zipBytes)
221+
222+
assertEquals("com.example.multi-jar-plugin", pluginId)
223+
}
224+
225+
/**
226+
* Tests extraction with the actual mcp-steroids plugin zip structure simulation.
227+
* This mimics: intellij-mcp-steroid/lib/intellij-mcp-steroid-VERSION.jar
228+
*/
229+
fun testExtractPluginIdLikeMcpSteroids() {
230+
val pluginXml = """
231+
<idea-plugin>
232+
<id>com.jonnyzzz.intellij.mcp-steroid</id>
233+
<name>IntelliJ MCP Steroid</name>
234+
<vendor>jonnyzzz.com</vendor>
235+
</idea-plugin>
236+
""".trimIndent()
237+
238+
// Create the plugin jar
239+
val pluginJarBytes = ByteArrayOutputStream().use { jarBaos ->
240+
ZipOutputStream(jarBaos).use { jarZos ->
241+
jarZos.putNextEntry(ZipEntry("META-INF/"))
242+
jarZos.closeEntry()
243+
jarZos.putNextEntry(ZipEntry("META-INF/plugin.xml"))
244+
jarZos.write(pluginXml.toByteArray(StandardCharsets.UTF_8))
245+
jarZos.closeEntry()
246+
// Add some fake class files like a real plugin would have
247+
jarZos.putNextEntry(ZipEntry("com/jonnyzzz/intellij/mcp/SteroidsMcpServer.class"))
248+
jarZos.write("fake class".toByteArray())
249+
jarZos.closeEntry()
250+
}
251+
jarBaos.toByteArray()
252+
}
253+
254+
// Create the outer ZIP with the exact structure buildPlugin produces
255+
val zipBytes = ByteArrayOutputStream().use { baos ->
256+
ZipOutputStream(baos).use { zos ->
257+
zos.putNextEntry(ZipEntry("intellij-mcp-steroid/"))
258+
zos.closeEntry()
259+
zos.putNextEntry(ZipEntry("intellij-mcp-steroid/lib/"))
260+
zos.closeEntry()
261+
zos.putNextEntry(ZipEntry("intellij-mcp-steroid/lib/intellij-mcp-steroid-0.84.0-SNAPSHOT.jar"))
262+
zos.write(pluginJarBytes)
263+
zos.closeEntry()
264+
}
265+
baos.toByteArray()
266+
}
267+
268+
val service = PluginHotReloadService()
269+
val pluginId = service.extractPluginId(zipBytes)
270+
271+
assertEquals("com.jonnyzzz.intellij.mcp-steroid", pluginId)
272+
}
273+
274+
/**
275+
* Tests that attempting to reload the hot-reload plugin itself is blocked.
276+
* The plugin cannot reload itself - it would unload mid-execution.
277+
*/
278+
fun testSelfReloadIsBlocked() {
279+
val pluginXml = """
280+
<idea-plugin>
281+
<id>com.jonnyzzz.intellij.hot-reload</id>
282+
<name>Hot Reload</name>
283+
</idea-plugin>
284+
""".trimIndent()
285+
286+
val zipBytes = createPluginZip("hot-reload", pluginXml)
287+
288+
val service = PluginHotReloadService()
289+
val result = service.reloadPlugin(zipBytes)
290+
291+
assertFalse("Should fail when trying to reload self", result.success)
292+
assertTrue("Should mention cannot reload itself", result.message.contains("cannot", ignoreCase = true) || result.message.contains("Cannot", ignoreCase = true))
293+
assertEquals("Plugin ID should be set", getSelfPluginId(), result.pluginId)
294+
}
295+
296+
/**
297+
* Tests that attempting to reload via jar structure is also blocked.
298+
*/
299+
fun testSelfReloadFromJarIsBlocked() {
300+
val pluginXml = """
301+
<idea-plugin>
302+
<id>com.jonnyzzz.intellij.hot-reload</id>
303+
<name>Hot Reload Plugin</name>
304+
</idea-plugin>
305+
""".trimIndent()
306+
307+
// Create the plugin jar with META-INF/plugin.xml
308+
val pluginJarBytes = ByteArrayOutputStream().use { jarBaos ->
309+
ZipOutputStream(jarBaos).use { jarZos ->
310+
jarZos.putNextEntry(ZipEntry("META-INF/"))
311+
jarZos.closeEntry()
312+
jarZos.putNextEntry(ZipEntry("META-INF/plugin.xml"))
313+
jarZos.write(pluginXml.toByteArray(StandardCharsets.UTF_8))
314+
jarZos.closeEntry()
315+
}
316+
jarBaos.toByteArray()
317+
}
318+
319+
// Create the outer ZIP with nested jar structure
320+
val zipBytes = ByteArrayOutputStream().use { baos ->
321+
ZipOutputStream(baos).use { zos ->
322+
zos.putNextEntry(ZipEntry("intellij-plugin-hot-reload/"))
323+
zos.closeEntry()
324+
zos.putNextEntry(ZipEntry("intellij-plugin-hot-reload/lib/"))
325+
zos.closeEntry()
326+
zos.putNextEntry(ZipEntry("intellij-plugin-hot-reload/lib/intellij-plugin-hot-reload-1.0.0.jar"))
327+
zos.write(pluginJarBytes)
328+
zos.closeEntry()
329+
}
330+
baos.toByteArray()
331+
}
332+
333+
val service = PluginHotReloadService()
334+
val result = service.reloadPlugin(zipBytes)
335+
336+
assertFalse("Should fail when trying to reload self from jar structure", result.success)
337+
assertEquals("Plugin ID should be set", getSelfPluginId(), result.pluginId)
338+
}
339+
120340
fun testReloadPluginWithEmptyBytes() {
121341
val service = PluginHotReloadService()
122342
val result = service.reloadPlugin(ByteArray(0))

0 commit comments

Comments
 (0)