diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7ef2e5b37..a62638ee99 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask import org.jetbrains.compose.internal.de.undercouch.gradle.tasks.download.Download plugins{ @@ -30,6 +31,11 @@ sourceSets{ srcDirs("src") } } + test{ + kotlin{ + srcDirs("test") + } + } } compose.desktop { @@ -99,12 +105,177 @@ dependencies { implementation(libs.compottie) implementation(libs.kaml) + + testImplementation(kotlin("test")) + testImplementation(libs.mockitoKotlin) + testImplementation(libs.junitJupiter) + testImplementation(libs.junitJupiterParams) } tasks.compileJava{ options.encoding = "UTF-8" } +tasks.test { + useJUnitPlatform() + workingDir = file("build/test") + workingDir.mkdirs() +} + +tasks.register("installCreateDmg") { + onlyIf { org.gradle.internal.os.OperatingSystem.current().isMacOsX } + commandLine("arch", "-arm64", "brew", "install", "--quiet", "create-dmg") +} +tasks.register("packageCustomDmg"){ + onlyIf { org.gradle.internal.os.OperatingSystem.current().isMacOsX } + group = "compose desktop" + + val distributable = tasks.named("createDistributable").get() + dependsOn(distributable, "installCreateDmg") + + val packageName = distributable.packageName.get() + val dir = distributable.destinationDir.get() + val dmg = dir.file("../dmg/$packageName-$version.dmg").asFile + val app = dir.file("$packageName.app").asFile + + dmg.parentFile.deleteRecursively() + dmg.parentFile.mkdirs() + + val extra = mutableListOf() + val isSigned = compose.desktop.application.nativeDistributions.macOS.signing.sign.get() + + if(!isSigned) { + val content = """ + run 'xattr -d com.apple.quarantine Processing-${version}.dmg' to remove the quarantine flag + """.trimIndent() + val instructions = dmg.parentFile.resolve("INSTRUCTIONS.txt") + instructions.writeText(content) + extra.add("--add-file") + extra.add("INSTRUCTIONS.txt") + extra.add(instructions.path) + extra.add("200") + extra.add("25") + } + + commandLine("brew", "install", "--quiet", "create-dmg") + + commandLine("create-dmg", + "--volname", packageName, + "--volicon", file("macos/volume.icns"), + "--background", file("macos/background.png"), + "--icon", "$packageName.app", "200", "200", + "--window-pos", "200", "200", + "--window-size", "775", "485", + "--app-drop-link", "500", "200", + "--hide-extension", "$packageName.app", + *extra.toTypedArray(), + dmg, + app + ) +} + +tasks.register("packageCustomMsi"){ + onlyIf { org.gradle.internal.os.OperatingSystem.current().isWindows } + dependsOn("createDistributable") + workingDir = file("windows") + group = "compose desktop" + + commandLine( + "dotnet", + "build", + "/p:Platform=x64", + "/p:Version=$version", + "/p:DefineConstants=\"Version=$version;\"" + ) +} + +tasks.register("generateSnapConfiguration"){ + onlyIf { org.gradle.internal.os.OperatingSystem.current().isLinux } + val distributable = tasks.named("createDistributable").get() + dependsOn(distributable) + + val arch = when (System.getProperty("os.arch")) { + "amd64", "x86_64" -> "amd64" + "aarch64" -> "arm64" + else -> System.getProperty("os.arch") + } + + val dir = distributable.destinationDir.get() + val content = """ + name: ${rootProject.name} + version: ${rootProject.version} + base: core22 + summary: A creative coding editor + description: | + Processing is a flexible software sketchbook and a programming language designed for learning how to code. + confinement: strict + + apps: + processing: + command: opt/processing/bin/Processing + desktop: opt/processing/lib/processing-Processing.desktop + plugs: + - desktop + - desktop-legacy + - wayland + - x11 + + parts: + processing: + plugin: dump + source: deb/processing_$version-1_$arch.deb + source-type: deb + stage-packages: + - openjdk-17-jdk + override-prime: | + snapcraftctl prime + chmod -R +x opt/processing/lib/app/resources/jdk-* + """.trimIndent() + dir.file("../snapcraft.yaml").asFile.writeText(content) +} + +tasks.register("packageSnap"){ + onlyIf { org.gradle.internal.os.OperatingSystem.current().isLinux } + dependsOn("packageDeb", "generateSnapConfiguration") + group = "compose desktop" + + val distributable = tasks.named("createDistributable").get() + workingDir = distributable.destinationDir.dir("../").get().asFile + + commandLine("snapcraft") +} +tasks.register("zipDistributable"){ + dependsOn("createDistributable") + group = "compose desktop" + + val distributable = tasks.named("createDistributable").get() + val dir = distributable.destinationDir.get() + val packageName = distributable.packageName.get() + + from(dir){ eachFile{ permissions{ unix("755") } } } + archiveBaseName.set(packageName) + destinationDirectory.set(dir.file("../").asFile) +} + +afterEvaluate{ + tasks.named("createDistributable").configure{ + finalizedBy("zipDistributable") + } + tasks.named("packageDmg").configure{ + dependsOn("packageCustomDmg") + group = "compose desktop" + actions = emptyList() + } + tasks.named("packageMsi").configure{ + dependsOn("packageCustomMsi") + group = "compose desktop" + actions = emptyList() + } + tasks.named("packageDistributionForCurrentOS").configure { + dependsOn("packageSnap") + } +} + // LEGACY TASKS // Most of these are shims to be compatible with the old build system diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index 78e07f34a1..4690c6946c 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -1364,10 +1364,10 @@ private File moveLikeSketchFolder(File pdeFile, String baseName) throws IOExcept * @param schemeUri the full URI, including pde:// */ public Editor handleScheme(String schemeUri) { -// var result = Schema.handleSchema(schemeUri, this); -// if (result != null) { -// return result; -// } + var result = Schema.handleSchema(schemeUri, this); + if (result != null) { + return result; + } String location = schemeUri.substring(6); if (location.length() > 0) { diff --git a/app/src/processing/app/Schema.kt b/app/src/processing/app/Schema.kt index 8ea12e7f6f..53977b6be8 100644 --- a/app/src/processing/app/Schema.kt +++ b/app/src/processing/app/Schema.kt @@ -53,7 +53,11 @@ class Schema { private fun handleSketchUrl(uri: URI): Editor?{ val url = File(uri.path.replace("/url/", "")) - val tempSketchFolder = File(Base.untitledFolder, url.nameWithoutExtension) + val rand = (1..6) + .map { (('a'..'z') + ('A'..'Z')).random() } + .joinToString("") + + val tempSketchFolder = File(File(Base.untitledFolder, rand), url.nameWithoutExtension) tempSketchFolder.mkdirs() val tempSketchFile = File(tempSketchFolder, "${tempSketchFolder.name}.pde") @@ -81,7 +85,7 @@ class Schema { downloadFiles(uri, code, File(sketchFolder, "code")) } options["pde"]?.let{ pde -> - downloadFiles(uri, pde, sketchFolder) + downloadFiles(uri, pde, sketchFolder, "pde") } options["mode"]?.let{ mode -> val modeFile = File(sketchFolder, "sketch.properties") @@ -89,7 +93,7 @@ class Schema { } } - private fun downloadFiles(uri: URI, urlList: String, targetFolder: File){ + private fun downloadFiles(uri: URI, urlList: String, targetFolder: File, extension: String = ""){ Thread{ targetFolder.mkdirs() @@ -101,37 +105,31 @@ class Schema { val files = urlList.split(",") files.filter { it.isNotBlank() } - .map{ it.split(":", limit = 2) } - .map{ segments -> - if(segments.size == 2){ - if(segments[0].isBlank()){ - return@map listOf(null, segments[1]) - } - return@map segments - } - return@map listOf(null, segments[0]) + .map { + if (it.contains(":")) it + else "$it:$it" } + .map{ it.split(":", limit = 2) } .forEach { (name, content) -> + var target = File(targetFolder, name) + if(extension.isNotBlank() && target.extension != extension){ + target = File(targetFolder, "$name.$extension") + } try{ - // Try to decode the content as base64 val file = Base64.getDecoder().decode(content) - if(name == null){ + if(name.isBlank()){ Messages.err("Base64 files needs to start with a file name followed by a colon") return@forEach } - File(targetFolder, name).writeBytes(file) + target.writeBytes(file) }catch(_: IllegalArgumentException){ - // Assume it's a URL and download it - var url = URI.create(content) - if(url.host == null){ - url = URI.create("https://$base/$content") - } - if(url.scheme == null){ - url = URI.create("https://$content") - } - - val target = File(targetFolder, name ?: url.path.split("/").last()) - url.toURL().openStream().use { input -> + val url = URL(when{ + content.startsWith("https://") -> content + content.startsWith("http://") -> content.replace("http://", "https://") + URL("https://$content").path.isNotBlank() -> "https://$content" + else -> "https://$base/$content" + }) + url.openStream().use { input -> target.outputStream().use { output -> input.copyTo(output) } diff --git a/app/test/kotlin/processing/app/SchemaTest.kt b/app/test/kotlin/processing/app/SchemaTest.kt new file mode 100644 index 0000000000..12ff67c4ea --- /dev/null +++ b/app/test/kotlin/processing/app/SchemaTest.kt @@ -0,0 +1,115 @@ +package processing.app + +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.ArgumentCaptor +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import java.io.File +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test + + +class SchemaTest { + private val base: Base = mock{ + + } + companion object { + val preferences: MockedStatic = mockStatic(Preferences::class.java) + } + + + @Test + fun testLocalFiles() { + val file = "/this/is/a/local/file" + Schema.handleSchema("pde://$file", base) + verify(base).handleOpen(file) + } + + @Test + fun testNewSketch() { + Schema.handleSchema("pde://sketch/new", base) + verify(base).handleNew() + } + + @OptIn(ExperimentalEncodingApi::class) + @Test + fun testBase64SketchAndExtraFiles() { + val sketch = """ + void setup(){ + + } + void draw(){ + + } + """.trimIndent() + + val base64 = Base64.encode(sketch.toByteArray()) + Schema.handleSchema("pde://sketch/base64/$base64?pde=Module:$base64", base) + val captor = ArgumentCaptor.forClass(String::class.java) + + verify(base).handleOpenUntitled(captor.capture()) + + val file = File(captor.value) + assert(file.exists()) + assert(file.readText() == sketch) + + val extra = file.parentFile.resolve("Module.pde") + assert(extra.exists()) + assert(extra.readText() == sketch) + file.parentFile.deleteRecursively() + } + + @Test + fun testURLSketch() { + Schema.handleSchema("pde://sketch/url/github.com/processing/processing-examples/raw/refs/heads/main/Basics/Arrays/Array/Array.pde", base) + + val captor = ArgumentCaptor.forClass(String::class.java) + verify(base).handleOpenUntitled(captor.capture()) + val output = File(captor.value) + assert(output.exists()) + assert(output.name == "Array.pde") + assert(output.extension == "pde") + assert(output.parentFile.name == "Array") + + output.parentFile.parentFile.deleteRecursively() + } + + @ParameterizedTest + @ValueSource(strings = [ + "Module.pde:https://github.com/processing/processing-examples/raw/refs/heads/main/Basics/Arrays/ArrayObjects/Module.pde", + "Module.pde", + "Module:Module.pde", + "Module:https://github.com/processing/processing-examples/raw/refs/heads/main/Basics/Arrays/ArrayObjects/Module.pde", + "Module.pde:github.com/processing/processing-examples/raw/refs/heads/main/Basics/Arrays/ArrayObjects/Module.pde" + ]) + fun testURLSketchWithFile(file: String){ + Schema.handleSchema("pde://sketch/url/github.com/processing/processing-examples/raw/refs/heads/main/Basics/Arrays/ArrayObjects/ArrayObjects.pde?pde=$file", base) + + val captor = ArgumentCaptor.forClass(String::class.java) + verify(base).handleOpenUntitled(captor.capture()) + + // wait for threads to resolve + Thread.sleep(1000) + + val output = File(captor.value) + assert(output.parentFile.name == "ArrayObjects") + assert(output.exists()) + assert(output.parentFile.resolve("Module.pde").exists()) + output.parentFile.parentFile.deleteRecursively() + } + + @Test + fun testPreferences() { + Schema.handleSchema("pde://preferences?test=value", base) + preferences.verify { + Preferences.set("test", "value") + Preferences.save() + } + } + + +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61703c19a5..89d4602ec6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ kotlin = "2.0.20" compose-plugin = "1.7.1" jogl = "2.5.0" +jupiter = "5.12.0" [libraries] jogl = { module = "org.jogamp.jogl:jogl-all-main", version.ref = "jogl" } @@ -12,7 +13,10 @@ jnaplatform = { module = "net.java.dev.jna:jna-platform", version = "5.12.1" } compottie = { module = "io.github.alexzhirkevich:compottie", version = "2.0.0-rc02" } kaml = { module = "com.charleskorn.kaml:kaml", version = "0.65.0" } junit = { module = "junit:junit", version = "4.13.2" } +junitJupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "jupiter" } +junitJupiterParams = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "jupiter" } mockito = { module = "org.mockito:mockito-core", version = "4.11.0" } +mockitoKotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "5.4.0" } antlr = { module = "org.antlr:antlr4", version = "4.7.2" } eclipseJDT = { module = "org.eclipse.jdt:org.eclipse.jdt.core", version = "3.16.0" } eclipseJDTCompiler = { module = "org.eclipse.jdt:org.eclipse.jdt.compiler.apt", version = "1.3.400" }