diff --git a/platform/built-in-server/resources/META-INF/builtInServer.xml b/platform/built-in-server/resources/META-INF/builtInServer.xml index 5f142ad06f3a..bef631e4fdf4 100644 --- a/platform/built-in-server/resources/META-INF/builtInServer.xml +++ b/platform/built-in-server/resources/META-INF/builtInServer.xml @@ -37,6 +37,9 @@ + + + { * * Use the [onResult] to pass a result back to Toolbox and * to close the connection. The [lifetime] is disposed after - * the connection is closed too + * the connection is closed too, it must not be used after [onResult] + * callback is executed */ fun handleToolboxRequest( lifetime: Disposable, @@ -160,17 +161,23 @@ internal class ToolboxRestService : RestService() { } .thenAcceptAsync( { json -> - try { + //kill the heartbeat, it may close the lifetime too + runCatching { heartbeat.cancel(false) heartbeat.await() - channel.write(Unpooled.copiedBuffer(gson.toJson(json), Charsets.UTF_8)) } - finally { - runCatching { - channel.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) - } - Disposer.dispose(lifetime) + + //no need to do anything if it's already disposed + if (Disposer.isDisposed(lifetime)) { + //closing the channel just in case + runCatching { channel.close() } + return@thenAcceptAsync } + + runCatching { channel.write(Unpooled.copiedBuffer(gson.toJson(json), Charsets.UTF_8)) } + runCatching { channel.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) } + + Disposer.dispose(lifetime) }, AppExecutorUtil.getAppExecutorService() ) diff --git a/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdateActions.kt b/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdateActions.kt new file mode 100644 index 000000000000..32aeb14789a0 --- /dev/null +++ b/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdateActions.kt @@ -0,0 +1,77 @@ +// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package org.jetbrains.ide + +import com.intellij.ide.actions.SettingsEntryPointAction +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.components.* +import com.intellij.openapi.util.Disposer +import com.intellij.util.Alarm +import com.intellij.util.ui.update.MergingUpdateQueue +import com.intellij.util.ui.update.Update +import java.util.concurrent.ConcurrentHashMap + +internal class ToolboxSettingsActionRegistryState: BaseState() { + val knownActions by list() +} + +@Service(Service.Level.APP) +@State(name = "toolbox-update-state", storages = [Storage(StoragePathMacros.CACHE_FILE)], allowLoadInTests = true) +internal class ToolboxSettingsActionRegistry : SimplePersistentStateComponent(ToolboxSettingsActionRegistryState()), Disposable { + private val pendingActions : MutableMap = ConcurrentHashMap() + + private val alarm = MergingUpdateQueue("toolbox-updates", 500, true, null, this, null, Alarm.ThreadToUse.POOLED_THREAD).usePassThroughInUnitTestMode() + + override fun dispose() = Unit + + fun scheduleUpdate() { + alarm.queue(object: Update(this){ + override fun run() { + //we would not like it to overflow + if (state.knownActions.size > 300) { + val tail = state.knownActions.toList().takeLast(30).toHashSet() + state.knownActions.clear() + state.knownActions.addAll(tail) + state.intIncrementModificationCount() + } + + val ids = pendingActions.keys.toSortedSet() + val iconState = if (!state.knownActions.containsAll(ids)) { + state.knownActions.addAll(ids) + state.intIncrementModificationCount() + + SettingsEntryPointAction.IconState.ApplicationUpdate + } else { + SettingsEntryPointAction.IconState.Current + } + + invokeLater { + SettingsEntryPointAction.updateState(iconState) + } + } + }) + } + + fun registerUpdateAction(lifetime: Disposable, persistentActionId: String, action: AnAction) { + val dispose = Disposable { + pendingActions.remove(persistentActionId, action) + scheduleUpdate() + } + + pendingActions[persistentActionId] = action + if (!Disposer.tryRegister(lifetime, dispose)) { + Disposer.dispose(dispose) + return + } + + scheduleUpdate() + } + + fun getActions() : List = pendingActions.entries.sortedBy { it.key }.map { it.value } +} + +class ToolboxSettingsActionRegistryActionProvider : SettingsEntryPointAction.ActionProvider { + override fun getUpdateActions(context: DataContext) = service().getActions() +} diff --git a/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdateNotificationHandler.kt b/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdateNotificationHandler.kt index 2f52ad04897a..a23047054b7d 100644 --- a/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdateNotificationHandler.kt +++ b/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdateNotificationHandler.kt @@ -3,26 +3,82 @@ package org.jetbrains.ide import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.intellij.ide.IdeBundle import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.util.Disposer +import com.intellij.util.Consumer +import com.intellij.util.concurrency.AppExecutorUtil +import java.util.concurrent.TimeUnit -internal class ToolboxUpdateNotificationHandler : ToolboxServiceHandler { - data class UpdateNotification(val version: String, val build: String) +internal data class UpdateNotification(val version: String, val build: String) +private fun parseUpdateNotificationRequest(request: JsonElement): UpdateNotification { + require(request.isJsonObject) { "JSON Object was expected" } + val obj = request.asJsonObject + + val build = obj["build"]?.asString + val version = obj["version"]?.asString + + require(!build.isNullOrBlank()) { "the `build` attribute must not be blank" } + require(!version.isNullOrBlank()) { "the `version` attribute must not be blank" } + return UpdateNotification(version = version, build = build) +} + +internal class ToolboxUpdateNotificationHandler : ToolboxServiceHandler { override val requestName: String = "update-notification" - - override fun parseRequest(request: JsonElement): UpdateNotification { - require(request.isJsonObject) { "JSON Object was expected" } - val obj = request.asJsonObject - - val build = obj["build"]?.asString - val version = obj["version"]?.asString - - require(!build.isNullOrBlank()) { "the `build` attribute must not be blank" } - require(!version.isNullOrBlank()) { "the `version` attribute must not be blank" } - return UpdateNotification(version = version, build = build) - } + override fun parseRequest(request: JsonElement) = parseUpdateNotificationRequest(request) override fun handleToolboxRequest(lifetime: Disposable, request: UpdateNotification, onResult: (JsonElement) -> Unit) { - onResult(JsonObject().apply { addProperty("status", "not implemented") }) + val actionHandler = Consumer { + onResult(JsonObject().apply { addProperty("status", "accepted") }) + } + + val title = IdeBundle.message("toolbox.updates.download.update.action.text", request.build, request.version) + val description = IdeBundle.message("toolbox.updates.download.update.action.description", request.build, request.version) + val action = DumbAwareAction.create(title, actionHandler) + action.templatePresentation.description = description + service().registerUpdateAction(lifetime, "toolbox-02-update-${request.build}", action) + } +} + +internal class ToolboxRestartNotificationHandler : ToolboxServiceHandler { + override val requestName: String = "restart-notification" + override fun parseRequest(request: JsonElement) = parseUpdateNotificationRequest(request) + + override fun handleToolboxRequest(lifetime: Disposable, request: UpdateNotification, onResult: (JsonElement) -> Unit) { + val actionHandler = Consumer { + //at the normal scenario, the lifetime is disposed after the connection is closed + //so Toolbox should get everything needed to handle the restart + //otherwise an exception is thrown here, so it's OK + Disposer.register(lifetime) { + AppExecutorUtil.getAppScheduledExecutorService().schedule(Runnable { + invokeLater { + val app = ApplicationManager.getApplication() + if (app?.isUnitTestMode == false) { + app.exit(false, true, false) + } else { + System.setProperty("toolbox-service-test-restart", "1") + } + } + }, 300, TimeUnit.MICROSECONDS) + } + + onResult(JsonObject().apply { + addProperty("status", "accepted") + addProperty("pid", ProcessHandle.current().pid()) + }) + } + + val title = IdeBundle.message("toolbox.updates.download.ready.action.text", request.build, request.version) + val description = IdeBundle.message("toolbox.updates.download.ready.action.description", request.build, request.version) + val action = DumbAwareAction.create(title, actionHandler) + action.templatePresentation.description = description + + service().registerUpdateAction(lifetime, "toolbox-02-update-${request.build}", action) } } diff --git a/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdates.http b/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdates.http new file mode 100644 index 000000000000..79f69e9db7b6 --- /dev/null +++ b/platform/built-in-server/src/org/jetbrains/ide/ToolboxUpdates.http @@ -0,0 +1,25 @@ + +# run IDEA with -Dtoolbox.notification.token=token234 + + +POST 127.0.0.1:63343/api/toolbox/update-notification +Authorization: toolbox token234 +Content-Type: application/json + +{ + "build": "2024.3.5 RC", + "version": "2021.1 RC" +} + + +### + +POST 127.0.0.1:63343/api/toolbox/restart-notification +Authorization: toolbox token234 +Content-Type: application/json + +{ + "build": "2024.3.5 RC", + "version": "2021.1 RC" +} + diff --git a/platform/built-in-server/testSrc/org/jetbrains/ide/ToolboxUpdateServiceTest.kt b/platform/built-in-server/testSrc/org/jetbrains/ide/ToolboxUpdateServiceTest.kt index c2d351f64556..29a0157767b1 100644 --- a/platform/built-in-server/testSrc/org/jetbrains/ide/ToolboxUpdateServiceTest.kt +++ b/platform/built-in-server/testSrc/org/jetbrains/ide/ToolboxUpdateServiceTest.kt @@ -4,13 +4,23 @@ package org.jetbrains.ide import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser +import com.intellij.ide.actions.SettingsEntryPointAction import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.impl.NonBlockingReadActionImpl import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.ApplicationRule import com.intellij.testFramework.ExtensionTestUtil +import com.intellij.testFramework.LightPlatformTestCase +import com.intellij.testFramework.TestApplicationManager +import com.intellij.util.ui.UIUtil import io.netty.handler.codec.http.HttpResponseStatus import org.assertj.core.api.Assertions -import org.junit.Assert -import org.junit.Test +import org.junit.* +import java.awt.event.KeyEvent import java.io.InputStream import java.net.URI import java.net.http.HttpClient @@ -20,16 +30,31 @@ import java.nio.charset.StandardCharsets import java.util.* import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import javax.swing.JPanel +import kotlin.concurrent.thread private abstract class ToolboxServiceHandlerX : ToolboxServiceHandler { override val requestName: String = "update-notification" override fun parseRequest(request: JsonElement): JsonElement = request } -internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() { +internal class ToolboxUpdateServiceTest { private val notifyUpdatePayload = "{'build': '123', 'version': '2021.1 RC'}" private val notifyUpdateAction = "update-notification" + private val notifyRestartPayload = "{'build': '123', 'version': '2021.1 RC'}" + private val notifyRestartAction = "restart-notification" + + @Rule + @JvmField + val appRule = ApplicationRule() + + @Before + fun runServer() { + BuiltInServerManager.getInstance().waitForStart() + } + @Test fun testShouldCheckToken_noToken() { doToolbox( @@ -45,6 +70,89 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() { ) } + @Test + fun testRestartAction() = doActionTest(notifyRestartAction, notifyRestartPayload) { + Assert.assertEquals("$actions", 1, actions.size) + val theAction = actions.single() + + runAction(theAction) + doEventsWhile(disconnected) + + println("payload: $payload") + + val json = JsonParser.parseString(payload) + Assert.assertTrue(json.isJsonObject) + Assert.assertEquals("accepted", json.asJsonObject["status"]?.asString) + Assert.assertEquals(ProcessHandle.current().pid(), json.asJsonObject["pid"]?.asLong) + + doEventsWhile(iterations = 10) { actions.isNotEmpty() } + Assert.assertEquals("$actions", 0, actions.size) + + val cond = { System.getProperty("toolbox-service-test-restart") == "1" } + doEventsWhile(iterations = 10) { !cond() } + Assert.assertTrue("Application must be disposed", cond()) + } + + @Test + fun testUpdateAction() = doActionTest(notifyUpdateAction, notifyRestartPayload) { + Assert.assertEquals("$actions", 1, actions.size) + val theAction = actions.single() + + runAction(theAction) + doEventsWhile(disconnected) + println("payload: $payload") + + val json = JsonParser.parseString(payload) + Assert.assertTrue(json.isJsonObject) + Assert.assertEquals("accepted", json.asJsonObject["status"]?.asString) + + doEventsWhile(10) { actions.isNotEmpty() } + Assert.assertEquals("$actions", 0, actions.size) + } + + private class ToolboxActionFixture { + val connected = CountDownLatch(1) + val disconnected = CountDownLatch(1) + lateinit var payload: String + + val actions + get() = SettingsEntryPointAction.ActionProvider.EP_NAME + .extensionList.filterIsInstance() + .flatMap { + it.getUpdateActions(DataContext.EMPTY_CONTEXT) + } + } + + private fun doActionTest(requestName: String, + requestPayload: String, + logic: ToolboxActionFixture.() -> Unit) { + val f = ToolboxActionFixture() + val thread = thread { + doToolbox( + action = requestName, payload = requestPayload, + includeToken = true, responseStatus = HttpResponseStatus.OK + ) { + f.connected.countDown() + try { + f.payload = it.body().readAllBytes().toString(Charsets.UTF_8) + } + finally { + f.disconnected.countDown() + } + } + } + + try { + doEventsWhile(f.connected) + doEventsWhile(10) { f.actions.isEmpty() } + return f.logic() + } + finally { + thread.interrupt() + thread.join() + } + } + @Test fun testShouldCheckToken_withToken_beat_and_response() { val condition = CountDownLatch(10) @@ -61,7 +169,7 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() { withExtension(ext) { doToolbox( - action = notifyUpdateAction, payload = notifyUpdatePayload, + action = ext.requestName, includeToken = true, responseStatus = HttpResponseStatus.OK, ) { response -> val body = response.body() @@ -93,7 +201,7 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() { withExtension(ext) { doToolbox( - action = notifyUpdateAction, payload = notifyUpdatePayload, + action = ext.requestName, includeToken = true, responseStatus = HttpResponseStatus.OK, ) { response -> var counter = 10 @@ -121,7 +229,7 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() { } private fun doToolbox(action: String, - payload: String, + payload: String = " { } ", includeToken: Boolean = true, responseStatus: HttpResponseStatus, body : (HttpResponse) -> Unit = {} @@ -165,3 +273,29 @@ private inline fun withSystemProperty(key: String, value: String, action: () } } } + +private fun doEventsWhile(latch: CountDownLatch) = doEventsWhile { + !latch.await(10, TimeUnit.MILLISECONDS) +} + +private fun doEventsWhile(iterations: Int = Int.MAX_VALUE / 2, + condition: () -> Boolean = { true }) { + repeat(iterations) { + if (!condition()) return + + ApplicationManager.getApplication().invokeAndWait { + NonBlockingReadActionImpl.waitForAsyncTaskCompletion() + UIUtil.dispatchAllInvocationEvents() + } + + if (!condition()) return + Thread.sleep(30) + } +} + +private fun runAction(theAction: AnAction) { + ApplicationManager.getApplication().invokeAndWait { + val event = KeyEvent(JPanel(), 1, 0, 0, 0, ' ') + ActionManager.getInstance().tryToExecute(theAction, event, null, null, true) + } +} diff --git a/platform/platform-api/resources/messages/IdeBundle.properties b/platform/platform-api/resources/messages/IdeBundle.properties index cc4475535b8c..b9d1ebe849f9 100644 --- a/platform/platform-api/resources/messages/IdeBundle.properties +++ b/platform/platform-api/resources/messages/IdeBundle.properties @@ -2563,3 +2563,10 @@ trusted.hosts.settings.new.trusted.folder.file.chooser.title=Select Trusted Loca windows.native.common.dialog.open=Open windows.native.common.dialog.select.folder=Select Folder dialog.message.press.for.field.hints=Press {0} for field hints + +toolbox.updates.download.update.action.text=Update to {0}... +toolbox.updates.download.update.action.description=Toolbox App will download the update to {0} ({1}) and let you \ + know when the update is ready to switch + +toolbox.updates.download.ready.action.text=Restart to {0}... +toolbox.updates.download.ready.action.description=Toolbox App has prepared an update to {0} ({1}). Restart to the new version?