diff --git a/components/ide/jetbrains/launcher/main.go b/components/ide/jetbrains/launcher/main.go index 0fb3c1a30e1aee..37855db21a3c4a 100644 --- a/components/ide/jetbrains/launcher/main.go +++ b/components/ide/jetbrains/launcher/main.go @@ -178,6 +178,13 @@ func main() { launch(launchCtx) return } + + err = configureToolboxCliProperties(backendDir) + if err != nil { + log.WithError(err).Error("failed to write toolbox cli config file") + return + } + // we should start serving immediately and postpone launch // in order to enable a JB Gateway to connect as soon as possible go launch(launchCtx) @@ -1184,3 +1191,37 @@ func resolveProjectContextDir(launchCtx *LaunchContext) string { return launchCtx.projectDir } + +func configureToolboxCliProperties(backendDir string) error { + userHomeDir, err := os.UserHomeDir() + if err != nil { + return err + } + + toolboxCliPropertiesDir := fmt.Sprintf("%s/.local/share/JetBrains/Toolbox", userHomeDir) + _, err = os.Stat(toolboxCliPropertiesDir) + if !os.IsNotExist(err) { + return err + } + err = os.MkdirAll(toolboxCliPropertiesDir, os.ModePerm) + if err != nil { + return err + } + + toolboxCliPropertiesFilePath := fmt.Sprintf("%s/environment.json", toolboxCliPropertiesDir) + + content := fmt.Sprintf(`{ + "tools": { + "allowInstallation": false, + "allowUpdate": false, + "allowUninstallation": false, + "location": [ + { + "path": "%s" + } + ] + } +}`, backendDir) + + return os.WriteFile(toolboxCliPropertiesFilePath, []byte(content), 0o644) +} diff --git a/components/ide/jetbrains/toolbox/.gitattributes b/components/ide/jetbrains/toolbox/.gitattributes new file mode 100644 index 00000000000000..afd59d8fce15d0 --- /dev/null +++ b/components/ide/jetbrains/toolbox/.gitattributes @@ -0,0 +1,8 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf diff --git a/components/ide/jetbrains/toolbox/.gitignore b/components/ide/jetbrains/toolbox/.gitignore new file mode 100644 index 00000000000000..83d0ea8e397220 --- /dev/null +++ b/components/ide/jetbrains/toolbox/.gitignore @@ -0,0 +1,6 @@ +# Gradle +.gradle +build + +# IntelliJ IDEA +.idea diff --git a/components/ide/jetbrains/toolbox/README.md b/components/ide/jetbrains/toolbox/README.md new file mode 100644 index 00000000000000..869005a898825d --- /dev/null +++ b/components/ide/jetbrains/toolbox/README.md @@ -0,0 +1,17 @@ +# Gitpod Toolbox Plugin + +To load plugin into the provided Toolbox App, run `./gradlew build copyPlugin` + +or put files in the following directory: + +* Windows: `%LocalAppData%/JetBrains/Toolbox/cache/plugins/plugin-id` +* macOS: `~/Library/Caches/JetBrains/Toolbox/plugins/plugin-id` +* Linux: `~/.local/share/JetBrains/Toolbox/plugins/plugin-id` + + +## How to Develop + +- Open the Toolbox App in debug mode +```bash +TOOLBOX_DEV_DEBUG_SUSPEND=true && open /Applications/JetBrains\ Toolbox.app +``` diff --git a/components/ide/jetbrains/toolbox/build.gradle.kts b/components/ide/jetbrains/toolbox/build.gradle.kts new file mode 100644 index 00000000000000..ff6b421b5ec1e3 --- /dev/null +++ b/components/ide/jetbrains/toolbox/build.gradle.kts @@ -0,0 +1,189 @@ +import com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter +import com.github.jk1.license.render.JsonReportRenderer +import org.jetbrains.intellij.pluginRepository.PluginRepositoryFactory +import org.jetbrains.kotlin.com.intellij.openapi.util.SystemInfoRt +import java.nio.file.Path +import kotlin.io.path.div + +plugins { + alias(libs.plugins.kotlin) + alias(libs.plugins.serialization) + `java-library` + alias(libs.plugins.dependency.license.report) + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +buildscript { + dependencies { + classpath(libs.marketplace.client) + } +} + +repositories { + mavenCentral() + maven("https://packages.jetbrains.team/maven/p/tbx/gateway") +} + +dependencies { + implementation(project(":supervisor-api")) + implementation(project(":gitpod-publicapi")) + + // connect rpc dependencies + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.connectrpc:connect-kotlin-okhttp:0.6.0") + implementation("com.connectrpc:connect-kotlin:0.6.0") + // Java specific dependencies. + implementation("com.connectrpc:connect-kotlin-google-java-ext:0.6.0") + implementation("com.google.protobuf:protobuf-java:4.26.0") + // WebSocket + compileOnly("javax.websocket:javax.websocket-api:1.1") + compileOnly("org.eclipse.jetty.websocket:websocket-api:9.4.54.v20240208") + implementation("org.eclipse.jetty.websocket:javax-websocket-client-impl:9.4.54.v20240208") + // RD-Core + implementation("com.jetbrains.rd:rd-core:2024.1.1") + + implementation(libs.gateway.api) + implementation(libs.slf4j) + implementation(libs.bundles.serialization) + implementation(libs.coroutines.core) + implementation(libs.okhttp) +} + + +val pluginId = "io.gitpod.toolbox.gateway" +val pluginVersion = "0.0.1" + +tasks.shadowJar { + archiveBaseName.set(pluginId) + archiveVersion.set(pluginVersion) + + val excludedGroups = listOf( + "com.jetbrains.toolbox.gateway", + "com.jetbrains", + "org.jetbrains", + "com.squareup.okhttp3", + "org.slf4j", + "org.jetbrains.intellij", + "com.squareup.okio", + "kotlin." + ) + + val includeGroups = listOf( + "com.jetbrains.rd" + ) + + dependencies { + exclude { + excludedGroups.any { group -> + if (includeGroups.any { includeGroup -> it.name.startsWith(includeGroup) }) { + return@any false + } + it.name.startsWith(group) + } + } + } +} + +licenseReport { + renderers = arrayOf(JsonReportRenderer("dependencies.json")) + filters = arrayOf(ExcludeTransitiveDependenciesFilter()) + // jq script to convert to our format: + // `jq '[.dependencies[] | {name: .moduleName, version: .moduleVersion, url: .moduleUrl, license: .moduleLicense, licenseUrl: .moduleLicenseUrl}]' < build/reports/dependency-license/dependencies.json > src/main/resources/dependencies.json` +} + +tasks.compileKotlin { + kotlinOptions.freeCompilerArgs += listOf( + "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", + ) +} + +val restartToolbox by tasks.creating { + group = "01.Gitpod" + description = "Restarts the JetBrains Toolbox app." + + doLast { + when { + SystemInfoRt.isMac -> { + exec { + commandLine("sh", "-c", "pkill -f 'JetBrains Toolbox' || true") + } + Thread.sleep(3000) + exec { + commandLine("sh", "-c", "echo debugClean > ~/Library/Logs/JetBrains/Toolbox/toolbox.log") + } + exec { + commandLine("open", "/Applications/JetBrains Toolbox.app") + } + } + else -> { + println("restart Toolbox to make plugin works.") + } + } + } +} + +val copyPlugin by tasks.creating(Sync::class.java) { + group = "01.Gitpod" + + dependsOn(tasks.named("shadowJar")) + from(tasks.named("shadowJar").get().outputs.files) + + val userHome = System.getProperty("user.home").let { Path.of(it) } + val toolboxCachesDir = when { + SystemInfoRt.isWindows -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local") + // currently this is the location that TBA uses on Linux + SystemInfoRt.isLinux -> System.getenv("XDG_DATA_HOME")?.let { Path.of(it) } ?: (userHome / ".local" / "share") + SystemInfoRt.isMac -> userHome / "Library" / "Caches" + else -> error("Unknown os") + } / "JetBrains" / "Toolbox" + + val pluginsDir = when { + SystemInfoRt.isWindows -> toolboxCachesDir / "cache" + SystemInfoRt.isLinux || SystemInfoRt.isMac -> toolboxCachesDir + else -> error("Unknown os") + } / "plugins" + + val targetDir = pluginsDir / pluginId + + from("src/main/resources") { + include("extension.json") + include("dependencies.json") + include("icon.svg") + } + + into(targetDir) + + finalizedBy(restartToolbox) +} + +val pluginZip by tasks.creating(Zip::class) { + dependsOn(tasks.named("shadowJar")) + from(tasks.named("shadowJar").get().outputs.files) + + from("src/main/resources") { + include("extension.json") + include("dependencies.json") + } + from("src/main/resources") { + include("icon.svg") + rename("icon.svg", "pluginIcon.svg") + } + archiveBaseName.set("$pluginId-$pluginVersion") +} + +val uploadPlugin by tasks.creating { + dependsOn(pluginZip) + + doLast { + val instance = PluginRepositoryFactory.create( + "https://plugins.jetbrains.com", + project.property("pluginMarketplaceToken").toString() + ) + + // first upload + // instance.uploader.uploadNewPlugin(pluginZip.outputs.files.singleFile, listOf("toolbox", "gateway"), LicenseUrl.APACHE_2_0, ProductFamily.TOOLBOX) + + // subsequent updates + instance.uploader.upload(pluginId, pluginZip.outputs.files.singleFile) + } +} diff --git a/components/ide/jetbrains/toolbox/gradle.properties b/components/ide/jetbrains/toolbox/gradle.properties new file mode 100644 index 00000000000000..9d45e708410982 --- /dev/null +++ b/components/ide/jetbrains/toolbox/gradle.properties @@ -0,0 +1,4 @@ +pluginVersion=0.0.1 +environmentName=latest +supervisorApiProjectPath=../../../supervisor-api/java +gitpodPublicApiProjectPath=../../../public-api/java diff --git a/components/ide/jetbrains/toolbox/gradle/libs.versions.toml b/components/ide/jetbrains/toolbox/gradle/libs.versions.toml new file mode 100644 index 00000000000000..cdac41588bd02c --- /dev/null +++ b/components/ide/jetbrains/toolbox/gradle/libs.versions.toml @@ -0,0 +1,29 @@ +[versions] +gateway = "2.4.0.30948" +kotlin = "1.9.0" +coroutines = "1.7.3" +serialization = "1.5.0" +okhttp = "4.10.0" +slf4j = "2.0.3" +dependency-license-report = "2.5" +marketplace-client = "2.0.38" + +[libraries] +kotlin-stdlib = { module = "com.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +gateway-api = { module = "com.jetbrains.toolbox.gateway:gateway-api", version.ref = "gateway" } +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } +serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "serialization" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } + +marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" } + +[bundles] +serialization = [ "serialization-core", "serialization-json", "serialization-json-okio" ] + +[plugins] +kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +dependency-license-report = { id = "com.github.jk1.dependency-license-report", version.ref = "dependency-license-report" } diff --git a/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.jar b/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000000..c1962a79e29d3e Binary files /dev/null and b/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.jar differ diff --git a/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.properties b/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000000..0c85a1f7519700 --- /dev/null +++ b/components/ide/jetbrains/toolbox/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/components/ide/jetbrains/toolbox/gradlew b/components/ide/jetbrains/toolbox/gradlew new file mode 100755 index 00000000000000..aeb74cbb43e393 --- /dev/null +++ b/components/ide/jetbrains/toolbox/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/components/ide/jetbrains/toolbox/gradlew.bat b/components/ide/jetbrains/toolbox/gradlew.bat new file mode 100644 index 00000000000000..93e3f59f135dd2 --- /dev/null +++ b/components/ide/jetbrains/toolbox/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/components/ide/jetbrains/toolbox/settings.gradle.kts b/components/ide/jetbrains/toolbox/settings.gradle.kts new file mode 100644 index 00000000000000..9732826b70f69c --- /dev/null +++ b/components/ide/jetbrains/toolbox/settings.gradle.kts @@ -0,0 +1,9 @@ +rootProject.name = "gitpod-toolbox-gateway" + +include(":supervisor-api") +val supervisorApiProjectPath: String by settings +project(":supervisor-api").projectDir = File(supervisorApiProjectPath) + +include(":gitpod-publicapi") +val gitpodPublicApiProjectPath: String by settings +project(":gitpod-publicapi").projectDir = File(gitpodPublicApiProjectPath) diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodAuthManager.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodAuthManager.kt new file mode 100644 index 00000000000000..d7148bd98a7f52 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodAuthManager.kt @@ -0,0 +1,189 @@ +// Copyright (c) 2024 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.toolbox.auth + +import com.jetbrains.toolbox.gateway.auth.* +import io.gitpod.publicapi.v1.UserServiceClient +import io.gitpod.toolbox.service.GitpodPublicApiManager +import io.gitpod.toolbox.service.Utils +import kotlinx.coroutines.future.future +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.slf4j.LoggerFactory +import java.net.URI +import java.util.* +import java.util.concurrent.Future + +class GitpodAuthManager { + private val logger = LoggerFactory.getLogger(javaClass) + private val manager: PluginAuthManager + private var loginListeners: MutableList<() -> Unit> = mutableListOf() + private var logoutListeners: MutableList<() -> Unit> = mutableListOf() + + init { + manager = Utils.sharedServiceLocator.getAuthManager( + "gitpod", + GitpodAccount::class.java, + { it.encode() }, + { GitpodAccount.decode(it) }, + { oauthToken, authCfg -> getAuthenticatedUser(authCfg.baseUrl, oauthToken) }, + { oauthToken, gpAccount -> getAuthenticatedUser(gpAccount.getHost(), oauthToken) }, + { gpLoginCfg -> + val authParams = mapOf( + "response_type" to "code", + "client_id" to "toolbox-gateway-gitpod-plugin", + "scope" to "function:*", + ) + val tokenParams = + mapOf("grant_type" to "authorization_code", "client_id" to "toolbox-gateway-gitpod-plugin") + AuthConfiguration( + authParams, + tokenParams, + gpLoginCfg.host, + gpLoginCfg.host + "/api/oauth/authorize", + gpLoginCfg.host + "/api/oauth/token", + "code_challenge", + "S256", + "code_verifier", + "Bearer" + ) + }, + { RefreshConfiguration("", mapOf(), "", ContentType.JSON) }, + ) + + manager.addEventListener { + when (it.type) { + AuthEvent.Type.LOGIN -> { + logger.info("account ${it.accountId} logged in") + loginListeners.forEach { it() } + } + AuthEvent.Type.LOGOUT -> { + logger.info("account ${it.accountId} logged out") + logoutListeners.forEach { it() } + } + } + } + } + + fun getCurrentAccount(): GitpodAccount? { + return manager.accountsWithStatus.firstOrNull()?.account + } + + fun logout() { + getCurrentAccount()?.let { manager.logout(it.id) } + } + + fun getOAuthLoginUrl(gitpodHost: String): String { + logger.info("get oauth url of $gitpodHost") + return manager.initiateLogin(GitpodLoginConfiguration(gitpodHost)) + } + + fun tryHandle(uri: URI): Boolean { + if (!this.manager.canHandle(uri)) { + return false + } + Utils.toolboxUi.showWindow() + this.manager.handle(uri) + return true + } + + fun addLoginListener(listener: () -> Unit) { + loginListeners.add(listener) + } + + fun addLogoutListener(listener: () -> Unit) { + logoutListeners.add(listener) + } + + private fun getAuthenticatedUser(gitpodHost: String, oAuthToken: OAuthToken): Future { + return Utils.coroutineScope.future { + val bearerToken = getBearerToken(oAuthToken) + val client = GitpodPublicApiManager.createClient(gitpodHost, bearerToken) + val user = GitpodPublicApiManager.tryGetAuthenticatedUser(UserServiceClient(client), logger) + GitpodAccount(bearerToken, user.id, user.name, gitpodHost) + } + } + + private fun getBearerToken(oAuthToken: OAuthToken): String { + val parts = oAuthToken.authorizationHeader.replace("Bearer ", "").split(".") + // We don't validate jwt token + if (parts.size != 3) { + throw IllegalArgumentException("Invalid JWT") + } + val decoded = String(Base64.getUrlDecoder().decode(parts[1].toByteArray())) + val jsonElement = Json.parseToJsonElement(decoded) + val payloadMap = jsonElement.jsonObject.mapValues { + it.value.jsonPrimitive.content + } + return payloadMap["jti"] ?: throw IllegalArgumentException("Failed to parse JWT token") + } + +} + +class GitpodLoginConfiguration(val host: String) + +@Serializable +class GitpodAccount( + private val credentials: String, + private val id: String, + private val name: String, + private val host: String +) : Account { + private val orgSelectedListeners: MutableList<(String) -> Unit> = mutableListOf() + private val logger = LoggerFactory.getLogger(javaClass) + + override fun getId() = id + override fun getFullName() = name + fun getCredentials() = credentials + fun getHost() = host + + private fun getStoreKey(key: String) = "USER:${id}:${key}" + + var organizationId: String? + get() = Utils.settingStore[getStoreKey("ORG")] + set(value){ + if (value == null) { + return + } + Utils.settingStore[getStoreKey("ORG")] = value + orgSelectedListeners.forEach { it(value) } + } + + var preferEditor: String? + get() = Utils.settingStore[getStoreKey("EDITOR")] + set(value){ + if (value == null) { + return + } + Utils.settingStore[getStoreKey("EDITOR")] = value + } + + var preferWorkspaceClass: String? + get() = Utils.settingStore[getStoreKey("WS_CLS")] + set(value){ + if (value == null) { + return + } + Utils.settingStore[getStoreKey("WS_CLS")] = value + } + + fun onOrgSelected(listener: (String) -> Unit) { + orgSelectedListeners.add(listener) + } + + fun encode(): String { + return Json.encodeToString(this) + } + + companion object { + fun decode(str: String): GitpodAccount { + return Json.decodeFromString(str) + } + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodLoginPage.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodLoginPage.kt new file mode 100644 index 00000000000000..110caf92b5e6c1 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodLoginPage.kt @@ -0,0 +1,43 @@ +// Copyright (c) 2024 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.toolbox.auth + +import com.jetbrains.toolbox.gateway.ui.* +import io.gitpod.toolbox.components.AbstractUiPage +import io.gitpod.toolbox.components.GitpodIcon +import io.gitpod.toolbox.components.SimpleButton +import io.gitpod.toolbox.service.Utils + +class GitpodLoginPage(private val authManager: GitpodAuthManager) : AbstractUiPage() { + private val hostField = TextField("Host", "https://exp-migration.preview.gitpod-dev.com", null) { + if (it.isBlank()) { + ValidationResult.Invalid("Host should not be empty") + } + if (!it.startsWith("https://")) { + ValidationResult.Invalid("Host should start with https://") + } + ValidationResult.Valid + } + + override fun getFields(): MutableList { + return mutableListOf(hostField, LinkField("Learn more", "https://gitpod.io/docs")) + } + + override fun getActionButtons(): MutableList { + return mutableListOf(SimpleButton("Login") action@{ + val host = getFieldValue(hostField) ?: return@action + val url = authManager.getOAuthLoginUrl(host) + Utils.openUrl(url) + }) + } + + override fun getTitle() = "Login to Gitpod" + + override fun getDescription() = "Always ready to code." + + override fun getSvgIcon(): ByteArray { + return GitpodIcon() + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodOrganizationPage.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodOrganizationPage.kt new file mode 100644 index 00000000000000..09f0cd8e6bc92e --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/auth/GitpodOrganizationPage.kt @@ -0,0 +1,58 @@ +// Copyright (c) 2024 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.toolbox.auth + +import com.jetbrains.toolbox.gateway.ui.AutocompleteTextField +import com.jetbrains.toolbox.gateway.ui.AutocompleteTextField.AutocompleteItem +import com.jetbrains.toolbox.gateway.ui.AutocompleteTextField.MenuItem +import com.jetbrains.toolbox.gateway.ui.UiField +import com.jetbrains.toolbox.gateway.ui.ValidationResult +import io.gitpod.publicapi.v1.OrganizationOuterClass +import io.gitpod.toolbox.components.AbstractUiPage +import io.gitpod.toolbox.service.GitpodPublicApiManager +import io.gitpod.toolbox.service.Utils +import java.util.function.Consumer + +class GitpodOrganizationPage(val authManager: GitpodAuthManager, val publicApi: GitpodPublicApiManager) : + AbstractUiPage() { + private var organizations = emptyList() + private lateinit var orgField: AutocompleteTextField + + + suspend fun loadData() { + organizations = publicApi.listOrganizations() + } + + private fun getOrgField() = run { + val options = mutableListOf() + options.addAll(organizations.map { org -> + MenuItem(org.name, null, null) { + authManager.getCurrentAccount()?.organizationId = org.id + Utils.toolboxUi.hideUiPage(this) + } + }) + val orgName = organizations.find { it.id == authManager.getCurrentAccount()?.organizationId }?.name ?: "" + AutocompleteTextField("Organization", orgName, options, 1.0f) { + if (it.isNullOrEmpty()) { + ValidationResult.Invalid("Organization is required") + } + ValidationResult.Valid + } + } + + override fun getFields(): MutableList { + this.orgField = getOrgField() + return mutableListOf(this.orgField) + } + + override fun getTitle(): String { + return "Select organization" + } + + override fun setPageChangedListener(listener: Consumer) { + super.setPageChangedListener(listener) + listener.accept(null) + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/colima/ColimaTestEnvironment.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/colima/ColimaTestEnvironment.kt new file mode 100644 index 00000000000000..e081717757af31 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/colima/ColimaTestEnvironment.kt @@ -0,0 +1,70 @@ +// Copyright (c) 2024 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.toolbox.colima + +import com.jetbrains.toolbox.gateway.AbstractRemoteProviderEnvironment +import com.jetbrains.toolbox.gateway.EnvironmentVisibilityState +import com.jetbrains.toolbox.gateway.environments.EnvironmentContentsView +import com.jetbrains.toolbox.gateway.environments.ManualEnvironmentContentsView +import com.jetbrains.toolbox.gateway.environments.SshEnvironmentContentsView +import com.jetbrains.toolbox.gateway.ssh.SshConnectionInfo +import com.jetbrains.toolbox.gateway.states.StandardRemoteEnvironmentState +import com.jetbrains.toolbox.gateway.ui.ActionDescription +import com.jetbrains.toolbox.gateway.ui.ActionListener +import com.jetbrains.toolbox.gateway.ui.ObservableList +import io.gitpod.toolbox.service.Utils +import kotlinx.coroutines.launch +import java.util.concurrent.CompletableFuture + +class ColimaTestEnvironment() : AbstractRemoteProviderEnvironment() { + private val actionListeners = mutableSetOf() + private val contentsViewFuture: CompletableFuture = + CompletableFuture.completedFuture(ColimaSSHEnvironmentContentsView()) + + init { + Utils.coroutineScope.launch { + Thread.sleep(2000) + listenerSet.forEach { it.consume(StandardRemoteEnvironmentState.Active) } + } + } + + override fun getId(): String = "colima" + override fun getName(): String = "colima" + + override fun getContentsView(): CompletableFuture = contentsViewFuture + + override fun setVisible(visibilityState: EnvironmentVisibilityState) { + + } + + override fun getActionList(): ObservableList { + return Utils.observablePropertiesFactory.emptyObservableList() + } + +} + +class ColimaSSHEnvironmentContentsView : SshEnvironmentContentsView, ManualEnvironmentContentsView { + private val listenerSet = mutableSetOf() + + override fun getConnectionInfo(): CompletableFuture { + return CompletableFuture.completedFuture(object : SshConnectionInfo { + override fun getHost(): String = "127.0.0.1" + override fun getPort() = 51710 + override fun getUserName() = "hwen" + override fun getPrivateKeyPaths(): MutableList? { + return mutableListOf("/Users/hwen/.colima/_lima/_config/user") + } + }) + } + + override fun addEnvironmentContentsListener(listener: ManualEnvironmentContentsView.Listener) { + listenerSet += listener + } + + override fun removeEnvironmentContentsListener(listener: ManualEnvironmentContentsView.Listener) { + listenerSet -= listener + } + +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/AbstractUiPage.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/AbstractUiPage.kt new file mode 100644 index 00000000000000..56f0d4c534d885 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/AbstractUiPage.kt @@ -0,0 +1,33 @@ +// Copyright (c) 2024 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.toolbox.components + +import com.jetbrains.toolbox.gateway.ui.UiField +import com.jetbrains.toolbox.gateway.ui.UiPage +import java.util.function.BiConsumer +import java.util.function.Consumer +import java.util.function.Function + +abstract class AbstractUiPage : UiPage { + private var stateSetter: BiConsumer? = null + private var stateGetter: Function? = null + private var errorSetter: Consumer? = null + + @Suppress("UNCHECKED_CAST") + fun getFieldValue(field: UiField) = stateGetter?.apply(field) as T? + fun setFieldValue(field: UiField, value: Any) = stateSetter?.accept(field, value) + fun setActionErrorMessage(error: String) = errorSetter?.accept(Throwable(error)) + + override fun setStateAccessor(setter: BiConsumer?, getter: Function?) { + super.setStateAccessor(setter, getter) + stateGetter = getter + stateSetter = setter + } + + override fun setActionErrorNotifier(notifier: Consumer?) { + super.setActionErrorNotifier(notifier) + errorSetter = notifier + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Button.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Button.kt new file mode 100644 index 00000000000000..2814128842a584 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Button.kt @@ -0,0 +1,16 @@ +// Copyright (c) 2024 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.toolbox.components + +import com.jetbrains.toolbox.gateway.ui.RunnableActionDescription + +open class SimpleButton(private val title: String, private val action: () -> Unit = {}): RunnableActionDescription { + override fun getLabel(): String { + return title + } + override fun run() { + action() + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/ComponentsPage.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/ComponentsPage.kt new file mode 100644 index 00000000000000..28d3851c9b7935 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/ComponentsPage.kt @@ -0,0 +1,64 @@ +// Copyright (c) 2024 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.toolbox.components + +import com.jetbrains.toolbox.gateway.ui.* +import com.jetbrains.toolbox.gateway.ui.AutocompleteTextField.AutocompleteItem +import com.jetbrains.toolbox.gateway.ui.AutocompleteTextField.MenuItem +import org.slf4j.LoggerFactory + +class ComponentsPage() : AbstractUiPage() { + private val logger = LoggerFactory.getLogger(javaClass) + override fun getFields(): MutableList { + val item: AutocompleteItem = MenuItem("item1", "group1", "group desc") { + logger.info("selected item1") + } + val item2: AutocompleteItem = MenuItem("item2", "group1", "group desc") { + logger.info("selected item1") + } + val item3: AutocompleteItem = MenuItem("item3", "group2", "group desc") { + logger.info("selected item3") + } + return mutableListOf( + AutocompleteTextField("AutocompleteTextField", "", mutableListOf(item, item2, item3), null, null), + CheckboxField(false, "Checkbox"), + ComboBoxField( + "ComboBoxField", + "s1", + mutableListOf(ComboBoxField.LabelledValue("s1", "s1"), ComboBoxField.LabelledValue("s2", "s2")) + ), + LabelField("LabelField"), + RadioButtonField(false, "RadioButtonField2", "group_1"), + RowGroup( + RowGroup.RowField(RadioButtonField(true, "RadioButtonField-2", "group_1"), RowGroup.RowFieldSettings(1.0f)), + RowGroup.RowField(RadioButtonField(true, "RadioButtonField-1", "group_1"), RowGroup.RowFieldSettings(1.0f)), + ), + RowGroup( + RowGroup.RowField(LabelField("LabelField-1"), RowGroup.RowFieldSettings(1.0f)), + RowGroup.RowField(LabelField("LabelField-2"), RowGroup.RowFieldSettings(1.0f)), + ), + TextField("TextField", "", TextType.Password), + LinkField("LinkField", "https://gitpod.io/docs"), + ) + } + + override fun getActionButtons(): MutableList { + return mutableListOf(SimpleButton("Button") { + logger.info("button clicked") + }, object : RunnableActionDescription { + override fun getLabel() = "Dangerous Button" + + override fun run() { + logger.info("danger") + } + + override fun isDangerous() = true + }) + } + + override fun getTitle(): String { + return "View components" + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Icon.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Icon.kt new file mode 100644 index 00000000000000..2e0cacef0e5c98 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/components/Icon.kt @@ -0,0 +1,12 @@ +// Copyright (c) 2024 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.toolbox.components + +import io.gitpod.toolbox.gateway.GitpodGatewayExtension + +@Suppress("FunctionName") +fun GitpodIcon(): ByteArray { + return GitpodGatewayExtension::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf() +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodGatewayExtension.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodGatewayExtension.kt new file mode 100644 index 00000000000000..1d9565e981784d --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodGatewayExtension.kt @@ -0,0 +1,18 @@ +// Copyright (c) 2024 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.toolbox.gateway + +import com.jetbrains.toolbox.gateway.GatewayExtension +import com.jetbrains.toolbox.gateway.RemoteEnvironmentConsumer +import com.jetbrains.toolbox.gateway.RemoteProvider +import com.jetbrains.toolbox.gateway.ToolboxServiceLocator +import io.gitpod.toolbox.service.Utils + +class GitpodGatewayExtension : GatewayExtension { + override fun createRemoteProviderPluginInstance(serviceLocator: ToolboxServiceLocator): RemoteProvider { + Utils.initialize(serviceLocator) + return GitpodRemoteProvider(serviceLocator.getService(RemoteEnvironmentConsumer::class.java)) + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodLogger.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodLogger.kt new file mode 100644 index 00000000000000..f9688e928bc094 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodLogger.kt @@ -0,0 +1,18 @@ +// Copyright (c) 2024 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.toolbox.gateway + +import com.jetbrains.toolbox.gateway.deploy.DiagnosticInfoCollector +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import org.slf4j.Logger + + +class GitpodLogger(private val logger:Logger) : DiagnosticInfoCollector { + override fun collectAdditionalDiagnostics(p0: Path): CompletableFuture<*> { + logger.info(">>>>>> $p0") + TODO("Not yet implemented") + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodNewEnvironmentPage.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodNewEnvironmentPage.kt new file mode 100644 index 00000000000000..4032bec4d9eaf9 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodNewEnvironmentPage.kt @@ -0,0 +1,110 @@ +// Copyright (c) 2024 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.toolbox.gateway + +import com.jetbrains.toolbox.gateway.ui.ActionDescription +import com.jetbrains.toolbox.gateway.ui.ComboBoxField +import com.jetbrains.toolbox.gateway.ui.TextField +import com.jetbrains.toolbox.gateway.ui.TextType +import com.jetbrains.toolbox.gateway.ui.UiField +import io.gitpod.toolbox.auth.GitpodAuthManager +import io.gitpod.toolbox.components.AbstractUiPage +import io.gitpod.toolbox.components.SimpleButton +import io.gitpod.toolbox.service.GitpodPublicApiManager +import io.gitpod.toolbox.service.Utils +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + +class GitpodNewEnvironmentPage(val authManager: GitpodAuthManager, val publicApi: GitpodPublicApiManager) : + AbstractUiPage() { + private val logger = LoggerFactory.getLogger(javaClass) + + override fun getFields(): MutableList { + return mutableListOf(orgField, contextUrlField, editorField, workspaceClassField) + } + + override fun getTitle(): String { + return "New environment" + } + + override fun getActionButtons(): MutableList { + return mutableListOf(SimpleButton("Create") { + val contextUrl = getFieldValue(contextUrlField) ?: return@SimpleButton + val editor = getFieldValue(editorField) ?: return@SimpleButton + val workspaceClass = getFieldValue(workspaceClassField) ?: return@SimpleButton + if (contextUrl.isBlank()) { + setActionErrorMessage("Context URL is required") + return@SimpleButton + } + if (editor.isBlank()) { + setActionErrorMessage("Editor is required") + return@SimpleButton + } + if (workspaceClass.isBlank()) { + setActionErrorMessage("Workspace class is required") + return@SimpleButton + } + Utils.coroutineScope.launch { + val workspace = publicApi.createAndStartWorkspace(contextUrl, editor, workspaceClass, null) + logger.info("workspace: ${workspace.id} created") + } + }) + } + + private val orgField = getOrgField() + private fun getOrgField(): TextField { + // TODO: Use ComboBoxField or AutocompleteTextField with org results + return TextField("Organization", authManager.getCurrentAccount()?.organizationId ?: "", TextType.General) + } + + // TODO: Use AutocompleteTextField with suggestions from API + // TODO: Add account recent repositories related field? Or get from auto start options + private val contextUrlField = + TextField("Context URL", "https://github.com/Gitpod-Samples/spring-petclinic", TextType.General) + + // TODO: get from API + private val editorField = ComboBoxField( + "Editor", + authManager.getCurrentAccount()?.preferEditor ?: "intellij", + listOf( + ComboBoxField.LabelledValue("IntelliJ IDEA", "intellij"), + ComboBoxField.LabelledValue("Goland", "goland") + ) + ) + + // TODO: get from API + private val workspaceClassField = ComboBoxField( + "Workspace Class", + authManager.getCurrentAccount()?.preferWorkspaceClass ?: "g1-standard", + listOf(ComboBoxField.LabelledValue("Standard", "g1-standard"), ComboBoxField.LabelledValue("Small", "g1-small")) + ) + + override fun fieldChanged(field: UiField) { + super.fieldChanged(field) + val account = authManager.getCurrentAccount() ?: return + if (field == orgField) { + val orgId = getFieldValue(orgField) ?: return + logger.info("set prefer orgId: $orgId") + account.organizationId = orgId + // Not works +// setFieldValue(orgField, orgId) + return + } + if (field == editorField) { + val editor = getFieldValue(editorField) ?: return + logger.info("set prefer editor: $editor") + account.preferEditor = editor + return + } + if (field == workspaceClassField) { + val cls = getFieldValue(workspaceClassField) ?: return + logger.info("set prefer workspaceClass: $cls") + account.preferWorkspaceClass = cls + // Not works +// setFieldValue(workspaceClassField, cls) + return + } + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProvider.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProvider.kt new file mode 100644 index 00000000000000..23ef461c3e6056 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProvider.kt @@ -0,0 +1,197 @@ +// Copyright (c) 2024 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.toolbox.gateway + +import com.jetbrains.toolbox.gateway.ProviderVisibilityState +import com.jetbrains.toolbox.gateway.RemoteEnvironmentConsumer +import com.jetbrains.toolbox.gateway.RemoteProvider +import com.jetbrains.toolbox.gateway.deploy.DiagnosticInfoCollector +import com.jetbrains.toolbox.gateway.ui.AccountDropdownField +import com.jetbrains.toolbox.gateway.ui.ActionDescription +import com.jetbrains.toolbox.gateway.ui.UiPage +import io.gitpod.toolbox.auth.GitpodAuthManager +import io.gitpod.toolbox.auth.GitpodLoginPage +import io.gitpod.toolbox.auth.GitpodOrganizationPage +import io.gitpod.toolbox.colima.ColimaTestEnvironment +import io.gitpod.toolbox.components.GitpodIcon +import io.gitpod.toolbox.components.SimpleButton +import io.gitpod.toolbox.service.GitpodPublicApiManager +import io.gitpod.toolbox.service.Utils +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import java.net.URI +import java.net.URLEncoder + +class GitpodRemoteProvider( + private val consumer: RemoteEnvironmentConsumer, +) : RemoteProvider { + private val logger = LoggerFactory.getLogger(javaClass) + private val authManger = GitpodAuthManager() + private val publicApi = GitpodPublicApiManager(authManger) + private val loginPage = GitpodLoginPage(authManger) + private val newEnvPage = GitpodNewEnvironmentPage(authManger, publicApi) + private val organizationPage = GitpodOrganizationPage(authManger, publicApi) + + // cache consumed environments map locally + private val environmentMap = mutableMapOf() + + private val openInToolboxUriHandler = GitpodOpenInToolboxUriHandler { connectParams -> + Utils.toolboxUi.showPluginEnvironmentsPage() + setEnvironmentVisibility(connectParams.workspaceId) + } + + // TODO: multiple host support + private fun setEnvironmentVisibility(workspaceId: String) { + logger.info("setEnvironmentVisibility $workspaceId") + Utils.toolboxUi.showWindow() + val env = environmentMap[workspaceId] + if (env != null) { + env.markActive() + Utils.clientHelper.setAutoConnectOnEnvironmentReady(workspaceId, "GO-233.15026.17", "/workspace/empty") + } else { + GitpodRemoteProviderEnvironment(authManger, workspaceId, publicApi).apply { + environmentMap[workspaceId] = this + this.markActive() + consumer.consumeEnvironments(listOf(this)) + Utils.clientHelper.setAutoConnectOnEnvironmentReady(workspaceId, "GO-233.15026.17", "/workspace/empty") + } + } + } + + init { + startup() + authManger.addLoginListener { + logger.info("user logged in ${authManger.getCurrentAccount()?.id}") + startup() + // TODO: showPluginEnvironmentsPage not refresh the page + Utils.toolboxUi.showPluginEnvironmentsPage() + } + authManger.addLogoutListener { + logger.info("user logged out ${authManger.getCurrentAccount()?.id}") + // TODO: showPluginEnvironmentsPage not refresh the page + Utils.toolboxUi.showPluginEnvironmentsPage() + } + Utils.coroutineScope.launch { + Utils.dataManager.sharedWorkspaceList.collect { workspaces -> + if (workspaces.isEmpty()) { + return@collect + } + workspaces.forEach{ + val host = URLEncoder.encode("https://exp-migration.preview.gitpod-dev.com", "UTF-8") + val workspaceId = URLEncoder.encode(it.id, "UTF-8") + val debugWorkspace = "false" + val newUri = "jetbrains://gateway/io.gitpod.toolbox.gateway/open-in-toolbox?host=${host}&workspaceId=${workspaceId}&debugWorkspace=${debugWorkspace}" + logger.info("workspace ${it.id} $newUri") + } + consumer.consumeEnvironments(workspaces.map { + val env = environmentMap[it.id] + if (env != null) { + env + } else { + val newEnv = GitpodRemoteProviderEnvironment(authManger, it.id, publicApi) + environmentMap[it.id] = newEnv + newEnv + } + }) + } + } + } + + private fun startup() { + val account = authManger.getCurrentAccount() ?: return + publicApi.setup() + val orgId = account.organizationId + logger.info("user logged in, current selected org: $orgId") + if (orgId != null) { + Utils.dataManager.startWatchWorkspaces(publicApi) + } else { + Utils.coroutineScope.launch { + organizationPage.loadData() + Utils.toolboxUi.showUiPage(organizationPage) + } + } + authManger.getCurrentAccount()?.onOrgSelected { + Utils.dataManager.startWatchWorkspaces(publicApi) + } + } + + override fun getOverrideUiPage(): UiPage? { + logger.info("getOverrideUiPage") + authManger.getCurrentAccount() ?: return loginPage + return null + } + + override fun close() {} + + override fun getName(): String = "Gitpod" + override fun getSvgIcon() = GitpodIcon() + + override fun getNewEnvironmentUiPage() = newEnvPage + + override fun getAccountDropDown(): AccountDropdownField? { + val account = authManger.getCurrentAccount() ?: return null + return AccountDropdownField(account.fullName) { + authManger.logout() + } + } + + private fun testColima() = run { + val env = ColimaTestEnvironment() + consumer.consumeEnvironments(listOf(env)) + Utils.clientHelper.setAutoConnectOnEnvironmentReady("colima", "IU-241.14494.240", "/home/hwen.linux/project") + } + + override fun getAdditionalPluginActions(): MutableList { + return mutableListOf( + SimpleButton("View documents") { + Utils.openUrl("https://gitpod.io/docs") + }, + SimpleButton("Colima") { + testColima() + }, + SimpleButton("Show toast") { + logger.info("toast shown") + val t = Utils.toolboxUi.showInfoPopup("This is header", "This is content", "okText") + Utils.coroutineScope.launch { + t.get() + logger.info("toast closed") + } + }, + SimpleButton("Select organization") { + Utils.coroutineScope.launch { + organizationPage.loadData() + Utils.toolboxUi.showUiPage(organizationPage) + } + } + ) + } + + override fun canCreateNewEnvironments(): Boolean = false + override fun isSingleEnvironment(): Boolean = false + + override fun setVisible(visibilityState: ProviderVisibilityState) {} + + override fun addEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} + override fun removeEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} + + override fun handleUri(uri: URI) { + if (authManger.tryHandle(uri)) { + return + } + if (openInToolboxUriHandler.tryHandle(uri)) { + return + } + when (uri.path) { + // TODO: + else -> { + logger.warn("Unknown request: {}", uri) + } + } + } + + override fun getDiagnosticInfoCollector(): DiagnosticInfoCollector? { + return GitpodLogger(logger) + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProviderEnvironment.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProviderEnvironment.kt new file mode 100644 index 00000000000000..4b1be52c5cb404 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodRemoteProviderEnvironment.kt @@ -0,0 +1,132 @@ +// Copyright (c) 2024 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.toolbox.gateway + +import com.jetbrains.toolbox.gateway.AbstractRemoteProviderEnvironment +import com.jetbrains.toolbox.gateway.EnvironmentVisibilityState +import com.jetbrains.toolbox.gateway.environments.EnvironmentContentsView +import com.jetbrains.toolbox.gateway.states.StandardRemoteEnvironmentState +import com.jetbrains.toolbox.gateway.ui.ActionDescription +import com.jetbrains.toolbox.gateway.ui.ObservableList +import io.gitpod.publicapi.v1.WorkspaceOuterClass +import io.gitpod.publicapi.v1.WorkspaceOuterClass.WorkspacePhase +import io.gitpod.toolbox.auth.GitpodAuthManager +import io.gitpod.toolbox.components.SimpleButton +import io.gitpod.toolbox.service.GitpodPublicApiManager +import io.gitpod.toolbox.service.Utils +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture + +class GitpodRemoteProviderEnvironment( + private val authManager: GitpodAuthManager, + private val workspaceId: String, + private val publicApi: GitpodPublicApiManager, +) : AbstractRemoteProviderEnvironment() { + private val logger = LoggerFactory.getLogger(javaClass) + private val actionList = Utils.observablePropertiesFactory.emptyObservableList(); + private val contentsViewFuture: CompletableFuture = CompletableFuture.completedFuture( + GitpodSSHEnvironmentContentsView( + authManager, + workspaceId, + publicApi, + ) + ) + + private val lastWSEnvState = MutableSharedFlow(1, 0, BufferOverflow.DROP_OLDEST) + private var lastPhase: WorkspacePhase = + WorkspacePhase.newBuilder().setNameValue(WorkspacePhase.Phase.PHASE_UNSPECIFIED_VALUE).build() + private var isMarkActive = false + set(value) { + if (field != value) { + field = value + lastWSEnvState.tryEmit(WorkspaceEnvState(lastPhase, value)) + } + } + + fun markActive() { + isMarkActive = true + } + + init { + logger.info("==================GitpodRemoteProviderEnvironment.init $workspaceId") + Utils.coroutineScope.launch { + Utils.dataManager.watchWorkspaceStatus(workspaceId) { + lastPhase = it.phase + lastWSEnvState.tryEmit(WorkspaceEnvState(it.phase, isMarkActive)) + } + } + + Utils.coroutineScope.launch { + lastWSEnvState.collect { lastState -> + val state = lastState.getState() + val actions = mutableListOf() + if (lastState.isConnectable) { + actions += SimpleButton("Connect") { + isMarkActive = true + } + } + if (lastState.isCloseable) { + actions += SimpleButton("Close") { + isMarkActive = false + Utils.coroutineScope.launch { contentsViewFuture.get().close() } + } + } + if (lastState.isStoppable) { + actions += SimpleButton("Stop workspace") { + logger.info("===============stop workspace clicked") + } + } + actionList.clear() + actionList.addAll(actions) + listenerSet.forEach { it.consume(state) } + } + } + } + + override fun getId(): String = workspaceId + override fun getName(): String = workspaceId + + override fun getContentsView(): CompletableFuture = contentsViewFuture + + override fun setVisible(visibilityState: EnvironmentVisibilityState) { + + } + + override fun getActionList(): ObservableList = actionList +} + + +private class WorkspaceEnvState(val phase: WorkspacePhase, val isMarkActive: Boolean) { + val isConnectable = phase.nameValue == WorkspaceOuterClass.WorkspacePhase.Phase.PHASE_RUNNING_VALUE && !isMarkActive + val isCloseable = isMarkActive + val isStoppable = phase.nameValue == WorkspaceOuterClass.WorkspacePhase.Phase.PHASE_RUNNING_VALUE + + fun getState() = run { + if (isMarkActive && phase.nameValue == WorkspaceOuterClass.WorkspacePhase.Phase.PHASE_RUNNING_VALUE) { + StandardRemoteEnvironmentState.Active + } else { + phaseToStateMap[phase.nameValue] ?: StandardRemoteEnvironmentState.Unreachable + } + } + + companion object { + val phaseToStateMap = mapOf( + WorkspacePhase.Phase.PHASE_UNSPECIFIED_VALUE to StandardRemoteEnvironmentState.Unreachable, + WorkspacePhase.Phase.PHASE_PREPARING_VALUE to StandardRemoteEnvironmentState.Unreachable, + WorkspacePhase.Phase.PHASE_IMAGEBUILD_VALUE to StandardRemoteEnvironmentState.Unreachable, + WorkspacePhase.Phase.PHASE_PENDING_VALUE to StandardRemoteEnvironmentState.Unreachable, + WorkspacePhase.Phase.PHASE_CREATING_VALUE to StandardRemoteEnvironmentState.Unreachable, + WorkspacePhase.Phase.PHASE_INITIALIZING_VALUE to StandardRemoteEnvironmentState.Unreachable, + WorkspacePhase.Phase.PHASE_RUNNING_VALUE to StandardRemoteEnvironmentState.Inactive, + WorkspacePhase.Phase.PHASE_INTERRUPTED_VALUE to StandardRemoteEnvironmentState.Error, + WorkspacePhase.Phase.PHASE_PAUSED_VALUE to StandardRemoteEnvironmentState.Inactive, + WorkspacePhase.Phase.PHASE_STOPPING_VALUE to StandardRemoteEnvironmentState.Inactive, + WorkspacePhase.Phase.PHASE_STOPPED_VALUE to StandardRemoteEnvironmentState.Inactive + ) + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodSSHEnvironmentContentsView.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodSSHEnvironmentContentsView.kt new file mode 100644 index 00000000000000..daa087ce41a485 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodSSHEnvironmentContentsView.kt @@ -0,0 +1,48 @@ +// Copyright (c) 2024 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.toolbox.gateway + +import com.jetbrains.toolbox.gateway.environments.ManualEnvironmentContentsView +import com.jetbrains.toolbox.gateway.environments.SshEnvironmentContentsView +import com.jetbrains.toolbox.gateway.ssh.SshConnectionInfo +import io.gitpod.toolbox.auth.GitpodAuthManager +import io.gitpod.toolbox.service.GitpodConnectionProvider +import io.gitpod.toolbox.service.GitpodPublicApiManager +import io.gitpod.toolbox.service.Utils +import kotlinx.coroutines.future.future +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture + +class GitpodSSHEnvironmentContentsView( + private val authManager: GitpodAuthManager, + private val workspaceId: String, + private val publicApi: GitpodPublicApiManager, +) : SshEnvironmentContentsView, ManualEnvironmentContentsView { + private var cancel = {} + private val stateListeners = mutableSetOf() + + private val logger = LoggerFactory.getLogger(javaClass) + + override fun getConnectionInfo(): CompletableFuture { + return Utils.coroutineScope.future { + val provider = GitpodConnectionProvider(authManager, workspaceId, publicApi) + val (connInfo, cancel) = provider.connect() + this@GitpodSSHEnvironmentContentsView.cancel = cancel + return@future connInfo + } + } + + override fun addEnvironmentContentsListener(p0: ManualEnvironmentContentsView.Listener) { + stateListeners += p0 + } + + override fun removeEnvironmentContentsListener(p0: ManualEnvironmentContentsView.Listener) { + stateListeners -= p0 + } + + override fun close() { + cancel() + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodUriHandler.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodUriHandler.kt new file mode 100644 index 00000000000000..74b7dd4e2f0c0b --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/GitpodUriHandler.kt @@ -0,0 +1,60 @@ +// Copyright (c) 2024 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.toolbox.gateway + +import io.gitpod.toolbox.service.ConnectParams +import org.slf4j.LoggerFactory +import java.net.URI +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Future + +interface UriHandler { + fun parseUri(uri: URI): T + fun handle(data: T): Future + fun tryHandle(uri: URI): Boolean +} + +abstract class AbstractUriHandler : UriHandler { + private val logger = LoggerFactory.getLogger(javaClass) + abstract override fun parseUri(uri: URI): T + abstract override fun handle(data: T): Future + + override fun tryHandle(uri: URI) = try { + val data = parseUri(uri) + handle(data) + true + } catch (e: Exception) { + logger.warn("cannot parse URI", e) + false + } +} + +class GitpodOpenInToolboxUriHandler(val handler: (ConnectParams) -> Unit) : AbstractUriHandler() { + override fun handle(data: ConnectParams): Future = CompletableFuture.runAsync { handler(data) } + + override fun parseUri(uri: URI): ConnectParams { + val path = uri.path.split("/").last() + if (path != "open-in-toolbox") { + throw IllegalArgumentException("invalid URI: $path") + } + val query = uri.query ?: throw IllegalArgumentException("invalid URI: ${uri.query}") + val params = query.split("&").map { it.split("=") }.associate { it[0] to it[1] } + val host = params["host"] + val workspaceId = params["workspaceId"] + val debugWorkspace = params["debugWorkspace"]?.toBoolean() ?: false + + if (host.isNullOrEmpty() || workspaceId.isNullOrEmpty()) { + throw IllegalArgumentException("invalid URI: host or workspaceId is missing: $uri") + } + + try { + URI.create(host) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("invalid host: $host") + } + + return ConnectParams(host, workspaceId, debugWorkspace) + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/await.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/await.kt new file mode 100644 index 00000000000000..12e1be26d3b2fd --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/gateway/await.kt @@ -0,0 +1,26 @@ +// Copyright (c) 2024 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.toolbox.gateway + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import java.io.IOException + +suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resumeWith(Result.success(response)) + } + override fun onFailure(call: Call, e: IOException) { + if (continuation.isCancelled) return + continuation.resumeWith(Result.failure(e)) + } + }) + continuation.invokeOnCancellation { + try { cancel() } catch (_: Exception) { } + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/DataManager.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/DataManager.kt new file mode 100644 index 00000000000000..7e4feb841e6463 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/DataManager.kt @@ -0,0 +1,85 @@ +// Copyright (c) 2024 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.toolbox.service + +import io.gitpod.publicapi.v1.WorkspaceOuterClass +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.slf4j.LoggerFactory +import java.net.SocketTimeoutException + +class DataManager { + val sharedWorkspaceList = MutableSharedFlow>(1, 0, BufferOverflow.DROP_OLDEST) + private val logger = LoggerFactory.getLogger(javaClass) + + private var workspaceList = listOf() + private val workspaceStatusListeners = mutableListOf<(String, WorkspaceOuterClass.WorkspaceStatus) -> Unit>() + + + init { + Utils.coroutineScope.launch { + sharedWorkspaceList.collect { + workspaceList = it + } + } + } + + private var watchWorkspaceStatusJob: Job? = null + private val watchWorkspaceStatusMutex = Mutex() + fun startWatchWorkspaces(publicApi: GitpodPublicApiManager) { + Utils.coroutineScope.launch { + watchWorkspaceStatusMutex.withLock { + try { + publicApi.listWorkspaces().let { + sharedWorkspaceList.tryEmit(it.workspacesList) + } + watchWorkspaceStatusJob?.cancel() + watchWorkspaceStatusJob = publicApi.watchWorkspace(null) { workspaceId, status -> + val found = workspaceList.find { it.id == workspaceId } + if (found != null) { + val newList = workspaceList.map { + if (it.id == workspaceId) { + it.toBuilder().setStatus(status).build() + } else { + it + } + } + sharedWorkspaceList.tryEmit(newList) + } else { + Utils.coroutineScope.launch { + publicApi.listWorkspaces().let { + sharedWorkspaceList.tryEmit(it.workspacesList) + } + } + } + workspaceStatusListeners.forEach{ it(workspaceId, status)} + } + } catch (e: SocketTimeoutException) { + startWatchWorkspaces(publicApi) + } catch (e: Exception) { + logger.error("Error in startWatchWorkspaces", e) + } + } + } + } + + /** + * watchWorkspaceStatus locally to save server load + */ + fun watchWorkspaceStatus(workspaceId: String, consumer: (WorkspaceOuterClass.WorkspaceStatus) -> Unit) { + workspaceList.find { it.id == workspaceId }?.let { + consumer(it.status) + } + workspaceStatusListeners.add { wsId, status -> + if (wsId == workspaceId) { + consumer(status) + } + } + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodConnectionProvider.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodConnectionProvider.kt new file mode 100644 index 00000000000000..ea981091b8920f --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodConnectionProvider.kt @@ -0,0 +1,96 @@ +// Copyright (c) 2024 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.toolbox.service + +import com.jetbrains.rd.util.ConcurrentHashMap +import com.jetbrains.rd.util.URI +import com.jetbrains.toolbox.gateway.ssh.SshConnectionInfo +import io.gitpod.publicapi.v1.WorkspaceOuterClass +import io.gitpod.toolbox.auth.GitpodAuthManager +import kotlinx.serialization.Serializable + +class GitpodConnectionProvider( + private val authManager: GitpodAuthManager, + private val workspaceId: String, + private val publicApi: GitpodPublicApiManager, +) { + private val activeConnections = ConcurrentHashMap() + + suspend fun connect(): Pair Unit> { + val workspace = publicApi.getWorkspace(workspaceId).workspace + val ownerTokenResp = publicApi.getWorkspaceOwnerToken(workspaceId) + val account = authManager.getCurrentAccount() ?: throw Exception("No account found") + + // TODO: debug workspace + val connectParams = ConnectParams(account.getHost(), workspaceId, false) + + val (serverPort, cancel) = tunnelWithWebSocket(workspace, connectParams, ownerTokenResp.ownerToken) + + val connInfo = GitpodWebSocketSshConnectionInfo( + "gitpod", + "localhost", + serverPort, + ) + return (connInfo to cancel) + } + + private fun tunnelWithWebSocket( + workspace: WorkspaceOuterClass.Workspace, + connectParams: ConnectParams, + ownerToken: String, + ): Pair Unit> { + val connectionKeyId = connectParams.uniqueID + + var found = true + activeConnections.computeIfAbsent(connectionKeyId) { + found = false + true + } + + if (found) { + val errMessage = "A connection to the same workspace already exists: $connectionKeyId" + throw IllegalStateException(errMessage) + } + + val workspaceHost = URI.create(workspace.status.workspaceUrl).host + val server = + GitpodWebSocketTunnelServer("wss://${workspaceHost}/_supervisor/tunnel/ssh", ownerToken) + + val cancelServer = server.start() + + return (server.port to { + activeConnections.remove(connectionKeyId) + cancelServer() + }) + } +} + +class GitpodWebSocketSshConnectionInfo( + private val username: String, + private val host: String, + private val port: Int, +) : SshConnectionInfo { + override fun getHost() = host + override fun getPort() = port + override fun getUserName() = username + override fun getShouldAskForPassword() = false + override fun getShouldUseSystemSshAgent() = true +} + +data class ConnectParams( + val gitpodHost: String, + val workspaceId: String, + val debugWorkspace: Boolean = false, +) { + val resolvedWorkspaceId = "${if (debugWorkspace) "debug-" else ""}$workspaceId" + val title = "$resolvedWorkspaceId ($gitpodHost)" + val uniqueID = "$gitpodHost-$workspaceId-$debugWorkspace" +} + +@Serializable +private data class SSHPublicKey( + val type: String, + val value: String +) diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodPublicApiManager.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodPublicApiManager.kt new file mode 100644 index 00000000000000..504878a358d4cc --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodPublicApiManager.kt @@ -0,0 +1,187 @@ +// Copyright (c) 2024 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.toolbox.service + +import com.connectrpc.* +import com.connectrpc.extensions.GoogleJavaProtobufStrategy +import com.connectrpc.http.clone +import com.connectrpc.impl.ProtocolClient +import com.connectrpc.okhttp.ConnectOkHttpClient +import com.connectrpc.protocols.NetworkProtocol +import io.gitpod.publicapi.v1.* +import io.gitpod.toolbox.auth.GitpodAccount +import io.gitpod.toolbox.auth.GitpodAuthManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.time.Duration + +class GitpodPublicApiManager(private val authManger: GitpodAuthManager) { + private var workspaceApi: WorkspaceServiceClientInterface? = null + private var organizationApi: OrganizationServiceClientInterface? = null + private var userApi: UserServiceClientInterface? = null + private var account: GitpodAccount? = null + private val logger = LoggerFactory.getLogger(javaClass) + + init { + setup() + authManger.addLoginListener { + setup() + } + authManger.addLogoutListener { + workspaceApi = null + organizationApi = null + userApi = null + account = null + } + } + + fun setup() { + val account = authManger.getCurrentAccount() ?: return + this.account = account + logger.debug("setup papi client ${account.getHost()}") + val client = createClient(account.getHost(), account.getCredentials()) + workspaceApi = WorkspaceServiceClient(client) + organizationApi = OrganizationServiceClient(client) + userApi = UserServiceClient(client) + } + + private val orgId: String + get() = account?.organizationId ?: throw IllegalStateException("Organization not selected") + + suspend fun listOrganizations(): List { + val organizationApi = organizationApi ?: throw IllegalStateException("No client") + val resp = organizationApi.listOrganizations(OrganizationOuterClass.ListOrganizationsRequest.newBuilder().build()) + return this.handleResp("listOrganizations", resp).organizationsList + } + + suspend fun createAndStartWorkspace(contextUrl: String, editor: String, workspaceClass: String, configurationId: String?): WorkspaceOuterClass.Workspace { + val workspaceApi = workspaceApi ?: throw IllegalStateException("No client") + val meta = WorkspaceOuterClass.WorkspaceMetadata.newBuilder().setOrganizationId(orgId) + if (configurationId != null) { + meta.setConfigurationId(configurationId) + } + val contextInfo = WorkspaceOuterClass.CreateAndStartWorkspaceRequest.ContextURL.newBuilder() + .setUrl(contextUrl) + .setWorkspaceClass(workspaceClass) + .setEditor(Editor.EditorReference.newBuilder().setName(editor).build()) + val req = WorkspaceOuterClass.CreateAndStartWorkspaceRequest.newBuilder() + .setMetadata(meta) + .setContextUrl(contextInfo) + val resp = workspaceApi.createAndStartWorkspace(req.build()) + return this.handleResp("createWorkspace", resp).workspace + } + + fun watchWorkspace(workspaceId: String?, consumer: (String, WorkspaceOuterClass.WorkspaceStatus) -> Unit): Job { + val workspaceApi = workspaceApi ?: throw IllegalStateException("No client") + return Utils.coroutineScope.launch { + val req = WorkspaceOuterClass.WatchWorkspaceStatusRequest.newBuilder() + if (!workspaceId.isNullOrEmpty()) { + req.setWorkspaceId(workspaceId) + } + val stream = workspaceApi.watchWorkspaceStatus() + stream.sendAndClose(req.build()) + val chan = stream.responseChannel() + try { + for (response in chan) { + consumer(response.workspaceId, response.status) + } + } + finally { + chan.cancel() + } + } + } + + suspend fun listWorkspaces(): WorkspaceOuterClass.ListWorkspacesResponse { + val workspaceApi = workspaceApi ?: throw IllegalStateException("No client") + val resp = workspaceApi.listWorkspaces( + WorkspaceOuterClass.ListWorkspacesRequest.newBuilder().setOrganizationId(orgId).build() + ) + return this.handleResp("listWorkspaces", resp) + } + + suspend fun getWorkspace(workspaceId: String): WorkspaceOuterClass.GetWorkspaceResponse { + val workspaceApi = workspaceApi ?: throw IllegalStateException("No client") + val resp = workspaceApi.getWorkspace( + WorkspaceOuterClass.GetWorkspaceRequest.newBuilder().setWorkspaceId(workspaceId).build() + ) + return this.handleResp("getWorkspace", resp) + } + + suspend fun getWorkspaceOwnerToken(workspaceId: String): WorkspaceOuterClass.GetWorkspaceOwnerTokenResponse { + val workspaceApi = workspaceApi ?: throw IllegalStateException("No client") + val resp = workspaceApi.getWorkspaceOwnerToken( + WorkspaceOuterClass.GetWorkspaceOwnerTokenRequest.newBuilder().setWorkspaceId(workspaceId).build() + ) + return this.handleResp("getWorkspaceOwnerToken", resp) + } + + suspend fun getAuthenticatedUser(): UserOuterClass.User { + return tryGetAuthenticatedUser(userApi, logger) + } + + private fun handleResp(method: String, resp: ResponseMessage): T { + val data = resp.success { it.message } + val error = resp.failure { + logger.error("failed to call papi.${method} $it") + it.cause + } + return data ?: throw error!! + } + + companion object { + fun createClient(gitpodHost: String, token: String): ProtocolClient { + // TODO: 6m? + val client = Utils.httpClient.newBuilder().readTimeout(Duration.ofMinutes(6)).build() + val authInterceptor = AuthorizationInterceptor(token) + return ProtocolClient( + httpClient = ConnectOkHttpClient(client), + ProtocolClientConfig( + host = "$gitpodHost/public-api", + serializationStrategy = GoogleJavaProtobufStrategy(), // Or GoogleJavaJSONStrategy for JSON. + networkProtocol = NetworkProtocol.CONNECT, + interceptors = listOf { authInterceptor } + ), + ) + } + + /** + * Tries to get the authenticated user from the given API client. + * Used in GitpodAuthManager + */ + suspend fun tryGetAuthenticatedUser(api: UserServiceClientInterface?, logger: Logger): UserOuterClass.User { + val userApi = api ?: throw IllegalStateException("No client") + val resp = userApi.getAuthenticatedUser(UserOuterClass.GetAuthenticatedUserRequest.newBuilder().build()) + val user = resp.success { it.message.user } + val err = resp.failure { + logger.error("failed to call papi.getAuthenticatedUser $it") + it.cause + } + return user ?: throw err!! + } + } +} + +class AuthorizationInterceptor(private val token: String) : Interceptor { + override fun streamFunction() = StreamFunction({ + val headers = mutableMapOf>() + headers.putAll(it.headers) + headers["Authorization"] = listOf("Bearer $token") + return@StreamFunction it.clone(headers = headers) + }) + + override fun unaryFunction() = UnaryFunction( + { + val headers = mutableMapOf>() + headers.putAll(it.headers) + headers["Authorization"] = listOf("Bearer $token") + return@UnaryFunction it.clone(headers = headers) + }, + ) +} + +// TODO: logger interceptor diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodWebSocketTunnelServer.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodWebSocketTunnelServer.kt new file mode 100644 index 00000000000000..571c47d89f61c6 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/GitpodWebSocketTunnelServer.kt @@ -0,0 +1,218 @@ +// Copyright (c) 2024 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.toolbox.service + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.eclipse.jetty.client.HttpClient +import org.eclipse.jetty.client.HttpProxy +import org.eclipse.jetty.client.Socks4Proxy +import org.eclipse.jetty.util.ssl.SslContextFactory +import org.eclipse.jetty.websocket.jsr356.ClientContainer +import org.slf4j.LoggerFactory +import java.net.* +import java.nio.ByteBuffer +import java.util.* +import java.util.concurrent.CopyOnWriteArrayList +import javax.net.ssl.SSLContext +import javax.websocket.* +import javax.websocket.ClientEndpointConfig.Configurator +import javax.websocket.MessageHandler.Partial + +class GitpodWebSocketTunnelServer( + private val url: String, + private val ownerToken: String, +) { + private val serverSocket = ServerSocket(0) // pass 0 to have the system choose a free port + private val logger = LoggerFactory.getLogger(javaClass) + + val port: Int + get() = serverSocket.localPort + + private fun thisLogger() = logger + + private val clients = CopyOnWriteArrayList() + + fun start(): () -> Unit { + val job = Utils.coroutineScope.launch(Dispatchers.IO) { + thisLogger().info("gitpod: tunnel[$url]: listening on port $port") + try { + while (isActive) { + try { + val clientSocket = serverSocket.accept() + launch(Dispatchers.IO) { + handleClientConnection(clientSocket) + } + } catch (t: Throwable) { + if (isActive) { + thisLogger().error("gitpod: tunnel[$url]: failed to accept", t) + } + } + } + } catch (t: Throwable) { + if (isActive) { + thisLogger().error("gitpod: tunnel[$url]: failed to listen", t) + } + } finally { + thisLogger().info("gitpod: tunnel[$url]: stopped") + } + } + return { + job.cancel() + serverSocket.close() + clients.forEach { it.close() } + clients.clear() + } + } + + private fun handleClientConnection(clientSocket: Socket) { + val socketClient = GitpodWebSocketTunnelClient(url, clientSocket) + try { + val inputStream = clientSocket.getInputStream() + val outputStream = clientSocket.getOutputStream() + + // Forward data from WebSocket to TCP client + socketClient.onMessageCallback = { data -> + outputStream.write(data) + thisLogger().trace("gitpod: tunnel[$url]: received ${data.size} bytes") + } + + connectToWebSocket(socketClient) + + clients.add(socketClient) + + val buffer = ByteArray(1024) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + // Forward data from TCP to WebSocket + socketClient.sendData(buffer.copyOfRange(0, read)) + thisLogger().trace("gitpod: tunnel[$url]: sent $read bytes") + } + } catch (t: Throwable) { + if (t is SocketException && t.message?.contains("Socket closed") == true) { + return + } + thisLogger().error("gitpod: tunnel[$url]: failed to pipe", t) + } finally { + clients.remove(socketClient) + socketClient.close() + } + } + + private fun connectToWebSocket(socketClient: GitpodWebSocketTunnelClient) { + val ssl: SslContextFactory = SslContextFactory.Client() + ssl.sslContext = SSLContext.getDefault() + val httpClient = HttpClient(ssl) + val proxies = Utils.getProxyList() + for (proxy in proxies) { + if (proxy.type() == Proxy.Type.DIRECT) { + continue + } + val proxyAddress = proxy.address() + if (proxyAddress !is InetSocketAddress) { + thisLogger().warn("gitpod: tunnel[$url]: unexpected proxy: $proxy") + continue + } + val hostName = proxyAddress.hostString + val port = proxyAddress.port + if (proxy.type() == Proxy.Type.HTTP) { + httpClient.proxyConfiguration.proxies.add(HttpProxy(hostName, port)) + } else if (proxy.type() == Proxy.Type.SOCKS) { + httpClient.proxyConfiguration.proxies.add(Socks4Proxy(hostName, port)) + } + } + val container = ClientContainer(httpClient) + + // stop container immediately since we close only when a session is already gone + container.stopTimeout = 0 + + // allow clientContainer to own httpClient (for start/stop lifecycle) + container.client.addManaged(httpClient) + container.start() + + // Create config to add custom headers + val config = ClientEndpointConfig.Builder.create() + .configurator(object : Configurator() { + override fun beforeRequest(headers: MutableMap>) { + headers["x-gitpod-owner-token"] = Collections.singletonList(ownerToken) + headers["user-agent"] = Collections.singletonList("gitpod-toolbox") + } + }) + .build() + + try { + socketClient.container = container; + container.connectToServer(socketClient, config, URI(url)) + } catch (t: Throwable) { + container.stop() + throw t + } + } + +} + +class GitpodWebSocketTunnelClient( + private val url: String, + private val tcpSocket: Socket +) : Endpoint(), Partial { + private val logger = LoggerFactory.getLogger(javaClass) + private lateinit var webSocketSession: Session + var onMessageCallback: ((ByteArray) -> Unit)? = null + var container: ClientContainer? = null + + private fun thisLogger() = logger + + override fun onOpen(session: Session, config: EndpointConfig) { + session.addMessageHandler(this) + this.webSocketSession = session + } + + override fun onClose(session: Session, closeReason: CloseReason) { + thisLogger().info("gitpod: tunnel[$url]: closed ($closeReason)") + this.doClose() + } + + override fun onError(session: Session?, thr: Throwable?) { + thisLogger().error("gitpod: tunnel[$url]: failed", thr) + this.doClose() + } + + private fun doClose() { + try { + tcpSocket.close() + } catch (t: Throwable) { + thisLogger().error("gitpod: tunnel[$url]: failed to close socket", t) + } + try { + container?.stop() + } catch (t: Throwable) { + thisLogger().error("gitpod: tunnel[$url]: failed to stop container", t) + } + } + + fun sendData(data: ByteArray) { + webSocketSession.asyncRemote.sendBinary(ByteBuffer.wrap(data)) + } + + fun close() { + try { + webSocketSession.close() + } catch (t: Throwable) { + thisLogger().error("gitpod: tunnel[$url]: failed to close", t) + } + try { + container?.stop() + } catch (t: Throwable) { + thisLogger().error("gitpod: tunnel[$url]: failed to stop container", t) + } + } + + override fun onMessage(partialMessage: ByteBuffer, last: Boolean) { + val data = ByteArray(partialMessage.remaining()) + partialMessage.get(data) + onMessageCallback?.invoke(data) + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/PageRouter.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/PageRouter.kt new file mode 100644 index 00000000000000..d82cb5b0583ede --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/PageRouter.kt @@ -0,0 +1,75 @@ +// Copyright (c) 2024 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.toolbox.service + +import com.jetbrains.toolbox.gateway.ui.LabelField +import com.jetbrains.toolbox.gateway.ui.UiField +import com.jetbrains.toolbox.gateway.ui.UiPage +import org.slf4j.LoggerFactory + +interface Route { + val path: String + val page: UiPage +} + +class PageRouter { + private val logger = LoggerFactory.getLogger(javaClass) + private val history = mutableListOf() + private val routes: MutableList = mutableListOf() + private val listeners: MutableList<(String?) -> Unit> = mutableListOf() + + fun addRoutes(vararg newRoutes: Route) { + logger.info("add routes: {}", newRoutes.map { it.path }) + this.routes.addAll(newRoutes) + } + + fun goTo(path: String) { + val route = routes.find { it.path == path } ?: kotlin.run { + logger.warn("route not found: $path") + return + } + logger.info("go to route: ${route.path}") + history.add(route) + notifyListeners() + } + + fun goBack() { + logger.info("go back") + if (history.size >= 1) { + history.removeAt(history.size - 1) + notifyListeners() + } else { + logger.warn("no route to go back") + return + } + } + + fun getCurrentPage(): Pair { + logger.info("current page: ${history.lastOrNull()?.page}") + val route = history.lastOrNull() ?: return PageNotFound() to true + return route.page to false + } + + fun addListener(listener: (String?) -> Unit): () -> Unit { + listeners.add(listener) + return { + listeners.remove(listener) + } + } + + private fun notifyListeners() { + listeners.forEach { it(history.lastOrNull()?.path) } + } +} + +class PageNotFound : UiPage { + override fun getTitle(): String { + return "Not Found" + } + + override fun getFields(): MutableList { + return mutableListOf(LabelField("Not found")) + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/Utils.kt b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/Utils.kt new file mode 100644 index 00000000000000..05b785e6400084 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/kotlin/io/gitpod/toolbox/service/Utils.kt @@ -0,0 +1,63 @@ +// Copyright (c) 2024 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.toolbox.service + +import com.jetbrains.toolbox.gateway.PluginSettingsStore +import com.jetbrains.toolbox.gateway.ToolboxServiceLocator +import com.jetbrains.toolbox.gateway.connection.ClientHelper +import com.jetbrains.toolbox.gateway.connection.ToolboxProxySettings +import com.jetbrains.toolbox.gateway.ssh.validation.SshConnectionValidator +import com.jetbrains.toolbox.gateway.ui.ObservablePropertiesFactory +import com.jetbrains.toolbox.gateway.ui.ToolboxUi +import kotlinx.coroutines.CoroutineScope +import okhttp3.OkHttpClient +import java.net.Proxy +import java.util.concurrent.atomic.AtomicBoolean + +object Utils { + lateinit var sharedServiceLocator: ToolboxServiceLocator private set + lateinit var coroutineScope: CoroutineScope private set + lateinit var settingStore: PluginSettingsStore private set + lateinit var sshConnectionValidator: SshConnectionValidator private set + lateinit var httpClient: OkHttpClient private set + lateinit var clientHelper: ClientHelper private set + lateinit var observablePropertiesFactory: ObservablePropertiesFactory private set + lateinit var proxySettings: ToolboxProxySettings private set + + lateinit var dataManager: DataManager private set + + lateinit var toolboxUi: ToolboxUi private set + + + fun initialize(serviceLocator: ToolboxServiceLocator) { + if (!isInitialized.compareAndSet(false, true)) { + return + } + sharedServiceLocator = serviceLocator + coroutineScope = serviceLocator.getService(CoroutineScope::class.java) + toolboxUi = serviceLocator.getService(ToolboxUi::class.java) + settingStore = serviceLocator.getService(PluginSettingsStore::class.java) + sshConnectionValidator = serviceLocator.getService(SshConnectionValidator::class.java) + httpClient = serviceLocator.getService(OkHttpClient::class.java) + clientHelper = serviceLocator.getService(ClientHelper::class.java) + observablePropertiesFactory = serviceLocator.getService(ObservablePropertiesFactory::class.java) + proxySettings = serviceLocator.getService(ToolboxProxySettings::class.java) + dataManager = DataManager() + } + + fun openUrl(url: String) { + toolboxUi.openUrl(url) + } + + fun getProxyList(): List { + val proxyList = mutableListOf() + if (proxySettings.proxy != null && proxySettings.proxy != Proxy.NO_PROXY) { + proxyList.add(proxySettings.proxy!!) + } + return proxyList + } + + private val isInitialized = AtomicBoolean(false) +} diff --git a/components/ide/jetbrains/toolbox/src/main/resources/META-INF/services/com.jetbrains.toolbox.gateway.GatewayExtension b/components/ide/jetbrains/toolbox/src/main/resources/META-INF/services/com.jetbrains.toolbox.gateway.GatewayExtension new file mode 100644 index 00000000000000..b225999a57740a --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/resources/META-INF/services/com.jetbrains.toolbox.gateway.GatewayExtension @@ -0,0 +1 @@ +io.gitpod.toolbox.gateway.GitpodGatewayExtension diff --git a/components/ide/jetbrains/toolbox/src/main/resources/dependencies.json b/components/ide/jetbrains/toolbox/src/main/resources/dependencies.json new file mode 100644 index 00000000000000..01b3cbeb86ebf6 --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/resources/dependencies.json @@ -0,0 +1,44 @@ +[ + { + "name": "Toolbox App plugin API", + "version": "2.1.0.16946", + "url": "https://jetbrains.com/toolbox-app/", + "license": "The Apache Software License, Version 2.0", + "licenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "name": "com.squareup.okhttp3:okhttp", + "version": "4.10.0", + "url": "https://square.github.io/okhttp/", + "license": "The Apache Software License, Version 2.0", + "licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "name": "Kotlin", + "version": "1.9.0", + "url": "https://kotlinlang.org/", + "license": "The Apache License, Version 2.0", + "licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "name": "kotlinx.coroutines", + "version": "1.7.3", + "url": "https://github.com/Kotlin/kotlinx.coroutines/", + "license": "The Apache License, Version 2.0", + "licenseUrl": "https://github.com/Kotlin/kotlinx.coroutines/blob/master/LICENSE.txt" + }, + { + "name": "kotlinx.serialization", + "version": "1.5.0", + "url": "https://github.com/Kotlin/kotlinx.serialization/", + "license": "The Apache License, Version 2.0", + "licenseUrl": "https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt" + }, + { + "name": "org.slf4j:slf4j-api", + "version": "2.0.3", + "url": "http://www.slf4j.org", + "license": "MIT License", + "licenseUrl": "http://www.opensource.org/licenses/mit-license.php" + } +] diff --git a/components/ide/jetbrains/toolbox/src/main/resources/extension.json b/components/ide/jetbrains/toolbox/src/main/resources/extension.json new file mode 100644 index 00000000000000..762823c95e115e --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/resources/extension.json @@ -0,0 +1,20 @@ +{ + "id": "io.gitpod.toolbox.gateway", + "version": "0.0.1", + "meta": { + "readableName": "Gitpod plugin", + "description": "Gitpod CDE(Cloud Development Environment) integration into JetBrains Toolbox App", + "vendor": "Toolbox + Gateway", + "url": "https://github.com/gitpod-io/gitpod", + "backgroundColors": { + "start": { "hex": "#fdb60d", "opacity": 0.6 }, + "top": { "hex": "#ff318c", "opacity": 0.6 }, + "end": { "hex": "#6b57ff", "opacity": 0.6 } + } + }, + "apiVersion": "0.1.0", + "compatibleVersionRange": { + "from": "2.1.0", + "to": "2.2.0" + } +} diff --git a/components/ide/jetbrains/toolbox/src/main/resources/icon.svg b/components/ide/jetbrains/toolbox/src/main/resources/icon.svg new file mode 100644 index 00000000000000..788431d80e068f --- /dev/null +++ b/components/ide/jetbrains/toolbox/src/main/resources/icon.svg @@ -0,0 +1 @@ + diff --git a/components/server/src/oauth-server/db.ts b/components/server/src/oauth-server/db.ts index a6191d504d5dc2..7f1aa17ebba996 100644 --- a/components/server/src/oauth-server/db.ts +++ b/components/server/src/oauth-server/db.ts @@ -141,6 +141,17 @@ const desktopClient: OAuthClient = { ], }; +const toolbox: OAuthClient = { + id: "toolbox-gateway-gitpod-plugin", + name: "JetBrains Toolbox Gitpod Plugin", + redirectUris: ["jetbrains://gateway/io.gitpod.toolbox.gateway/auth"], + allowedGrants: ["authorization_code"], + scopes: [ + // We scope all so that it can work in papi like a PAT + { name: "function:*" }, + ], +}; + const vscode = createVSCodeClient("vscode", "VS Code"); const vscodeInsiders = createVSCodeClient("vscode-insiders", "VS Code Insiders"); @@ -157,6 +168,7 @@ export const inMemoryDatabase: InMemory = { [vscodium.id]: vscodium, [cursor.id]: cursor, [desktopClient.id]: desktopClient, + [toolbox.id]: toolbox, }, tokens: {}, scopes: {},