Connect minimal hosts to snapshots, target workers and shared Kit editing
This commit is contained in:
@@ -194,6 +194,15 @@ public final class BuildRefresh implements AutoCloseable {
|
||||
details.put("buildJava", Integer.toString(build.javaFeature()));
|
||||
details.put("targetJavaHome", target.javaHome().toString());
|
||||
details.put("targetJava", Integer.toString(target.javaFeature()));
|
||||
details.put(
|
||||
"sourceRoots",
|
||||
new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.writeValueAsString(
|
||||
captured.reactor().modules().stream()
|
||||
.flatMap(m -> m.sourceRoots().stream())
|
||||
.map(Path::toString)
|
||||
.distinct()
|
||||
.toList()));
|
||||
details.put("wireVersion", "1.0");
|
||||
details.put("manifestVersion", "1");
|
||||
details.put("coordinates", captured.coordinates().toString());
|
||||
@@ -359,6 +368,14 @@ public final class BuildRefresh implements AutoCloseable {
|
||||
}
|
||||
|
||||
public SnapshotStore.Lease previewCurrent(Request request) throws Exception {
|
||||
return previewCurrent(request, Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved FXML may differ only when that exact resource is supplied by the immutable draft overlay.
|
||||
*/
|
||||
public SnapshotStore.Lease previewCurrent(Request request, Set<URI> overlaidFXML)
|
||||
throws Exception {
|
||||
var state = store.state(request.context());
|
||||
if (state.outdated() || state.lastSuccessful() == null)
|
||||
throw new IllegalStateException("Choose Preview with last successful build explicitly");
|
||||
@@ -371,7 +388,7 @@ public final class BuildRefresh implements AutoCloseable {
|
||||
.get("configuration")
|
||||
.equals(request.configuration().toRealPath().toUri().toString())
|
||||
|| !runtimeMatches(request, lease.snapshot())
|
||||
|| !lease.snapshot().inputs().equals(fingerprint(root))
|
||||
|| !inputsMatch(lease.snapshot().inputs(), fingerprint(root), root, overlaidFXML)
|
||||
|| !dirty(root).isEmpty())
|
||||
throw new IllegalStateException(
|
||||
"Build is outdated; choose last successful build explicitly");
|
||||
@@ -383,6 +400,23 @@ public final class BuildRefresh implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inputsMatch(
|
||||
Map<String, String> captured, Map<String, String> current, Path root, Set<URI> overlays)
|
||||
throws IOException {
|
||||
var before = new TreeMap<>(captured);
|
||||
var after = new TreeMap<>(current);
|
||||
for (URI uri : overlays) {
|
||||
Path file = Path.of(uri).toRealPath();
|
||||
if (!file.startsWith(root) || !file.toString().endsWith(".fxml")) continue;
|
||||
String key = root.relativize(file).toString();
|
||||
if (before.containsKey(key) && after.containsKey(key)) {
|
||||
before.remove(key);
|
||||
after.remove(key);
|
||||
}
|
||||
}
|
||||
return before.equals(after);
|
||||
}
|
||||
|
||||
private boolean runtimeMatches(Request request, SnapshotStore.Snapshot snapshot)
|
||||
throws Exception {
|
||||
var context =
|
||||
|
||||
@@ -26,6 +26,15 @@ public record HostServices(
|
||||
public interface Documents {
|
||||
CompletionStage<DocumentSession> open(URI document);
|
||||
|
||||
/** Fired synchronously with host edits; listeners must not block the host thread. */
|
||||
default AutoCloseable onChange(Consumer<URI> listener) {
|
||||
return () -> {};
|
||||
}
|
||||
|
||||
default CompletionStage<List<de.neitzel.fxdesigner.contracts.DocumentSnapshot>> snapshots() {
|
||||
return unsaved();
|
||||
}
|
||||
|
||||
default CompletionStage<List<de.neitzel.fxdesigner.contracts.DocumentSnapshot>> unsaved() {
|
||||
return java.util.concurrent.CompletableFuture.failedFuture(
|
||||
new UnsupportedOperationException("Host must enumerate known unsaved build inputs"));
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package de.neitzel.fxdesigner.workflow;
|
||||
|
||||
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
|
||||
|
||||
import de.neitzel.fxdesigner.build.*;
|
||||
import de.neitzel.fxdesigner.contracts.*;
|
||||
import de.neitzel.fxdesigner.host.*;
|
||||
import de.neitzel.fxdesigner.protocol.*;
|
||||
import java.net.URI;
|
||||
import java.nio.file.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.*;
|
||||
|
||||
/**
|
||||
* Shared build, discovery and render owner. Hosts supply only documents, runtimes and presentation.
|
||||
*/
|
||||
public final class DesignerWorkflow implements AutoCloseable {
|
||||
public record Example(String id, String label, String viewResource, String componentPackage) {}
|
||||
|
||||
public record Image(
|
||||
String node,
|
||||
int width,
|
||||
int height,
|
||||
byte[] rgba,
|
||||
Map<String, Long> revisions,
|
||||
String snapshot) {
|
||||
public Image {
|
||||
rgba = rgba.clone();
|
||||
revisions = Map.copyOf(revisions);
|
||||
}
|
||||
|
||||
public byte[] rgba() {
|
||||
return rgba.clone();
|
||||
}
|
||||
}
|
||||
|
||||
private final HostServices host;
|
||||
private final BuildRefresh build;
|
||||
private final BuildRefresh.Request request;
|
||||
private final List<Path> tools;
|
||||
private final Consumer<String> log;
|
||||
private PreviewWorker active;
|
||||
private long generation;
|
||||
private boolean closed;
|
||||
|
||||
public DesignerWorkflow(
|
||||
HostServices host,
|
||||
Path configuration,
|
||||
String context,
|
||||
Path maven,
|
||||
List<Path> tools,
|
||||
Consumer<String> log)
|
||||
throws Exception {
|
||||
this.host = host;
|
||||
this.tools = List.copyOf(tools);
|
||||
this.log = log;
|
||||
request = new BuildRefresh.Request(configuration, context, maven);
|
||||
build =
|
||||
new BuildRefresh(
|
||||
host,
|
||||
new SnapshotStore(
|
||||
configuration
|
||||
.toAbsolutePath()
|
||||
.getParent()
|
||||
.resolve(".neitzel-fx-designer/snapshots")));
|
||||
host.lifecycle().own(this);
|
||||
}
|
||||
|
||||
public CompletionStage<URI> componentSource(String type) {
|
||||
if (!type.matches("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*"))
|
||||
return CompletableFuture.failedFuture(
|
||||
new IllegalArgumentException("Invalid Component class name"));
|
||||
return CompletableFuture.supplyAsync(
|
||||
() -> {
|
||||
try (var lease = build.previewLastSuccessful(request.context())) {
|
||||
var roots =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(lease.snapshot().details().get("sourceRoots"), String[].class);
|
||||
var matches = new LinkedHashSet<Path>();
|
||||
for (String root : roots) {
|
||||
Path path = Path.of(root).resolve(type.replace('.', '/') + ".fxml");
|
||||
if (Files.isRegularFile(path)) matches.add(path.toRealPath());
|
||||
}
|
||||
if (matches.size() != 1)
|
||||
throw new IllegalStateException(
|
||||
"Component has no unique editable FXML source in the captured reactor: " + type);
|
||||
return matches.iterator().next().toUri();
|
||||
} catch (Exception e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public BuildRefresh.Run build(Map<URI, Long> approved) {
|
||||
cancel();
|
||||
return build.start(request, approved, line -> log.accept(line.line()));
|
||||
}
|
||||
|
||||
public CompletionStage<List<Example>> discover(boolean lastSuccessful) {
|
||||
return execute(
|
||||
lastSuccessful,
|
||||
List.of(),
|
||||
worker -> {
|
||||
var packet =
|
||||
worker.request(
|
||||
Operation.DISCOVER, Map.of(), 0, 0, new byte[0], Duration.ofSeconds(15));
|
||||
var data = PreviewMessages.decode(packet.payload());
|
||||
if (!"1".equals(data.get("format")))
|
||||
throw new IllegalArgumentException("Unsupported metadata version");
|
||||
int count = Integer.parseInt(data.get("count"));
|
||||
if (count < 0 || count > 1024)
|
||||
throw new IllegalArgumentException("Invalid example count");
|
||||
var result = new ArrayList<Example>();
|
||||
var config = worker.context();
|
||||
for (int i = 0; i < count; i++) {
|
||||
String p = "example." + i + ".";
|
||||
String id = data.get(p + "id");
|
||||
if (config.preview().provider().equals(data.get(p + "providerClass"))
|
||||
&& (config.preview().examples().isEmpty()
|
||||
|| config.preview().examples().contains(id)))
|
||||
result.add(
|
||||
new Example(
|
||||
id,
|
||||
data.get(p + "label"),
|
||||
data.get(p + "viewResource"),
|
||||
data.get(p + "componentPackage")));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
});
|
||||
}
|
||||
|
||||
public record Size(int width, int height) {}
|
||||
|
||||
public CompletionStage<Image> render(
|
||||
boolean lastSuccessful,
|
||||
String example,
|
||||
String node,
|
||||
int width,
|
||||
int height,
|
||||
List<DocumentSnapshot> drafts) {
|
||||
return renderImages(lastSuccessful, example, Map.of(node, new Size(width, height)), drafts)
|
||||
.thenApply(images -> images.getFirst());
|
||||
}
|
||||
|
||||
/** All Component images in this batch come from one loaded parent and one shared Model graph. */
|
||||
public CompletionStage<List<Image>> renderImages(
|
||||
boolean lastSuccessful,
|
||||
String example,
|
||||
Map<String, Size> nodes,
|
||||
List<DocumentSnapshot> drafts) {
|
||||
var requested = new LinkedHashMap<>(nodes);
|
||||
var captured = List.copyOf(drafts);
|
||||
if (requested.isEmpty() || requested.size() > 128)
|
||||
return CompletableFuture.failedFuture(
|
||||
new IllegalArgumentException("Invalid render node count"));
|
||||
long bytes = 0;
|
||||
for (var size : requested.values()) {
|
||||
if (size.width() < 1
|
||||
|| size.height() < 1
|
||||
|| size.width() > 8192
|
||||
|| size.height() > 8192
|
||||
|| (long) size.width() * size.height() * 4 > MAX_IMAGE)
|
||||
return CompletableFuture.failedFuture(
|
||||
new IllegalArgumentException("Invalid render dimensions"));
|
||||
bytes += (long) size.width() * size.height() * 4;
|
||||
}
|
||||
if (bytes > 64L * 1024 * 1024)
|
||||
return CompletableFuture.failedFuture(
|
||||
new IllegalArgumentException("Render batch exceeds 64 MiB"));
|
||||
return execute(
|
||||
lastSuccessful,
|
||||
captured,
|
||||
worker -> {
|
||||
var revisions = new TreeMap<String, Long>();
|
||||
captured.forEach(d -> revisions.put(d.document().toString(), d.revision()));
|
||||
var images = new ArrayList<Image>();
|
||||
for (var entry : requested.entrySet()) {
|
||||
var size = entry.getValue();
|
||||
String node = entry.getKey();
|
||||
var result =
|
||||
worker.request(
|
||||
Operation.RENDER,
|
||||
revisions,
|
||||
size.width(),
|
||||
size.height(),
|
||||
PreviewMessages.encode(Map.of("example", example, "node", node)),
|
||||
Duration.ofSeconds(30));
|
||||
images.add(
|
||||
new Image(
|
||||
node,
|
||||
size.width(),
|
||||
size.height(),
|
||||
result.payload(),
|
||||
revisions,
|
||||
result.identity().snapshot()));
|
||||
}
|
||||
for (var draft : captured) {
|
||||
try (var doc = host.documents().open(draft.document()).toCompletableFuture().get()) {
|
||||
if (doc.snapshot().toCompletableFuture().get().revision() != draft.revision())
|
||||
throw new CancellationException("Draft changed during rendering");
|
||||
}
|
||||
}
|
||||
return List.copyOf(images);
|
||||
});
|
||||
}
|
||||
|
||||
private interface Work<T> {
|
||||
T run(PreviewWorker worker) throws Exception;
|
||||
}
|
||||
|
||||
private <T> CompletionStage<T> execute(
|
||||
boolean last, List<DocumentSnapshot> drafts, Work<T> work) {
|
||||
cancel();
|
||||
long ticket;
|
||||
synchronized (this) {
|
||||
if (closed)
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Designer closed"));
|
||||
ticket = generation;
|
||||
}
|
||||
var result = new CompletableFuture<T>();
|
||||
Thread.ofVirtual()
|
||||
.start(
|
||||
() -> {
|
||||
PreviewWorker worker = null;
|
||||
try {
|
||||
var lease =
|
||||
last
|
||||
? build.previewLastSuccessful(request.context())
|
||||
: build.previewCurrent(
|
||||
request,
|
||||
drafts.stream()
|
||||
.map(DocumentSnapshot::document)
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
worker = new PreviewWorker(new DraftOverlay(lease, drafts));
|
||||
synchronized (this) {
|
||||
if (closed || ticket != generation) throw new CancellationException();
|
||||
active = worker;
|
||||
}
|
||||
var owned = worker;
|
||||
// Hard deadline also covers unresponsive FX, construction and handshake.
|
||||
var watchdog =
|
||||
Thread.ofVirtual()
|
||||
.start(
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(45000);
|
||||
owned.close();
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
});
|
||||
try {
|
||||
worker.start(tools, log);
|
||||
T value = work.run(worker);
|
||||
synchronized (this) {
|
||||
if (closed || ticket != generation) throw new CancellationException();
|
||||
result.complete(value);
|
||||
}
|
||||
} finally {
|
||||
watchdog.interrupt();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
result.completeExceptionally(e);
|
||||
} finally {
|
||||
if (worker != null) worker.close();
|
||||
synchronized (this) {
|
||||
if (active == worker) active = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public synchronized void cancel() {
|
||||
generation++;
|
||||
var old = active;
|
||||
active = null;
|
||||
if (old != null) Thread.ofVirtual().start(old::close);
|
||||
}
|
||||
|
||||
public synchronized void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
cancel();
|
||||
build.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package de.neitzel.fxdesigner.workflow;
|
||||
|
||||
import de.neitzel.fxdesigner.build.SnapshotStore;
|
||||
import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.*;
|
||||
|
||||
/** Private runtime copies, retaining their immutable snapshot lease until the worker exits. */
|
||||
public final class DraftOverlay implements AutoCloseable {
|
||||
private final SnapshotStore.Lease lease;
|
||||
private final Path directory;
|
||||
private final List<Path> runtime = new ArrayList<>();
|
||||
private final Map<String, Long> revisions = new TreeMap<>();
|
||||
|
||||
public DraftOverlay(SnapshotStore.Lease lease, List<DocumentSnapshot> drafts) throws Exception {
|
||||
this.lease = lease;
|
||||
directory = Files.createTempDirectory("neitzel-preview-");
|
||||
try {
|
||||
if (drafts.size() > 128) throw new IOException("Too many draft overlays");
|
||||
var roots =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(
|
||||
lease.snapshot().details().getOrDefault("sourceRoots", "[]"), String[].class);
|
||||
var resources = new TreeMap<String, byte[]>();
|
||||
for (var draft : drafts) {
|
||||
Path file = Path.of(draft.document()).toAbsolutePath().normalize();
|
||||
if (!file.toString().endsWith(".fxml")) continue;
|
||||
var matches = new HashSet<String>();
|
||||
for (String root : roots) {
|
||||
Path base = Path.of(root);
|
||||
if (file.startsWith(base))
|
||||
matches.add(base.relativize(file).toString().replace(File.separatorChar, '/'));
|
||||
}
|
||||
if (matches.size() != 1)
|
||||
throw new IOException("FXML draft has no unambiguous captured source root: " + file);
|
||||
String name = matches.iterator().next();
|
||||
byte[] bytes = draft.text().getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
if (bytes.length > 1024 * 1024) throw new IOException("FXML draft exceeds 1 MiB");
|
||||
if (resources.putIfAbsent(name, bytes) != null)
|
||||
throw new IOException("Ambiguous FXML resource: " + name);
|
||||
revisions.put(draft.document().toString(), draft.revision());
|
||||
}
|
||||
var found = new HashSet<String>();
|
||||
int i = 0;
|
||||
for (Path source : lease.runtime()) {
|
||||
boolean affected;
|
||||
if (Files.isDirectory(source))
|
||||
affected =
|
||||
resources.keySet().stream()
|
||||
.anyMatch(name -> Files.isRegularFile(source.resolve(name)));
|
||||
else
|
||||
try (var jar = new ZipFile(source.toFile())) {
|
||||
affected = resources.keySet().stream().anyMatch(name -> jar.getEntry(name) != null);
|
||||
}
|
||||
if (!affected) {
|
||||
runtime.add(source);
|
||||
continue;
|
||||
}
|
||||
Path dest = directory.resolve(Integer.toString(i++)).resolve(source.getFileName());
|
||||
Files.createDirectories(dest.getParent());
|
||||
if (Files.isDirectory(source)) {
|
||||
try (var paths = Files.walk(source)) {
|
||||
for (Path p : paths.toList()) {
|
||||
Path target = dest.resolve(source.relativize(p));
|
||||
if (Files.isDirectory(p)) Files.createDirectories(target);
|
||||
else Files.copy(p, target);
|
||||
}
|
||||
}
|
||||
for (var e : resources.entrySet()) {
|
||||
Path file = dest.resolve(e.getKey());
|
||||
if (Files.isRegularFile(file)) {
|
||||
Files.write(file, e.getValue());
|
||||
found.add(e.getKey());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try (var in = new ZipFile(source.toFile());
|
||||
var out = new ZipOutputStream(Files.newOutputStream(dest))) {
|
||||
var entries = in.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
var entry = entries.nextElement();
|
||||
String name = entry.getName();
|
||||
if (!resources.isEmpty()
|
||||
&& name.startsWith("META-INF/")
|
||||
&& (name.endsWith(".SF") || name.endsWith(".RSA")))
|
||||
throw new IOException("Signed runtime JAR cannot be overlaid");
|
||||
out.putNextEntry(new ZipEntry(name));
|
||||
if (resources.containsKey(name)) {
|
||||
out.write(resources.get(name));
|
||||
found.add(name);
|
||||
} else
|
||||
try (var stream = in.getInputStream(entry)) {
|
||||
stream.transferTo(out);
|
||||
}
|
||||
out.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.add(dest);
|
||||
}
|
||||
if (!found.containsAll(resources.keySet()))
|
||||
throw new IOException("Draft resource absent from captured runtime");
|
||||
} catch (Exception e) {
|
||||
close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public List<Path> copyTools(List<Path> tools) throws Exception {
|
||||
var result = new ArrayList<Path>();
|
||||
int index = 0;
|
||||
for (Path source : tools) {
|
||||
Path target =
|
||||
directory
|
||||
.resolve("tools")
|
||||
.resolve(Integer.toString(index++))
|
||||
.resolve(source.getFileName());
|
||||
Files.createDirectories(target.getParent());
|
||||
byte[] before =
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(source));
|
||||
Files.copy(source, target);
|
||||
byte[] copied =
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(target));
|
||||
byte[] after =
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(source));
|
||||
if (!Arrays.equals(before, copied) || !Arrays.equals(before, after))
|
||||
throw new IOException("Target tooling changed during launch");
|
||||
result.add(target);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
public List<Path> runtime() {
|
||||
return List.copyOf(runtime);
|
||||
}
|
||||
|
||||
public Map<String, Long> revisions() {
|
||||
return Map.copyOf(revisions);
|
||||
}
|
||||
|
||||
public SnapshotStore.Snapshot snapshot() {
|
||||
return lease.snapshot();
|
||||
}
|
||||
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
try (var paths = Files.walk(directory)) {
|
||||
for (Path p : paths.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(p);
|
||||
}
|
||||
} finally {
|
||||
lease.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.neitzel.fxdesigner.workflow;
|
||||
|
||||
import de.neitzel.fxdesigner.config.LocalSettings;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Explicit machine-local launch setup, independent of application configuration. */
|
||||
public record HostLaunch(
|
||||
Path configuration, String context, Path maven, List<Path> tools, LocalSettings runtimes) {
|
||||
public static HostLaunch load(Path file) throws Exception {
|
||||
var p = new Properties();
|
||||
try (var reader = Files.newBufferedReader(file)) {
|
||||
p.load(reader);
|
||||
}
|
||||
Path base = file.toAbsolutePath().getParent();
|
||||
Path config = resolve(base, p.getProperty("configuration"));
|
||||
var contexts = de.neitzel.fxdesigner.config.ConfigurationFiles.load(config).contexts();
|
||||
String context = p.getProperty("context");
|
||||
if (context == null && contexts.size() == 1) context = contexts.keySet().iterator().next();
|
||||
if (context == null || !contexts.containsKey(context))
|
||||
throw new IllegalArgumentException("Select a configured context in " + file);
|
||||
var tools = new ArrayList<Path>();
|
||||
for (String item : p.getProperty("tools", "").split(java.io.File.pathSeparator)) {
|
||||
Path path = resolve(base, item);
|
||||
if (!Files.isRegularFile(path))
|
||||
throw new IllegalArgumentException("Target tooling JAR missing: " + path);
|
||||
tools.add(path);
|
||||
}
|
||||
return new HostLaunch(
|
||||
config,
|
||||
context,
|
||||
p.containsKey("maven") ? resolve(base, p.getProperty("maven")) : null,
|
||||
List.copyOf(tools),
|
||||
LocalSettings.load(resolve(base, p.getProperty("settings"))));
|
||||
}
|
||||
|
||||
private static Path resolve(Path base, String value) {
|
||||
if (value == null || value.isBlank())
|
||||
throw new IllegalArgumentException("Incomplete local designer launch settings");
|
||||
return base.resolve(value).normalize().toAbsolutePath();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package de.neitzel.fxdesigner.workflow;
|
||||
|
||||
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
|
||||
|
||||
import de.neitzel.fxdesigner.config.DesignerConfiguration;
|
||||
import de.neitzel.fxdesigner.protocol.*;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.file.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* One target process, authenticated socket and reserved owner pipe. All blocking calls run off UI.
|
||||
*/
|
||||
public final class PreviewWorker implements AutoCloseable {
|
||||
private final DraftOverlay overlay;
|
||||
private Process process;
|
||||
private Socket socket;
|
||||
private ServerSocket server;
|
||||
private ProtocolSession protocol;
|
||||
private volatile boolean closed;
|
||||
|
||||
public PreviewWorker(DraftOverlay overlay) {
|
||||
this.overlay = overlay;
|
||||
}
|
||||
|
||||
public synchronized long pid() {
|
||||
return process == null ? -1 : process.pid();
|
||||
}
|
||||
|
||||
public DesignerConfiguration.Context context() throws Exception {
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(
|
||||
overlay.snapshot().details().get("contextSpec"), DesignerConfiguration.Context.class);
|
||||
}
|
||||
|
||||
public void start(List<Path> tools, Consumer<String> log) throws Exception {
|
||||
var snapshot = overlay.snapshot();
|
||||
var config = context();
|
||||
var command = new ArrayList<String>();
|
||||
command.add(Path.of(snapshot.details().get("targetJavaHome"), "bin", "java").toString());
|
||||
ArrayList<Path> paths;
|
||||
synchronized (this) {
|
||||
if (closed) throw new CancellationException();
|
||||
paths = new ArrayList<>(overlay.copyTools(tools));
|
||||
}
|
||||
paths.addAll(overlay.runtime());
|
||||
boolean modular = config.launchMode().equals("module-path");
|
||||
if (modular) {
|
||||
var modules = new LinkedHashMap<String, Path>();
|
||||
for (Path path : paths) {
|
||||
var refs = java.lang.module.ModuleFinder.of(path).findAll();
|
||||
if (refs.size() != 1) throw new IOException("Not a single module: " + path);
|
||||
String name = refs.iterator().next().descriptor().name();
|
||||
modules.putIfAbsent(name, path);
|
||||
}
|
||||
if (!modules.containsKey(config.modulePath().entryModule()))
|
||||
throw new IOException("Configured entry module absent from snapshot");
|
||||
command.addAll(
|
||||
List.of("--module-path", join(modules.values()), "--add-modules", "ALL-MODULE-PATH"));
|
||||
for (String value : config.modulePath().addReads())
|
||||
command.addAll(List.of("--add-reads", value));
|
||||
for (String value : config.modulePath().addOpens())
|
||||
command.addAll(List.of("--add-opens", value));
|
||||
command.addAll(
|
||||
List.of("-m", "de.neitzel.fxdesigner.target/de.neitzel.fxdesigner.target.WorkerMain"));
|
||||
} else command.addAll(List.of("-cp", join(paths), "de.neitzel.fxdesigner.target.WorkerMain"));
|
||||
String id = UUID.randomUUID().toString(), token = ProtocolSession.newToken();
|
||||
synchronized (this) {
|
||||
if (closed) throw new CancellationException();
|
||||
server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
|
||||
server.setSoTimeout(15000);
|
||||
command.add(Integer.toString(server.getLocalPort()));
|
||||
command.add(id);
|
||||
process = new ProcessBuilder(command).start();
|
||||
protocol =
|
||||
new ProtocolSession(
|
||||
id,
|
||||
process.pid(),
|
||||
token,
|
||||
Set.of("discover-v1", "render-rgba-v1"),
|
||||
config.requiredCapabilities());
|
||||
}
|
||||
drain(process.getInputStream(), log);
|
||||
drain(process.getErrorStream(), log);
|
||||
var owner = new DataOutputStream(process.getOutputStream());
|
||||
owner.writeUTF(token);
|
||||
owner.flush();
|
||||
Socket connection = server.accept();
|
||||
synchronized (this) {
|
||||
if (closed) {
|
||||
connection.close();
|
||||
throw new CancellationException();
|
||||
}
|
||||
socket = connection;
|
||||
}
|
||||
socket.setSoTimeout(15000);
|
||||
var hello = FrameCodec.read(socket.getInputStream());
|
||||
if (!(hello instanceof Hello h)) throw new IOException("Worker did not send HELLO");
|
||||
protocol.authenticate(socket.getInetAddress(), h);
|
||||
FrameCodec.write(socket.getOutputStream(), protocol.welcome());
|
||||
socket.getOutputStream().flush();
|
||||
server.close();
|
||||
}
|
||||
|
||||
private static String join(Collection<Path> paths) {
|
||||
return String.join(File.pathSeparator, paths.stream().map(Path::toString).toList());
|
||||
}
|
||||
|
||||
private static void drain(InputStream input, Consumer<String> log) {
|
||||
Thread.ofVirtual()
|
||||
.start(
|
||||
() -> {
|
||||
try (var reader = new BufferedReader(new InputStreamReader(input))) {
|
||||
char[] chunk = new char[2048];
|
||||
int n;
|
||||
while ((n = reader.read(chunk)) >= 0) log.accept(new String(chunk, 0, n));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Packet request(
|
||||
Operation operation,
|
||||
Map<String, Long> revisions,
|
||||
int width,
|
||||
int height,
|
||||
byte[] payload,
|
||||
Duration timeout)
|
||||
throws Exception {
|
||||
if (closed) throw new CancellationException();
|
||||
var snapshot = overlay.snapshot();
|
||||
var packet =
|
||||
protocol.request(
|
||||
operation,
|
||||
snapshot.context(),
|
||||
snapshot.id(),
|
||||
revisions,
|
||||
width,
|
||||
height,
|
||||
payload,
|
||||
Instant.now(),
|
||||
timeout);
|
||||
socket.setSoTimeout(Math.toIntExact(timeout.toMillis()));
|
||||
FrameCodec.write(socket.getOutputStream(), packet);
|
||||
socket.getOutputStream().flush();
|
||||
var response = FrameCodec.read(socket.getInputStream());
|
||||
if (!(response instanceof Packet result) || !protocol.accept(result, Instant.now()))
|
||||
throw new IOException("Stale or unrelated worker response");
|
||||
if (result.kind() == Kind.ERROR)
|
||||
throw new IOException(new String(result.payload(), java.nio.charset.StandardCharsets.UTF_8));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
Process child;
|
||||
synchronized (this) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
child = process;
|
||||
try {
|
||||
if (server != null) server.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
try {
|
||||
if (socket != null) socket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
if (protocol != null) protocol.close();
|
||||
}
|
||||
if (child != null) {
|
||||
try {
|
||||
child.getOutputStream().close();
|
||||
if (!child.waitFor(2, TimeUnit.SECONDS)) {
|
||||
child.descendants().forEach(ProcessHandle::destroyForcibly);
|
||||
child.destroyForcibly();
|
||||
child.waitFor(3, TimeUnit.SECONDS);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
child.destroyForcibly();
|
||||
}
|
||||
}
|
||||
try {
|
||||
overlay.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package de.neitzel.fxdesigner.workflow;
|
||||
|
||||
import de.neitzel.fxdesigner.build.*;
|
||||
import de.neitzel.fxdesigner.contracts.*;
|
||||
import de.neitzel.fxdesigner.host.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
public final class ConnectedWorkflowContract {
|
||||
private int checks;
|
||||
|
||||
private void check(boolean value, String message) {
|
||||
if (!value) throw new AssertionError(message);
|
||||
checks++;
|
||||
}
|
||||
|
||||
private static <T> T await(CompletionStage<T> stage) throws Exception {
|
||||
return stage.toCompletableFuture().get(120, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public int run(HostServices host, Path root, Path fixture) throws Exception {
|
||||
var tools =
|
||||
List.of(
|
||||
root.resolve("designer-target-tools/target/designer-target-tools-0.1.0-SNAPSHOT.jar"),
|
||||
root.resolve("designer-contracts/target/designer-contracts-0.1.0-SNAPSHOT.jar"),
|
||||
root.resolve("designer-preview-api/target/designer-preview-api-0.1.0-SNAPSHOT.jar"));
|
||||
var workflow =
|
||||
new DesignerWorkflow(
|
||||
host,
|
||||
fixture.resolve("neitzel-fx-designer.json"),
|
||||
"fixture",
|
||||
root.resolve("mvnw"),
|
||||
tools,
|
||||
System.out::println);
|
||||
var built = await(workflow.build(Map.of()).completion());
|
||||
check(built.status() == BuildRefresh.Status.READY, "Build failed: " + built.message());
|
||||
var examples = await(workflow.discover(false));
|
||||
check(examples.size() == 9, "Discovery from snapshot");
|
||||
Path file = fixture.resolve("app/src/main/resources/fixture/components/Person.fxml"),
|
||||
child = fixture.resolve("app/src/main/resources/fixture/components/Card.fxml");
|
||||
try (var doc = await(host.documents().open(file.toUri()));
|
||||
var component = await(host.documents().open(child.toUri()))) {
|
||||
var initial = await(doc.snapshot());
|
||||
var first = await(workflow.render(false, "standard", "card", 310, 220, List.of(initial)));
|
||||
check(first.rgba().length == 310 * 220 * 4, "Image frame");
|
||||
var batch =
|
||||
await(
|
||||
workflow.renderImages(
|
||||
false,
|
||||
"standard",
|
||||
Map.of(
|
||||
"card",
|
||||
new DesignerWorkflow.Size(310, 220),
|
||||
"second",
|
||||
new DesignerWorkflow.Size(310, 220)),
|
||||
List.of(initial)));
|
||||
check(
|
||||
batch.size() == 2
|
||||
&& batch.stream().allMatch(image -> image.snapshot().equals(first.snapshot())),
|
||||
"Shared parent render batch");
|
||||
check(
|
||||
Path.of(await(workflow.componentSource("fixture.components.Card")))
|
||||
.equals(child.toRealPath()),
|
||||
"Component navigation source");
|
||||
|
||||
var changed =
|
||||
await(
|
||||
doc.apply(
|
||||
new EditBatch(
|
||||
initial.revision(),
|
||||
"Caption",
|
||||
List.of(
|
||||
new EditBatch.Replacement(
|
||||
0,
|
||||
initial.text().length(),
|
||||
initial.text().replace("FXML caption", "Draft caption"))))));
|
||||
var second = await(workflow.render(false, "standard", "card", 310, 220, List.of(changed)));
|
||||
check(!Arrays.equals(first.rgba(), second.rgba()), "Parent draft not rendered");
|
||||
check(first.snapshot().equals(second.snapshot()), "FXML edit rebuilt Java");
|
||||
var childInitial = await(component.snapshot());
|
||||
var childDraft =
|
||||
await(
|
||||
component.apply(
|
||||
new EditBatch(
|
||||
childInitial.revision(),
|
||||
"Component",
|
||||
List.of(
|
||||
new EditBatch.Replacement(
|
||||
0,
|
||||
childInitial.text().length(),
|
||||
childInitial
|
||||
.text()
|
||||
.replace(
|
||||
"<TextField",
|
||||
"<TextField style=\"-fx-background-color: red;\""))))));
|
||||
var third =
|
||||
await(workflow.render(false, "standard", "card", 310, 220, List.of(changed, childDraft)));
|
||||
check(!Arrays.equals(second.rgba(), third.rgba()), "Component draft not rendered");
|
||||
var undo = await(doc.undo());
|
||||
check(undo.text().equals(initial.text()), "Native host undo");
|
||||
var redo = await(doc.redo());
|
||||
check(redo.text().equals(changed.text()), "Native host redo");
|
||||
var obsolete = workflow.render(false, "standard", "card", 310, 220, List.of(redo));
|
||||
var newer =
|
||||
await(
|
||||
doc.apply(
|
||||
new EditBatch(
|
||||
redo.revision(),
|
||||
"Concurrent edit",
|
||||
List.of(
|
||||
new EditBatch.Replacement(
|
||||
0,
|
||||
redo.text().length(),
|
||||
redo.text().replace("Draft caption", "Concurrent caption"))))));
|
||||
try {
|
||||
await(obsolete);
|
||||
throw new AssertionError("Obsolete render accepted");
|
||||
} catch (Exception expected) {
|
||||
checks++;
|
||||
}
|
||||
redo = await(doc.undo());
|
||||
var bad =
|
||||
await(
|
||||
doc.apply(
|
||||
new EditBatch(
|
||||
redo.revision(),
|
||||
"Broken source",
|
||||
List.of(
|
||||
new EditBatch.Replacement(
|
||||
0,
|
||||
redo.text().length(),
|
||||
redo.text()
|
||||
.replace("prefWidth=\"310\"", "prefWidth=\"invalid\""))))));
|
||||
try {
|
||||
await(workflow.render(false, "standard", "card", 310, 220, List.of(bad)));
|
||||
throw new AssertionError("Invalid draft accepted");
|
||||
} catch (ExecutionException expected) {
|
||||
check(
|
||||
expected.getCause().getMessage().contains("LOAD_FAILED"), "Missing target diagnostic");
|
||||
}
|
||||
check(await(doc.snapshot()).writable(), "Failure disabled source");
|
||||
await(doc.undo());
|
||||
var saved = await(doc.save(await(doc.snapshot()).revision()));
|
||||
var savedChild = await(component.save(childDraft.revision()));
|
||||
var savedRender =
|
||||
await(workflow.render(false, "standard", "card", 310, 220, List.of(saved, savedChild)));
|
||||
check(
|
||||
savedRender.snapshot().equals(first.snapshot()),
|
||||
"Saving overlaid FXML required Java rebuild");
|
||||
var store = new SnapshotStore(fixture.resolve(".neitzel-fx-designer/snapshots"));
|
||||
try (var lease = store.lease(built.snapshot())) {
|
||||
check(lease.snapshot().id().equals(built.snapshot()), "Overlay mutated snapshot");
|
||||
}
|
||||
var pending =
|
||||
workflow.render(
|
||||
false,
|
||||
"timeout",
|
||||
"card",
|
||||
310,
|
||||
220,
|
||||
List.of(await(doc.snapshot()), await(component.snapshot())));
|
||||
Thread.sleep(500);
|
||||
workflow.cancel();
|
||||
try {
|
||||
await(pending);
|
||||
throw new AssertionError("Cancellation accepted result");
|
||||
} catch (Exception expected) {
|
||||
checks++;
|
||||
}
|
||||
}
|
||||
workflow.close();
|
||||
return checks;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user