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
+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)