Package and qualify standalone macOS designer with bundled runtime

This commit is contained in:
2026-11-13 22:06:35 +01:00
parent 9ab1a4f530
commit e865d7b4df
10 changed files with 600 additions and 10 deletions
+3 -1
View File
@@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and
The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits. The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits.
The repository builds six shared Maven modules, a Maven capture plugin, a separate Model metadata processor and an IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation, now with [Table-specific compatibility and declared Row alignment discovery](docs/table-compatibility.md). The shared [reference-safe Structure editor](docs/reference-safe-structure.md) now renames identifiers, copies subtrees with fresh identifiers and deletes associated bindings through source-preserving host Undo/Redo, including destination validation for cross-View paste. The [shared Problems panel and IntelliJ Problems integration](docs/shared-diagnostics.md) now expose current diagnostics with authoritative FXML navigation and editor-scoped cleanup. Shared [source highlighting and diagnostic quick fixes](docs/source-highlighting-fixes.md) now add explicit source-preserving corrections with host Undo/Redo and native IntelliJ Alt+Enter actions. Opt-in [generated Model metadata](docs/generated-model-metadata.md) now feeds the shared discovery and binding authoring workflows from immutable build snapshots. Opt-in [generated Java ViewModels and registry defaults](docs/generated-viewmodels.md) now reuse the runtime storage and identity rules, with shared discovery in both hosts. [Reviewed FXML View generation](docs/reviewed-view-generation.md) now proposes editable forms from shared metadata, validates explicit choices and creates new documents in both hosts. Product packaging remains later work. The repository builds six shared Maven modules, a Maven capture plugin, a separate Model metadata processor and an IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation, now with [Table-specific compatibility and declared Row alignment discovery](docs/table-compatibility.md). The shared [reference-safe Structure editor](docs/reference-safe-structure.md) now renames identifiers, copies subtrees with fresh identifiers and deletes associated bindings through source-preserving host Undo/Redo, including destination validation for cross-View paste. The [shared Problems panel and IntelliJ Problems integration](docs/shared-diagnostics.md) now expose current diagnostics with authoritative FXML navigation and editor-scoped cleanup. Shared [source highlighting and diagnostic quick fixes](docs/source-highlighting-fixes.md) now add explicit source-preserving corrections with host Undo/Redo and native IntelliJ Alt+Enter actions. Opt-in [generated Model metadata](docs/generated-model-metadata.md) now feeds the shared discovery and binding authoring workflows from immutable build snapshots. Opt-in [generated Java ViewModels and registry defaults](docs/generated-viewmodels.md) now reuse the runtime storage and identity rules, with shared discovery in both hosts. [Reviewed FXML View generation](docs/reviewed-view-generation.md) now proposes editable forms from shared metadata, validates explicit choices and creates new documents in both hosts. The first [bundled standalone distribution](docs/standalone-distribution.md) adds a relocatable macOS app and ZIP with its Java runtime and Kit; release signing and wider platform qualification remain later work.
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 and validation rules. 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 and validation rules.
@@ -30,6 +30,8 @@ Maven is pinned to **3.9.11** (Wrapper **3.3.4**); the separate IntelliJ build u
## Run the minimal hosts ## Run the minimal hosts
For a bundled runtime and native standalone launcher, see [packaging and automated qualification](docs/standalone-distribution.md). The commands below remain development launchers.
After the full build, launch a disposable fixture with either command; add `--mode modulepath` for named modules: After the full build, launch a disposable fixture with either command; add `--mode modulepath` for named modules:
```sh ```sh
@@ -8,7 +8,7 @@ import javafx.application.Application;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.stage.Stage; import javafx.stage.Stage;
/** Development standalone entry: launch settings file and initial FXML path. */ /** Standalone document windows and lifecycle, shared by development and packaged launch. */
public final class DesignerApplication extends Application { public final class DesignerApplication extends Application {
private HostLaunch launch; private HostLaunch launch;
private StandaloneHost host; private StandaloneHost host;
@@ -17,12 +17,24 @@ public final class DesignerApplication extends Application {
@Override @Override
public void start(Stage stage) throws Exception { public void start(Stage stage) throws Exception {
var args = getParameters().getRaw(); var options = StandaloneOptions.parse(getParameters().getRaw());
if (args.size() != 2) Path file = options.document();
throw new IllegalArgumentException("Arguments: local-launch.properties View.fxml"); if (file == null) {
var chooser = new javafx.stage.FileChooser();
chooser.setTitle("Open FXML View");
chooser
.getExtensionFilters()
.add(new javafx.stage.FileChooser.ExtensionFilter("FXML Views", "*.fxml"));
var selected = chooser.showOpenDialog(stage);
if (selected == null) {
javafx.application.Platform.exit();
return;
}
file = selected.toPath();
}
try { try {
launch = HostLaunch.load(Path.of(args.get(0))); launch = options.load();
open(stage, Path.of(args.get(1))); open(stage, file);
} catch (Exception setup) { } catch (Exception setup) {
if (host != null) host.close(); if (host != null) host.close();
host = host =
@@ -32,7 +44,7 @@ public final class DesignerApplication extends Application {
diagnostic -> System.err.println(diagnostic.message())); diagnostic -> System.err.println(diagnostic.message()));
var source = var source =
new de.neitzel.fxdesigner.kit.SourceOnlyPane( new de.neitzel.fxdesigner.kit.SourceOnlyPane(
host.services(), Path.of(args.get(1)), String.valueOf(setup.getMessage())); host.services(), file, String.valueOf(setup.getMessage()));
stage.setTitle("Neitzel FX Designer — source"); stage.setTitle("Neitzel FX Designer — source");
stage.setScene(new Scene(source, 1000, 700)); stage.setScene(new Scene(source, 1000, 700));
stage.setOnCloseRequest( stage.setOnCloseRequest(
@@ -119,7 +131,7 @@ public final class DesignerApplication extends Application {
}, },
d -> System.err.println(d.message())); d -> System.err.println(d.message()));
var pane = new DesignerPane(host.services(), launch, file); var pane = new DesignerPane(host.services(), launch, file);
stage.setTitle("Neitzel FX Designer — " + file.getFileName()); stage.setTitle("Neitzel FX Designer — " + file.getFileName() + " [" + launch.context() + "]");
stage.setScene(new Scene(pane, 1300, 850)); stage.setScene(new Scene(pane, 1300, 850));
windows++; windows++;
editors.put(identity, stage); editors.put(identity, stage);
@@ -1,8 +1,27 @@
package de.neitzel.fxdesigner.standalone; package de.neitzel.fxdesigner.standalone;
/** Plain launcher supports the development classpath packaging of JavaFX. */ /** Plain entry point shared by the packaged launcher and development runs. */
public final class Launcher { public final class Launcher {
public static void main(String[] args) { public static void main(String[] args) {
if (args.length == 1 && args[0].equals("--help")) {
System.out.println(StandaloneOptions.USAGE);
return;
}
if (args.length == 1 && args[0].equals("--version")) {
System.out.println(
"Neitzel FX Designer 0.1.0-SNAPSHOT; Java "
+ System.getProperty("java.version")
+ "; runtime "
+ System.getProperty("java.home"));
return;
}
try {
StandaloneOptions.parse(java.util.List.of(args));
} catch (IllegalArgumentException error) {
System.err.println(error.getMessage() + "\n" + StandaloneOptions.USAGE);
System.exit(2);
return;
}
javafx.application.Application.launch(DesignerApplication.class, args); javafx.application.Application.launch(DesignerApplication.class, args);
} }
} }
@@ -0,0 +1,80 @@
package de.neitzel.fxdesigner.standalone;
import de.neitzel.fxdesigner.config.*;
import de.neitzel.fxdesigner.workflow.HostLaunch;
import java.nio.file.*;
import java.util.*;
/** Standalone launch inputs; application runtimes remain explicitly configured. */
public record StandaloneOptions(
Path legacy, Path configuration, Path settings, String context, Path maven, Path document) {
public static final String USAGE =
"Neitzel FX Designer\n"
+ " --configuration project/neitzel-fx-designer.json --settings runtimes.json\n"
+ " [--context name] [--maven /path/to/mvnw] [View.fxml]\n"
+ " --launch host.properties [View.fxml]\n"
+ " --help | --version\n"
+ "Without View.fxml, select a document in the file chooser. Multiple contexts require"
+ " --context.";
public static StandaloneOptions parse(List<String> args) {
if (args.size() == 2 && !args.get(0).startsWith("--"))
return new StandaloneOptions(
Path.of(args.get(0)), null, null, null, null, Path.of(args.get(1)));
var values = new HashMap<String, String>();
Path document = null;
for (int i = 0; i < args.size(); i++) {
String arg = args.get(i);
if (arg.startsWith("--")) {
if (!Set.of("--launch", "--configuration", "--settings", "--context", "--maven")
.contains(arg)
|| i + 1 == args.size()
|| args.get(i + 1).startsWith("--")
|| values.containsKey(arg))
throw new IllegalArgumentException("Unknown, duplicate or incomplete option: " + arg);
values.put(arg, args.get(++i));
} else {
if (document != null)
throw new IllegalArgumentException("Specify only one initial FXML document");
document = Path.of(arg);
}
}
if (values.containsKey("--launch")) {
if (values.size() != 1)
throw new IllegalArgumentException("--launch cannot be combined with project options");
} else if (!values.containsKey("--configuration") || !values.containsKey("--settings"))
throw new IllegalArgumentException("Supply --configuration and --settings, or --launch");
return new StandaloneOptions(
path(values, "--launch"),
path(values, "--configuration"),
path(values, "--settings"),
values.get("--context"),
path(values, "--maven"),
document);
}
private static Path path(Map<String, String> values, String key) {
return values.containsKey(key) ? Path.of(values.get(key)).toAbsolutePath().normalize() : null;
}
public HostLaunch load() throws Exception {
if (legacy != null) return HostLaunch.load(legacy);
var contexts = ConfigurationFiles.load(configuration).contexts();
String selected = context;
if (selected == null && contexts.size() == 1) selected = contexts.keySet().iterator().next();
if (selected == null || !contexts.containsKey(selected))
throw new IllegalArgumentException(
"Select --context from: " + String.join(", ", contexts.keySet()));
Path app =
Path.of(StandaloneOptions.class.getProtectionDomain().getCodeSource().getLocation().toURI())
.getParent();
Path directory = app.resolve("tools");
List<Path> tools;
try (var entries = Files.list(directory)) {
tools = entries.filter(p -> p.toString().endsWith(".jar")).sorted().toList();
}
if (tools.size() != 3)
throw new IllegalArgumentException("Bundled target tools missing: " + directory);
return new HostLaunch(configuration, selected, maven, tools, LocalSettings.load(settings));
}
}
@@ -0,0 +1,37 @@
package de.neitzel.fxdesigner.standalone;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.Test;
class StandaloneOptionsTest {
@Test
void supportsProjectArgumentsAndOptionalDocument() {
var options =
StandaloneOptions.parse(
List.of("--configuration", "project/config.json", "--settings", "local.json"));
assertNull(options.document());
assertTrue(options.configuration().isAbsolute());
assertEquals(
"sample",
StandaloneOptions.parse(List.of("--launch", "host.properties", "sample"))
.document()
.toString());
assertNotNull(StandaloneOptions.parse(List.of("host.properties", "View.fxml")).legacy());
}
@Test
void rejectsAmbiguousAndIncompleteArguments() {
for (var args :
List.of(
List.<String>of(),
List.of("--bad"),
List.of("--launch"),
List.of("--launch", "a", "--context", "b"),
List.of("--launch", "a", "--launch", "b"),
List.of("--configuration", "a"),
List.of("--launch", "a", "one", "two")))
assertThrows(IllegalArgumentException.class, () -> StandaloneOptions.parse(args));
}
}
+4
View File
@@ -163,3 +163,7 @@ The [reliability follow-up](ide-host-reliability.md) closes the generated-ViewMo
## Reviewed FXML View generation ## Reviewed FXML View generation
The [reviewed generation slice](reviewed-view-generation.md) adds a shared metadata-driven form setup, structural/FXML preview, target validation and non-overwriting creation in both hosts. Explicit field, Component, Converter and Table/Column choices produce a new normal editable View. Approval is invalidated by changed drafts or choices and checked against the current build before creation. Broader scaffolding, generation settings and product packaging retain their follow-up scope. Automated qualification is recorded in the slice document. The [reviewed generation slice](reviewed-view-generation.md) adds a shared metadata-driven form setup, structural/FXML preview, target validation and non-overwriting creation in both hosts. Explicit field, Component, Converter and Table/Column choices produce a new normal editable View. Approval is invalidated by changed drafts or choices and checked against the current build before creation. Broader scaffolding, generation settings and product packaging retain their follow-up scope. Automated qualification is recorded in the slice document.
## Bundled standalone packaging
The [first distribution slice](standalone-distribution.md) adds a macOS app image/ZIP with a linked Java runtime, pinned Kit/JavaFX, a native project-aware CLI and separate worker tools. Automated qualification runs the relocated production launcher with classpath/module-path applications and checks setup failures, source editing and cleanup. Context-choice UI, signed installation, public artifact delivery and other platforms remain later work.
+61
View File
@@ -0,0 +1,61 @@
# Bundled standalone distribution
The first packaging slice produces a relocatable macOS `NeitzelFXDesigner.app`, a ZIP containing that app, and a SHA-256 file inventory. The application carries a Java 25 runtime, JavaFX 26, Kit 26 and the shared designer modules. It does not use `JAVA_HOME` to choose its host JVM. Application builds and previews still use the explicitly configured application JDKs and dependencies.
## Build and launch
On the qualified macOS workstation, with JDK 25 and the recorded local NeitzelLib artifacts installed:
```sh
python3 scripts/package-standalone.py --java-home "$JAVA_HOME"
```
The script verifies the runtime artifact baseline, builds/tests the standalone dependency reactor and target tooling, resolves production dependencies, creates a runtime with `jlink`, and builds the native app image with `jpackage`. It refuses to replace existing package outputs; use `--destination` for another output directory. Default output is `target/distribution/`.
Launch the bundled executable directly, including from an IDE external-tool command:
```sh
"/path/to/NeitzelFXDesigner.app/Contents/MacOS/NeitzelFXDesigner" \
--configuration /project/neitzel-fx-designer.json \
--settings /project/.neitzel-fx-designer/runtimes.json \
--context desktop \
/project/app/src/main/resources/example/Person.fxml
```
`--context` can be omitted when exactly one context exists. The active context appears in each editor window title and Component navigation inherits it. For multiple contexts this slice requires an explicit selection; it does not yet provide the graphical selection/remembering flow. `--maven /path/to/mvnw` provides a fallback when the project has no Maven wrapper. See [configuration and runtimes](configuration-and-protocol.md) for the shared configuration and machine-local JDK mappings. No target JDK is silently substituted with the bundled host JVM.
Omit the FXML argument to select a View in a file chooser. `--help` and `--version` run without opening JavaFX; unknown, duplicate or incomplete options fail with usage and exit status 2. The prior `host.properties View.fxml` invocation and the explicit `--launch host.properties [View.fxml]` form remain supported. The project-options form obtains worker tooling from the bundle; legacy launch settings retain their explicit tool paths.
This slice is command-line launched. Finder project selection, file associations, install/update flows and remembered context selection remain follow-ups. There is no claim that double-clicking an unconfigured app opens a project.
## Package boundaries and provenance
The launcher manifest orders the patched shared Kit classes before upstream Kit. Production JARs retain their embedded license/notice resources, the app includes `THIRD-PARTY-NOTICES.md`, and the linked JDK retains its legal files. The package inventory records the source commit and dirty-tree flag, JDK release metadata, architecture and every bundled file hash. It identifies a development artifact, not a published release or a reproducible-build claim.
`jpackage` adds nested input JARs to its generated host classpath. Worker tools are therefore copied to `Contents/app/tools` after launcher creation; an assembly assertion rejects leaked tool paths. The host package includes no IntelliJ SDK, target application library or test JARs. Target tools, contracts and preview API are supplied separately to the target JVM, which resolves application dependencies from the immutable build snapshot.
The application version remains `0.1.0-SNAPSHOT`. The macOS bundle uses numeric version `1.0` because this JDK's `jpackage` rejects a zero leading version component; that bundle value is not a product 1.0 release. The archive is a local development distribution, without Developer ID signing or notarization. Public distribution still needs the product license decision, release/provenance review and separately published project-facing tools/runtime artifacts. Application building still requires its configured JDK, Maven and dependency access; bundling the designer does not make builds offline.
## Automated qualification
```sh
python3 scripts/check-standalone-package.py \
--app target/distribution/NeitzelFXDesigner.app \
--java-home "$JAVA_HOME" --target-java-home "$TARGET_JAVA_HOME"
```
The test verifies the inventory, extracts the ZIP into a path containing spaces, verifies the extracted files against the inventory, and launches its native executable with an invalid `JAVA_HOME` and a restricted `PATH`. An external Java agent observes the real production application, drives the shared authoring suite, and verifies runtime identity, JavaFX version, patched Kit origin and absence of the preview API from the host classloader. Each run extracts JavaFX natives into a fresh cache and verifies their hashes against the bundled JARs. Test classes are never included in the distribution. Agent options are added only to the disposable copy's launcher configuration, so they do not propagate into Maven or target JVMs.
The classpath and named-module fixtures exercise Build and Refresh, rendering, binding/structure edits, diagnostics, reviewed generation, navigation, exact source/history/save behavior and target loading. Tests close actual application windows and check worker termination. Invalid configuration exercises source-only editing and saving; missing application-JDK setup must fail the build without disabling source editing. A native crash guard checks for new JVM crash reports. `--mode` selects one scenario for focused regression runs.
The optional-FXML file chooser is implemented but native chooser interaction is not part of this automated qualification. Existing skipped human-input checks stay skipped. Other CPU architectures, Windows/Linux, quarantined downloads and signed/notarized installation remain unqualified.
## Qualification record — 2026-11-13
Qualified on macOS 27.2 arm64 with Homebrew Java 25.0.5.0.3 as the bundled host and the existing Java 21 / JavaFX 21.0.7 target fixtures. The full bootstrap passed 160 unit/adapter tests with zero failures/errors/skips, 38 target processes with 548 checks, both native standalone workflows, 48 lifecycle cycles, abrupt owner death, bytecode/dependency boundaries, plugin packaging and documentation checks. No registered IntelliJ GUI rerun was needed for this standalone-only change; its document adapters are included in the bootstrap.
The relocated production app passed complete classpath and module-path authoring workflows, then invalid-configuration source editing/saving and missing-JDK failure/source-availability scenarios. Every case verified bundled runtime identity, Kit patch origin, target-API exclusion, fresh native-library hashes and worker shutdown. The final ZIP was extracted into another path with spaces; every extracted file matched the qualified app inventory, and its startup/source-only save/cleanup smoke passed. Output-collision refusal and CLI usage/version/error checks passed.
Initial assembly caught manifest-line folding and macOS numeric bundle-version requirements. Launcher inspection caught `jpackage`'s recursive tool-JAR classpath inclusion, corrected before qualification. Two early harness runs were invalidated: environment-wide agent injection reached Maven, and a concurrent clean build removed a test copy under `target`. The final harness uses host-only launcher options and disposable copies outside the build tree. Neither invalid run is counted as a pass.
Local evidence: `/tmp/standalone-package-build-qualified.log`, `/tmp/standalone-package-bootstrap.log`, `/tmp/standalone-package-qualified.log`, `/tmp/standalone-package-archive.log`, and the `designer-package-check-b46en3a8` / `designer-package-check-wq50e0ct` temporary directories reported by those logs. The archive is `target/distribution/NeitzelFXDesigner-macos.zip`, SHA-256 `dcbb95c4d80371daca612b863701cb120a1a922a114984c3f0683c9edb176fa1`; the sibling `package-manifest.json` records individual file hashes and the pre-commit working-tree build provenance. These local outputs are not committed or published releases. No manual testing was used.
@@ -0,0 +1,219 @@
package qualification;
import com.oracle.javafx.scenebuilder.kit.fxom.*;
import de.neitzel.fxdesigner.kit.*;
import java.lang.instrument.Instrumentation;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import javafx.application.Platform;
import javafx.scene.control.*;
import javafx.stage.*;
/** External qualification agent; never included in the distributable application. */
public final class PackageProbe {
static <T> T fx(Callable<T> action) throws Exception {
var result = new CompletableFuture<T>();
Platform.runLater(
() -> {
try {
result.complete(action.call());
} catch (Throwable e) {
result.completeExceptionally(e);
}
});
return result.get(20, TimeUnit.SECONDS);
}
static void await(Callable<Boolean> condition) throws Exception {
long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(100);
while (System.nanoTime() < until) {
if (condition.call()) return;
Thread.sleep(100);
}
throw new AssertionError("Packaged workflow condition timed out");
}
public static void premain(String ignored, Instrumentation instrumentation) {
Thread.ofPlatform()
.daemon()
.start(
() -> {
Path evidence = Path.of(System.getProperty("package.evidence"));
try {
// Main launches JavaFX normally; the probe only observes an existing application.
for (int i = 0; i < 200; i++) {
try {
fx(() -> null);
break;
} catch (IllegalStateException e) {
Thread.sleep(100);
}
}
await(
() ->
fx(
() ->
Window.getWindows().stream()
.anyMatch(w -> w instanceof Stage s && s.getScene() != null)));
String home = System.getProperty("java.home");
if (!home.contains("NeitzelFXDesigner.app/Contents/runtime"))
throw new AssertionError("External host JVM: " + home);
if (!System.getProperty("javafx.runtime.version").startsWith("26"))
throw new AssertionError("Wrong JavaFX");
String patch =
Class.forName(
"com.oracle.javafx.scenebuilder.kit.editor.panel.inspector.editors.I18nStringEditor")
.getProtectionDomain()
.getCodeSource()
.getLocation()
.toString();
try {
Class.forName("de.neitzel.fxdesigner.preview.PreviewSession");
throw new AssertionError("Target API leaked into the host classpath");
} catch (ClassNotFoundException expected) {
}
if (!patch.contains("designer-kit-ui-"))
throw new AssertionError("Unpatched Kit won classpath order: " + patch);
if (Boolean.getBoolean("package.sourceOnly")) {
await(
() ->
fx(
() ->
Window.getWindows().stream()
.anyMatch(
w ->
w instanceof Stage s
&& s.getScene().getRoot()
instanceof SourceOnlyPane)));
var source =
fx(
() ->
(SourceOnlyPane)
Window.getWindows().stream()
.filter(
w ->
w instanceof Stage st
&& st.getScene().getRoot()
instanceof SourceOnlyPane)
.map(w -> ((Stage) w).getScene().getRoot())
.findFirst()
.orElseThrow());
await(() -> fx(() -> ((TextArea) source.getCenter()).isEditable()));
String original =
Files.readString(Path.of(System.getProperty("package.document")));
fx(
() -> {
((TextArea) source.getCenter())
.appendText("<!-- packaged fallback edit -->");
return null;
});
fx(source::save).toCompletableFuture().get(10, TimeUnit.SECONDS);
if (!Files.readString(Path.of(System.getProperty("package.document")))
.equals(original + "<!-- packaged fallback edit -->"))
throw new AssertionError("Fallback did not save exact source");
} else {
var pane =
fx(
() ->
(DesignerPane)
Window.getWindows().stream()
.filter(
w ->
w instanceof Stage s
&& s.getScene().getRoot() instanceof DesignerPane)
.map(w -> ((Stage) w).getScene().getRoot())
.findFirst()
.orElseThrow());
if (!fx(
() -> ((Stage) pane.getScene().getWindow()).getTitle().contains("[fixture]")))
throw new AssertionError("Active application context is not displayed");
await(() -> fx(() -> pane.kit().editor().getFxomDocument() != null));
fx(
() -> {
pane.lookupAll(".button").stream()
.filter(
n ->
n instanceof Button b
&& b.getText().equals("Build and Refresh"))
.map(n -> (Button) n)
.findFirst()
.orElseThrow()
.fire();
return null;
});
if (Boolean.getBoolean("package.missingRuntime")) {
var member = DesignerPane.class.getDeclaredField("buildRun");
member.setAccessible(true);
var run =
fx(() -> (de.neitzel.fxdesigner.build.BuildRefresh.Run) member.get(pane));
var result = run.completion().toCompletableFuture().get(30, TimeUnit.SECONDS);
if (result.status() != de.neitzel.fxdesigner.build.BuildRefresh.Status.FAILED
|| !result.message().contains("Invalid JDK installation"))
throw new AssertionError("Missing runtime not diagnosed: " + result);
var source = fx(() -> (TextArea) pane.lookup("#fxml-source"));
await(() -> fx(source::isEditable));
if (source == null) throw new AssertionError("Source editing unavailable");
} else {
await(
() ->
fx(
() -> {
var object =
new FXOMFxIdIndex(pane.kit().editor().getFxomDocument())
.lookup("card");
return object instanceof FXOMInstance i
&& i.getSceneGraphObject() instanceof SnapshotRegion r
&& r.image() != null
&& r.statusText().equals("Target runtime");
}));
try (var doc =
pane.kit()
.host()
.documents()
.open(Path.of(System.getProperty("package.document")).toUri())
.toCompletableFuture()
.get()) {
ProjectionHostChecks.run(pane, doc);
}
}
}
Files.writeString(
evidence.resolve("runtime.txt"),
home + "\n" + patch + "\n" + System.getProperty("javafx.runtime.version"));
Files.writeString(
evidence.resolve("workers.txt"),
String.join(
"\n",
ProcessHandle.current()
.descendants()
.map(p -> Long.toString(p.pid()))
.toList()));
fx(
() -> {
for (var window : List.copyOf(Window.getWindows()))
if (window instanceof Stage stage) {
// All checks save their drafts; exercise real onHidden cleanup without a
// confirmation dialog.
stage.setOnCloseRequest(null);
stage.close();
}
return null;
});
Files.writeString(
evidence.resolve("SUCCESS.txt"),
"PASS packaged launcher, bundled runtime/Kit, workflow and window cleanup\n");
Platform.exit();
} catch (Throwable failure) {
try (var out =
new java.io.PrintWriter(
Files.newBufferedWriter(evidence.resolve("FAILURE.txt")))) {
failure.printStackTrace(out);
} catch (Exception ignoredError) {
}
failure.printStackTrace();
System.exit(1);
}
});
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Qualify the actual relocated macOS app launcher using an external Java test agent."""
import argparse,hashlib,json,os,subprocess,sys,tempfile,time,zipfile
from pathlib import Path
from native_crashes import NativeCrashGuard
ROOT=Path(__file__).resolve().parent.parent
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--app',type=Path,required=True)
p.add_argument('--java-home',type=Path,required=True,help='Compiler for the external test agent only')
p.add_argument('--target-java-home',type=Path,required=True)
p.add_argument('--mode',choices=['all','classpath','modulepath','invalid-configuration','missing-runtime'],default='all')
a=p.parse_args()
manifest=json.loads((a.app.parent/'package-manifest.json').read_text())
for name,digest in manifest['files'].items():
assert hashlib.sha256((a.app/name).read_bytes()).hexdigest()==digest, name
assert list((a.app/'Contents/runtime').rglob('legal')), 'Runtime legal notices missing'
evidence=Path(tempfile.mkdtemp(prefix='designer-package-check-')).resolve()
print('Evidence: '+str(evidence),flush=True)
app=evidence/'relocated application with spaces'/'NeitzelFXDesigner.app'
app.parent.mkdir()
subprocess.run(['/usr/bin/ditto','-x','-k',a.app.parent/'NeitzelFXDesigner-macos.zip',app.parent],check=True)
for name,digest in manifest['files'].items():
assert hashlib.sha256((app/name).read_bytes()).hexdigest()==digest, 'Archive differs: '+name
launcher=app/'Contents/MacOS/NeitzelFXDesigner'
env=dict(os.environ,JAVA_HOME='/does/not/exist',PATH='/usr/bin:/bin')
for name in ('JAVA_TOOL_OPTIONS','JDK_JAVA_OPTIONS','_JAVA_OPTIONS','CLASSPATH'):env.pop(name,None)
version=subprocess.run([launcher,'--version'],env=env,text=True,capture_output=True,check=True)
assert str(app) in version.stdout,version
assert 'Neitzel FX Designer' in subprocess.check_output([launcher,'--help'],env=env,text=True)
invalid=subprocess.run([launcher,'--unknown'],env=env,text=True,capture_output=True)
assert invalid.returncode==2 and 'Unknown' in invalid.stderr,invalid
jars=list((app/'Contents/app').glob('*.jar'))
checks=ROOT/'designer-kit-ui/target/designer-kit-ui-0.1.0-SNAPSHOT-tests.jar'
classes=evidence/'agent-classes';classes.mkdir()
subprocess.run([a.java_home/'bin/javac','-cp',os.pathsep.join(map(str,[*jars,checks])), '-d',classes,
ROOT/'integration-tests/standalone/PackageProbe.java'],check=True)
agent=evidence/'probe.jar'
with zipfile.ZipFile(agent,'w') as out:
out.writestr('META-INF/MANIFEST.MF','Manifest-Version: 1.0\nPremain-Class: qualification.PackageProbe\n\n')
for file in classes.rglob('*.class'):out.write(file,str(file.relative_to(classes)))
with zipfile.ZipFile(checks) as source:
for name in source.namelist():
if name.endswith('.class'):out.writestr(name,source.read(name))
launcher_config=app/'Contents/app/NeitzelFXDesigner.cfg'
base_config=launcher_config.read_text()
crashes=NativeCrashGuard(ROOT)
for mode in (('classpath','modulepath','invalid-configuration','missing-runtime') if a.mode=='all' else (a.mode,)):
output=evidence/mode;output.mkdir()
fixture=output/'project'
subprocess.run([sys.executable,ROOT/'scripts/prepare-fixture-host.py','--destination',fixture,
'--target-java-home',a.target_java_home,'--mode','modulepath' if mode=='modulepath' else 'classpath'],check=True)
document=fixture/'app/src/main/resources/fixture/components/Person.fxml'
if mode=='missing-runtime':
settings=fixture/'.neitzel-fx-designer/runtimes.json'
values=json.loads(settings.read_text());values['runtimes']['java21']['build']='/missing/build-jdk'
settings.write_text(json.dumps(values))
# Only target runtime settings and Maven come from the project; worker tools come from the app.
config=fixture/'neitzel-fx-designer.json'
if mode=='invalid-configuration':config.write_text('{"schemaVersion":"999"}')
args=[launcher,'--configuration',config,'--settings',fixture/'.neitzel-fx-designer/runtimes.json','--maven',ROOT/'mvnw',document]
# Instrument only the packaged host. Environment-wide JVM options would also
# inject the agent into Maven and target JVMs, violating the test boundary.
options=[f'-javaagent:{agent}',f'-Dpackage.evidence={output}',f'-Dpackage.document={document}',f'-Djavafx.cachedir={output / "native-cache"}']
if mode=='invalid-configuration':options.append('-Dpackage.sourceOnly=true')
if mode=='missing-runtime':options.append('-Dpackage.missingRuntime=true')
launcher_config.write_text(base_config+'\n'+''.join('java-options='+v+'\n' for v in options))
with (output/'launcher.log').open('w') as log:
process=subprocess.Popen(list(map(str,args)),env=env,cwd=output,stdout=log,stderr=subprocess.STDOUT)
try:code=process.wait(timeout=300)
except subprocess.TimeoutExpired:
process.terminate();process.wait(timeout=15);raise
assert code==0 and (output/'SUCCESS.txt').exists(), (output,(output/'launcher.log').read_text()[-6000:])
for pid in (output/'workers.txt').read_text().splitlines():
until=time.monotonic()+10
while time.monotonic()<until:
try:os.kill(int(pid),0)
except ProcessLookupError:break
time.sleep(.1)
else:raise AssertionError('Worker survived packaged host: '+pid)
expected={}
for jar in jars:
with zipfile.ZipFile(jar) as archive:
for name in archive.namelist():
if name.endswith('.dylib'):expected.setdefault(Path(name).name,set()).add(hashlib.sha256(archive.read(name)).hexdigest())
natives=list((output/'native-cache').rglob('*.dylib'))
assert any(f.name=='libglass.dylib' for f in natives), 'Packaged JavaFX natives were not extracted'
for file in natives:assert hashlib.sha256(file.read_bytes()).hexdigest() in expected.get(file.name,set()), file
crashes.check()
print('PASS packaged '+mode,flush=True)
print('Evidence: '+str(evidence),flush=True)
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Build a macOS app image with a bundled runtime and isolated target tooling."""
import argparse, hashlib, json, os, platform, shutil, subprocess, tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--java-home', type=Path, required=True)
p.add_argument('--destination', type=Path, default=ROOT/'target/distribution')
a=p.parse_args()
if platform.system()!='Darwin': p.error('This packaging slice qualifies macOS only')
java=a.java_home.resolve(); destination=a.destination.resolve()
app=destination/'NeitzelFXDesigner.app'
for output in (app,destination/'package-manifest.json',destination/'NeitzelFXDesigner-macos.zip'):
if output.exists() or output.is_symlink(): p.error(f'Refusing to replace existing output: {output}')
env=dict(os.environ, JAVA_HOME=str(java))
def run(cmd): subprocess.run(list(map(str,cmd)),cwd=ROOT,env=env,check=True)
run([java/'bin/java','-version'])
run([os.sys.executable,ROOT/'scripts/check-neitzellib-artifact.py'])
run([ROOT/'mvnw','-B','-ntp','-pl','designer-standalone,designer-target-tools','-am','install'])
run([ROOT/'mvnw','-B','-ntp','-pl','designer-standalone','org.apache.maven.plugins:maven-dependency-plugin:3.6.1:build-classpath','-DincludeScope=runtime','-Dmdep.outputFile=target/package-classpath.txt'])
with tempfile.TemporaryDirectory(prefix='designer-package-') as tmp:
stage=Path(tmp)/'input'; stage.mkdir()
# Explicit manifest order: the verified Kit patch must precede upstream Kit.
paths=[ROOT/'designer-standalone/target/designer-standalone-0.1.0-SNAPSHOT.jar']
deps=[Path(s) for s in (ROOT/'designer-standalone/target/package-classpath.txt').read_text().strip().split(os.pathsep)]
paths += sorted(deps,key=lambda f:(0 if f.name.startswith('designer-kit-ui-') else 1,f.name))
for file in paths:
if file.name.endswith('-tests.jar') or any(s in file.name for s in ('junit','intellij','target-tools','preview-api')):
raise RuntimeError('Unexpected host dependency: '+str(file))
if (stage/file.name).exists(): raise RuntimeError('Duplicate artifact name: '+file.name)
shutil.copy2(file,stage/file.name)
manifest=Path(tmp)/'MANIFEST.MF'
# Fold ASCII artifact names into legal manifest continuation lines.
classpath='Class-Path: '+' '.join(f.name for f in paths)
lines=[classpath[:70]]
classpath=classpath[70:]
while classpath:
lines.append(' '+classpath[:69]); classpath=classpath[69:]
manifest.write_text('Manifest-Version: 1.0\nMain-Class: de.neitzel.fxdesigner.standalone.Launcher\n'+'\n'.join(lines)+'\n\n')
empty=Path(tmp)/'empty'; empty.mkdir()
run([java/'bin/jar','cfm',stage/'launcher.jar',manifest,'-C',empty,'.'])
tools=Path(tmp)/'tools'; tools.mkdir()
for name in ('target-tools','contracts','preview-api'):
file=ROOT/f'designer-{name}/target/designer-{name}-0.1.0-SNAPSHOT.jar'
shutil.copy2(file,tools/file.name)
shutil.copy2(ROOT/'THIRD-PARTY-NOTICES.md',stage)
runtime=Path(tmp)/'runtime'
run([java/'bin/jlink','--add-modules','java.se,jdk.unsupported,jdk.unsupported.desktop,jdk.xml.dom,jdk.crypto.ec,jdk.zipfs,jdk.localedata,jdk.management',
'--strip-debug','--no-header-files','--no-man-pages','--output',runtime])
destination.mkdir(parents=True,exist_ok=True)
run([java/'bin/jpackage','--type','app-image','--name','NeitzelFXDesigner','--app-version','1.0',
'--vendor','Neitzel','--input',stage,'--main-jar','launcher.jar','--runtime-image',runtime,
'--java-options','--enable-native-access=ALL-UNNAMED','--dest',destination])
# jpackage adds every input JAR recursively to the host classpath. Copy worker
# artifacts only after it generates the launcher to keep the process boundary.
shutil.copytree(tools,app/'Contents/app/tools')
cfg=(app/'Contents/app/NeitzelFXDesigner.cfg').read_text()
assert '/tools/' not in cfg, 'Target tools leaked into the host launcher'
inventory={str(f.relative_to(app)):hashlib.sha256(f.read_bytes()).hexdigest() for f in sorted(app.rglob('*')) if f.is_file()}
(destination/'package-manifest.json').write_text(json.dumps({'platform':platform.platform(),'architecture':platform.machine(),
'applicationVersion':'0.1.0-SNAPSHOT','bundleVersion':'1.0',
'source':subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip(),
'workingTreeDirty':bool(subprocess.check_output(['git','status','--porcelain'],cwd=ROOT,text=True).strip()),
'javaRelease':(java/'release').read_text(),'files':inventory},indent=2)+'\n')
run(['/usr/bin/ditto','-c','-k','--sequesterRsrc','--keepParent',app,destination/'NeitzelFXDesigner-macos.zip'])
print(app)