diff --git a/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/GitpodServer.java b/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/GitpodServer.java index 0a95ad325e5984..8535c7fcd92f7d 100644 --- a/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/GitpodServer.java +++ b/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/GitpodServer.java @@ -34,4 +34,7 @@ public interface GitpodServer { @JsonRequest CompletableFuture getIDEOptions(); + + @JsonRequest + CompletableFuture openPort(String workspaceId, WorkspaceInstancePort port); } diff --git a/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/entities/PortVisibility.java b/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/entities/PortVisibility.java new file mode 100644 index 00000000000000..59c4295b357e4b --- /dev/null +++ b/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/entities/PortVisibility.java @@ -0,0 +1,19 @@ +// Copyright (c) 2022 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package io.gitpod.gitpodprotocol.api.entities; + +public enum PortVisibility { + PUBLIC("public"), PRIVATE("private"); + + private final String toString; + + private PortVisibility(String toString) { + this.toString = toString; + } + + public String toString() { + return toString; + } +} diff --git a/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/entities/WorkspaceInstancePort.java b/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/entities/WorkspaceInstancePort.java new file mode 100644 index 00000000000000..5ee1d23b9adcbd --- /dev/null +++ b/components/gitpod-protocol/java/src/main/java/io/gitpod/gitpodprotocol/api/entities/WorkspaceInstancePort.java @@ -0,0 +1,29 @@ +// Copyright (c) 2022 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package io.gitpod.gitpodprotocol.api.entities; + +public class WorkspaceInstancePort { + private Number port; + private String visibility; + private String url; + + public void setPort(Number port) { + this.port = port; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public void setUrl(String url) { + this.url = url; + } + + public Number getPort() { return this.port; } + + public String getVisibility() { return this.visibility; } + + public String getUrl() { return this.url; } +} diff --git a/components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodClientProjectSessionTracker.kt b/components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodClientProjectSessionTracker.kt index 5bcaa79bea6644..43e33accb792c1 100644 --- a/components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodClientProjectSessionTracker.kt +++ b/components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodClientProjectSessionTracker.kt @@ -4,6 +4,12 @@ package io.gitpod.jetbrains.remote +import com.intellij.codeWithMe.ClientId +import com.intellij.ide.BrowserUtil +import com.intellij.idea.StartupUtil +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.client.ClientProjectSession import com.intellij.openapi.components.service @@ -11,21 +17,38 @@ import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.fileTypes.LanguageFileType +import com.intellij.remoteDev.util.onTerminationOrNow +import com.intellij.util.application +import com.jetbrains.rd.util.lifetime.Lifetime import io.gitpod.gitpodprotocol.api.entities.RemoteTrackMessage +import io.gitpod.gitpodprotocol.api.entities.WorkspaceInstancePort import io.gitpod.supervisor.api.Info -import kotlinx.coroutines.GlobalScope +import io.gitpod.supervisor.api.Status +import io.gitpod.supervisor.api.Status.PortVisibility +import io.gitpod.supervisor.api.Status.PortsStatus +import io.gitpod.supervisor.api.StatusServiceGrpc +import io.grpc.stub.ClientCallStreamObserver +import io.grpc.stub.ClientResponseObserver +import kotlinx.coroutines.* import kotlinx.coroutines.future.await -import kotlinx.coroutines.launch +import org.jetbrains.ide.BuiltInServerManager +import java.util.concurrent.CancellationException +import java.util.concurrent.CompletableFuture class GitpodClientProjectSessionTracker( private val session: ClientProjectSession -) { +) : Disposable { private val manager = service() private lateinit var info: Info.WorkspaceInfoResponse private val versionName = ApplicationInfo.getInstance().versionName private val fullVersion = ApplicationInfo.getInstance().fullVersion + private val lifetime = Lifetime.Eternal.createNested() + + override fun dispose() { + lifetime.terminate() + } init { GlobalScope.launch { @@ -35,6 +58,140 @@ class GitpodClientProjectSessionTracker( } } + private fun isExposedServedPort(port: Status.PortsStatus?) : Boolean { + if (port === null) { + return false + } + return port.served && port.hasExposed() + } + + private fun showOpenServiceNotification(port: PortsStatus, offerMakePublic: Boolean = false) { + val message = "A service is available on port ${port.localPort}" + val notification = manager.notificationGroup.createNotification(message, NotificationType.INFORMATION) + + val openBrowserAction = NotificationAction.createSimple("Open Browser") { + openBrowser(port.exposed.url) + } + notification.addAction(openBrowserAction) + + if (offerMakePublic) { + val makePublicLambda = { + runBlocking { + makePortPublic(info.workspaceId, port) + } + } + val makePublicAction = NotificationAction.createSimple("Make Public", makePublicLambda) + notification.addAction(makePublicAction) + } + + ClientId.withClientId(session.clientId) { + notification.notify(null) + } + } + + private suspend fun makePortPublic(workspaceId: String, port: PortsStatus) { + val p = WorkspaceInstancePort() + p.port = port.localPort + p.visibility = io.gitpod.gitpodprotocol.api.entities.PortVisibility.PUBLIC.toString() + p.url = port.exposed.url + + try { + manager.client.server.openPort(workspaceId, p).await() + } catch (e: Exception) { + thisLogger().error("gitpod: failed to open port ${port.localPort}: ", e) + } + } + + private fun openBrowser(url: String) { + ClientId.withClientId(session.clientId) { + BrowserUtil.browse(url) + } + } + + private val portsObserveJob = GlobalScope.launch { + if (application.isHeadlessEnvironment) { + return@launch + } + + // Ignore ports that aren't actually used by the user (e.g. ports used internally by JetBrains IDEs) + val backendPort = BuiltInServerManager.getInstance().waitForStart().port + val serverPort = StartupUtil.getServerFuture().await().port + val ignorePorts = listOf(backendPort, serverPort, 5990) + val portsStatus = hashMapOf() + + val status = StatusServiceGrpc.newStub(GitpodManager.supervisorChannel) + while (isActive) { + try { + val f = CompletableFuture() + status.portsStatus( + Status.PortsStatusRequest.newBuilder().setObserve(true).build(), + object : ClientResponseObserver { + + override fun beforeStart(requestStream: ClientCallStreamObserver) { + lifetime.onTerminationOrNow { + requestStream.cancel(null, null) + } + } + + override fun onNext(ps: Status.PortsStatusResponse) { + for (port in ps.portsList) { + // Avoiding undesired notifications + if (ignorePorts.contains(port.localPort)) { + continue + } + + val previous = portsStatus[port.localPort] + portsStatus[port.localPort] = port + + val shouldSendNotification = !isExposedServedPort(previous) && isExposedServedPort(port) + + if (shouldSendNotification) { + if (port.exposed.onExposed.number == Status.OnPortExposedAction.ignore_VALUE) { + continue + } + + if (port.exposed.onExposed.number == Status.OnPortExposedAction.open_browser_VALUE || port.exposed.onExposed.number == Status.OnPortExposedAction.open_preview_VALUE) { + openBrowser(port.exposed.url) + continue + } + + if (port.exposed.onExposed.number == Status.OnPortExposedAction.notify_VALUE) { + showOpenServiceNotification(port) + continue + } + + if (port.exposed.onExposed.number == Status.OnPortExposedAction.notify_private_VALUE) { + showOpenServiceNotification(port, port.exposed.visibilityValue !== PortVisibility.public_visibility_VALUE) + continue + } + } + } + } + + override fun onError(t: Throwable) { + f.completeExceptionally(t) + } + + override fun onCompleted() { + f.complete(null) + } + }) + f.await() + } catch (t: Throwable) { + if (t is CancellationException) { + throw t + } + thisLogger().error("gitpod: failed to stream ports status: ", t) + } + delay(1000L) + } + } + init { + lifetime.onTerminationOrNow { + portsObserveJob.cancel() + } + } + private fun registerActiveLanguageAnalytics() { val activeLanguages = mutableSetOf() session.project.messageBus.connect().subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener { diff --git a/components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodManager.kt b/components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodManager.kt index 2478ccd4334eac..f75fb169b2f926 100644 --- a/components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodManager.kt +++ b/components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodManager.kt @@ -133,7 +133,7 @@ class GitpodManager : Disposable { GitVcsApplicationSettings.getInstance().isUseCredentialHelper = true } - private val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup("Gitpod Notifications") + val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup("Gitpod Notifications") private val notificationsJob = GlobalScope.launch { if (application.isHeadlessEnvironment) { return@launch @@ -234,6 +234,7 @@ class GitpodManager : Disposable { .setHost(info.gitpodApi.host) .addScope("function:sendHeartBeat") .addScope("function:trackEvent") + .addScope("function:openPort") .setKind("gitpod") .build()