Make configuration and compatibility contracts executable

This commit is contained in:
2026-11-12 08:27:53 +01:00
parent 306266ae40
commit 85a75383a0
28 changed files with 2284 additions and 8 deletions
+3 -1
View File
@@ -6,6 +6,8 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with planned standalone and
The repository builds six shared Maven modules and a separate IntelliJ adapter plugin. Document transactions, native undo, build execution/cancellation, explicit runtime mappings and lifecycle services have initial implementations and tests. The shared UI exposes a Kit construction seam. There is **no runnable designer application or registered IntelliJ designer editor yet**. See [module and host boundaries](docs/module-host-boundaries.md) for the implemented scope and limitations.
Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats, limits and the remaining Maven/worker integration.
The Component builder fix is integrated in NeitzelLib main. A [verified local development snapshot](docs/runtime/neitzellib-baseline.md) is recorded with source/artifact identity; a corrected released version is still unestablished. NeitzelLib sources and prototype implementations are not copied here.
## Validate the foundation
@@ -23,7 +25,7 @@ Maven is pinned to **3.9.11** (Wrapper **3.3.4**); the separate IntelliJ build u
## Implementation references
- [Initial project contract](docs/initial-project-contract.md): module responsibilities, configuration, snapshots, runtimes, preview sessions and communication.
- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: make configuration and compatibility executable.
- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: implement Build and Refresh with immutable snapshots.
- [Decision provenance](docs/provenance.md): workspace planning and captured prototype evidence.
- [Third-party notices](THIRD-PARTY-NOTICES.md): Maven wrapper provenance.
+2
View File
@@ -5,3 +5,5 @@
The Gradle wrapper in `designer-intellij/` is generated by Gradle 9.1.0; its scripts and JAR retain upstream notices (Apache License 2.0). The wrapper distribution checksum is pinned. Build dependencies are resolved from upstream artifact repositories, not copied source trees.
The shared UI depends on Scene Builder Kit 26.0.0 and OpenJFX 26. The IntelliJ adapter builds against the locally supplied IntelliJ SDK with IntelliJ Platform Gradle Plugin 2.20.1. Target tools depend on the separately recorded NeitzelLib development artifacts. Preserve the upstream license/notice resources in packaged dependencies. Product distribution and any patched Kit source import require their separate notice/provenance review; this foundation does not assign a license to original NeitzelFXDesigner code.
Core configuration validation uses networknt json-schema-validator 1.5.9 and its Jackson dependencies, resolved as Maven artifacts. Preserve their bundled license resources when packaging; [upstream source and license](https://github.com/networknt/json-schema-validator/tree/1.5.9) provide provenance. They do not enter the application-facing preview API or target protocol artifact.
@@ -0,0 +1,45 @@
package de.neitzel.fxdesigner.config;
import java.util.*;
/** Versioned shared configuration. Validated and resolved by core before use. */
public record DesignerConfiguration(String schemaVersion, Map<String, Context> contexts) {
public DesignerConfiguration {
contexts = Map.copyOf(contexts);
}
public record Preview(
String sourceSet,
String module,
String provider,
String defaultExample,
List<String> examples) {
public Preview {
examples = List.copyOf(examples);
}
}
public record ModulePath(String entryModule, List<String> addReads, List<String> addOpens) {
public ModulePath {
addReads = List.copyOf(addReads);
addOpens = List.copyOf(addOpens);
}
}
public record Context(
String applicationModule,
List<String> mavenProfiles,
String launchMode,
String buildRuntime,
String targetRuntime,
int buildJava,
int targetJava,
Set<String> requiredCapabilities,
Preview preview,
ModulePath modulePath) {
public Context {
mavenProfiles = List.copyOf(mavenProfiles);
requiredCapabilities = Set.copyOf(requiredCapabilities);
}
}
}
@@ -0,0 +1,182 @@
package de.neitzel.fxdesigner.protocol;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import java.io.*;
import java.nio.*;
import java.nio.charset.*;
import java.util.*;
/**
* Network byte order length-prefix, fixed magic, strict bounded fields. No object deserialization.
*/
public final class FrameCodec {
private static final int MAGIC = 0x4e465831;
private FrameCodec() {}
public static void write(OutputStream stream, Message message) throws IOException {
var bytes = new ByteArrayOutputStream();
var out = new DataOutputStream(bytes);
out.writeInt(MAGIC);
if (message instanceof Hello h) {
out.writeByte(0);
text(out, h.session());
out.writeLong(h.workerPid());
text(out, h.token());
version(out, h.wire());
version(out, h.metadata());
version(out, h.preview());
strings(out, h.capabilities());
strings(out, h.required());
} else if (message instanceof Welcome w) {
out.writeByte(2);
text(out, w.session());
version(out, w.wire());
var names = new HashSet<String>();
w.operations().forEach(o -> names.add(o.name()));
strings(out, names);
} else if (message instanceof Packet p) {
out.writeByte(1);
out.writeByte(p.kind().ordinal());
out.writeByte(p.operation().ordinal());
var i = p.identity();
text(out, i.session());
text(out, i.request());
text(out, i.context());
text(out, i.snapshot());
out.writeInt(i.revisions().size());
for (var e : new TreeMap<>(i.revisions()).entrySet()) {
text(out, e.getKey());
out.writeLong(e.getValue());
}
out.writeInt(i.width());
out.writeInt(i.height());
byte[] data = p.payload();
out.writeInt(data.length);
out.write(data);
}
if (bytes.size() > MAX_FRAME) throw new IOException("Frame exceeds limit");
var target = new DataOutputStream(stream);
target.writeInt(bytes.size());
bytes.writeTo(target);
target.flush();
}
/** EOF/truncation is a transport failure. Length is checked before allocating the frame. */
public static Message read(InputStream stream) throws IOException {
var source = new DataInputStream(stream);
int size = source.readInt();
if (size < 5 || size > MAX_FRAME) throw new IOException("Invalid frame length");
byte[] data = source.readNBytes(size);
if (data.length != size) throw new EOFException("Truncated frame");
var in = new DataInputStream(new ByteArrayInputStream(data));
try {
if (in.readInt() != MAGIC) throw new IOException("Invalid protocol magic");
Message message;
int type = in.readUnsignedByte();
if (type == 0)
message =
new Hello(
text(in),
in.readLong(),
text(in),
version(in),
version(in),
version(in),
strings(in),
strings(in));
else if (type == 1) {
int k = in.readUnsignedByte(), o = in.readUnsignedByte();
if (k >= Kind.values().length || o >= Operation.values().length)
throw new IOException("Unknown message kind/operation");
String session = text(in), request = text(in), context = text(in), snapshot = text(in);
int count = count(in, MAX_DOCUMENTS);
var revisions = new LinkedHashMap<String, Long>();
for (int n = 0; n < count; n++) {
String key = text(in);
if (revisions.put(key, in.readLong()) != null)
throw new IOException("Duplicate document revision");
}
var identity =
new Identity(
session, request, context, snapshot, revisions, in.readInt(), in.readInt());
int length =
count(
in,
o == Operation.RENDER.ordinal() && k == Kind.RESULT.ordinal()
? MAX_IMAGE
: MAX_METADATA);
if (o == Operation.RENDER.ordinal()
&& k == Kind.RESULT.ordinal()
&& length != (long) identity.width() * identity.height() * 4)
throw new IOException("Image dimensions do not match payload");
if (length > in.available()) throw new EOFException("Truncated payload");
message =
new Packet(Kind.values()[k], Operation.values()[o], identity, in.readNBytes(length));
} else if (type == 2) {
String session = text(in);
var wire = version(in);
var operations = EnumSet.noneOf(Operation.class);
for (String operation : strings(in)) operations.add(Operation.valueOf(operation));
message = new Welcome(session, wire, operations);
} else throw new IOException("Unknown frame type");
if (in.available() != 0) throw new IOException("Trailing frame content");
return message;
} catch (IllegalArgumentException e) {
throw new IOException("Invalid protocol fields", e);
}
}
private static int count(DataInputStream in, int maximum) throws IOException {
int n = in.readInt();
if (n < 0 || n > maximum) throw new IOException("Field exceeds limit");
return n;
}
private static void text(DataOutputStream out, String value) throws IOException {
if (value.length() > MAX_TEXT) throw new IOException("Text exceeds limit");
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_TEXT) throw new IOException("Text exceeds limit");
out.writeInt(bytes.length);
out.write(bytes);
}
private static String text(DataInputStream in) throws IOException {
int n = count(in, MAX_TEXT);
byte[] b = in.readNBytes(n);
if (b.length != n) throw new EOFException("Truncated text");
try {
return StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(b))
.toString();
} catch (CharacterCodingException e) {
throw new IOException("Invalid UTF-8", e);
}
}
private static void version(DataOutputStream out, Version v) throws IOException {
out.writeInt(v.major());
out.writeInt(v.minor());
}
private static Version version(DataInputStream in) throws IOException {
return new Version(in.readInt(), in.readInt());
}
private static void strings(DataOutputStream out, Set<String> values) throws IOException {
if (values.size() > 128) throw new IOException("Too many capabilities");
out.writeInt(values.size());
for (String v : new TreeSet<>(values)) text(out, v);
}
private static Set<String> strings(DataInputStream in) throws IOException {
int count = count(in, 128);
var s = new HashSet<String>();
for (int n = 0; n < count; n++)
if (!s.add(text(in))) throw new IOException("Duplicate capability");
return s;
}
}
@@ -0,0 +1,167 @@
package de.neitzel.fxdesigner.protocol;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.*;
import java.util.*;
/**
* Host-side authentication/negotiation and bounded request correlation, independent of sockets/UI.
*/
public final class ProtocolSession implements AutoCloseable {
private enum State {
NEW,
READY,
CLOSED
}
private State state = State.NEW;
private final String session, token;
private final long workerPid;
private final Set<String> supported, required;
private Set<Operation> operations = Set.of();
private record Pending(Packet request, Instant deadline) {}
private final Map<String, Pending> pending = new HashMap<>();
private long sequence;
public ProtocolSession(
String session, long workerPid, String token, Set<String> supported, Set<String> required) {
if (session == null
|| session.isBlank()
|| workerPid < 1
|| token == null
|| !token.matches("[0-9a-f]{64}"))
throw new IllegalArgumentException("Invalid launch identity");
this.session = session;
this.workerPid = workerPid;
this.token = token;
this.supported = Set.copyOf(supported);
this.required = Set.copyOf(required);
}
public static String newToken() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
public synchronized Set<Operation> authenticate(InetAddress peer, Hello hello) {
if (state != State.NEW) throw new IllegalStateException("Handshake already consumed");
state = State.CLOSED;
if (!peer.isLoopbackAddress()
|| !session.equals(hello.session())
|| workerPid != hello.workerPid()
|| !MessageDigest.isEqual(
token.getBytes(StandardCharsets.US_ASCII),
hello.token().getBytes(StandardCharsets.UTF_8)))
throw new IllegalArgumentException("Worker authentication failed");
if (hello.wire().major() != 1)
throw new IllegalArgumentException("Unsupported wire major version");
if (!supported.containsAll(hello.required()) || !hello.capabilities().containsAll(required))
throw new IllegalArgumentException("Required capability unavailable");
var usable = EnumSet.noneOf(Operation.class);
if (hello.metadata().major() == 1
&& supported.contains("discover-v1")
&& hello.capabilities().contains("discover-v1")) usable.add(Operation.DISCOVER);
if (hello.preview().major() == 1
&& supported.contains("render-rgba-v1")
&& hello.capabilities().contains("render-rgba-v1")) usable.add(Operation.RENDER);
var requiredByEither = new HashSet<>(required);
requiredByEither.addAll(hello.required());
if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER))
|| (requiredByEither.contains("render-rgba-v1") && !usable.contains(Operation.RENDER)))
throw new IllegalArgumentException("Required capability version incompatible");
operations = Set.copyOf(usable);
state = State.READY;
return operations;
}
public synchronized Welcome welcome() {
ready();
return new Welcome(session, new Version(1, 0), operations);
}
public synchronized Packet request(
Operation operation,
String context,
String snapshot,
Map<String, Long> revisions,
int width,
int height,
byte[] payload,
Instant now,
Duration timeout) {
ready();
if (!operations.contains(operation))
throw new IllegalStateException("Operation not negotiated");
if (timeout.isNegative() || timeout.isZero() || timeout.compareTo(Duration.ofMinutes(5)) > 0)
throw new IllegalArgumentException("Invalid request timeout");
expire(now);
if (pending.size() >= MAX_PENDING) throw new IllegalStateException("Too many pending requests");
var packet =
new Packet(
Kind.REQUEST,
operation,
new Identity(
session, Long.toString(++sequence), context, snapshot, revisions, width, height),
payload);
pending.put(packet.identity().request(), new Pending(packet, now.plus(timeout)));
return packet;
}
/** False means stale/unrelated/mismatched; such results must not mutate the UI. */
public synchronized boolean accept(Packet result, Instant now) {
ready();
expire(now);
if (result.kind() != Kind.RESULT && result.kind() != Kind.ERROR) return false;
var p = pending.get(result.identity().request());
if (p == null
|| p.request.operation() != result.operation()
|| !p.request.identity().equals(result.identity())) return false;
pending.remove(result.identity().request());
return true;
}
public synchronized Optional<Packet> cancel(String request) {
ready();
var p = pending.remove(request);
return p == null
? Optional.empty()
: Optional.of(
new Packet(Kind.CANCEL, p.request.operation(), p.request.identity(), new byte[0]));
}
/**
* On any render input/size/snapshot change the caller cancels the superseded request before
* issuing another.
*/
public synchronized List<Packet> expire(Instant now) {
var cancelled = new ArrayList<Packet>();
var iterator = pending.values().iterator();
while (iterator.hasNext()) {
var p = iterator.next();
if (!now.isBefore(p.deadline)) {
cancelled.add(
new Packet(Kind.CANCEL, p.request.operation(), p.request.identity(), new byte[0]));
iterator.remove();
}
}
return List.copyOf(cancelled);
}
private void ready() {
if (state != State.READY)
throw new IllegalStateException("Session not authenticated or closed");
}
public synchronized void close() {
state = State.CLOSED;
pending.clear();
}
}
@@ -0,0 +1,153 @@
package de.neitzel.fxdesigner.protocol;
import java.util.*;
/** Draft wire 1.0 types. Product, metadata and preview versions are independent. */
public final class WireProtocol {
private WireProtocol() {}
public static final int MAX_TEXT = 4096,
MAX_METADATA = 1024 * 1024,
MAX_IMAGE = 16 * 1024 * 1024,
MAX_FRAME = MAX_IMAGE + MAX_METADATA,
MAX_DOCUMENTS = 128,
MAX_PENDING = 128;
public record Version(int major, int minor) {
public Version {
if (major < 0 || minor < 0) throw new IllegalArgumentException("Negative version");
}
}
public sealed interface Message permits Hello, Welcome, Packet {}
public record Hello(
String session,
long workerPid,
String token,
Version wire,
Version metadata,
Version preview,
Set<String> capabilities,
Set<String> required)
implements Message {
public Hello {
Objects.requireNonNull(session);
Objects.requireNonNull(token);
Objects.requireNonNull(wire);
Objects.requireNonNull(metadata);
Objects.requireNonNull(preview);
capabilities = Set.copyOf(capabilities);
required = Set.copyOf(required);
}
@Override
public String toString() {
return "Hello[session=" + session + ", workerPid=" + workerPid + ", credentials=<redacted>]";
}
}
public record Welcome(String session, Version wire, Set<Operation> operations)
implements Message {
public Welcome {
Objects.requireNonNull(session);
Objects.requireNonNull(wire);
operations = Set.copyOf(operations);
}
}
public enum Kind {
REQUEST,
RESULT,
ERROR,
CANCEL
}
public enum ErrorCode {
INVALID_REQUEST,
LOAD_FAILED,
TIMEOUT,
CANCELLED,
INTERNAL_ERROR
}
public enum Operation {
DISCOVER,
RENDER
}
public record Identity(
String session,
String request,
String context,
String snapshot,
Map<String, Long> revisions,
int width,
int height) {
public Identity {
for (String s : List.of(session, request, context, snapshot))
if (s.isBlank() || s.length() > MAX_TEXT)
throw new IllegalArgumentException("Invalid correlation identity");
revisions = Map.copyOf(revisions);
if (revisions.size() > MAX_DOCUMENTS || revisions.values().stream().anyMatch(r -> r < 0))
throw new IllegalArgumentException("Invalid revisions");
if (width < 0
|| height < 0
|| width > 8192
|| height > 8192
|| (long) width * height * 4 > MAX_IMAGE)
throw new IllegalArgumentException("Invalid image dimensions");
}
}
/**
* RENDER RESULT payloads are raw RGBA8; other payloads are opaque UTF-8 metadata owned by later
* adapters.
*/
public record Packet(Kind kind, Operation operation, Identity identity, byte[] payload)
implements Message {
public Packet {
Objects.requireNonNull(kind);
Objects.requireNonNull(operation);
Objects.requireNonNull(identity);
Objects.requireNonNull(payload);
boolean render = operation == Operation.RENDER;
if (render
&& (identity.width() == 0 || identity.height() == 0 || identity.revisions().isEmpty()))
throw new IllegalArgumentException("Rendering needs dimensions and document revisions");
if (!render
&& (identity.width() != 0 || identity.height() != 0 || !identity.revisions().isEmpty()))
throw new IllegalArgumentException("Discovery does not carry render identity");
boolean image = render && kind == Kind.RESULT;
if (image
? payload.length != (long) identity.width() * identity.height() * 4
: payload.length > MAX_METADATA)
throw new IllegalArgumentException("Invalid payload size");
if (kind == Kind.CANCEL && payload.length != 0)
throw new IllegalArgumentException("Cancellation payload must be empty");
if (!image) {
try {
String text =
java.nio.charset.StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.decode(java.nio.ByteBuffer.wrap(payload))
.toString();
if (kind == Kind.ERROR) {
int split = text.indexOf('\n');
if (split < 1) throw new IllegalArgumentException("Error needs code and detail");
ErrorCode.valueOf(text.substring(0, split));
}
} catch (java.nio.charset.CharacterCodingException e) {
throw new IllegalArgumentException("Invalid UTF-8 payload", e);
}
}
payload = payload.clone();
}
@Override
public byte[] payload() {
return payload.clone();
}
}
}
@@ -0,0 +1,210 @@
{
"type": "object",
"properties": {
"schemaVersion": {
"const": "1.0"
},
"contexts": {
"type": "object",
"minProperties": 1,
"maxProperties": 128,
"propertyNames": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"additionalProperties": {
"type": "object",
"properties": {
"applicationModule": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"mavenProfiles": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
},
"launchMode": {
"enum": [
"classpath",
"module-path"
]
},
"buildRuntime": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"targetRuntime": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"buildJava": {
"type": "integer",
"minimum": 21,
"maximum": 99
},
"targetJava": {
"type": "integer",
"minimum": 21,
"maximum": 99
},
"requiredCapabilities": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
},
"preview": {
"type": "object",
"properties": {
"sourceSet": {
"enum": [
"module",
"test"
]
},
"module": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"provider": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"defaultExample": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"examples": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
}
},
"required": [
"sourceSet",
"provider"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"sourceSet": {
"const": "module"
}
}
},
"then": {
"required": [
"module"
]
},
"else": {
"not": {
"required": [
"module"
]
}
}
}
]
},
"modulePath": {
"type": "object",
"properties": {
"entryModule": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"addReads": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
},
"addOpens": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
}
},
"required": [
"entryModule"
],
"additionalProperties": false
}
},
"required": [
"applicationModule",
"launchMode",
"buildRuntime",
"targetRuntime",
"preview"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"launchMode": {
"const": "module-path"
}
}
},
"then": {
"required": [
"modulePath"
]
},
"else": {
"not": {
"required": [
"modulePath"
]
}
}
}
]
}
}
},
"required": [
"schemaVersion",
"contexts"
],
"additionalProperties": false,
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:neitzelfxdesigner:configuration:1.0"
}
@@ -0,0 +1,50 @@
{
"type": "object",
"properties": {
"schemaVersion": {
"const": "1.0"
},
"runtimes": {
"type": "object",
"maxProperties": 128,
"propertyNames": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"additionalProperties": {
"type": "object",
"properties": {
"build": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 512
}
},
"required": [],
"additionalProperties": false
}
},
"rememberedContexts": {
"type": "object",
"additionalProperties": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"maxProperties": 1024
}
},
"required": [
"schemaVersion",
"runtimes"
],
"additionalProperties": false,
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:neitzelfxdesigner:local-settings:1.0"
}
@@ -0,0 +1,381 @@
package de.neitzel.fxdesigner.protocol;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
import java.net.*;
import java.time.*;
import java.util.*;
import org.junit.jupiter.api.Test;
class ProtocolTest {
private static final String TOKEN = "a".repeat(64);
private static final Set<String> CAPS = Set.of("discover-v1", "render-rgba-v1");
private static final Instant NOW = Instant.parse("2026-11-12T00:00:00Z");
private Hello hello() {
return new Hello(
"session",
42,
TOKEN,
new Version(1, 0),
new Version(1, 0),
new Version(1, 0),
CAPS,
Set.of());
}
private ProtocolSession session() throws Exception {
var s = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
s.authenticate(InetAddress.getLoopbackAddress(), hello());
return s;
}
private Packet render(ProtocolSession s) throws Exception {
return s.request(
Operation.RENDER,
"desktop",
"sha256-build",
Map.of("View.fxml", 3L),
2,
2,
new byte[0],
NOW,
Duration.ofSeconds(5));
}
private Packet response(Packet request) {
return new Packet(
Kind.RESULT,
request.operation(),
request.identity(),
request.operation() == Operation.RENDER ? new byte[16] : "{}".getBytes());
}
@Test
void fixedWireFixtureMatchesDocumentedNetworkByteOrder() throws Exception {
byte[] fixture;
try (var in = getClass().getResourceAsStream("/protocol/discovery-request.hex")) {
fixture =
HexFormat.of()
.parseHex(
new String(in.readAllBytes(), java.nio.charset.StandardCharsets.US_ASCII)
.strip());
}
var packet = (Packet) FrameCodec.read(new ByteArrayInputStream(fixture));
assertEquals(new Identity("s", "1", "c", "b", Map.of(), 0, 0), packet.identity());
assertEquals(Kind.REQUEST, packet.kind());
assertEquals(Operation.DISCOVER, packet.operation());
assertArrayEquals("{}".getBytes(), packet.payload());
var out = new ByteArrayOutputStream();
FrameCodec.write(out, packet);
assertArrayEquals(fixture, out.toByteArray());
}
@Test
void welcomeIsExplicitAndContainsNoCredentials() throws Exception {
var welcome = session().welcome();
var out = new ByteArrayOutputStream();
FrameCodec.write(out, welcome);
assertEquals(welcome, FrameCodec.read(new ByteArrayInputStream(out.toByteArray())));
assertEquals(Set.of(Operation.DISCOVER, Operation.RENDER), welcome.operations());
assertFalse(welcome.toString().contains(TOKEN));
}
@Test
void exactPayloadLimitsAreUsable() throws Exception {
var image =
new Packet(
Kind.RESULT,
Operation.RENDER,
new Identity("s", "r", "c", "b", Map.of("View.fxml", 0L), 2048, 2048),
new byte[MAX_IMAGE]);
var out = new ByteArrayOutputStream();
FrameCodec.write(out, image);
assertEquals(
MAX_IMAGE,
((Packet) FrameCodec.read(new ByteArrayInputStream(out.toByteArray()))).payload().length);
assertDoesNotThrow(
() ->
new Packet(
Kind.RESULT,
Operation.DISCOVER,
new Identity("s", "r", "c", "b", Map.of(), 0, 0),
new byte[MAX_METADATA]));
}
@Test
void handshakeAndImageRoundTripThroughFragmentedFrames() throws Exception {
var out = new ByteArrayOutputStream();
FrameCodec.write(out, hello());
var request = render(session());
FrameCodec.write(out, response(request));
var fragmented =
new FilterInputStream(new ByteArrayInputStream(out.toByteArray())) {
@Override
public int read(byte[] b, int off, int len) throws IOException {
return super.read(b, off, Math.min(1, len));
}
};
assertEquals(hello(), FrameCodec.read(fragmented));
var decoded = (Packet) FrameCodec.read(fragmented);
assertEquals(request.identity(), decoded.identity());
assertArrayEquals(new byte[16], decoded.payload());
assertThrows(EOFException.class, () -> FrameCodec.read(fragmented));
}
@Test
void lengthIsRejectedBeforeReadingOrAllocatingPayload() throws Exception {
for (int length : new int[] {-1, 0, 4, MAX_FRAME + 1, Integer.MAX_VALUE}) {
byte[] prefix = java.nio.ByteBuffer.allocate(4).putInt(length).array();
var in =
new ByteArrayInputStream(prefix) {
@Override
public synchronized byte[] readNBytes(int len) {
fail("Must reject length before payload allocation");
return null;
}
};
assertThrows(IOException.class, () -> FrameCodec.read(in));
}
var out = new ByteArrayOutputStream();
FrameCodec.write(out, hello());
byte[] bytes = out.toByteArray();
assertThrows(
EOFException.class,
() -> FrameCodec.read(new ByteArrayInputStream(Arrays.copyOf(bytes, bytes.length - 1))));
bytes[4] = 0;
assertThrows(IOException.class, () -> FrameCodec.read(new ByteArrayInputStream(bytes)));
}
@Test
void strictFieldValidationRejectsBadDimensionsEncodingAndTrailingBytes() throws Exception {
assertThrows(
IllegalArgumentException.class,
() -> new Identity("s", "r", "c", "b", Map.of("v", 1L), 8192, 8192));
var request = render(session());
assertThrows(
IllegalArgumentException.class,
() -> new Packet(Kind.RESULT, Operation.RENDER, request.identity(), new byte[15]));
assertThrows(
IllegalArgumentException.class,
() ->
new Packet(
Kind.REQUEST, Operation.RENDER, request.identity(), new byte[MAX_METADATA + 1]));
assertThrows(
IllegalArgumentException.class,
() ->
new Packet(
Kind.ERROR, Operation.RENDER, request.identity(), "unstructured error".getBytes()));
assertThrows(
IllegalArgumentException.class,
() ->
new Packet(
Kind.REQUEST, Operation.RENDER, request.identity(), new byte[] {(byte) 0xff}));
var out = new ByteArrayOutputStream();
FrameCodec.write(out, hello());
byte[] original = out.toByteArray(), extra = Arrays.copyOf(original, original.length + 1);
java.nio.ByteBuffer.wrap(extra).putInt(original.length - 3);
assertThrows(IOException.class, () -> FrameCodec.read(new ByteArrayInputStream(extra)));
}
@Test
void authenticationRequiresLoopbackTokenSessionAndExpectedWorkerAndCannotRetry()
throws Exception {
var unauthenticated = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertThrows(IllegalStateException.class, () -> render(unauthenticated));
for (int test = 0; test < 4; test++) {
var s = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
var h = hello();
var bad =
new Hello(
test == 1 ? "other" : h.session(),
test == 2 ? 43 : 42,
test == 0 ? "b".repeat(64) : TOKEN,
h.wire(),
h.metadata(),
h.preview(),
CAPS,
Set.of());
InetAddress address =
test == 3
? InetAddress.getByAddress(new byte[] {(byte) 192, 0, 2, 1})
: InetAddress.getLoopbackAddress();
assertThrows(IllegalArgumentException.class, () -> s.authenticate(address, bad));
assertThrows(
IllegalStateException.class,
() -> s.authenticate(InetAddress.getLoopbackAddress(), hello()));
}
assertFalse(hello().toString().contains(TOKEN));
assertNotEquals(ProtocolSession.newToken(), ProtocolSession.newToken());
}
@Test
void versionsAndCapabilitiesGateDiscoveryAndRenderingIndependently() throws Exception {
var h = hello();
var s = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertEquals(
Set.of(Operation.DISCOVER),
s.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
h.wire(),
h.metadata(),
new Version(2, 0),
CAPS,
Set.of())));
assertThrows(IllegalStateException.class, () -> render(s));
var rendering = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertEquals(
Set.of(Operation.RENDER),
rendering.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(), 42, TOKEN, h.wire(), new Version(2, 0), h.preview(), CAPS, Set.of())));
var major = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertThrows(
IllegalArgumentException.class,
() ->
major.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
new Version(2, 0),
h.metadata(),
h.preview(),
CAPS,
Set.of())));
var required = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertThrows(
IllegalArgumentException.class,
() ->
required.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
h.wire(),
h.metadata(),
h.preview(),
CAPS,
Set.of("unknown-required"))));
var optional = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
var extras = new HashSet<>(CAPS);
extras.add("unknown-optional");
assertEquals(
2,
optional
.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
new Version(1, 99),
h.metadata(),
h.preview(),
extras,
Set.of()))
.size());
}
@Test
void requiredRenderingCannotNegotiateAnIncompatiblePreviewFamily() throws Exception {
var h = hello();
var s = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of("render-rgba-v1"));
assertThrows(
IllegalArgumentException.class,
() ->
s.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
h.wire(),
h.metadata(),
new Version(2, 0),
CAPS,
Set.of())));
}
@Test
void resultsMustMatchEveryIdentityAndAreAcceptedOnlyOnce() throws Exception {
var s = session();
var request = render(s);
var id = request.identity();
for (int t = 0; t < 6; t++) {
var wrong =
new Identity(
t == 0 ? "wrong" : id.session(),
t == 1 ? "wrong" : id.request(),
t == 2 ? "wrong" : id.context(),
t == 3 ? "wrong" : id.snapshot(),
t == 4 ? Map.of("View.fxml", 4L) : id.revisions(),
t == 5 ? 1 : 2,
2);
assertFalse(
s.accept(
new Packet(Kind.RESULT, Operation.RENDER, wrong, new byte[t == 5 ? 8 : 16]), NOW));
}
assertTrue(s.accept(response(request), NOW));
assertFalse(s.accept(response(request), NOW));
}
@Test
void cancelTimeoutErrorAndDisconnectRetireRequests() throws Exception {
var s = session();
var cancelled = render(s);
assertEquals(Kind.CANCEL, s.cancel(cancelled.identity().request()).orElseThrow().kind());
assertFalse(s.accept(response(cancelled), NOW));
var timeout = render(s);
assertEquals(1, s.expire(NOW.plusSeconds(5)).size());
assertFalse(s.accept(response(timeout), NOW.plusSeconds(5)));
var failed = render(s);
assertTrue(
s.accept(
new Packet(
Kind.ERROR,
Operation.RENDER,
failed.identity(),
"LOAD_FAILED\nComponent construction failed".getBytes()),
NOW));
assertFalse(s.accept(response(failed), NOW));
s.close();
assertThrows(IllegalStateException.class, () -> render(s));
}
@Test
void pendingRequestsAndTimeoutsAreBounded() throws Exception {
var s = session();
assertThrows(
IllegalArgumentException.class,
() ->
s.request(
Operation.DISCOVER, "c", "b", Map.of(), 0, 0, new byte[0], NOW, Duration.ZERO));
for (int i = 0; i < MAX_PENDING; i++)
s.request(
Operation.DISCOVER, "c", "b", Map.of(), 0, 0, new byte[0], NOW, Duration.ofSeconds(1));
assertThrows(
IllegalStateException.class,
() ->
s.request(
Operation.DISCOVER,
"c",
"b",
Map.of(),
0,
0,
new byte[0],
NOW,
Duration.ofSeconds(1)));
assertEquals(MAX_PENDING, s.expire(NOW.plusSeconds(1)).size());
}
}
@@ -0,0 +1 @@
0000002d4e4658310100000000000173000000013100000001630000000162000000000000000000000000000000027b7d
+21
View File
@@ -11,6 +11,11 @@
<module.java.release>21</module.java.release>
</properties>
<dependencies>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.5.9</version>
</dependency>
<dependency>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-contracts</artifactId>
@@ -24,6 +29,22 @@
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>test-jar</goal>
</goals>
<configuration>
<includes>
<include>**/ConfigurationHostContract.class</include>
</includes>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
@@ -0,0 +1,15 @@
package de.neitzel.fxdesigner.config;
/** A tooling setup failure; never a reason to close or disable a source document. */
public final class ConfigurationException extends RuntimeException {
private final String code;
public ConfigurationException(String code, String message) {
super(message);
this.code = code;
}
public String code() {
return code;
}
}
@@ -0,0 +1,109 @@
package de.neitzel.fxdesigner.config;
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import com.networknt.schema.*;
import de.neitzel.fxdesigner.config.DesignerConfiguration.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Strict, bounded JSON input, validated against the packaged schemas; never resolves user $refs.
*/
public final class ConfigurationFiles {
public static final int MAX_BYTES = 1024 * 1024;
private static final ObjectMapper JSON =
new ObjectMapper(
JsonFactory.builder()
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
.streamReadConstraints(
StreamReadConstraints.builder()
.maxNestingDepth(32)
.maxStringLength(65536)
.build())
.build())
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private ConfigurationFiles() {}
public static JsonNode read(Path file, String schema) {
try (var in = Files.newInputStream(file)) {
byte[] bytes = in.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES)
throw new ConfigurationException("CONFIG_SIZE", "Configuration exceeds 1 MiB");
return validate(bytes, schema);
} catch (IOException e) {
throw new ConfigurationException("CONFIG_IO", "Cannot read configuration: " + file);
}
}
public static JsonNode validate(byte[] bytes, String schema) {
if (bytes.length > MAX_BYTES)
throw new ConfigurationException("CONFIG_SIZE", "Configuration exceeds 1 MiB");
if (!Set.of("configuration", "local-settings").contains(schema))
throw new IllegalArgumentException("Unknown built-in schema");
try (var resource =
ConfigurationFiles.class.getResourceAsStream("/schema/" + schema + "-1.0.json")) {
JsonNode node = JSON.readTree(bytes);
var validator =
JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(resource);
var errors = validator.validate(node);
if (!errors.isEmpty())
throw new ConfigurationException(
"CONFIG_SCHEMA",
errors.stream()
.map(ValidationMessage::getMessage)
.sorted()
.limit(8)
.reduce((a, b) -> a + "; " + b)
.orElse("Invalid configuration"));
return node;
} catch (IOException e) {
throw new ConfigurationException("CONFIG_JSON", "Malformed JSON configuration");
}
}
static List<String> strings(JsonNode node) {
var result = new ArrayList<String>();
node.forEach(n -> result.add(n.asText()));
return List.copyOf(result);
}
public static DesignerConfiguration load(Path file) {
JsonNode root = read(file, "configuration");
var contexts = new TreeMap<String, Context>();
root.get("contexts")
.fields()
.forEachRemaining(
e -> {
var n = e.getValue();
var p = n.get("preview");
var m = n.get("modulePath");
contexts.put(
e.getKey(),
new Context(
n.get("applicationModule").asText(),
strings(n.path("mavenProfiles")),
n.get("launchMode").asText(),
n.get("buildRuntime").asText(),
n.get("targetRuntime").asText(),
n.path("buildJava").asInt(21),
n.path("targetJava").asInt(21),
new HashSet<>(strings(n.path("requiredCapabilities"))),
new Preview(
p.get("sourceSet").asText(),
p.path("module").asText(null),
p.get("provider").asText(),
p.path("defaultExample").asText(null),
strings(p.path("examples"))),
m == null
? null
: new ModulePath(
m.get("entryModule").asText(),
strings(m.path("addReads")),
strings(m.path("addOpens")))));
});
return new DesignerConfiguration("1.0", contexts);
}
}
@@ -0,0 +1,58 @@
package de.neitzel.fxdesigner.config;
import de.neitzel.fxdesigner.host.*;
import java.util.*;
import java.util.concurrent.*;
/** Shared tooling gate. Failing setup reports diagnostics but never mutates host document state. */
public final class ConfigurationGate {
public record Ready(
String context,
HostServices.RuntimeInstallation build,
HostServices.RuntimeInstallation target) {}
private final HostServices host;
public ConfigurationGate(HostServices host) {
this.host = Objects.requireNonNull(host);
}
public CompletionStage<Ready> check(
ContextCatalog catalog, String name, Set<String> capabilities) {
try {
var context = catalog.context(name);
var missing = new TreeSet<>(context.requiredCapabilities());
missing.removeAll(capabilities);
if (!missing.isEmpty())
throw new ConfigurationException(
"CAPABILITY_MISSING", "Unsupported required capabilities: " + missing);
return host.runtimes()
.resolve(context.buildRuntime(), HostServices.RuntimeRole.BUILD)
.thenCombine(
host.runtimes().resolve(context.targetRuntime(), HostServices.RuntimeRole.TARGET),
(build, target) -> {
if (build.javaFeature() < context.buildJava()
|| target.javaFeature() < context.targetJava())
throw new ConfigurationException(
"RUNTIME_VERSION",
"Selected build/target JDK does not satisfy context requirements");
return new Ready(name, build, target);
})
.whenComplete(
(r, e) -> {
if (e != null) report(e);
});
} catch (RuntimeException e) {
report(e);
return CompletableFuture.failedFuture(e);
}
}
private void report(Throwable e) {
while (e instanceof CompletionException && e.getCause() != null) e = e.getCause();
host.diagnostics()
.report(
new HostServices.Diagnostic(
"configuration", HostServices.Severity.ERROR, e.getMessage(), Optional.empty()));
}
}
@@ -0,0 +1,174 @@
package de.neitzel.fxdesigner.config;
import de.neitzel.fxdesigner.config.DesignerConfiguration.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Context selection over an explicit effective Maven reactor model supplied by build integration.
*/
public final class ContextCatalog {
public record MavenModule(Path directory, List<Path> sourceRoots, Set<Path> dependencies) {
public MavenModule {
if (!directory.isAbsolute()
|| sourceRoots.stream().anyMatch(p -> !p.isAbsolute())
|| dependencies.stream().anyMatch(p -> !p.isAbsolute()))
throw failure("REACTOR_PATH", "Effective Maven paths must be absolute");
sourceRoots = List.copyOf(sourceRoots);
dependencies = Set.copyOf(dependencies);
}
}
public record ReactorView(List<String> profiles, Collection<MavenModule> modules) {
public ReactorView {
profiles = List.copyOf(profiles);
modules = List.copyOf(modules);
}
}
public record Selection(Optional<String> selected, List<String> candidates) {
public Selection {
candidates = List.copyOf(candidates);
}
}
private final Path configuration;
private final DesignerConfiguration settings;
private final Map<String, Set<Path>> roots = new TreeMap<>();
/**
* Convenience for a single unprofiled effective reactor. Profiled contexts require explicit
* views.
*/
public ContextCatalog(
Path configuration, DesignerConfiguration settings, Collection<MavenModule> reactor) {
this(configuration, settings, unprofiled(settings, reactor));
}
private static Map<String, ReactorView> unprofiled(
DesignerConfiguration settings, Collection<MavenModule> reactor) {
var views = new HashMap<String, ReactorView>();
settings
.contexts()
.forEach(
(name, c) -> {
if (!c.mavenProfiles().isEmpty())
throw failure(
"REACTOR_PROFILES", "Supply a profile-qualified effective reactor view");
views.put(name, new ReactorView(List.of(), reactor));
});
return views;
}
public ContextCatalog(
Path configuration, DesignerConfiguration settings, Map<String, ReactorView> views) {
this.configuration = real(configuration);
this.settings = settings;
Path root = this.configuration.getParent();
for (var entry : settings.contexts().entrySet()) {
var c = entry.getValue();
var view = views.get(entry.getKey());
if (view == null || !view.profiles().equals(c.mavenProfiles()))
throw failure(
"REACTOR_PROFILES",
"Missing or mismatched effective reactor profiles for " + entry.getKey());
var modules = new HashMap<Path, MavenModule>();
for (var m : view.modules()) {
Path dir = real(m.directory());
if (!dir.startsWith(root) || !Files.isRegularFile(dir.resolve("pom.xml")))
throw failure("REACTOR_MODULE", "Invalid reactor module: " + dir);
if (modules.put(dir, m) != null)
throw failure("REACTOR_MODULE", "Duplicate reactor module");
}
if (!modules.containsKey(root))
throw failure("REACTOR_ROOT", "Configuration must live at the effective reactor root");
Path app = module(root, c.applicationModule(), modules);
var collected = new HashSet<Path>();
collect(app, modules, new HashSet<>(), collected);
if (c.preview().sourceSet().equals("module"))
collect(module(root, c.preview().module(), modules), modules, new HashSet<>(), collected);
validateAccess(c.modulePath());
roots.put(entry.getKey(), Set.copyOf(collected));
}
}
private static void validateAccess(ModulePath m) {
if (m == null) return;
String qualified = "[A-Za-z_$][A-Za-z0-9_$]*(?:[.][A-Za-z_$][A-Za-z0-9_$]*)*";
String targets = "(?:" + qualified + "|ALL-UNNAMED)(?:,(?:" + qualified + "|ALL-UNNAMED))*";
if (!m.entryModule().matches(qualified)) throw failure("MODULE_ACCESS", "Invalid entry module");
for (String s : m.addReads())
if (!s.matches(qualified + "=" + targets))
throw failure("MODULE_ACCESS", "Invalid addReads declaration");
for (String s : m.addOpens())
if (!s.matches(qualified + "/" + qualified + "=" + targets))
throw failure("MODULE_ACCESS", "Invalid addOpens declaration");
}
private static Path module(Path root, String name, Map<Path, MavenModule> modules) {
Path relative = Path.of(name);
if (relative.isAbsolute()) throw failure("MODULE_PATH", "Module path must be relative");
Path p = real(root.resolve(relative));
if (!p.startsWith(root) || !modules.containsKey(p))
throw failure("MODULE_PATH", "Module is not in the effective reactor: " + name);
return p;
}
private static void collect(
Path dir, Map<Path, MavenModule> modules, Set<Path> visited, Set<Path> result) {
if (!visited.add(dir)) return;
var m = modules.get(dir);
if (m == null) throw failure("REACTOR_DEPENDENCY", "Dependency missing from reactor model");
for (Path s : m.sourceRoots()) {
Path p = real(s);
if (!p.startsWith(dir)) throw failure("SOURCE_PATH", "Source root outside module: " + p);
result.add(p);
}
for (Path d : m.dependencies()) collect(real(d), modules, visited, result);
}
private static Path real(Path path) {
try {
return path.toRealPath();
} catch (IOException e) {
throw failure("PATH_MISSING", "Path does not exist: " + path);
}
}
private static ConfigurationException failure(String c, String m) {
return new ConfigurationException(c, m);
}
public Path configuration() {
return configuration;
}
public Context context(String name) {
var c = settings.contexts().get(name);
if (c == null) throw failure("CONTEXT_UNKNOWN", "Unknown context: " + name);
return c;
}
/**
* Inherited context is explicit parent ownership, even for a Component outside the parent's
* roots.
*/
public Selection select(Path document, Optional<String> inherited, Optional<String> remembered) {
real(document);
if (inherited.isPresent()) {
context(inherited.get());
return new Selection(inherited, List.of(inherited.get()));
}
Path file = real(document);
List<String> matches =
roots.entrySet().stream()
.filter(e -> e.getValue().stream().anyMatch(file::startsWith))
.map(Map.Entry::getKey)
.toList();
if (matches.size() == 1) return new Selection(Optional.of(matches.getFirst()), matches);
if (remembered.isPresent() && matches.contains(remembered.get()))
return new Selection(remembered, matches);
return new Selection(Optional.empty(), matches);
}
}
@@ -0,0 +1,103 @@
package de.neitzel.fxdesigner.config;
import de.neitzel.fxdesigner.host.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
/** Machine-local mappings. Probes JDK release metadata without executing application code. */
public final class LocalSettings implements HostServices.Runtimes {
private final Map<ConfiguredRuntimes.Key, Path> homes;
private final Map<String, String> remembered;
private LocalSettings(Map<ConfiguredRuntimes.Key, Path> homes, Map<String, String> remembered) {
this.homes = Map.copyOf(homes);
this.remembered = Map.copyOf(remembered);
}
public static LocalSettings load(Path file) {
var root = ConfigurationFiles.read(file, "local-settings");
var homes = new HashMap<ConfiguredRuntimes.Key, Path>();
root.get("runtimes")
.fields()
.forEachRemaining(
e -> {
for (var role : HostServices.RuntimeRole.values()) {
var value = e.getValue().get(role.name().toLowerCase(Locale.ROOT));
if (value != null) {
Path p = Path.of(value.asText());
if (!p.isAbsolute())
throw new ConfigurationException(
"RUNTIME_PATH", "Local JDK paths must be absolute");
homes.put(new ConfiguredRuntimes.Key(e.getKey(), role), p);
}
}
});
var remembered = new HashMap<String, String>();
root.path("rememberedContexts")
.fields()
.forEachRemaining(e -> remembered.put(e.getKey(), e.getValue().asText()));
return new LocalSettings(homes, remembered);
}
public Optional<String> remembered(Path configuration) {
try {
return Optional.ofNullable(remembered.get(configuration.toRealPath().toUri().toString()));
} catch (IOException e) {
return Optional.empty();
}
}
/** Atomically persist a choice locally; callers reload settings after this explicit action. */
public static void remember(Path settingsFile, Path configuration, String context)
throws IOException {
if (!context.matches("[A-Za-z_][A-Za-z0-9_.-]{0,127}"))
throw new IllegalArgumentException("Invalid context name");
var root =
(com.fasterxml.jackson.databind.node.ObjectNode)
ConfigurationFiles.read(settingsFile, "local-settings");
var choices = root.withObject("/rememberedContexts");
choices.put(configuration.toRealPath().toUri().toString(), context);
byte[] bytes = root.toPrettyString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
ConfigurationFiles.validate(bytes, "local-settings");
Path file = settingsFile.toRealPath();
Path temporary = Files.createTempFile(file.getParent(), "designer-settings-", ".json");
try {
Files.write(temporary, bytes);
if (Files.getFileAttributeView(file, java.nio.file.attribute.PosixFileAttributeView.class)
!= null) Files.setPosixFilePermissions(temporary, Files.getPosixFilePermissions(file));
Files.move(
temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(temporary);
}
}
public CompletionStage<HostServices.RuntimeInstallation> resolve(
String name, HostServices.RuntimeRole role) {
return CompletableFuture.supplyAsync(
() -> {
Path home = homes.get(new ConfiguredRuntimes.Key(name, role));
if (home == null)
throw new ConfigurationException(
"RUNTIME_MISSING", "Configure " + role + " runtime: " + name);
try {
home = home.toRealPath();
String suffix = System.getProperty("os.name").startsWith("Windows") ? ".exe" : "";
if (!Files.isExecutable(home.resolve("bin/java" + suffix))
|| !Files.isExecutable(home.resolve("bin/javac" + suffix))) throw new IOException();
Properties release = new Properties();
try (var reader = Files.newBufferedReader(home.resolve("release"))) {
release.load(reader);
}
String version = release.getProperty("JAVA_VERSION", "").replace("\"", "");
int feature = Runtime.Version.parse(version).feature();
return new HostServices.RuntimeInstallation(home, feature);
} catch (IOException | IllegalArgumentException e) {
throw new ConfigurationException(
"RUNTIME_INVALID", "Invalid JDK installation for " + role + ": " + name);
}
});
}
}
@@ -0,0 +1,58 @@
package de.neitzel.fxdesigner.config;
import de.neitzel.fxdesigner.contracts.*;
import de.neitzel.fxdesigner.host.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
/** Same behavioral contract executed with real standalone and IntelliJ document stores. */
public final class ConfigurationHostContract {
public static final String VALID =
"""
{"schemaVersion":"1.0","contexts":{"desktop":{"applicationModule":".","launchMode":"classpath","buildRuntime":"build","targetRuntime":"target","preview":{"sourceSet":"test","provider":"fixtures.Examples"}}}}
""";
public static ContextCatalog fixture(Path root) throws Exception {
Files.createDirectories(root.resolve("src"));
Files.writeString(root.resolve("pom.xml"), "<project/>");
Files.writeString(root.resolve("src/View.fxml"), "original");
Path file = root.resolve("neitzel-fx-designer.json");
Files.writeString(file, VALID);
return new ContextCatalog(
file,
ConfigurationFiles.load(file),
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of())));
}
public static void exercise(HostServices host, ContextCatalog catalog) throws Exception {
Path file = catalog.configuration().getParent().resolve("src/View.fxml");
var document =
host.documents().open(file.toUri()).toCompletableFuture().get(10, TimeUnit.SECONDS);
try {
var choice = catalog.select(file, Optional.empty(), Optional.empty());
if (!choice.selected().equals(Optional.of("desktop")))
throw new AssertionError("Context not selected");
var gate = new ConfigurationGate(host);
try {
gate.check(catalog, "desktop", Set.of()).toCompletableFuture().get(10, TimeUnit.SECONDS);
throw new AssertionError("Missing runtime accepted");
} catch (ExecutionException expected) {
}
var draft = document.snapshot().toCompletableFuture().get(10, TimeUnit.SECONDS);
var changed =
document
.apply(
new EditBatch(
draft.revision(),
"Edit despite setup failure",
List.of(new EditBatch.Replacement(0, 8, "editable"))))
.toCompletableFuture()
.get(10, TimeUnit.SECONDS);
if (!changed.writable() || !changed.text().equals("editable"))
throw new AssertionError("Configuration failure disabled source editing");
} finally {
document.close();
}
}
}
@@ -0,0 +1,323 @@
package de.neitzel.fxdesigner.config;
import static org.junit.jupiter.api.Assertions.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import de.neitzel.fxdesigner.host.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class ConfigurationTest {
@TempDir Path root;
private final ObjectMapper json = new ObjectMapper();
private ObjectNode valid() throws Exception {
return (ObjectNode) json.readTree(ConfigurationHostContract.VALID);
}
private ObjectNode context(ObjectNode n) {
return (ObjectNode) n.path("contexts").path("desktop");
}
private void validate(ObjectNode n) {
ConfigurationFiles.validate(
n.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8), "configuration");
}
@Test
void packagedFixturesValidate() throws Exception {
for (String name : List.of("classpath-module", "module-path-test")) {
try (var in = getClass().getResourceAsStream("/configuration/" + name + ".json")) {
assertNotNull(in);
ConfigurationFiles.validate(in.readAllBytes(), "configuration");
}
}
}
@Test
void strictSchemaRejectsVersionsUnknownFieldsMissingConditionalFieldsAndDuplicateKeys()
throws Exception {
validate(valid());
var n = valid();
n.put("schemaVersion", "2.0");
assertThrows(ConfigurationException.class, () -> validate(n));
var unknown = valid();
context(unknown).put("typo", true);
assertThrows(ConfigurationException.class, () -> validate(unknown));
var module = valid();
context(module).put("launchMode", "module-path");
assertThrows(ConfigurationException.class, () -> validate(module));
context(module).putObject("modulePath").put("entryModule", "app");
validate(module);
var preview = valid();
((ObjectNode) context(preview).get("preview")).put("sourceSet", "module");
assertThrows(ConfigurationException.class, () -> validate(preview));
((ObjectNode) context(preview).get("preview")).put("module", "preview");
validate(preview);
assertThrows(
ConfigurationException.class,
() ->
ConfigurationFiles.validate(
"{\"schemaVersion\":\"1.0\",\"schemaVersion\":\"1.0\"}".getBytes(),
"configuration"));
assertThrows(
ConfigurationException.class,
() ->
ConfigurationFiles.validate(
(ConfigurationHostContract.VALID + " {}").getBytes(), "configuration"));
assertThrows(
ConfigurationException.class,
() ->
ConfigurationFiles.validate(
new byte[ConfigurationFiles.MAX_BYTES + 1], "configuration"));
}
@Test
void emptyNullAndOverNestedJsonFailAsConfigurationErrors() {
for (String value : List.of("", "null", "[".repeat(40) + "0" + "]".repeat(40)))
assertThrows(
ConfigurationException.class,
() -> ConfigurationFiles.validate(value.getBytes(), "configuration"));
assertThrows(
ConfigurationException.class,
() -> new ContextCatalog.MavenModule(Path.of("relative"), List.of(), Set.of()));
}
@Test
void selectAmbiguousRememberedInheritedAndNoMatch() throws Exception {
var base = ConfigurationHostContract.fixture(root);
var n = valid();
((ObjectNode) n.get("contexts")).set("other", context(n).deepCopy());
Files.writeString(base.configuration(), n.toString());
var catalog =
new ContextCatalog(
base.configuration(),
ConfigurationFiles.load(base.configuration()),
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of())));
Path view = root.resolve("src/View.fxml");
assertEquals(2, catalog.select(view, Optional.empty(), Optional.empty()).candidates().size());
assertTrue(catalog.select(view, Optional.empty(), Optional.empty()).selected().isEmpty());
assertEquals(
"other",
catalog.select(view, Optional.empty(), Optional.of("other")).selected().orElseThrow());
assertTrue(catalog.select(view, Optional.empty(), Optional.of("removed")).selected().isEmpty());
Path external = Files.writeString(root.resolve("Component.fxml"), "component");
assertTrue(catalog.select(external, Optional.empty(), Optional.empty()).candidates().isEmpty());
assertEquals(
"desktop",
catalog
.select(external, Optional.of("desktop"), Optional.of("other"))
.selected()
.orElseThrow());
assertThrows(
ConfigurationException.class,
() -> catalog.select(view, Optional.of("removed"), Optional.empty()));
}
@Test
void modulePathsMustBelongToEffectiveReactorIncludingSymlinkEscapes() throws Exception {
var base = ConfigurationHostContract.fixture(root);
Path fake = Files.createDirectories(root.resolve("unlisted"));
Files.writeString(fake.resolve("pom.xml"), "<project/>");
var n = valid();
context(n).put("applicationModule", "unlisted");
Files.writeString(base.configuration(), n.toString());
var model =
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of()));
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model));
context(n).put("applicationModule", "missing");
Files.writeString(base.configuration(), n.toString());
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model));
context(n).put("applicationModule", root.toString());
Files.writeString(base.configuration(), n.toString());
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model));
Path outside = Files.createTempDirectory("designer-outside-");
try {
Files.createSymbolicLink(root.resolve("escape"), outside);
context(n).put("applicationModule", "escape");
Files.writeString(base.configuration(), n.toString());
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model));
} finally {
Files.deleteIfExists(outside);
}
}
@Test
void dependencySourceRootsAndPreviewModuleParticipateInMatching() throws Exception {
var base = ConfigurationHostContract.fixture(root);
Path app = Files.createDirectories(root.resolve("app")),
shared = Files.createDirectories(root.resolve("shared")),
preview = Files.createDirectories(root.resolve("preview"));
for (Path p : List.of(app, shared, preview)) {
Files.writeString(p.resolve("pom.xml"), "<project/>");
Files.createDirectories(p.resolve("src"));
}
var n = valid();
context(n).put("applicationModule", "app");
var p = (ObjectNode) context(n).get("preview");
p.put("sourceSet", "module");
p.put("module", "preview");
Files.writeString(base.configuration(), n.toString());
var model =
List.of(
new ContextCatalog.MavenModule(root, List.of(), Set.of()),
new ContextCatalog.MavenModule(app, List.of(app.resolve("src")), Set.of(shared)),
new ContextCatalog.MavenModule(shared, List.of(shared.resolve("src")), Set.of()),
new ContextCatalog.MavenModule(preview, List.of(preview.resolve("src")), Set.of()));
var catalog =
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model);
Path component = Files.writeString(shared.resolve("src/Card.fxml"), "component");
assertEquals(
"desktop",
catalog.select(component, Optional.empty(), Optional.empty()).selected().orElseThrow());
}
@Test
void profileSpecificReactorViewsAndExplicitModuleAccessAreRequired() throws Exception {
var base = ConfigurationHostContract.fixture(root);
var n = valid();
context(n).putArray("mavenProfiles").add("preview");
context(n).put("launchMode", "module-path");
var access = context(n).putObject("modulePath").put("entryModule", "example.app");
access.putArray("addOpens").add("example.app/example.model=ALL-UNNAMED");
Files.writeString(base.configuration(), n.toString());
var config = ConfigurationFiles.load(base.configuration());
var modules =
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of()));
assertThrows(
ConfigurationException.class,
() -> new ContextCatalog(base.configuration(), config, modules));
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(),
config,
Map.of("desktop", new ContextCatalog.ReactorView(List.of("wrong"), modules))));
assertNotNull(
new ContextCatalog(
base.configuration(),
config,
Map.of("desktop", new ContextCatalog.ReactorView(List.of("preview"), modules))));
access.putArray("addOpens").add("--add-opens=anything");
Files.writeString(base.configuration(), n.toString());
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(),
ConfigurationFiles.load(base.configuration()),
Map.of("desktop", new ContextCatalog.ReactorView(List.of("preview"), modules))));
}
@Test
void localMappingsVerifyInstallationsRolesAndPreserveRememberedChoice() throws Exception {
Files.writeString(root.resolve("neitzel-fx-designer.json"), ConfigurationHostContract.VALID);
Path home = Path.of(System.getProperty("java.home"));
Path local = root.resolve("local.json");
var n = json.createObjectNode().put("schemaVersion", "1.0");
n.putObject("runtimes").putObject("java").put("build", home.toString());
n.putObject("rememberedContexts")
.put(root.resolve("neitzel-fx-designer.json").toRealPath().toUri().toString(), "desktop");
Files.writeString(local, n.toString());
var settings = LocalSettings.load(local);
assertEquals(
Runtime.version().feature(),
settings
.resolve("java", HostServices.RuntimeRole.BUILD)
.toCompletableFuture()
.get()
.javaFeature());
assertThrows(
ExecutionException.class,
() ->
settings.resolve("java", HostServices.RuntimeRole.TARGET).toCompletableFuture().get());
assertEquals(
"desktop", settings.remembered(root.resolve("neitzel-fx-designer.json")).orElseThrow());
LocalSettings.remember(local, root.resolve("neitzel-fx-designer.json"), "other");
assertEquals(
"other",
LocalSettings.load(local)
.remembered(root.resolve("neitzel-fx-designer.json").toRealPath())
.orElseThrow());
((ObjectNode) n.path("runtimes").path("java")).put("target", root.toString());
Files.writeString(local, n.toString());
assertThrows(
ExecutionException.class,
() ->
LocalSettings.load(local)
.resolve("java", HostServices.RuntimeRole.TARGET)
.toCompletableFuture()
.get());
((ObjectNode) n.path("runtimes").path("java")).put("target", "relative");
Files.writeString(local, n.toString());
assertThrows(ConfigurationException.class, () -> LocalSettings.load(local));
}
@Test
void gateReportsCapabilitiesAndRuntimeRequirementsSeparatelyWithoutFallback() throws Exception {
var catalog = ConfigurationHostContract.fixture(root);
var n = valid();
context(n).put("targetJava", 99);
Files.writeString(catalog.configuration(), n.toString());
catalog =
new ContextCatalog(
catalog.configuration(),
ConfigurationFiles.load(catalog.configuration()),
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of())));
var diagnostics = new ArrayList<HostServices.Diagnostic>();
var install =
new HostServices.RuntimeInstallation(Path.of(System.getProperty("java.home")), 25);
var host =
new HostServices(
uri ->
CompletableFuture.failedFuture(
new AssertionError("Documents must not be accessed")),
(r, o) -> {
throw new AssertionError("Build must not run");
},
(name, role) -> CompletableFuture.completedFuture(install),
location -> CompletableFuture.completedFuture(null),
diagnostics::add,
new SessionScope());
var gate = new ConfigurationGate(host);
var checked = catalog;
assertThrows(
ExecutionException.class,
() -> gate.check(checked, "desktop", Set.of()).toCompletableFuture().get());
assertEquals(1, diagnostics.size());
context(n).put("targetJava", 21);
context(n).putArray("requiredCapabilities").add("unknown");
Files.writeString(catalog.configuration(), n.toString());
var capCatalog =
new ContextCatalog(
catalog.configuration(),
ConfigurationFiles.load(catalog.configuration()),
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of())));
assertThrows(
ExecutionException.class,
() -> gate.check(capCatalog, "desktop", Set.of()).toCompletableFuture().get());
assertNotNull(gate.check(capCatalog, "desktop", Set.of("unknown")).toCompletableFuture().get());
}
}
@@ -0,0 +1,26 @@
{
"schemaVersion": "1.0",
"contexts": {
"desktop": {
"applicationModule": "app",
"mavenProfiles": [
"designer-preview"
],
"launchMode": "classpath",
"buildRuntime": "build-java",
"targetRuntime": "application-java",
"buildJava": 25,
"targetJava": 21,
"requiredCapabilities": [
"discover-v1",
"render-rgba-v1"
],
"preview": {
"sourceSet": "module",
"module": "preview",
"provider": "example.preview.PersonExamples",
"defaultExample": "person.standard"
}
}
}
}
@@ -0,0 +1,33 @@
{
"schemaVersion": "1.0",
"contexts": {
"desktop": {
"applicationModule": "app",
"mavenProfiles": [
"designer-preview"
],
"launchMode": "module-path",
"buildRuntime": "build-java",
"targetRuntime": "application-java",
"buildJava": 25,
"targetJava": 21,
"requiredCapabilities": [
"discover-v1",
"render-rgba-v1"
],
"preview": {
"sourceSet": "test",
"provider": "example.preview.PersonExamples"
},
"modulePath": {
"entryModule": "example.app",
"addReads": [
"example.app=example.preview"
],
"addOpens": [
"example.app/example.model=ALL-UNNAMED"
]
}
}
}
}
+1
View File
@@ -24,6 +24,7 @@ dependencies {
runtimeOnly("org.openjfx:javafx-$it:26:$fxPlatform")
}
testImplementation("junit:junit:4.13.2")
testImplementation("de.neitzel.fxdesigner:designer-core:0.1.0-SNAPSHOT:tests")
}
intellijPlatform {
pluginConfiguration {
@@ -0,0 +1,26 @@
package de.neitzel.fxdesigner.intellij;
import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess;
import com.intellij.testFramework.fixtures.BasePlatformTestCase;
import de.neitzel.fxdesigner.config.*;
import de.neitzel.fxdesigner.host.*;
import java.nio.file.*;
import java.util.*;
public final class ConfigurationIntegrationTest extends BasePlatformTestCase {
public void testConfigurationFailureKeepsRealIdeDocumentEditable() throws Exception {
Path root = Files.createTempDirectory("designer-config-").toRealPath();
VfsRootAccess.allowRootAccess(getTestRootDisposable(), root.toString());
var catalog = ConfigurationHostContract.fixture(root);
var diagnostics = new ArrayList<HostServices.Diagnostic>();
try (var host =
new IntellijHost(getProject(), new ConfiguredRuntimes(Map.of()), diagnostics::add)) {
ConfigurationHostContract.exercise(host.services(), catalog);
assertFalse(diagnostics.isEmpty());
} finally {
try (var files = Files.walk(root)) {
for (Path p : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(p);
}
}
}
}
+7
View File
@@ -11,6 +11,13 @@
<module.java.release>25</module.java.release>
</properties>
<dependencies>
<dependency>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-core</artifactId>
<version>${project.version}</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-kit-ui</artifactId>
@@ -0,0 +1,27 @@
package de.neitzel.fxdesigner.standalone;
import de.neitzel.fxdesigner.config.*;
import de.neitzel.fxdesigner.host.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class ConfigurationIntegrationTest {
@TempDir Path root;
@Test
void configurationFailureKeepsRealStandaloneDocumentEditable() throws Exception {
var catalog = ConfigurationHostContract.fixture(root);
var diagnostics = new ArrayList<HostServices.Diagnostic>();
try (var host =
new StandaloneHost(
new ConfiguredRuntimes(Map.of()),
l -> CompletableFuture.completedFuture(null),
diagnostics::add)) {
ConfigurationHostContract.exercise(host.services(), catalog);
Assertions.assertFalse(diagnostics.isEmpty());
}
}
}
+100
View File
@@ -0,0 +1,100 @@
# Executable configuration and compatibility
Bootstrap work package 4 supplies the packaged schemas, shared configuration/context/runtime implementation, and a draft wire codec/session validator. Both host adapters use the same `HostServices` interface; the same configuration-failure/editing contract runs against standalone files and real IntelliJ Documents. No configuration check changes document writability or closes a draft.
## Configuration 1.0
The authoritative [application schema](../designer-contracts/src/main/resources/schema/configuration-1.0.json) and [local settings schema](../designer-contracts/src/main/resources/schema/local-settings-1.0.json) use JSON Schema Draft 2020-12. They ship in contracts without a validator dependency. Core executes them using networknt json-schema-validator 1.5.9. The [upstream validation interface](https://github.com/networknt/json-schema-validator/tree/1.5.9#usage) is the basis for that integration.
`ConfigurationFiles.load` reads `neitzel-fx-designer.json` at the effective Maven reactor root. Inputs are capped at 1 MiB and 32 nesting levels. Duplicate keys, trailing JSON values, unknown fields, missing required fields and versions other than `1.0` are rejected. Validation uses only bundled schemas, never schemas or references supplied by application JSON. No automatic migration occurs.
Named contexts require `applicationModule`, `launchMode`, `buildRuntime`, `targetRuntime` and `preview`. `mavenProfiles` and `requiredCapabilities` default to empty lists; `buildJava` and `targetJava` default to minimum feature 21. `preview.sourceSet` is either `module` (requiring a reactor-relative `module`) or `test` (using the application module, forbidding that extra field). A provider name and optional example IDs are metadata only: validation does not load classes or construct Models. Public provider/session signatures remain work package 6.
`module-path` requires `modulePath.entryModule` and permits explicit `addReads` and `addOpens` declarations. `classpath` forbids the module-path block. These are structured declarations, not an arbitrary JVM argument list. Actual module existence/access is validated with the target fixtures later; this step validates declaration syntax and does not silently add opens.
Validated examples cover [classpath with a preview module](../designer-core/src/test/resources/configuration/classpath-module.json) and [module-path with test previews](../designer-core/src/test/resources/configuration/module-path-test.json).
## Effective reactor and context selection
`ContextCatalog` consumes `ReactorView` values keyed by context. Each view carries the requested Maven profile list and the effective reactor's modules, source/resource roots and reactor dependency relationships. Profile lists must match exactly. The convenience collection constructor is for unprofiled contexts only. Obtaining these effective models from Maven belongs to Build and Refresh (work package 5); this implementation does not parse raw POMs and guess profile inheritance.
All module selections resolve relative to the configuration's canonical directory. Modules must exist, contain a POM and belong to the supplied reactor. Traversal/symlink escapes are rejected. Source roots and dependencies are validated, and matching includes the application's reactor dependency closure plus its preview module. The current model requires existing roots inside their module directories; externally generated/out-of-reactor roots need an explicit later support decision.
Selection returns a selected name plus candidates:
- One matching context selects automatically.
- Several matches return a choice unless a still-matching local remembered selection exists.
- No match returns an empty selection for setup; no arbitrary default is chosen.
- An explicit inherited parent context takes precedence for an opened Component, including a Component outside ordinary matching roots. Unknown inherited names fail.
Context choice does not create another draft. Both hosts retain their existing one-document ownership semantics.
## Local runtime mappings
Local settings remain outside shared application configuration. Example (paths are illustrative):
```json
{
"schemaVersion": "1.0",
"runtimes": {
"build-java": { "build": "/local/jdk-25/Contents/Home" },
"application-java": { "target": "/local/jdk-21/Contents/Home" }
},
"rememberedContexts": {}
}
```
`LocalSettings.load` requires absolute installation paths. Resolution runs off the UI thread, checks executable `bin/java` and `bin/javac`, and reads the JDK's `release` metadata. Build and target roles have separate mappings, even if they share a logical name or installation. Missing, invalid or too-old runtimes fail explicitly; the designer JVM is never substituted. This checks installation metadata, not a cryptographic/runtime-launch identity; target startup checks remain required.
`LocalSettings.remember` atomically saves an explicitly chosen context under the canonical configuration file URI and preserves other settings. Reload to obtain the new immutable mapping. It is a single-writer settings operation, not the future coordinated host-handoff protocol.
`ConfigurationGate.check` checks required capabilities and each runtime's minimum version, returns the resolved pair, and reports errors through host diagnostics. Parsing/catalog construction also expose stable `ConfigurationException.code()` values (`CONFIG_*`, `REACTOR_*`, `MODULE_*`, `PATH_MISSING`, `SOURCE_PATH`, `CONTEXT_UNKNOWN`, `RUNTIME_*`, `CAPABILITY_MISSING`). Callers display these tooling failures while retaining normal source editing. The editor/setup presentation is work package 7.
## Draft wire protocol 1.0
`FrameCodec` and `ProtocolSession` are executable framing and host-side negotiation/correlation rules. They deliberately contain no socket launcher or UI dependency. Schema, wire, metadata and preview compatibility are independent; a product version is not a compatibility test. Version 1 here is a bootstrap wire/metadata capability family, not a released Java preview API.
The transport implementation must bind a dedicated loopback socket, associate it with the launched worker handle, keep stdout/stderr as logs, and apply bounded handshake/read deadlines. It must close the connection on malformed frames or authentication/negotiation failure. The reserved owner-lifetime channel remains independent of FX responsiveness. Socket lifecycle, worker launch and owner-death cleanup are not implemented in this step.
### Framing
All integers are signed, big-endian: 32 bits except worker PID and document revisions (64 bits). Counts must be nonnegative. Each frame is a 32-bit byte length **excluding that prefix**, followed by magic `0x4e465831` (`NFX1`), a one-byte message type and these fields:
| Type | Fields in wire order |
| --- | --- |
| 0 HELLO | session text, worker PID, token text, wire version, metadata version, preview version, advertised capability set, required capability set |
| 1 PACKET | kind byte, operation byte, session/request/context/snapshot texts, revision-map count and text-key/int64 pairs, width, height, payload length, payload bytes |
| 2 WELCOME | session text, selected wire version, operation-name set |
A version is two int32 values (major, minor). Text is an int32 UTF-8 byte length then strict UTF-8 bytes. A set is an int32 count then unique texts. Packet kinds are REQUEST=0, RESULT=1, ERROR=2, CANCEL=3. Operations are DISCOVER=0 and RENDER=1. Unknown discriminators, negative versions/revisions, duplicate map/set entries, invalid UTF-8, truncation and trailing bytes fail. A fixed independently encoded [wire fixture](../designer-contracts/src/test/resources/protocol/discovery-request.hex) locks down byte order and field order.
Limits are checked before allocating nested fields:
| Resource | Limit |
| --- | --- |
| Entire frame body | 17 MiB |
| Individual text field | 4 KiB UTF-8 |
| Metadata payload | 1 MiB |
| Raw image payload | 16 MiB |
| Image dimensions | 1–8192 each, additionally width × height × 4 ≤ 16 MiB |
| Revisions or capability set | 128 entries |
| Pending requests per session | 128 |
| Request timeout | Positive, at most five minutes |
RENDER results carry exactly width × height × 4 bytes, row-major RGBA8 (top-left origin, straight alpha). There is no compressed image decoder in this protocol. Other payloads are strict UTF-8; discovery/request metadata semantics are intentionally reserved for the real target adapter. ERROR payloads are `CODE` + newline + human detail, using `INVALID_REQUEST`, `LOAD_FAILED`, `TIMEOUT`, `CANCELLED` or `INTERNAL_ERROR`. CANCEL has no payload. Log text cannot be accepted as a frame.
### Negotiation and request lifetime
The launcher generates 32 cryptographically random bytes encoded as 64 lowercase hex characters, unique per launch. Worker HELLO must match the expected session, launched PID and token over a loopback connection. Token comparison is constant-time for equal-length credentials; HELLO's string representation redacts credentials. Credentials must not enter logs. Authentication is single-attempt; failure closes the validator state.
Wire major 1 is required; additive peer minor versions negotiate down to 1.0 in WELCOME. Unknown optional capabilities are ignored; unmet required capabilities reject negotiation. `discover-v1` additionally requires metadata major 1. `render-rgba-v1` additionally requires preview major 1. These operations are enabled independently, so an incompatible preview family can leave discovery usable. The host emits WELCOME after authentication; workers must wait for it before processing requests.
Each host-created request has a session-unique increasing ID, context and immutable snapshot identity. Renders also identify document revisions and requested dimensions. A result/error is accepted once only when **every** identity field and operation match a live pending request. Unrelated or stale replies return false. On any input, context, snapshot or size change, the caller cancels the old request before issuing its replacement; this explicit step invalidates the old result. Cancel, timeout, error and close retire pending requests. `expire(now)` returns cancellation packets for transport delivery; timer ownership and actual worker interruption belong to the future transport. Request/accept also retire expired entries defensively but do not themselves write to a socket.
## Validation and remaining scope
The standard bootstrap command runs the configuration/schema/context/runtime tests, framing/negotiation/correlation tests, and the shared setup-failure/editing test in both hosts. Existing bytecode, artifact identity, dependency isolation and plugin packaging gates remain in force. The IDE compatibility restriction warning documented in the previous step is unchanged.
Verified on 2026-11-12: **33 tests passed** (30 Maven, three IntelliJ), with zero failures, errors or skipped tests. The full clean bootstrap check also passed Java 21 target linkage, bytecode/dependency checks, plugin packaging and documentation validation. No socket/worker or rendered UI qualification is implied.
Next: scoped Maven build planning and immutable successful-build snapshots. Real provider discovery, metadata payload schemas, target rendering, socket integration, setup UI, OS qualification and the first connected two-host workflow retain their later gates.
+4 -4
View File
@@ -1,6 +1,6 @@
# NeitzelFXDesigner initial project contract
Status: accepted architecture consolidated from decision rounds Q1–Q15 on 2026-11-12. Module and JSON field spellings below are proposed implementation names; public Java signatures and numerical protocol limits must pass their bootstrap validation before publication. This document refines roadmap stage 1, not the complete product specification.
Status: accepted architecture consolidated from decision rounds Q1–Q15 on 2026-11-12. Module names and configuration 1.0 fields are implemented by bootstrap work packages 3–4. Public preview Java signatures remain provisional; the draft wire codec and limits now have regression coverage. This document refines roadmap stage 1, not the complete product specification.
Decision provenance: [accepted workspace decisions](provenance.md). Execution order: [repository bootstrap plan](repository-bootstrap-plan.md). Existing authoring, preview, binding, generation and host requirements remain in the [broader roadmap context](provenance.md#broader-roadmap).
@@ -52,7 +52,7 @@ Each host owns its own engine/UI instances. The authoritative document and undo
`neitzel-fx-designer.json` lives at the application's Maven reactor root. It contains a schema version and named application contexts. A context selects an application module, Maven profiles, classpath/module-path launch mode, preview sources/module and examples. Relative paths resolve from the configuration location, never the process working directory. Validate modules and paths against the selected reactor. Obtain dependency graphs and ordinary source/resource roots from Maven rather than maintaining competing lists.
Illustrative shape for the first schema (field names are a concrete draft, not a released schema):
Configuration 1.0 is now executable; see the [schemas and validation contract](configuration-and-protocol.md). The following example is valid input, with Java requirements defaulting to 21:
```json
{
@@ -75,7 +75,7 @@ Illustrative shape for the first schema (field names are a concrete draft, not a
}
```
The schema must also cover `test` preview sources in the application module, module-path entry/access settings and runtime requirements. Example IDs belong to provider descriptors; configuration selects a default and may select/filter descriptors rather than duplicating their metadata. A provider class name identifies a typed implementation, not an arbitrary reflective factory method. Provider registration/descriptor extraction must be validated against the library before the public API is frozen.
The schema covers `test` preview sources in the application module, module-path entry/access settings and runtime requirements. Example IDs belong to provider descriptors; configuration selects a default and may select/filter descriptors rather than duplicating their metadata. A provider class name identifies a typed implementation, not an arbitrary reflective factory method. Provider registration/descriptor extraction must be validated against the library before the public API is frozen.
Select the context automatically when exactly one matches. Otherwise ask, show the active context and remember the choice locally. Opening a Component from a parent inherits that context. Shared source documents retain one host-owned draft even when usable in multiple contexts.
@@ -107,7 +107,7 @@ A parent rendering snapshot shares its Model graph across embedded Components; s
Track configuration schema, wire/metadata contracts and preview API compatibility independently from product releases. Major changes represent breaking contracts; additive features use declared optional capabilities. Equal product versions alone are not proof of compatibility. Verify discovery and rendering independently so one unavailable capability does not unnecessarily disable the other.
Use a dedicated loopback-only socket with a per-launch random token and version/capability handshake for worker messages. Standard output/error remain captured application logs and cannot carry protocol framing. Do not log tokens. Bind the session to the launched worker and reject messages before authentication/negotiation. Use explicit bounded framing, validate lengths and resource limits before allocation, correlate requests/replies and define cancellation/error outcomes. Exact encoding and limits are bootstrap protocol-specification tasks, not facts established by the prototype.
Use a dedicated loopback-only socket with a per-launch random token and version/capability handshake for worker messages. Standard output/error remain captured application logs and cannot carry protocol framing. Do not log tokens. Bind the session to the launched worker and reject messages before authentication/negotiation. Use explicit bounded framing, validate lengths and resource limits before allocation, correlate requests/replies and define cancellation/error outcomes. Encoding, limits and negotiation are now defined by the [executable draft wire contract](configuration-and-protocol.md#draft-wire-protocol-10); transport integration remains later work.
Every applicable request/result identifies its worker session, request, context and build snapshot; rendering also identifies document revisions and requested size. Obsolete results cannot mutate current document/UI state. Diagnostics identify operation and available source origin while preserving editing.
+2 -2
View File
@@ -8,7 +8,7 @@ Work package 3 implements a buildable service foundation. It does not register a
| --- | --- | --- |
| designer-contracts | 21 | Immutable draft and edit DTOs; no framework dependencies |
| designer-preview-api | 21 | Provisional example descriptor; no framework dependencies |
| designer-core | 21 | Contracts; host interfaces, runtime mappings, process builds, lifecycle scope |
| designer-core | 21 | Contracts; schema validation, context selection, host interfaces, runtime mappings, process builds, lifecycle scope |
| designer-kit-ui | 25 | Core and Scene Builder Kit 26.0.0 (JavaFX 26); FX-thread construction seam |
| designer-standalone | 25 | Shared Kit UI; file-backed drafts and undo adapter |
| designer-target-tools | 21 | Contracts, preview API, recorded NeitzelLib snapshot; linkage smoke entry point |
@@ -43,7 +43,7 @@ Run the single command documented in [README](../README.md). Shared modules use
Tests exercise atomic multi-range edits, stale and invalid requests, independent handles, standalone/native IDE undo and redo, disk conflicts, separate runtime roles, process output/cancellation and scope cleanup. The Java 21 target smoke checks linkage to the recorded library, contracts and preview descriptor; it does not initialize a Model or JavaFX rendering. Plugin ZIP creation and project configuration verification do not replace Plugin Verifier, manual IDE interaction, accessibility, or JavaFX exclusivity qualification.
The next work package makes configuration/context resolution and compatibility executable. The first full two-host workflow remains work package 7.
Configuration/context resolution and compatibility are now [implemented](configuration-and-protocol.md). The first full two-host workflow remains work package 7.
Validation on 2026-11-12: eight Maven tests and two native IntelliJ tests passed, Java 21 linkage passed, and the clean plugin build passed. A deliberately injected JavaFX dependency in contracts was rejected by Maven Enforcer and then removed. The plugin archive is checked for host JavaFX implementation JARs and exclusion of target artifacts. Gradle needs explicit macOS classifier dependencies because it does not select Maven's OpenJFX platform profiles. Other OS packaging is not qualified yet.
+3 -1
View File
@@ -1,6 +1,6 @@
# NeitzelFXDesigner repository bootstrap plan
Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work packages 4–7 remain planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release.
Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work package 4 implements configuration and protocol validation; work packages 5–7 remain planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release.
## Starting-point evidence before bootstrap
@@ -58,6 +58,8 @@ Build shared libraries/fixtures using the Maven structure; verify and pin the ac
### 4. Make configuration and compatibility executable
Implemented: packaged schemas, profile-qualified effective-reactor context selection, local runtime mappings, shared compatibility gating, bounded wire codec and authentication/correlation state. See [implementation and validation](configuration-and-protocol.md). Effective Maven model acquisition, worker transport and setup UI retain their later implementation gates.
Implement JSON schema, context resolution and local runtime mappings. Turn the illustrative fields into a versioned v1 schema and fixtures. Check invalid paths/modules, missing runtimes, unknown schema versions/capabilities, multiple matching contexts and parent-to-Component context inheritance. Define protocol framing, bounded message/image payloads and negotiation/error fixtures before worker implementation depends on them.
**Exit:** common configuration tests pass in both host adapters; incompatible setup leaves source editing available; protocol specification is testable and independently versioned.