Many new things

This commit is contained in:
UnlegitDqrk
2026-02-28 17:39:42 +01:00
parent a84c626416
commit a9b0ccb8a7
30 changed files with 2490 additions and 150 deletions

View File

@@ -0,0 +1,35 @@
package org.openautonomousconnection.luascript.fx;
import javafx.application.Platform;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import org.openautonomousconnection.luascript.hosts.ClipboardHost;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
/**
* JavaFX clipboard host (text only).
*/
public final class FxClipboardHost implements ClipboardHost {
@Override
public void setText(String text) {
String s = text == null ? "" : text;
Platform.runLater(() -> {
ClipboardContent c = new ClipboardContent();
c.putString(s);
Clipboard.getSystemClipboard().setContent(c);
});
}
@Override
public String getText() {
AtomicReference<String> out = new AtomicReference<>("");
FxThreadBridge.runAndWait(() -> {
String s = Clipboard.getSystemClipboard().getString();
out.set(s == null ? "" : s);
});
return out.get();
}
}

View File

@@ -0,0 +1,122 @@
package org.openautonomousconnection.luascript.fx;
import javafx.scene.web.WebEngine;
import org.openautonomousconnection.luascript.hosts.CssHost;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* CSS host via internal JS bridge.
*/
public final class FxCssHost implements CssHost {
private final WebEngine engine;
private final FxDomHost dom;
public FxCssHost(WebEngine engine, FxDomHost dom) {
this.engine = Objects.requireNonNull(engine, "engine");
this.dom = Objects.requireNonNull(dom, "dom");
}
@Override
public String getComputedStyle(String elementId, String property) {
Objects.requireNonNull(elementId, "elementId");
Objects.requireNonNull(property, "property");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var prop=" + FxWebBridge.toJsLiteral(property) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " var cs=getComputedStyle(el);"
+ " var v=cs.getPropertyValue(prop) || cs[prop] || '';"
+ " return String(v);"
+ "})();";
Object ret = engine.executeScript(script);
return ret == null ? "" : String.valueOf(ret);
}));
}
@Override
public Map<String, String> getComputedStyles(String elementId, String[] properties) {
Objects.requireNonNull(elementId, "elementId");
Objects.requireNonNull(properties, "properties");
Map<String, String> out = new LinkedHashMap<>();
for (String p : properties) {
if (p == null || p.isBlank()) continue;
out.put(p, getComputedStyle(elementId, p));
}
return out;
}
@Override
public void setInlineStyle(String elementId, String property, String value) {
Objects.requireNonNull(elementId, "elementId");
Objects.requireNonNull(property, "property");
FxThreadBridge.runAndWait(() -> FxWebBridge.runWithJs(engine, () -> {
dom.requireDocument();
String v = value == null ? "" : value;
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var prop=" + FxWebBridge.toJsLiteral(property) + ";"
+ " var val=" + FxWebBridge.toJsLiteral(v) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " if(!val){ el.style.removeProperty(prop); return null; }"
+ " el.style.setProperty(prop, val);"
+ " return null;"
+ "})();";
engine.executeScript(script);
}));
}
@Override
public String getInlineStyle(String elementId, String property) {
Objects.requireNonNull(elementId, "elementId");
Objects.requireNonNull(property, "property");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var prop=" + FxWebBridge.toJsLiteral(property) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " var v=el.style.getPropertyValue(prop) || '';"
+ " return String(v);"
+ "})();";
Object ret = engine.executeScript(script);
return ret == null ? "" : String.valueOf(ret);
}));
}
@Override
public void setCssVariable(String elementId, String name, String value) {
Objects.requireNonNull(name, "name");
if (!name.trim().startsWith("--")) throw new IllegalArgumentException("CSS variable must start with '--': " + name);
setInlineStyle(elementId, name.trim(), value);
}
@Override
public String getCssVariable(String elementId, String name) {
Objects.requireNonNull(name, "name");
if (!name.trim().startsWith("--")) throw new IllegalArgumentException("CSS variable must start with '--': " + name);
return getComputedStyle(elementId, name.trim());
}
}

View File

@@ -0,0 +1,99 @@
package org.openautonomousconnection.luascript.fx;
import javafx.scene.web.WebEngine;
import org.openautonomousconnection.luascript.hosts.GeometryHost;
import java.util.Map;
import java.util.Objects;
/**
* Geometry/scroll host via internal JS bridge.
*/
public final class FxGeometryHost implements GeometryHost {
private final WebEngine engine;
private final FxDomHost dom;
public FxGeometryHost(WebEngine engine, FxDomHost dom) {
this.engine = Objects.requireNonNull(engine, "engine");
this.dom = Objects.requireNonNull(dom, "dom");
}
@Override
public Map<String, Object> getBoundingClientRect(String elementId) {
Objects.requireNonNull(elementId, "elementId");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " var r=el.getBoundingClientRect();"
+ " return {"
+ " x:r.x, y:r.y, width:r.width, height:r.height,"
+ " top:r.top, left:r.left, right:r.right, bottom:r.bottom"
+ " };"
+ "})();";
Object ret = engine.executeScript(script);
return FxWebBridge.toStringObjectMap(ret);
}));
}
@Override
public Map<String, Object> getViewport() {
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " return {"
+ " width: window.innerWidth || 0,"
+ " height: window.innerHeight || 0,"
+ " devicePixelRatio: window.devicePixelRatio || 1,"
+ " scrollX: window.scrollX || 0,"
+ " scrollY: window.scrollY || 0"
+ " };"
+ "})();";
Object ret = engine.executeScript(script);
return FxWebBridge.toStringObjectMap(ret);
}));
}
@Override
public void scrollTo(double x, double y) {
FxThreadBridge.runAndWait(() -> FxWebBridge.runWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " window.scrollTo(" + FxWebBridge.toJsLiteral(x) + "," + FxWebBridge.toJsLiteral(y) + ");"
+ " return null;"
+ "})();";
engine.executeScript(script);
}));
}
@Override
public void scrollIntoView(String elementId, String align) {
Objects.requireNonNull(elementId, "elementId");
String a = FxWebBridge.normalizeAlign(align);
FxThreadBridge.runAndWait(() -> FxWebBridge.runWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " el.scrollIntoView({block:" + FxWebBridge.toJsLiteral(a) + ", inline:" + FxWebBridge.toJsLiteral(a) + "});"
+ " return null;"
+ "})();";
engine.executeScript(script);
}));
}
}

View File

@@ -0,0 +1,246 @@
package org.openautonomousconnection.luascript.fx;
import javafx.scene.web.WebEngine;
import netscape.javascript.JSObject;
import org.openautonomousconnection.luascript.hosts.ObserverHost;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* Observer host implemented via JS observers calling back into Java.
*
* <p>Requires JavaScript enabled (bridge only).</p>
*/
public final class FxObserverHost implements ObserverHost {
private final WebEngine engine;
private final FxDomHost dom;
private volatile ObserverCallback callback;
private final ConcurrentHashMap<String, Boolean> mutationObserved = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Boolean> resizeObserved = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Boolean> intersectionObserved = new ConcurrentHashMap<>();
public FxObserverHost(WebEngine engine, FxDomHost dom) {
this.engine = Objects.requireNonNull(engine, "engine");
this.dom = Objects.requireNonNull(dom, "dom");
}
@Override
public void setCallback(ObserverCallback callback) {
this.callback = callback;
FxThreadBridge.runAndWait(this::installBridge);
}
@Override
public void observeMutations(String elementId, boolean subtree, boolean attributes, boolean childList, boolean characterData) {
Objects.requireNonNull(elementId, "elementId");
FxThreadBridge.runAndWait(() -> {
dom.requireDocument();
FxWebBridge.ensureJsEnabled(engine);
installBridge();
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " window.__oac_obs = window.__oac_obs || {};"
+ " window.__oac_obs.muts = window.__oac_obs.muts || new Map();"
+ " if(window.__oac_obs.muts.has(id)) return null;"
+ " var cfg={subtree:" + (subtree ? "true" : "false")
+ " ,attributes:" + (attributes ? "true" : "false")
+ " ,childList:" + (childList ? "true" : "false")
+ " ,characterData:" + (characterData ? "true" : "false")
+ " };"
+ " var mo=new MutationObserver(function(muts){"
+ " try{"
+ " var payload={count:muts.length};"
+ " window.__oac_bridge.emit('mutation', id, JSON.stringify(payload));"
+ " }catch(e){}"
+ " });"
+ " mo.observe(el,cfg);"
+ " window.__oac_obs.muts.set(id, mo);"
+ " return null;"
+ "})();";
engine.executeScript(script);
engine.setJavaScriptEnabled(false);
mutationObserved.put(elementId, true);
});
}
@Override
public void unobserveMutations(String elementId) {
Objects.requireNonNull(elementId, "elementId");
FxThreadBridge.runAndWait(() -> {
dom.requireDocument();
FxWebBridge.ensureJsEnabled(engine);
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " if(!window.__oac_obs || !window.__oac_obs.muts) return null;"
+ " var mo=window.__oac_obs.muts.get(id);"
+ " if(mo){ mo.disconnect(); window.__oac_obs.muts.delete(id); }"
+ " return null;"
+ "})();";
engine.executeScript(script);
engine.setJavaScriptEnabled(false);
mutationObserved.remove(elementId);
});
}
@Override
public void observeResize(String elementId) {
Objects.requireNonNull(elementId, "elementId");
FxThreadBridge.runAndWait(() -> {
dom.requireDocument();
FxWebBridge.ensureJsEnabled(engine);
installBridge();
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " if(!('ResizeObserver' in window)) return null;"
+ " window.__oac_obs = window.__oac_obs || {};"
+ " window.__oac_obs.res = window.__oac_obs.res || new Map();"
+ " if(window.__oac_obs.res.has(id)) return null;"
+ " var ro=new ResizeObserver(function(entries){"
+ " try{"
+ " var r=entries && entries[0] && entries[0].contentRect;"
+ " var payload=r?{x:r.x,y:r.y,width:r.width,height:r.height}:{count:(entries?entries.length:0)};"
+ " window.__oac_bridge.emit('resize', id, JSON.stringify(payload));"
+ " }catch(e){}"
+ " });"
+ " ro.observe(el);"
+ " window.__oac_obs.res.set(id, ro);"
+ " return null;"
+ "})();";
engine.executeScript(script);
engine.setJavaScriptEnabled(false);
resizeObserved.put(elementId, true);
});
}
@Override
public void unobserveResize(String elementId) {
Objects.requireNonNull(elementId, "elementId");
FxThreadBridge.runAndWait(() -> {
dom.requireDocument();
FxWebBridge.ensureJsEnabled(engine);
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " if(!window.__oac_obs || !window.__oac_obs.res) return null;"
+ " var ro=window.__oac_obs.res.get(id);"
+ " if(ro){ ro.disconnect(); window.__oac_obs.res.delete(id); }"
+ " return null;"
+ "})();";
engine.executeScript(script);
engine.setJavaScriptEnabled(false);
resizeObserved.remove(elementId);
});
}
@Override
public void observeIntersection(String elementId, double threshold) {
Objects.requireNonNull(elementId, "elementId");
double t = threshold;
if (t < 0.0) t = 0.0;
if (t > 1.0) t = 1.0;
double finalT = t;
FxThreadBridge.runAndWait(() -> {
dom.requireDocument();
FxWebBridge.ensureJsEnabled(engine);
installBridge();
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " if(!('IntersectionObserver' in window)) return null;"
+ " window.__oac_obs = window.__oac_obs || {};"
+ " window.__oac_obs.int = window.__oac_obs.int || new Map();"
+ " if(window.__oac_obs.int.has(id)) return null;"
+ " var io=new IntersectionObserver(function(entries){"
+ " try{"
+ " var e=entries && entries[0];"
+ " var payload=e?{"
+ " isIntersecting:!!e.isIntersecting,"
+ " ratio:(e.intersectionRatio||0)"
+ " }:{count:(entries?entries.length:0)};"
+ " window.__oac_bridge.emit('intersection', id, JSON.stringify(payload));"
+ " }catch(ex){}"
+ " }, {threshold:" + FxWebBridge.toJsLiteral(finalT) + "});"
+ " io.observe(el);"
+ " window.__oac_obs.int.set(id, io);"
+ " return null;"
+ "})();";
engine.executeScript(script);
engine.setJavaScriptEnabled(false);
intersectionObserved.put(elementId, true);
});
}
@Override
public void unobserveIntersection(String elementId) {
Objects.requireNonNull(elementId, "elementId");
FxThreadBridge.runAndWait(() -> {
dom.requireDocument();
FxWebBridge.ensureJsEnabled(engine);
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " if(!window.__oac_obs || !window.__oac_obs.int) return null;"
+ " var io=window.__oac_obs.int.get(id);"
+ " if(io){ io.disconnect(); window.__oac_obs.int.delete(id); }"
+ " return null;"
+ "})();";
engine.executeScript(script);
engine.setJavaScriptEnabled(false);
intersectionObserved.remove(elementId);
});
}
private void installBridge() {
dom.requireDocument();
FxWebBridge.ensureJsEnabled(engine);
JSObject win = (JSObject) engine.executeScript("window");
win.setMember("__oac_bridge", new Bridge());
}
/**
* Object exposed to JS.
*/
public final class Bridge {
/**
* Emits observer events from JS to Java.
*
* @param type observer type
* @param targetId element id
* @param json payload JSON string
*/
public void emit(String type, String targetId, String json) {
ObserverCallback cb = callback;
if (cb == null) return;
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("json", json == null ? "" : json);
try {
cb.onEvent(type == null ? "" : type, targetId == null ? "" : targetId, payload);
} catch (RuntimeException ex) {
System.err.println("[observer] callback failed: " + ex.getMessage());
ex.printStackTrace(System.err);
}
}
}
}

View File

@@ -0,0 +1,127 @@
package org.openautonomousconnection.luascript.fx;
import javafx.animation.AnimationTimer;
import javafx.application.Platform;
import org.openautonomousconnection.luascript.hosts.SchedulerHost;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* JavaFX-based scheduler providing setTimeout/setInterval/requestAnimationFrame.
*/
public final class FxSchedulerHost implements SchedulerHost, AutoCloseable {
private final ScheduledExecutorService scheduler;
private final AtomicLong seq = new AtomicLong(1);
private final ConcurrentHashMap<Long, ScheduledFuture<?>> scheduled = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Runnable> rafCallbacks = new ConcurrentHashMap<>();
private final Set<Long> canceledRaf = ConcurrentHashMap.newKeySet();
private final AnimationTimer rafTimer;
public FxSchedulerHost() {
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "oac-fx-scheduler");
t.setDaemon(true);
return t;
});
this.rafTimer = new AnimationTimer() {
@Override
public void handle(long now) {
// Drain RAF callbacks once per frame.
if (rafCallbacks.isEmpty()) return;
Map<Long, Runnable> snap = new ConcurrentHashMap<>(rafCallbacks);
rafCallbacks.clear();
for (Map.Entry<Long, Runnable> e : snap.entrySet()) {
long id = e.getKey();
Runnable cb = e.getValue();
if (canceledRaf.remove(id)) continue;
try {
cb.run();
} catch (RuntimeException ex) {
System.err.println("[scheduler.raf] callback failed: " + ex.getMessage());
ex.printStackTrace(System.err);
}
}
}
};
Platform.runLater(rafTimer::start);
}
@Override
public long setTimeout(long delayMillis, Runnable callback) {
if (delayMillis < 0) delayMillis = 0;
Runnable cb = Objects.requireNonNull(callback, "callback");
long id = seq.getAndIncrement();
ScheduledFuture<?> f = scheduler.schedule(() -> safeRun(cb, "timeout"), delayMillis, TimeUnit.MILLISECONDS);
scheduled.put(id, f);
return id;
}
@Override
public long setInterval(long intervalMillis, Runnable callback) {
if (intervalMillis <= 0) throw new IllegalArgumentException("intervalMillis must be > 0");
Runnable cb = Objects.requireNonNull(callback, "callback");
long id = seq.getAndIncrement();
ScheduledFuture<?> f = scheduler.scheduleAtFixedRate(() -> safeRun(cb, "interval"), intervalMillis, intervalMillis, TimeUnit.MILLISECONDS);
scheduled.put(id, f);
return id;
}
@Override
public boolean clear(long handle) {
ScheduledFuture<?> f = scheduled.remove(handle);
if (f == null) return false;
return f.cancel(false);
}
@Override
public long requestAnimationFrame(Runnable callback) {
Runnable cb = Objects.requireNonNull(callback, "callback");
long id = seq.getAndIncrement();
rafCallbacks.put(id, cb);
return id;
}
@Override
public boolean cancelAnimationFrame(long handle) {
// If already queued, mark as canceled.
boolean existed = rafCallbacks.remove(handle) != null;
canceledRaf.add(handle);
return existed;
}
private static void safeRun(Runnable cb, String kind) {
try {
cb.run();
} catch (RuntimeException ex) {
System.err.println("[scheduler." + kind + "] callback failed: " + ex.getMessage());
ex.printStackTrace(System.err);
}
}
@Override
public void close() {
try {
Platform.runLater(rafTimer::stop);
} catch (Exception ignored) {
// ignore
}
scheduler.shutdownNow();
scheduled.clear();
rafCallbacks.clear();
canceledRaf.clear();
}
}

View File

@@ -0,0 +1,114 @@
package org.openautonomousconnection.luascript.fx;
import javafx.scene.web.WebEngine;
import org.openautonomousconnection.luascript.hosts.SelectorHost;
import java.util.List;
import java.util.Objects;
/**
* CSS selector host implemented via internal JS bridge.
*/
public final class FxSelectorHost implements SelectorHost {
private final WebEngine engine;
private final FxDomHost dom;
public FxSelectorHost(WebEngine engine, FxDomHost dom) {
this.engine = Objects.requireNonNull(engine, "engine");
this.dom = Objects.requireNonNull(dom, "dom");
}
@Override
public String querySelector(String selector) {
Objects.requireNonNull(selector, "selector");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
dom.ensureAllElementsHaveId();
String script = ""
+ "(function(){"
+ " var sel = " + FxWebBridge.toJsLiteral(selector) + ";"
+ " var el = document.querySelector(sel);"
+ " if(!el) return null;"
+ " if(!el.id){"
+ " el.id='__auto_' + Math.floor(Math.random()*1e18).toString(36);"
+ " }"
+ " return el.id;"
+ "})();";
Object ret = engine.executeScript(script);
return ret == null ? null : String.valueOf(ret);
}));
}
@Override
public List<String> querySelectorAll(String selector) {
Objects.requireNonNull(selector, "selector");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
dom.ensureAllElementsHaveId();
String script = ""
+ "(function(){"
+ " var sel=" + FxWebBridge.toJsLiteral(selector) + ";"
+ " var els=document.querySelectorAll(sel);"
+ " var out=[];"
+ " for(var i=0;i<els.length;i++){"
+ " var el=els[i];"
+ " if(!el.id){ el.id='__auto_' + Math.floor(Math.random()*1e18).toString(36); }"
+ " out.push(el.id);"
+ " }"
+ " return out;"
+ "})();";
Object ret = engine.executeScript(script);
return FxWebBridge.toStringList(ret);
}));
}
@Override
public boolean matches(String elementId, String selector) {
Objects.requireNonNull(elementId, "elementId");
Objects.requireNonNull(selector, "selector");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var sel=" + FxWebBridge.toJsLiteral(selector) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " return !!el.matches(sel);"
+ "})();";
Object ret = engine.executeScript(script);
return ret instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(ret));
}));
}
@Override
public String closest(String elementId, String selector) {
Objects.requireNonNull(elementId, "elementId");
Objects.requireNonNull(selector, "selector");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var id=" + FxWebBridge.toJsLiteral(elementId) + ";"
+ " var sel=" + FxWebBridge.toJsLiteral(selector) + ";"
+ " var el=document.getElementById(id);"
+ " if(!el) throw new Error('Unknown element id: '+id);"
+ " var c=el.closest(sel);"
+ " if(!c) return null;"
+ " if(!c.id){ c.id='__auto_' + Math.floor(Math.random()*1e18).toString(36); }"
+ " return c.id;"
+ "})();";
Object ret = engine.executeScript(script);
return ret == null ? null : String.valueOf(ret);
}));
}
}

View File

@@ -0,0 +1,157 @@
package org.openautonomousconnection.luascript.fx;
import javafx.scene.web.WebEngine;
import org.openautonomousconnection.luascript.hosts.StorageHost;
import java.util.List;
import java.util.Objects;
/**
* Storage host backed by localStorage/sessionStorage via JS bridge.
*/
public final class FxStorageHost implements StorageHost {
private final WebEngine engine;
private final FxDomHost dom;
public FxStorageHost(WebEngine engine, FxDomHost dom) {
this.engine = Objects.requireNonNull(engine, "engine");
this.dom = Objects.requireNonNull(dom, "dom");
}
@Override
public List<String> localKeys() {
return keys("localStorage");
}
@Override
public String localGet(String key) {
return get("localStorage", key);
}
@Override
public void localSet(String key, String value) {
set("localStorage", key, value);
}
@Override
public void localRemove(String key) {
remove("localStorage", key);
}
@Override
public void localClear() {
clear("localStorage");
}
@Override
public List<String> sessionKeys() {
return keys("sessionStorage");
}
@Override
public String sessionGet(String key) {
return get("sessionStorage", key);
}
@Override
public void sessionSet(String key, String value) {
set("sessionStorage", key, value);
}
@Override
public void sessionRemove(String key) {
remove("sessionStorage", key);
}
@Override
public void sessionClear() {
clear("sessionStorage");
}
private List<String> keys(String storageName) {
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var s=window[" + FxWebBridge.toJsLiteral(storageName) + "];"
+ " if(!s) return [];"
+ " var out=[];"
+ " for(var i=0;i<s.length;i++){ out.push(String(s.key(i))); }"
+ " return out;"
+ "})();";
Object ret = engine.executeScript(script);
return FxWebBridge.toStringList(ret);
}));
}
private String get(String storageName, String key) {
Objects.requireNonNull(key, "key");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var s=window[" + FxWebBridge.toJsLiteral(storageName) + "];"
+ " if(!s) return null;"
+ " var v=s.getItem(" + FxWebBridge.toJsLiteral(key) + ");"
+ " return v===null?null:String(v);"
+ "})();";
Object ret = engine.executeScript(script);
return ret == null ? null : String.valueOf(ret);
}));
}
private void set(String storageName, String key, String value) {
Objects.requireNonNull(key, "key");
FxThreadBridge.runAndWait(() -> FxWebBridge.runWithJs(engine, () -> {
dom.requireDocument();
if (value == null) {
String script = ""
+ "(function(){"
+ " var s=window[" + FxWebBridge.toJsLiteral(storageName) + "];"
+ " if(!s) return null;"
+ " s.removeItem(" + FxWebBridge.toJsLiteral(key) + ");"
+ " return null;"
+ "})();";
engine.executeScript(script);
return;
}
String script = ""
+ "(function(){"
+ " var s=window[" + FxWebBridge.toJsLiteral(storageName) + "];"
+ " if(!s) return null;"
+ " s.setItem(" + FxWebBridge.toJsLiteral(key) + "," + FxWebBridge.toJsLiteral(value) + ");"
+ " return null;"
+ "})();";
engine.executeScript(script);
}));
}
private void remove(String storageName, String key) {
Objects.requireNonNull(key, "key");
set(storageName, key, null);
}
private void clear(String storageName) {
FxThreadBridge.runAndWait(() -> FxWebBridge.runWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var s=window[" + FxWebBridge.toJsLiteral(storageName) + "];"
+ " if(!s) return null;"
+ " s.clear();"
+ " return null;"
+ "})();";
engine.executeScript(script);
}));
}
}

View File

@@ -0,0 +1,137 @@
package org.openautonomousconnection.luascript.fx;
import javafx.scene.web.WebEngine;
import org.openautonomousconnection.luascript.hosts.UtilHost;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.*;
/**
* Utility host: Base64, random, URL parsing, query parsing, JSON stringify/normalize via JS.
*/
public final class FxUtilHost implements UtilHost {
private final WebEngine engine;
private final FxDomHost dom;
private final SecureRandom rng = new SecureRandom();
public FxUtilHost(WebEngine engine, FxDomHost dom) {
this.engine = Objects.requireNonNull(engine, "engine");
this.dom = Objects.requireNonNull(dom, "dom");
}
@Override
public String base64Encode(String text) {
String s = text == null ? "" : text;
return Base64.getEncoder().encodeToString(s.getBytes(StandardCharsets.UTF_8));
}
@Override
public String base64Decode(String base64) {
if (base64 == null) return "";
byte[] b = Base64.getDecoder().decode(base64);
return new String(b, StandardCharsets.UTF_8);
}
@Override
public String randomHex(int numBytes) {
if (numBytes <= 0) throw new IllegalArgumentException("numBytes must be > 0");
byte[] b = new byte[numBytes];
rng.nextBytes(b);
StringBuilder sb = new StringBuilder(numBytes * 2);
for (byte x : b) sb.append(String.format("%02x", x));
return sb.toString();
}
@Override
public Map<String, String> parseUrl(String url) {
Objects.requireNonNull(url, "url");
URI u = URI.create(url.trim());
Map<String, String> out = new LinkedHashMap<>();
out.put("scheme", safe(u.getScheme()));
out.put("host", safe(u.getHost()));
out.put("port", u.getPort() < 0 ? "" : String.valueOf(u.getPort()));
out.put("path", safe(u.getPath()));
out.put("query", safe(u.getQuery()));
out.put("fragment", safe(u.getFragment()));
return out;
}
@Override
public Map<String, List<String>> parseQuery(String query) {
String q = query == null ? "" : query.trim();
if (q.startsWith("?")) q = q.substring(1);
Map<String, List<String>> out = new LinkedHashMap<>();
if (q.isEmpty()) return out;
for (String part : q.split("&")) {
if (part.isEmpty()) continue;
String k;
String v;
int idx = part.indexOf('=');
if (idx < 0) {
k = decode(part);
v = "";
} else {
k = decode(part.substring(0, idx));
v = decode(part.substring(idx + 1));
}
out.computeIfAbsent(k, __ -> new ArrayList<>()).add(v);
}
return out;
}
@Override
public String jsonStringifyExpr(String elementId, String jsExpr) {
Objects.requireNonNull(elementId, "elementId");
Objects.requireNonNull(jsExpr, "jsExpr");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
// NOTE: jsExpr is intentionally raw JS expression; this is a trusted host API.
// If you need untrusted usage, wrap/validate upstream.
String script = ""
+ "(function(){"
+ " var v=(" + jsExpr + ");"
+ " return JSON.stringify(v);"
+ "})();";
Object ret = engine.executeScript(script);
return ret == null ? "null" : String.valueOf(ret);
}));
}
@Override
public String jsonNormalize(String elementId, String json) {
Objects.requireNonNull(elementId, "elementId");
Objects.requireNonNull(json, "json");
return FxThreadBridge.callAndWait(() -> FxWebBridge.callWithJs(engine, () -> {
dom.requireDocument();
String script = ""
+ "(function(){"
+ " var s=" + FxWebBridge.toJsLiteral(json) + ";"
+ " var obj=JSON.parse(s);"
+ " return JSON.stringify(obj);"
+ "})();";
Object ret = engine.executeScript(script);
return ret == null ? "null" : String.valueOf(ret);
}));
}
private static String safe(String s) {
return s == null ? "" : s;
}
private static String decode(String s) {
return URLDecoder.decode(s, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,227 @@
package org.openautonomousconnection.luascript.fx;
import javafx.scene.web.WebEngine;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Shared utilities for WebEngine JS bridging.
*/
public final class FxWebBridge {
private FxWebBridge() {
}
/**
* Ensures JavaScript is enabled for the engine.
*
* @param engine engine
*/
public static void ensureJsEnabled(WebEngine engine) {
Objects.requireNonNull(engine, "engine");
if (!engine.isJavaScriptEnabled()) {
engine.setJavaScriptEnabled(true);
}
}
/**
* Executes bridge code with JavaScript temporarily enabled.
*
* @param engine engine
* @param action action
*/
public static void runWithJs(WebEngine engine, Runnable action) {
Objects.requireNonNull(action, "action");
callWithJs(engine, () -> {
action.run();
return null;
});
}
/**
* Executes bridge code with JavaScript temporarily enabled and restores the prior state.
*
* @param engine engine
* @param action action
* @param <T> result type
* @return action result
*/
public static <T> T callWithJs(WebEngine engine, Supplier<T> action) {
Objects.requireNonNull(engine, "engine");
Objects.requireNonNull(action, "action");
boolean enabledBefore = engine.isJavaScriptEnabled();
if (!enabledBefore) {
engine.setJavaScriptEnabled(true);
}
try {
return action.get();
} finally {
if (!enabledBefore) {
engine.setJavaScriptEnabled(false);
}
}
}
/**
* Converts a Java value into a safe JavaScript literal.
*
* <p>Supported: null, String, Boolean, Number (finite).</p>
*
* @param v value
* @return JS literal
*/
public static String toJsLiteral(Object v) {
if (v == null) return "null";
if (v instanceof String s) {
return "'" + escapeJsSingleQuotedString(s) + "'";
}
if (v instanceof Boolean b) {
return b ? "true" : "false";
}
if (v instanceof Number n) {
double d = n.doubleValue();
if (!Double.isFinite(d)) {
throw new IllegalArgumentException("Non-finite number is not supported for JS literal: " + d);
}
return Double.toString(d);
}
throw new IllegalArgumentException("Unsupported value type for JS literal: " + v.getClass().getName());
}
/**
* Builds a JavaScript array literal from Java values.
*
* @param values values
* @return array literal
*/
public static String toJsArrayLiteral(Object[] values) {
if (values == null || values.length == 0) return "[]";
StringBuilder sb = new StringBuilder();
sb.append('[');
for (int i = 0; i < values.length; i++) {
if (i > 0) sb.append(',');
sb.append(toJsLiteral(values[i]));
}
sb.append(']');
return sb.toString();
}
/**
* Restricts input to a plain JavaScript identifier (prevents injection).
*
* @param s value
* @param label label
* @return identifier
*/
public static String requireJsIdentifier(String s, String label) {
if (s == null) throw new IllegalArgumentException(label + " is null");
String v = s.trim();
if (v.isEmpty()) throw new IllegalArgumentException(label + " is blank");
if (!v.matches("^[A-Za-z_$][A-Za-z0-9_$]*$")) {
throw new IllegalArgumentException(label + " must be a JS identifier: " + v);
}
return v;
}
/**
* Normalizes alignment options for scrollIntoView.
*
* @param align input
* @return normalized
*/
public static String normalizeAlign(String align) {
if (align == null) return "nearest";
String a = align.trim().toLowerCase(Locale.ROOT);
return switch (a) {
case "start", "center", "end", "nearest" -> a;
default -> "nearest";
};
}
/**
* Converts a JS array-like object into a Java string list.
*
* @param jsValue raw JS value
* @return list of string values
*/
public static List<String> toStringList(Object jsValue) {
if (jsValue == null) return List.of();
if (jsValue instanceof List<?> list) {
List<String> out = new ArrayList<>(list.size());
for (Object value : list) out.add(value == null ? null : String.valueOf(value));
return out;
}
try {
Class<?> jsObj = Class.forName("netscape.javascript.JSObject");
if (jsObj.isInstance(jsValue)) {
Object lenObj = jsObj.getMethod("getMember", String.class).invoke(jsValue, "length");
int len = Integer.parseInt(String.valueOf(lenObj));
List<String> out = new ArrayList<>(len);
for (int i = 0; i < len; i++) {
Object value = jsObj.getMethod("getSlot", int.class).invoke(jsValue, i);
out.add(value == null ? null : String.valueOf(value));
}
return out;
}
} catch (Exception ignored) {
// best-effort fallback below
}
return List.of(String.valueOf(jsValue));
}
/**
* Converts a JS object into a Java string-object map when possible.
*
* @param jsValue raw JS value
* @return mapped values
*/
public static Map<String, Object> toStringObjectMap(Object jsValue) {
if (jsValue == null) return Map.of();
if (jsValue instanceof Map<?, ?> map) {
Map<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getKey() == null) continue;
out.put(String.valueOf(entry.getKey()), entry.getValue());
}
return out;
}
return Map.of("value", jsValue);
}
private static String escapeJsSingleQuotedString(String s) {
if (s == null || s.isEmpty()) return "";
StringBuilder out = new StringBuilder(s.length() + 16);
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '\'' -> out.append("\\'");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
case '\u0000' -> out.append("\\0");
case '\u2028' -> out.append("\\u2028");
case '\u2029' -> out.append("\\u2029");
default -> {
if (c < 0x20) out.append(String.format("\\u%04x", (int) c));
else out.append(c);
}
}
}
return out.toString();
}
}