Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,55 @@ jobs:
- name: Build and test
run: mvn --batch-mode --no-transfer-progress verify

- name: Check shared release metadata
if: runner.os == 'Linux'
uses: actions/checkout@v7
with:
repository: openpatch/scratch-for-java
path: .validation/library
- name: Set up the catalog toolchain
if: runner.os == 'Linux'
uses: ./.validation/library/.github/actions/setup-maven
- name: Generate reference metadata for the catalog check
if: runner.os == 'Linux'
working-directory: .validation/library
run: mvn --batch-mode --no-transfer-progress -DskipTests prepare-package
- name: Require current offline catalogs
if: runner.os == 'Linux'
run: |
python3 .validation/library/scripts/release-catalogs.py --check --studio .
python3 .validation/library/scripts/release-catalogs.py --archive .validation/examples.zip
python3 scripts/sync-templates.py --artifact .validation/examples.zip --check

- name: Checkout course activities
if: runner.os == 'Linux'
uses: actions/checkout@v7
with:
repository: openpatch/hyperbook-informatik
path: .validation/curriculum
- name: Import and validate the complete offline course
if: runner.os == 'Linux'
run: python3 scripts/check-course-pack.py --library .validation/library --curriculum .validation/curriculum

- name: Checkout the embedded project adapter
if: runner.os == 'Linux'
uses: actions/checkout@v7
with:
repository: openpatch/online-ide
path: .validation/online-ide
- name: Prepare the browser transfer check
if: runner.os == 'Linux'
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Continue an edited curriculum project across environments
if: runner.os == 'Linux'
run: |
npm --prefix .validation/online-ide ci
npm --prefix .validation/online-ide run browser:install -- --with-deps
cd .validation/online-ide
node scripts/test-curriculum-transfer.mjs ../curriculum/book/projekte/spielwerkstatt/checkpoints/spielwerkstatt-q-07-testen.json ../..

- name: OpenGL run smoke (Linux, Xvfb)
# A real stage opens a window in a separate JVM and exits via the smoke switch.
if: runner.os == 'Linux'
Expand Down
33 changes: 20 additions & 13 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ jobs:
types: msi app-image
- name: macos-arm64
os: macos-15
types: dmg
types: dmg app-image
- name: macos-x64
os: macos-15-intel
types: dmg
types: dmg app-image
steps:
- uses: actions/checkout@v7

Expand Down Expand Up @@ -80,8 +80,14 @@ jobs:
scripts/package-ide.sh "$type"
if [ "$type" = app-image ]; then
# the portable build: unzip and run, no admin rights (USB sticks)
(cd target/package && 7z a -tzip "../../dist/scratch4j-studio-${{ matrix.name }}-portable.zip" "Scratch for Java Studio" >/dev/null \
|| zip -qr "../../dist/scratch4j-studio-${{ matrix.name }}-portable.zip" "Scratch for Java Studio")
archive="../../dist/scratch4j-studio-${{ matrix.name }}-portable.zip"
if [ "$RUNNER_OS" = macOS ]; then
(cd target/package && ditto -c -k --sequesterRsrc --keepParent "Scratch for Java Studio.app" "$archive")
elif [ "$RUNNER_OS" = Windows ]; then
(cd target/package && 7z a -tzip "$archive" "Scratch for Java Studio" >/dev/null)
else
(cd target/package && zip -qry "$archive" "Scratch for Java Studio")
fi
else
cp target/package/*.${type} dist/ 2>/dev/null || true
fi
Expand Down Expand Up @@ -122,16 +128,17 @@ jobs:
& $signtool.FullName sign /f $pfx /p $env:WINDOWS_CERTIFICATE_PASSWORD /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 $_.FullName
}

- name: Smoke test the packaged runtime (Linux, Xvfb)
if: runner.os == 'Linux'
- name: Start packaged Studio and run offline projects
shell: bash
run: |
sudo apt-get install -y xvfb >/dev/null 2>&1 || true
scripts/package-ide.sh app-image
APP="target/package/Scratch for Java Studio"
compgen -G "$APP/lib/app/library/scratch-*-all.jar" >/dev/null
"$APP/lib/runtime/bin/java" --list-modules | grep -q jdk.compiler
test -f "$APP/lib/app/javafx-controls-25-linux.jar"
"$APP/lib/runtime/bin/java" -XX:+AllowEnhancedClassRedefinition -version
if [ "$RUNNER_OS" = Linux ]; then
sudo apt-get install -y xvfb libgl1-mesa-dri
LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a python3 scripts/smoke-packaged.py "target/package/Scratch for Java Studio"
elif [ "$RUNNER_OS" = macOS ]; then
python3 scripts/smoke-packaged.py "target/package/Scratch for Java Studio.app"
else
python scripts/smoke-packaged.py "target/package/Scratch for Java Studio"
fi

- uses: actions/upload-artifact@v7
with:
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Scratch for Java Studio is a desktop IDE for students moving from Scratch to
Java. It builds projects with [Scratch for Java](https://github.com/openpatch/scratch-for-java)
and keeps them as ordinary Java source files that also work in BlueJ and VS Code.

**Status: 0.1.0-alpha.6 candidate.** The IDE and its
**Status: 0.1.0-alpha.7.** The IDE and its
project format may change while we test it with students and teachers.

![Scratch for Java Studio with the code editor and the visual stage designer side by side](docs/screenshot.png)
Expand Down Expand Up @@ -68,3 +68,30 @@ The IDE is [MIT licensed](LICENSE). Bundled Scratch for Java artwork and sounds
come from [Kenney](https://kenney.nl) under CC0. OGG and MP3 support uses
replaceable LGPL libraries; RichTextFX, AtlantaFX, Ikonli, and Feather icons
retain their respective licenses.


## Continue between browser and desktop

Open a browser workspace JSON or portable project ZIP through the Project menu.
Export a Browser project ZIP to continue in an embedded IDE revision with ZIP
support. Code, assets, flavor and lesson identity travel with the project. NRW
course classes remain ordinary source on desktop and are retained in the browser.

Use Project > Import course pack for the offline tutorial/Spielwerkstatt pack.
Run > Run behavior checks executes transferred browser test classes with bundled
JUnit, leaving their source unchanged. Scratch imports have a Scratch migration
tab linking timing/unsupported-block tasks to the original block and Java source;
the original .sb3 remains in the project.

Maintainer checks after `mvn verify`:

```sh
python3 scripts/check-course-pack.py --library /path/to/scratch-for-java \
--curriculum /path/to/hyperbook-informatik
python3 scripts/sync-templates.py --artifact /path/to/scratch-catalogs.zip --check
```

The course check imports and compiles all 18 projects and runs the score tests.
Use `--libraries /path/to/bundled/library` for a fully offline check. The native
packaging workflow runs `scripts/smoke-packaged.py` on each supported OS. Local
Linux validation uses Xvfb when no display is available.
2 changes: 1 addition & 1 deletion app/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>org.openpatch.scratch4j</groupId>
<artifactId>scratch4j-ide</artifactId>
<version>0.1.0-alpha.6</version>
<version>0.1.0-alpha.7</version>
</parent>

<artifactId>app</artifactId>
Expand Down
8 changes: 7 additions & 1 deletion core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@
<parent>
<groupId>org.openpatch.scratch4j</groupId>
<artifactId>scratch4j-ide</artifactId>
<version>0.1.0-alpha.6</version>
<version>0.1.0-alpha.7</version>
</parent>

<artifactId>core</artifactId>
<name>Scratch for Java Studio :: core</name>

<dependencies>
<!-- Offline adapter for portable browser @Test classes and the Studio test runner. -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-console-standalone</artifactId>
<version>1.11.4</version>
</dependency>
<!-- Pinned library: asset registries (internal package, guarded by a
contract test) and the runtime model of student projects. -->
<dependency>
Expand Down
100 changes: 98 additions & 2 deletions core/src/main/java/org/openpatch/scratch4j/core/api/ApiIndex.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,36 @@
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.zip.ZipFile;
import org.openpatch.scratch4j.core.project.ScratchProject;
import org.openpatch.scratch4j.core.project.LibraryCheck;

/**
* The API index the IDE ships (offline-first): every public library method
* with signature, summary, Scratch block and docs URL. Generated from the
* library's sources jar — see {@link ApiIndexGenerator}.
* library's release catalog. {@link ApiIndexGenerator} adapts older sources JARs.
*/
public final class ApiIndex {

private static final String RESOURCE = "/api-index.json";

private final List<ApiMethod> methods;
private List<ApiMethod> methods;
private String libraryVersion = "";

private ApiIndex(List<ApiMethod> methods) {
this.methods = List.copyOf(methods);
}

/** Loads the bundled index. */
public static ApiIndex load() {
try (InputStream in = ApiIndex.class.getResourceAsStream("/catalogs/api.json")) {
if (in != null) return catalog(in);
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("Cannot load bundled API catalog", e);
}
try (InputStream in = ApiIndex.class.getResourceAsStream(RESOURCE)) {
if (in == null) {
throw new IllegalStateException("Missing bundled resource " + RESOURCE);
Expand All @@ -35,6 +47,90 @@ public static ApiIndex load() {
}
}

private static ApiIndex catalog(InputStream in) throws IOException {
var root = JsonMapper.builder().build().readTree(in);
if (root.path("schemaVersion").asInt() != 1) throw new IOException("Unsupported API catalog");
List<ApiMethod> methods = new ArrayList<>();
for (var entry : root.path("methods")) {
List<String> params = new ArrayList<>();
entry.path("params").forEach(value -> params.add(value.asText()));
List<String> paramDocs = new ArrayList<>();
entry.path("paramDocs").forEach(value -> paramDocs.add(value.asText()));
ApiMethod method = new ApiMethod(entry.path("className").asText(), entry.path("methodName").asText(),
entry.path("returnType").asText(), params, entry.path("summary").asText(),
entry.path("scratchblock").isNull() ? null : entry.path("scratchblock").asText(),
"", entry.path("docsUrl").asText(), entry.path("description").asText(), paramDocs,
entry.path("returns").isNull() || entry.path("returns").isMissingNode() ? null : entry.path("returns").asText());
methods.add(new ApiMethod(method.className(), method.methodName(), method.returnType(), method.params(),
method.summary(), method.scratchblock(), Categories.of(method), method.docsUrl(),
method.description(), method.paramDocs(), method.returns()));
}
if (methods.isEmpty()) throw new IOException("Empty API catalog");
ApiIndex result = new ApiIndex(methods);
result.libraryVersion = root.path("libraryVersion").asText();
return result;
}

/** Select local release metadata for this project, with a sources adapter for older releases. */
public void useProject(ScratchProject project) throws IOException {
var status = LibraryCheck.status(project, load().libraryVersion);
ApiIndex selected = load();
if (status.jar() != null) {
try (ZipFile jar = new ZipFile(status.jar().toFile())) {
var entry = jar.getEntry("catalogs/api.json");
if (entry != null) {
try (InputStream in = jar.getInputStream(entry)) { selected = catalog(in); }
} else if (status.version() == null || !status.version().equals(selected.libraryVersion)) {
Path sources = status.jar().resolveSibling(status.jar().getFileName().toString()
.replaceFirst("(?:-all)?\\.jar$", "-sources.jar"));
if (Files.isRegularFile(sources)) selected = new ApiIndex(ApiIndexGenerator.generate(sources));
else selected = new ApiIndex(actualMembers(status.jar(), selected.methods()));
selected.libraryVersion = status.version() == null ? "unknown" : status.version();
}
}
}
methods = selected.methods;
libraryVersion = selected.libraryVersion;
}

/** No source metadata in a legacy jar: show only signatures that the chosen binary declares. */
private static List<ApiMethod> actualMembers(Path jar, List<ApiMethod> descriptions) throws IOException {
var owners = new java.util.HashMap<String, String>();
try (ZipFile archive = new ZipFile(jar.toFile())) {
archive.stream().map(java.util.zip.ZipEntry::getName)
.filter(name -> name.startsWith("org/openpatch/scratch/") && name.endsWith(".class")
&& !name.contains("$") && !name.contains("/internal/"))
.forEach(name -> owners.put(name.substring(name.lastIndexOf('/') + 1, name.length() - 6),
name.substring(0, name.length() - 6).replace('/', '.')));
}
List<ApiMethod> result = new ArrayList<>();
try (var loader = new java.net.URLClassLoader(new java.net.URL[] { jar.toUri().toURL() },
ClassLoader.getPlatformClassLoader())) {
for (ApiMethod method : descriptions) {
String owner = owners.get(method.className());
if (owner == null) continue;
try {
Class<?> type = Class.forName(owner, false, loader);
java.lang.reflect.Executable[] members = "constructor".equals(method.methodName())
? type.getConstructors() : type.getDeclaredMethods();
for (var member : members) {
if (!java.lang.reflect.Modifier.isPublic(member.getModifiers())) continue;
if (member instanceof java.lang.reflect.Method m && !m.getName().equals(method.methodName())) continue;
List<String> params = java.util.Arrays.stream(member.getParameterTypes()).map(Class::getSimpleName).toList();
List<String> wanted = method.params().stream().map(p -> p.replaceFirst("\\s+\\w+$", "")
.replaceAll("<.*>", "").replaceAll("\\b(?:\\w+\\.)+(\\w+)", "$1")).toList();
if (params.equals(wanted)) { result.add(method); break; }
}
} catch (ReflectiveOperationException | LinkageError ignored) {
// Missing optional classes have no usable signature in this project.
}
}
}
return result;
}

public String libraryVersion() { return libraryVersion; }

public List<ApiMethod> methods() {
return methods;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ public final class BuiltinAssetIndex {
private final List<String> sounds;

private BuiltinAssetIndex() {
try (var in = BuiltinAssetIndex.class.getResourceAsStream("/catalogs/assets.json")) {
if (in != null) {
var catalog = tools.jackson.databind.json.JsonMapper.builder().build().readTree(in);
if (catalog.path("schemaVersion").asInt() != 1) throw new IllegalStateException("Unsupported asset catalog");
List<BuiltinImage> loaded = new ArrayList<>();
for (var image : catalog.path("images")) {
// A development catalog may describe assets newer than Studio's bundled library.
if (!BuiltinAssets.contains(image.path("id").asText())) continue;
loaded.add(new BuiltinImage(image.path("name").asText(), image.path("sheet").asText(),
image.path("sheetPath").asText(), image.path("x").asInt(), image.path("y").asInt(),
image.path("width").asInt(), image.path("height").asInt(), image.path("direction").asDouble(),
image.path("referenceName").asText()));
}
images = List.copyOf(loaded);
List<String> names = new ArrayList<>();
catalog.path("sounds").forEach(sound -> names.add(sound.path("id").asText()));
sounds = List.copyOf(names);
return;
}
} catch (java.io.IOException e) { throw new java.io.UncheckedIOException(e); }
List<BuiltinImage> loaded = new ArrayList<>();
for (BuiltinAssets.Entry e : BuiltinAssets.getEntries()) {
loaded.add(new BuiltinImage(e.name, e.sheet, e.sheetPath, e.x, e.y,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ public CompileResult compile(List<Path> sources, List<Path> classpath, Path outD
return new CompileResult(true, List.of());
}
Files.createDirectories(outDir);
List<javax.tools.JavaFileObject> adapted = new ArrayList<>();
List<Path> actualClasspath = new ArrayList<>(classpath);
boolean teachingTests = false;
for (Path source : sources) {
String contents = Files.readString(source);
teachingTests |= TeachingTests.hasTests(contents);
String code = TeachingTests.adapt(contents);
adapted.add(new javax.tools.SimpleJavaFileObject(source.toAbsolutePath().toUri(),
javax.tools.JavaFileObject.Kind.SOURCE) {
@Override public CharSequence getCharContent(boolean ignoreErrors) { return code; }
});
}
if (teachingTests) actualClasspath.add(TeachingTests.junitJar());
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
if (compiler == null) {
throw new IllegalStateException("No system java compiler; is jdk.compiler present?");
Expand All @@ -40,14 +53,14 @@ public CompileResult compile(List<Path> sources, List<Path> classpath, Path outD
boolean success;
try (StandardJavaFileManager files = compiler.getStandardFileManager(diagnostics, null,
java.nio.charset.StandardCharsets.UTF_8)) {
if (!classpath.isEmpty()) {
if (!actualClasspath.isEmpty()) {
files.setLocation(StandardLocation.CLASS_PATH,
classpath.stream().map(Path::toFile).toList());
actualClasspath.stream().map(Path::toFile).toList());
}
success = compiler.getTask(null, files, diagnostics,
// -g: line numbers and local variable names for the debugger
List.of("-g", "-encoding", "UTF-8", "-d", outDir.toString()),
null, files.getJavaFileObjectsFromPaths(sources)).call();
null, adapted).call();
}
for (javax.tools.Diagnostic<? extends javax.tools.FileObject> d : diagnostics.getDiagnostics()) {
mapped.add(CompilerDiagnostic.of(d, messageLocale));
Expand Down
Loading
Loading