Skip to content

Commit

Permalink
Add Webview Asset Providers (#358)
Browse files Browse the repository at this point in the history
* Pass populated AWS_CA_BUNDLE env var to Flare (#341)

* Add UI notification to alert user of deprecated manifest version (#312)

* Add webview dependency missing logic to ViewRouter

* Revert commit 'Add UI notification to alert user of deprecated manifest version'

* Revert 'Pass populated AWS_CA_BUNDLE env var to Flare'

* Rebase changes from Browser Provider PR

* Fix ViewRouter

---------

Co-authored-by: Jonathan Breedlove <[email protected]>
Co-authored-by: Nicolas <[email protected]>
  • Loading branch information
3 people authored Feb 11, 2025
1 parent 4ec1937 commit 5dc56b9
Show file tree
Hide file tree
Showing 10 changed files with 412 additions and 260 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.providers.assets;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;

import com.fasterxml.jackson.databind.ObjectMapper;

import software.aws.toolkits.eclipse.amazonq.configuration.PluginStoreKeys;
import software.aws.toolkits.eclipse.amazonq.lsp.AwsServerCapabiltiesProvider;
import software.aws.toolkits.eclipse.amazonq.lsp.model.ChatOptions;
import software.aws.toolkits.eclipse.amazonq.lsp.model.QuickActions;
import software.aws.toolkits.eclipse.amazonq.lsp.model.QuickActionsCommandGroup;
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.providers.lsp.LspManagerProvider;
import software.aws.toolkits.eclipse.amazonq.util.ObjectMapperFactory;
import software.aws.toolkits.eclipse.amazonq.util.WebviewAssetServer;

public class ChatWebViewAssetProvider extends WebViewAssetProvider {

private WebviewAssetServer webviewAssetServer;

public ChatWebViewAssetProvider() {
Optional<String> content = getContent();
Activator.getEventBroker().post(WebViewAssetState.class,
content.isPresent() ? WebViewAssetState.RESOLVED : WebViewAssetState.DEPENDENCY_MISSING);
}

@Override
public Optional<String> getContent() {
var chatAsset = resolveJsPath();
if (!chatAsset.isPresent()) {
return Optional.empty();
}

String chatJsPath = chatAsset.get();

return Optional.of(String.format("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src %s 'unsafe-inline'; style-src %s 'unsafe-inline';
img-src 'self' data:; object-src 'none'; base-uri 'none'; connect-src swt:;"
>
<title>Amazon Q Chat</title>
%s
</head>
<body>
%s
</body>
</html>
""", chatJsPath, chatJsPath, generateCss(), generateJS(chatJsPath)));
}

private String generateCss() {
return """
<style>
body,
html {
background-color: var(--mynah-color-bg);
color: var(--mynah-color-text-default);
height: 100vh;
width: 100%%;
overflow: hidden;
margin: 0;
padding: 0;
}
.mynah-ui-icon-plus,
.mynah-ui-icon-cancel {
-webkit-mask-size: 155% !important;
mask-size: 155% !important;
mask-position: center;
scale: 60%;
}
.mynah-ui-icon-tabs {
-webkit-mask-size: 102% !important;
mask-size: 102% !important;
mask-position: center;
}
textarea:placeholder-shown {
line-height: 1.5rem;
}
</style>
""";
}

private String generateJS(final String jsEntrypoint) {
var chatQuickActionConfig = generateQuickActionConfig();
var disclaimerAcknowledged = Activator.getPluginStore().get(PluginStoreKeys.CHAT_DISCLAIMER_ACKNOWLEDGED);
return String.format("""
<script type="text/javascript" src="%s" defer></script>
<script type="text/javascript">
%s
const init = () => {
waitForFunction('ideCommand')
.then(() => {
amazonQChat.createChat({
postMessage: (message) => {
ideCommand(JSON.stringify(message));
}
}, {
quickActionCommands: %s,
disclaimerAcknowledged: %b
});
})
.catch(error => console.error('Error initializing chat:', error));
}
window.addEventListener('load', init);
</script>
""", jsEntrypoint, getWaitFunction(), chatQuickActionConfig, "true".equals(disclaimerAcknowledged));
}

/*
* Generates javascript for chat options to be supplied to Chat UI defined here
* https://github.com/aws/language-servers/blob/
* 785f8dee86e9f716fcfa29b2e27eb07a02387557/chat-client/src/client/chat.ts#L87
*/
private String generateQuickActionConfig() {
return Optional.ofNullable(AwsServerCapabiltiesProvider.getInstance().getChatOptions())
.map(ChatOptions::quickActions).map(QuickActions::quickActionsCommandGroups)
.map(this::serializeQuickActionCommands).orElse("[]");
}

private String serializeQuickActionCommands(final List<QuickActionsCommandGroup> quickActionCommands) {
try {
ObjectMapper mapper = ObjectMapperFactory.getInstance();
return mapper.writeValueAsString(quickActionCommands);
} catch (Exception e) {
Activator.getLogger().warn("Error occurred when json serializing quick action commands", e);
return "";
}
}

public Optional<String> resolveJsPath() {
var chatUiDirectory = getChatUiDirectory();

if (!isValid(chatUiDirectory)) {
Activator.getLogger().error(
"Error loading Chat UI. If override used, please verify the override env variables else restart Eclipse");
return Optional.empty();
}

String jsFile = Paths.get(chatUiDirectory.get()).resolve("amazonq-ui.js").toString();
var jsParent = Path.of(jsFile).getParent();
var jsDirectoryPath = Path.of(jsParent.toUri()).normalize().toString();

if (webviewAssetServer == null) {
webviewAssetServer = new WebviewAssetServer();
}

var result = webviewAssetServer.resolve(jsDirectoryPath);
if (!result) {
Activator.getLogger().error(String.format(
"Error loading Chat UI. Unable to find the `amazonq-ui.js` file in the directory: %s. Please verify and restart",
chatUiDirectory.get()));
return Optional.empty();
}

String chatJsPath = webviewAssetServer.getUri() + "amazonq-ui.js";

return Optional.ofNullable(chatJsPath);
}

private Optional<String> getChatUiDirectory() {
try {
return Optional.of(LspManagerProvider.getInstance().getLspInstallation().getClientDirectory());
} catch (Exception e) {
return Optional.empty();
}
}

private boolean isValid(final Optional<String> chatUiDirectory) {
return chatUiDirectory.isPresent() && Files.exists(Paths.get(chatUiDirectory.get()));
}

@Override
public void dispose() {
if (webviewAssetServer != null) {
webviewAssetServer.stop();
}
webviewAssetServer = null;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.providers.assets;

import java.io.IOException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Optional;

import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.util.PluginUtils;
import software.aws.toolkits.eclipse.amazonq.util.ThemeDetector;
import software.aws.toolkits.eclipse.amazonq.util.WebviewAssetServer;

public final class ToolkitLoginWebViewAssetProvider extends WebViewAssetProvider {

private WebviewAssetServer webviewAssetServer;
private static final ThemeDetector THEME_DETECTOR = new ThemeDetector();

public ToolkitLoginWebViewAssetProvider() {
Optional<String> content = getContent();
Activator.getEventBroker().post(WebViewAssetState.class,
content.isPresent() ? WebViewAssetState.RESOLVED : WebViewAssetState.DEPENDENCY_MISSING);
}

@Override
public Optional<String> getContent() {
try {
URL jsFile = PluginUtils.getResource("webview/build/assets/js/getStart.js");
String decodedPath = URLDecoder.decode(jsFile.getPath(), StandardCharsets.UTF_8);

// Remove leading slash for Windows paths
decodedPath = decodedPath.replaceFirst("^/([A-Za-z]:)", "$1");

Path jsParent = Paths.get(decodedPath).getParent();
String jsDirectoryPath = jsParent.normalize().toString();

webviewAssetServer = new WebviewAssetServer();
var result = webviewAssetServer.resolve(jsDirectoryPath);
if (!result) {
return Optional.of("Failed to load JS");
}
var loginJsPath = webviewAssetServer.getUri() + "getStart.js";
boolean isDarkTheme = THEME_DETECTOR.isDarkTheme();
return Optional.of(String.format(
"""
<!DOCTYPE html>
<html>
<head>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src %s 'unsafe-inline'; style-src %s 'unsafe-inline';
img-src 'self' data:; object-src 'none'; base-uri 'none'; connect-src swt:;"
>
<title>AWS Q</title>
</head>
<body class="jb-light">
<div id="app"></div>
<script type="text/javascript" src="%s" defer></script>
<script type="text/javascript">
%s
const init = () => {
changeTheme(%b);
Promise.all([
waitForFunction('ideCommand'),
waitForFunction('telemetryEvent')
])
.then(([ideCommand, telemetryEvent]) => {
const ideApi = {
postMessage(message) {
ideCommand(JSON.stringify(message));
}
};
window.ideApi = ideApi;
const telemetryApi = {
postClickEvent(event) {
telemetryEvent(event);
}
};
window.telemetryApi = telemetryApi;
ideCommand(JSON.stringify({"command":"onLoad"}));
})
.catch(error => console.error('Error in initialization:', error));
};
window.addEventListener('load', init);
</script>
</body>
</html>
""",
loginJsPath, loginJsPath, loginJsPath, getWaitFunction(), isDarkTheme));
} catch (IOException e) {
return Optional.of("Failed to load JS");
}
}

@Override
public void dispose() {
if (webviewAssetServer != null) {
webviewAssetServer.stop();
}
webviewAssetServer = null;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.providers.assets;

import java.util.Optional;

public abstract class WebViewAssetProvider {

public abstract Optional<String> getContent();

public abstract void dispose();

protected final String getWaitFunction() {
return """
function waitForFunction(functionName, timeout = 30000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const checkFunction = () => {
if (typeof window[functionName] === 'function') {
resolve(window[functionName]);
} else if (Date.now() - startTime > timeout) {
reject(new Error(`Timeout waiting for ${functionName}`));
} else {
setTimeout(checkFunction, 100);
}
};
checkFunction();
});
}
""";
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.eclipse.amazonq.providers.assets;

public enum WebViewAssetState {
RESOLVED, DEPENDENCY_MISSING;

public boolean isDependencyMissing() {
return this == DEPENDENCY_MISSING;
}

}
Loading

0 comments on commit 5dc56b9

Please sign in to comment.