Updated to latest Protocol Version
This commit is contained in:
@@ -26,7 +26,16 @@ import javax.xml.transform.TransformerFactory;
|
||||
import javax.xml.transform.dom.DOMSource;
|
||||
import javax.xml.transform.stream.StreamResult;
|
||||
import java.awt.*;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -64,8 +73,14 @@ public final class BrowserTab extends OACPanel {
|
||||
* @param onLocationChange callback invoked on URL changes
|
||||
* @param luaEnabled whether Lua is enabled for this tab
|
||||
* @param luaPolicy execution policy for Lua
|
||||
* @param protocolClient protocol client used for OAC network requests
|
||||
*/
|
||||
public BrowserTab(String key, String initialUrl, Consumer<String> onLocationChange, boolean luaEnabled, LuaExecutionPolicy luaPolicy, ClientImpl protocolClient) {
|
||||
public BrowserTab(String key,
|
||||
String initialUrl,
|
||||
Consumer<String> onLocationChange,
|
||||
boolean luaEnabled,
|
||||
LuaExecutionPolicy luaPolicy,
|
||||
ClientImpl protocolClient) {
|
||||
super();
|
||||
this.key = Objects.requireNonNull(key, "key");
|
||||
this.onLocationChanged = Objects.requireNonNull(onLocationChange, "onLocationChange");
|
||||
@@ -98,6 +113,11 @@ public final class BrowserTab extends OACPanel {
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the protocol client used by this tab.
|
||||
*
|
||||
* @return protocol client
|
||||
*/
|
||||
public ClientImpl getProtocolClient() {
|
||||
return protocolClient;
|
||||
}
|
||||
@@ -270,6 +290,78 @@ public final class BrowserTab extends OACPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a fully assembled stream payload from the protocol layer and renders or saves it.
|
||||
*
|
||||
* <p>Rules:
|
||||
* <ul>
|
||||
* <li>Renderable: HTML/text/images/pdf are rendered directly (no wrapper pages for non-html).</li>
|
||||
* <li>Not renderable: opens "Save As" dialog.</li>
|
||||
* <li>If user cancels "Save As": content is shown raw in the browser via data: URL.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param requestId request correlation id
|
||||
* @param tabId protocol tab id
|
||||
* @param pageId protocol page id
|
||||
* @param frameId protocol frame id
|
||||
* @param statusCode http-like status code
|
||||
* @param contentType mime type
|
||||
* @param headers response headers
|
||||
* @param content full payload bytes
|
||||
*/
|
||||
public void handleStreamFinished(long requestId,
|
||||
long tabId,
|
||||
long pageId,
|
||||
long frameId,
|
||||
int statusCode,
|
||||
String contentType,
|
||||
Map<String, String> headers,
|
||||
byte[] content) {
|
||||
|
||||
String ct = (contentType == null || contentType.isBlank())
|
||||
? "application/octet-stream"
|
||||
: contentType.trim();
|
||||
|
||||
byte[] data = (content == null) ? new byte[0] : content;
|
||||
|
||||
// ---- Renderable types -> render without extra wrapper pages ----
|
||||
if (isHtml(ct)) {
|
||||
Charset cs = charsetFromContentType(ct, StandardCharsets.UTF_8);
|
||||
String html = new String(data, cs);
|
||||
Platform.runLater(() -> {
|
||||
WebEngine e = engine;
|
||||
if (e != null) e.loadContent(html, "text/html");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isText(ct)) {
|
||||
Charset cs = charsetFromContentType(ct, StandardCharsets.UTF_8);
|
||||
String text = new String(data, cs);
|
||||
Platform.runLater(() -> {
|
||||
WebEngine e = engine;
|
||||
if (e != null) e.loadContent(text, "text/plain");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImage(ct) || isPdf(ct)) {
|
||||
renderRawDataUrl(ct, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Not renderable -> Save As; if cancelled -> raw data: URL ----
|
||||
String suggested = extractFilenameFromContentDisposition(headers);
|
||||
if (suggested == null || suggested.isBlank()) {
|
||||
String ext = extensionFromContentType(ct);
|
||||
suggested = "download_" + requestId + "_" + Instant.now().toEpochMilli() + ext;
|
||||
} else {
|
||||
suggested = sanitizeFilename(suggested);
|
||||
}
|
||||
|
||||
showSaveAsDialogAndWriteBytes(suggested, ct, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources.
|
||||
*/
|
||||
@@ -377,4 +469,179 @@ public final class BrowserTab extends OACPanel {
|
||||
// Best-effort shutdown.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- Stream render/save helpers --------------------
|
||||
|
||||
private void showSaveAsDialogAndWriteBytes(String suggestedFilename, String contentType, byte[] data) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
Window parent = SwingUtilities.getWindowAncestor(this);
|
||||
|
||||
JFileChooser chooser = new JFileChooser();
|
||||
chooser.setDialogTitle("Save As");
|
||||
chooser.setSelectedFile(new File(suggestedFilename));
|
||||
|
||||
int result = chooser.showSaveDialog(parent);
|
||||
if (result != JFileChooser.APPROVE_OPTION) {
|
||||
// Cancel -> show raw in browser
|
||||
renderRawDataUrl(contentType, data);
|
||||
return;
|
||||
}
|
||||
|
||||
File file = chooser.getSelectedFile();
|
||||
if (file == null) {
|
||||
renderRawDataUrl(contentType, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.isDirectory()) {
|
||||
JOptionPane.showMessageDialog(parent, "Please choose a file, not a directory.", "Save As", JOptionPane.WARNING_MESSAGE);
|
||||
renderRawDataUrl(contentType, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.exists()) {
|
||||
int overwrite = JOptionPane.showConfirmDialog(
|
||||
parent,
|
||||
"File already exists. Overwrite?\n" + file.getAbsolutePath(),
|
||||
"Confirm Overwrite",
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE
|
||||
);
|
||||
if (overwrite != JOptionPane.YES_OPTION) {
|
||||
renderRawDataUrl(contentType, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Files.write(file.toPath(), data);
|
||||
} catch (IOException ex) {
|
||||
JOptionPane.showMessageDialog(
|
||||
parent,
|
||||
"Failed to save file:\n" + ex.getMessage(),
|
||||
"Save As",
|
||||
JOptionPane.ERROR_MESSAGE
|
||||
);
|
||||
// On failure, still show raw so user can at least see bytes
|
||||
renderRawDataUrl(contentType, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderRawDataUrl(String contentType, byte[] data) {
|
||||
String mime = normalizeMime(contentType);
|
||||
String b64 = Base64.getEncoder().encodeToString(data == null ? new byte[0] : data);
|
||||
String dataUrl = "data:" + mime + ";base64," + b64;
|
||||
|
||||
Platform.runLater(() -> {
|
||||
WebEngine e = engine;
|
||||
if (e != null) e.load(dataUrl);
|
||||
});
|
||||
}
|
||||
|
||||
private static String normalizeMime(String contentType) {
|
||||
String ct = (contentType == null || contentType.isBlank()) ? "application/octet-stream" : contentType.trim();
|
||||
int semi = ct.indexOf(';');
|
||||
String base = (semi >= 0 ? ct.substring(0, semi) : ct).trim();
|
||||
return base.isEmpty() ? "application/octet-stream" : base;
|
||||
}
|
||||
|
||||
private static boolean isHtml(String contentType) {
|
||||
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
|
||||
return ct.equals("text/html") || ct.equals("application/xhtml+xml");
|
||||
}
|
||||
|
||||
private static boolean isText(String contentType) {
|
||||
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
|
||||
return ct.startsWith("text/") || ct.equals("application/json") || ct.equals("application/xml") || ct.endsWith("+json") || ct.endsWith("+xml");
|
||||
}
|
||||
|
||||
private static boolean isImage(String contentType) {
|
||||
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
|
||||
return ct.startsWith("image/");
|
||||
}
|
||||
|
||||
private static boolean isPdf(String contentType) {
|
||||
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
|
||||
return ct.equals("application/pdf");
|
||||
}
|
||||
|
||||
private static Charset charsetFromContentType(String contentType, Charset def) {
|
||||
if (contentType == null) return def;
|
||||
String[] parts = contentType.split(";");
|
||||
for (String p : parts) {
|
||||
String s = p.trim();
|
||||
if (s.toLowerCase(Locale.ROOT).startsWith("charset=")) {
|
||||
String name = s.substring("charset=".length()).trim();
|
||||
try {
|
||||
return Charset.forName(name);
|
||||
} catch (Exception ignored) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
private static String extractFilenameFromContentDisposition(Map<String, String> headers) {
|
||||
if (headers == null || headers.isEmpty()) return null;
|
||||
|
||||
String cd = null;
|
||||
for (Map.Entry<String, String> e : headers.entrySet()) {
|
||||
if (e.getKey() != null && e.getKey().equalsIgnoreCase("content-disposition")) {
|
||||
cd = e.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cd == null || cd.isBlank()) return null;
|
||||
|
||||
String lower = cd.toLowerCase(Locale.ROOT);
|
||||
int fn = lower.indexOf("filename=");
|
||||
if (fn < 0) return null;
|
||||
|
||||
String v = cd.substring(fn + "filename=".length()).trim();
|
||||
if (v.startsWith("\"")) {
|
||||
int end = v.indexOf('"', 1);
|
||||
if (end > 1) return v.substring(1, end);
|
||||
return null;
|
||||
}
|
||||
int semi = v.indexOf(';');
|
||||
if (semi >= 0) v = v.substring(0, semi).trim();
|
||||
return v.isBlank() ? null : v;
|
||||
}
|
||||
|
||||
private static String sanitizeFilename(String name) {
|
||||
String s = name.replace('\\', '_').replace('/', '_');
|
||||
s = s.replace("..", "_");
|
||||
s = s.replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_')
|
||||
.replace('<', '_').replace('>', '_').replace('|', '_');
|
||||
return s.isBlank() ? "download.bin" : s;
|
||||
}
|
||||
|
||||
private static String extensionFromContentType(String contentType) {
|
||||
String ct = normalizeMime(contentType).toLowerCase(Locale.ROOT);
|
||||
|
||||
if (ct.equals("application/pdf")) return ".pdf";
|
||||
if (ct.equals("application/zip")) return ".zip";
|
||||
if (ct.equals("application/x-7z-compressed")) return ".7z";
|
||||
if (ct.equals("application/x-rar-compressed")) return ".rar";
|
||||
if (ct.equals("application/gzip")) return ".gz";
|
||||
if (ct.equals("application/json")) return ".json";
|
||||
if (ct.equals("application/xml") || ct.endsWith("+xml")) return ".xml";
|
||||
|
||||
if (ct.startsWith("image/")) {
|
||||
int slash = ct.indexOf('/');
|
||||
if (slash > 0 && slash < ct.length() - 1) {
|
||||
String ext = ct.substring(slash + 1).trim();
|
||||
if (!ext.isEmpty()) return "." + ext;
|
||||
}
|
||||
}
|
||||
|
||||
if (ct.startsWith("text/")) return ".txt";
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
public void bindProtocolClient() {
|
||||
protocolClient.getLibImpl().bindTab(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user