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?