mirror of
https://github.com/SquidDev-CC/CC-Tweaked
synced 2025-02-23 22:40:03 +00:00
Build a web-based emulator for the documentation site (#1597)
Historically we've used copy-cat to provide a web-based emulator for running example code on our documentation site. However, copy-cat is often out-of-date with CC:T, which means example snippets fail when you try to run them! This commit vendors in copy-cat (or rather an updated version of it) into CC:T itself, allowing us to ensure the emulator is always in sync with the mod. While the ARCHITECTURE.md documentation goes into a little bit more detail here, the general implementation is as follows - In project/src/main we implement the core of the emulator. This includes a basic reimplementation of some of CC's classes to work on the web (mostly the HTTP API and ComputerThread), and some additional code to expose the computers to Javascript. - This is all then compiled to Javascript using [TeaVM][1] (we actually use a [personal fork of it][2] as there's a couple of changes I've not upstreamed yet). - The Javascript side then pulls in the these compiled classes (and the CC ROM) and hooks them up to [cc-web-term][3] to display the actual computer. - As we're no longer pulling in copy-cat, we can simplify our bundling system a little - we now just compile to ESM modules directly. [1]: https://github.com/konsoletyper/teavm [2]: https://github.com/SquidDev/teavm/tree/squid-patches [3]: https://github.com/squiddev-cc/cc-web-term
This commit is contained in:
parent
0a31de43c2
commit
c0643fadca
@ -66,6 +66,7 @@ repositories {
|
||||
includeGroup("me.shedaniel.cloth")
|
||||
includeGroup("me.shedaniel")
|
||||
includeGroup("mezz.jei")
|
||||
includeGroup("org.teavm")
|
||||
includeModule("com.terraformersmc", "modmenu")
|
||||
includeModule("me.lucko", "fabric-permissions-api")
|
||||
}
|
||||
@ -99,7 +100,10 @@ sourceSets.all {
|
||||
check("FutureReturnValueIgnored", CheckSeverity.OFF) // Too many false positives with Netty
|
||||
|
||||
check("NullAway", CheckSeverity.ERROR)
|
||||
option("NullAway:AnnotatedPackages", listOf("dan200.computercraft", "net.fabricmc.fabric.api").joinToString(","))
|
||||
option(
|
||||
"NullAway:AnnotatedPackages",
|
||||
listOf("dan200.computercraft", "cc.tweaked", "net.fabricmc.fabric.api").joinToString(","),
|
||||
)
|
||||
option("NullAway:ExcludedFieldAnnotations", listOf("org.spongepowered.asm.mixin.Shadow").joinToString(","))
|
||||
option("NullAway:CastToNonNullMethod", "dan200.computercraft.core.util.Nullability.assertNonNull")
|
||||
option("NullAway:CheckOptionalEmptiness")
|
||||
|
@ -5,6 +5,7 @@
|
||||
package cc.tweaked.gradle
|
||||
|
||||
import org.gradle.api.artifacts.dsl.DependencyHandler
|
||||
import org.gradle.api.file.FileSystemLocation
|
||||
import org.gradle.api.file.FileSystemLocationProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.provider.Provider
|
||||
@ -126,5 +127,5 @@ class CloseScope : AutoCloseable {
|
||||
/** Proxy method to avoid overload ambiguity. */
|
||||
fun <T> Property<T>.setProvider(provider: Provider<out T>) = set(provider)
|
||||
|
||||
/** Short-cut method to get the absolute path of a [FileSystemLocationProperty]. */
|
||||
fun FileSystemLocationProperty<*>.getAbsolutePath(): String = get().asFile.absolutePath
|
||||
/** Short-cut method to get the absolute path of a [FileSystemLocation] provider. */
|
||||
fun Provider<out FileSystemLocation>.getAbsolutePath(): String = get().asFile.absolutePath
|
||||
|
@ -16,4 +16,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
<!-- The commands API is documented in Lua. -->
|
||||
<suppress checks="SummaryJavadocCheck" files=".*[\\/]CommandAPI.java" />
|
||||
|
||||
<!-- Allow putting files in other packages if they look like our TeaVM stubs. -->
|
||||
<suppress checks="PackageName" files=".*[\\/]T[A-Za-z]+.java" />
|
||||
|
||||
<!-- Allow underscores in our test classes. -->
|
||||
<suppress checks="MethodName" files=".*Contract.java" />
|
||||
</suppressions>
|
||||
|
@ -29,7 +29,7 @@ for _, file in ipairs(files.getFiles()) do
|
||||
local size = file.seek("end")
|
||||
file.seek("set", 0)
|
||||
|
||||
print(file.getName() .. " " .. file.getSize())
|
||||
print(file.getName() .. " " .. size)
|
||||
end
|
||||
```
|
||||
|
||||
|
@ -30,7 +30,7 @@ kotlin = "1.8.10"
|
||||
kotlin-coroutines = "1.6.4"
|
||||
netty = "4.1.82.Final"
|
||||
nightConfig = "3.6.7"
|
||||
slf4j = "1.7.36"
|
||||
slf4j = "2.0.1"
|
||||
|
||||
# Minecraft mods
|
||||
emi = "1.0.8+1.20.1"
|
||||
@ -60,7 +60,7 @@ fabric-loom = "1.3.7"
|
||||
forgeGradle = "6.0.8"
|
||||
githubRelease = "2.4.1"
|
||||
ideaExt = "1.1.7"
|
||||
illuaminate = "0.1.0-40-g975cbc3"
|
||||
illuaminate = "0.1.0-44-g9ee0055"
|
||||
librarian = "1.+"
|
||||
minotaur = "2.+"
|
||||
mixinGradle = "0.7.+"
|
||||
@ -69,10 +69,12 @@ spotless = "6.21.0"
|
||||
taskTree = "2.1.1"
|
||||
vanillaGradle = "0.2.1-SNAPSHOT"
|
||||
vineflower = "1.11.0"
|
||||
teavm = "0.9.0-SQUID.1"
|
||||
|
||||
[libraries]
|
||||
# Normal dependencies
|
||||
asm = { module = "org.ow2.asm:asm", version.ref = "asm" }
|
||||
asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm" }
|
||||
autoService = { module = "com.google.auto.service:auto-service", version.ref = "autoService" }
|
||||
checkerFramework = { module = "org.checkerframework:checker-qual", version.ref = "checkerFramework" }
|
||||
cobalt = { module = "org.squiddev:Cobalt", version.ref = "cobalt" }
|
||||
@ -138,6 +140,14 @@ librarian = { module = "org.parchmentmc:librarian", version.ref = "librarian" }
|
||||
minotaur = { module = "com.modrinth.minotaur:Minotaur", version.ref = "minotaur" }
|
||||
nullAway = { module = "com.uber.nullaway:nullaway", version.ref = "nullAway" }
|
||||
spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" }
|
||||
teavm-classlib = { module = "org.teavm:teavm-classlib", version.ref = "teavm" }
|
||||
teavm-jso = { module = "org.teavm:teavm-jso", version.ref = "teavm" }
|
||||
teavm-jso-apis = { module = "org.teavm:teavm-jso-apis", version.ref = "teavm" }
|
||||
teavm-jso-impl = { module = "org.teavm:teavm-jso-impl", version.ref = "teavm" }
|
||||
teavm-metaprogramming-api = { module = "org.teavm:teavm-metaprogramming-api", version.ref = "teavm" }
|
||||
teavm-metaprogramming-impl = { module = "org.teavm:teavm-metaprogramming-impl", version.ref = "teavm" }
|
||||
teavm-platform = { module = "org.teavm:teavm-platform", version.ref = "teavm" }
|
||||
teavm-tooling = { module = "org.teavm:teavm-tooling", version.ref = "teavm" }
|
||||
vanillaGradle = { module = "org.spongepowered:vanillagradle", version.ref = "vanillaGradle" }
|
||||
vineflower = { module = "io.github.juuxel:loom-vineflower", version.ref = "vineflower" }
|
||||
|
||||
@ -151,6 +161,7 @@ mixinGradle = { id = "org.spongepowered.mixin", version.ref = "mixinGradle" }
|
||||
taskTree = { id = "com.dorongold.task-tree", version.ref = "taskTree" }
|
||||
|
||||
[bundles]
|
||||
annotations = ["jsr305", "checkerFramework", "jetbrainsAnnotations"]
|
||||
kotlin = ["kotlin-stdlib", "kotlin-coroutines"]
|
||||
|
||||
# Minecraft
|
||||
@ -164,3 +175,7 @@ externalMods-fabric-runtime = ["jei-fabric", "modmenu"]
|
||||
# Testing
|
||||
test = ["junit-jupiter-api", "junit-jupiter-params", "hamcrest", "jqwik-api"]
|
||||
testRuntime = ["junit-jupiter-engine", "jqwik-engine"]
|
||||
|
||||
# Build tools
|
||||
teavm-api = [ "teavm-jso", "teavm-jso-apis", "teavm-platform", "teavm-classlib", "teavm-metaprogramming-api" ]
|
||||
teavm-tooling = [ "teavm-tooling", "teavm-metaprogramming-impl", "teavm-jso-impl" ]
|
||||
|
@ -23,7 +23,7 @@
|
||||
(url https://tweaked.cc/)
|
||||
(source-link https://github.com/cc-tweaked/CC-Tweaked/blob/${commit}/${path}#L${line})
|
||||
|
||||
(styles /projects/web/src/frontend/styles.css)
|
||||
(styles /projects/web/build/rollup/index.css)
|
||||
(scripts /projects/web/build/rollup/index.js)
|
||||
(head doc/head.html))
|
||||
|
||||
|
1598
package-lock.json
generated
1598
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,11 +6,14 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@squid-dev/cc-web-term": "^2.0.0",
|
||||
"preact": "^10.5.5",
|
||||
"setimmediate": "^1.0.5",
|
||||
"tslib": "^2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-terser": "^0.4.0",
|
||||
"@rollup/plugin-node-resolve": "^15.2.1",
|
||||
"@rollup/plugin-terser": "^0.4.3",
|
||||
"@rollup/plugin-typescript": "^11.0.0",
|
||||
"@rollup/plugin-url": "^8.0.1",
|
||||
"@types/glob": "^8.1.0",
|
||||
@ -19,8 +22,8 @@
|
||||
"rehype": "^13.0.0",
|
||||
"rehype-highlight": "^7.0.0",
|
||||
"rehype-react": "^8.0.0",
|
||||
"requirejs": "^2.3.6",
|
||||
"rollup": "^3.19.1",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
"tsx": "^3.12.10",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
|
@ -18,9 +18,7 @@ val docApi by configurations.registering {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnlyApi(libs.jsr305)
|
||||
compileOnlyApi(libs.checkerFramework)
|
||||
compileOnlyApi(libs.jetbrainsAnnotations)
|
||||
compileOnlyApi(libs.bundles.annotations)
|
||||
|
||||
"docApi"(project(":common-api"))
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ import java.util.Objects;
|
||||
* This is used by {@link ComputerContext} to construct {@linkplain ComputerContext#peripheralMethods() the context-wide
|
||||
* method supplier}. It should not be used directly.
|
||||
*/
|
||||
public class PeripheralMethodSupplier {
|
||||
public final class PeripheralMethodSupplier {
|
||||
private static final Generator<PeripheralMethod> GENERATOR = new Generator<>(PeripheralMethod.class, List.of(ILuaContext.class, IComputerAccess.class),
|
||||
m -> (target, context, computer, args) -> {
|
||||
var escArgs = args.escapes();
|
||||
@ -31,6 +31,9 @@ public class PeripheralMethodSupplier {
|
||||
method -> (instance, context, computer, args) -> ((IDynamicPeripheral) instance).callMethod(computer, context, method, args)
|
||||
);
|
||||
|
||||
private PeripheralMethodSupplier() {
|
||||
}
|
||||
|
||||
public static MethodSupplier<PeripheralMethod> create(List<GenericMethod> genericMethods) {
|
||||
return new MethodSupplierImpl<>(genericMethods, GENERATOR, DYNAMIC, x -> x instanceof IDynamicPeripheral dynamic
|
||||
? Objects.requireNonNull(dynamic.getMethodNames(), "Dynamic methods cannot be null")
|
||||
|
43
projects/web/ARCHITECTURE.md
Normal file
43
projects/web/ARCHITECTURE.md
Normal file
@ -0,0 +1,43 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
# Architecture
|
||||
As mentioned in the main architecture guide, the web subproject is responsible for building CC: Tweaked's documentation
|
||||
website. This is surprisingly more complex than one might initially assume, hence the need for this document at all!
|
||||
|
||||
## Web-based emulator
|
||||
Most of the complexity comes from the web-based emulator we embed in our documentation. This uses [TeaVM] to compile
|
||||
CC: Tweaked's core to Javascript, and then call out to it in the main site.
|
||||
|
||||
The code for this is split into three separate components:
|
||||
- `src/main`: This holds the emulator itself: this is a basic Java project which depends on CC:T's core, and exposes an
|
||||
interface for Javascript code.
|
||||
|
||||
Some of our code (or dependencies) cannot be compiled to Javascript, for instance most of our HTTP implementation. In
|
||||
theses cases we provide a replacement class. These classes start with `T` (for instance `THttpRequest`), which are
|
||||
specially handled in the next step.
|
||||
|
||||
- `src/builder`: This module consumes the above code and compiles everything to Javascript using TeaVM. There's a
|
||||
couple of places where we need to patch the bytecode before compiling it, so this also includes a basic ASM
|
||||
rewriting system.
|
||||
|
||||
- `src/frontend`: This consumes the interface exposed by the main emulator, and actually embeds the emulator in the
|
||||
website.
|
||||
|
||||
## Static content
|
||||
Rendering the static portion of the website is fortunately much simpler.
|
||||
|
||||
- Doc generation: This is mostly handled in various Gradle files. The Forge Gradle script uses [cct-javadoc] to convert
|
||||
Javadoc on our peripherals and APIs to LDoc/[illuaminate] compatible documentation. This is then fed into illuaminate
|
||||
which spits out HTML.
|
||||
|
||||
- `src/htmlTransform`: We do a small amount of post-processing on the HTML in order. This project does syntax
|
||||
highlighting of non-Lua code blocks, and replaces special `<mc-recipe>` tags with a rendered view of a given
|
||||
Minecraft recipe.
|
||||
|
||||
[TeaVM]: https://github.com/konsoletyper/teavm "TeaVM - Compiler of Java bytecode to JavaScript"
|
||||
[cct-javadoc]: https://github.com/cc-tweaked/cct-javadoc: "cct-javadoc - A Javadoc doclet to extract documentation from @LuaFunction methods."
|
||||
[illuaminate]: https://github.com/Squiddev/illuaminate: "illuaminate - Very WIP static analysis for Lua"
|
@ -5,11 +5,13 @@
|
||||
import cc.tweaked.gradle.getAbsolutePath
|
||||
|
||||
plugins {
|
||||
`lifecycle-base`
|
||||
id("cc-tweaked.java-convention")
|
||||
id("cc-tweaked.node")
|
||||
id("cc-tweaked.illuaminate")
|
||||
}
|
||||
|
||||
val modVersion: String by extra
|
||||
|
||||
node {
|
||||
projectRoot.set(rootProject.projectDir)
|
||||
}
|
||||
@ -18,12 +20,61 @@ illuaminate {
|
||||
version.set(libs.versions.illuaminate)
|
||||
}
|
||||
|
||||
sourceSets.register("builder")
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(libs.bundles.teavm.api)
|
||||
implementation(libs.asm)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.netty.http)
|
||||
implementation(libs.slf4j)
|
||||
|
||||
"builderCompileOnly"(libs.bundles.annotations)
|
||||
"builderImplementation"(libs.bundles.teavm.tooling)
|
||||
"builderImplementation"(libs.asm)
|
||||
"builderImplementation"(libs.asm.commons)
|
||||
}
|
||||
|
||||
val compileTeaVM by tasks.registering(JavaExec::class) {
|
||||
group = LifecycleBasePlugin.BUILD_GROUP
|
||||
description = "Generate our classes and resources files"
|
||||
|
||||
val output = layout.buildDirectory.dir("teaVM")
|
||||
val minify = !project.hasProperty("noMinify")
|
||||
|
||||
inputs.property("version", modVersion)
|
||||
inputs.property("minify", minify)
|
||||
inputs.files(sourceSets.main.get().runtimeClasspath).withPropertyName("inputClasspath")
|
||||
outputs.dir(output).withPropertyName("output")
|
||||
|
||||
classpath = sourceSets["builder"].runtimeClasspath
|
||||
jvmArguments.addAll(
|
||||
provider {
|
||||
val main = sourceSets.main.get()
|
||||
listOf(
|
||||
"-Dcct.input=${main.output.classesDirs.asPath}",
|
||||
"-Dcct.version=$modVersion",
|
||||
"-Dcct.classpath=${main.runtimeClasspath.asPath}",
|
||||
"-Dcct.output=${output.getAbsolutePath()}",
|
||||
"-Dcct.minify=$minify",
|
||||
)
|
||||
},
|
||||
)
|
||||
mainClass.set("cc.tweaked.web.builder.Builder")
|
||||
javaLauncher.set(project.javaToolchains.launcherFor { languageVersion.set(java.toolchain.languageVersion) })
|
||||
}
|
||||
|
||||
val rollup by tasks.registering(cc.tweaked.gradle.NpxExecToDir::class) {
|
||||
group = LifecycleBasePlugin.BUILD_GROUP
|
||||
description = "Bundles JS into rollup"
|
||||
|
||||
val minify = !project.hasProperty("noMinify")
|
||||
inputs.property("minify", minify)
|
||||
|
||||
// Sources
|
||||
inputs.files(fileTree("src/frontend")).withPropertyName("sources")
|
||||
inputs.files(compileTeaVM)
|
||||
// Config files
|
||||
inputs.file("tsconfig.json").withPropertyName("Typescript config")
|
||||
inputs.file("rollup.config.js").withPropertyName("Rollup config")
|
||||
@ -31,7 +82,7 @@ val rollup by tasks.registering(cc.tweaked.gradle.NpxExecToDir::class) {
|
||||
// Output directory. Also defined in illuaminate.sexp and rollup.config.js
|
||||
output.set(layout.buildDirectory.dir("rollup"))
|
||||
|
||||
args = listOf("rollup", "--config", "rollup.config.js")
|
||||
args = listOf("rollup", "--config", "rollup.config.js") + if (minify) emptyList() else listOf("--configDebug")
|
||||
}
|
||||
|
||||
val illuaminateDocs by tasks.registering(cc.tweaked.gradle.IlluaminateExecToDir::class) {
|
||||
@ -44,9 +95,8 @@ val illuaminateDocs by tasks.registering(cc.tweaked.gradle.IlluaminateExecToDir:
|
||||
inputs.files(rootProject.fileTree("doc")).withPropertyName("docs")
|
||||
inputs.files(project(":core").fileTree("src/main/resources/data/computercraft/lua")).withPropertyName("lua rom")
|
||||
inputs.files(project(":forge").tasks.named("luaJavadoc"))
|
||||
// Additional assets
|
||||
// Assets
|
||||
inputs.files(rollup)
|
||||
inputs.file("src/frontend/styles.css").withPropertyName("styles")
|
||||
|
||||
// Output directory. Also defined in illuaminate.sexp.
|
||||
output.set(layout.buildDirectory.dir("illuaminate"))
|
||||
@ -72,7 +122,8 @@ val htmlTransform by tasks.registering(cc.tweaked.gradle.NpxExecToDir::class) {
|
||||
|
||||
argumentProviders.add {
|
||||
listOf(
|
||||
"tsx", sources.dir.resolve("index.tsx").absolutePath,
|
||||
"tsx",
|
||||
sources.dir.resolve("index.tsx").absolutePath,
|
||||
illuaminateDocs.get().output.getAbsolutePath(),
|
||||
sources.dir.resolve("export/index.json").absolutePath,
|
||||
output.getAbsolutePath(),
|
||||
|
@ -2,52 +2,48 @@
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import terser from "@rollup/plugin-terser";
|
||||
import resolve from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import url from "@rollup/plugin-url";
|
||||
import postcss from "rollup-plugin-postcss";
|
||||
|
||||
const input = "src/frontend";
|
||||
const requirejs = readFileSync("../../node_modules/requirejs/require.js");
|
||||
|
||||
/** @type import("rollup").RollupOptions */
|
||||
export default {
|
||||
const minify = args => !args.configDebug;
|
||||
|
||||
/** @type import("rollup").RollupOptionsFunction */
|
||||
export default args => ({
|
||||
input: [`${input}/index.tsx`],
|
||||
output: {
|
||||
// Also defined in build.gradle.kts
|
||||
dir: "build/rollup/",
|
||||
|
||||
// We bundle requirejs (and config) into the header. It's rather gross
|
||||
// but also works reasonably well.
|
||||
// Also suffix a ?v=${date} onto the end in the event we need to require a specific copy-cat version.
|
||||
banner: `
|
||||
${requirejs}
|
||||
require.config({
|
||||
paths: { copycat: "https://copy-cat.squiddev.cc" },
|
||||
urlArgs: function(id) { return id == "copycat/embed" ? "?v=20211221" : ""; }
|
||||
});
|
||||
`,
|
||||
format: "amd",
|
||||
format: "esm",
|
||||
generatedCode: {
|
||||
preset: "es2015",
|
||||
constBindings: true,
|
||||
},
|
||||
amd: {
|
||||
define: "require",
|
||||
}
|
||||
},
|
||||
context: "window",
|
||||
external: ["copycat/embed"],
|
||||
|
||||
plugins: [
|
||||
typescript(),
|
||||
resolve({ browser: true }),
|
||||
|
||||
url({
|
||||
include: "**/*.dfpwm",
|
||||
include: ["**/*.dfpwm", "**/*.worker.js", "**/*.png"],
|
||||
fileName: "[name]-[hash][extname]",
|
||||
publicPath: "/",
|
||||
limit: 0,
|
||||
}),
|
||||
|
||||
postcss({
|
||||
namedExports: true,
|
||||
minimize: minify(args),
|
||||
extract: true,
|
||||
}),
|
||||
|
||||
{
|
||||
@ -59,8 +55,14 @@ export default {
|
||||
? `export default ${JSON.stringify(code)};\n`
|
||||
: null;
|
||||
},
|
||||
|
||||
async resolveId(source) {
|
||||
if (source === "cct/classes") return path.resolve("build/teaVM/classes.js");
|
||||
if (source === "cct/resources") return path.resolve("build/teaVM/resources.js");
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
terser(),
|
||||
minify(args) && terser(),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
@ -0,0 +1,150 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.builder;
|
||||
|
||||
import org.teavm.common.JsonUtil;
|
||||
import org.teavm.tooling.ConsoleTeaVMToolLog;
|
||||
import org.teavm.tooling.TeaVMProblemRenderer;
|
||||
import org.teavm.tooling.TeaVMTargetType;
|
||||
import org.teavm.tooling.TeaVMTool;
|
||||
import org.teavm.vm.TeaVMOptimizationLevel;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* The main entrypoint to our Javascript builder.
|
||||
* <p>
|
||||
* This generates both our classes and resources JS files.
|
||||
*/
|
||||
public class Builder {
|
||||
public static void main(String[] args) throws Exception {
|
||||
try (var scope = new CloseScope()) {
|
||||
var input = getPath(scope, "cct.input");
|
||||
var classpath = getPath(scope, "cct.classpath");
|
||||
var output = getFile("cct.output");
|
||||
var version = System.getProperty("cct.version");
|
||||
var minify = Boolean.parseBoolean(System.getProperty("cct.minify", "true"));
|
||||
|
||||
buildClasses(input, classpath, output, minify);
|
||||
buildResources(version, input, classpath, output);
|
||||
} catch (UncheckedIOException e) {
|
||||
throw e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private static void buildClasses(List<Path> input, List<Path> classpath, Path output, boolean minify) throws Exception {
|
||||
var remapper = new TransformingClassLoader(classpath);
|
||||
// Remap several classes to our stubs. Really we should add all of these to TeaVM, but our current
|
||||
// implementations are a bit of a hack.
|
||||
remapper.remapClass("java/nio/channels/FileChannel", "cc/tweaked/web/stub/FileChannel");
|
||||
remapper.remapClass("java/util/concurrent/locks/ReentrantLock", "cc/tweaked/web/stub/ReentrantLock");
|
||||
// Add some additional transformers.
|
||||
remapper.addTransformer(PatchCobalt::patch);
|
||||
|
||||
// Scans the main input folders for classes starting with "T", and uses them as an overlay, replacing the
|
||||
// original class with this redefinition.
|
||||
for (var file : input) {
|
||||
traverseClasses(file, (fullName, path) -> {
|
||||
var lastPart = fullName.lastIndexOf('/');
|
||||
var className = fullName.substring(lastPart + 1);
|
||||
if (className.startsWith("T") && Character.isUpperCase(className.charAt(1))) {
|
||||
var originalName = fullName.substring(0, lastPart + 1) + className.substring(1);
|
||||
System.out.printf("Replacing %s with %s\n", originalName, fullName);
|
||||
remapper.remapClass(fullName, originalName, path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Then finally start the compiler!
|
||||
var tool = new TeaVMTool();
|
||||
tool.setTargetType(TeaVMTargetType.JAVASCRIPT);
|
||||
tool.setTargetDirectory(output.toFile());
|
||||
tool.setClassLoader(remapper);
|
||||
tool.setMainClass("cc.tweaked.web.Main");
|
||||
|
||||
tool.setOptimizationLevel(TeaVMOptimizationLevel.ADVANCED);
|
||||
tool.setObfuscated(minify);
|
||||
|
||||
tool.generate();
|
||||
TeaVMProblemRenderer.describeProblems(tool.getDependencyInfo().getCallGraph(), tool.getProblemProvider(), new ConsoleTeaVMToolLog(false));
|
||||
if (!tool.getProblemProvider().getSevereProblems().isEmpty()) System.exit(1);
|
||||
}
|
||||
|
||||
private static void buildResources(String version, List<Path> input, List<Path> classpath, Path output) throws IOException {
|
||||
try (var out = Files.newBufferedWriter(output.resolve("resources.js"))) {
|
||||
out.write("export const version = \"");
|
||||
JsonUtil.writeEscapedString(out, version);
|
||||
out.write("\";\n");
|
||||
out.write("export const resources = {\n");
|
||||
|
||||
Stream.of(input, classpath).flatMap(Collection::stream).forEach(root -> {
|
||||
var start = root.resolve("data/computercraft/lua");
|
||||
if (!Files.exists(start)) return;
|
||||
|
||||
try (var walker = Files.find(start, Integer.MAX_VALUE, (p, a) -> a.isRegularFile())) {
|
||||
walker.forEach(x -> {
|
||||
try {
|
||||
out.write(" \"");
|
||||
JsonUtil.writeEscapedString(out, start.relativize(x).toString());
|
||||
out.write("\": \"");
|
||||
JsonUtil.writeEscapedString(out, Files.readString(x, StandardCharsets.UTF_8));
|
||||
out.write("\",\n");
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
|
||||
out.write("};\n");
|
||||
}
|
||||
}
|
||||
|
||||
private static void traverseClasses(Path root, BiConsumer<String, Path> child) throws IOException {
|
||||
try (var walker = Files.walk(root)) {
|
||||
walker.forEach(entry -> {
|
||||
if (Files.isDirectory(entry)) return;
|
||||
|
||||
var name = root.relativize(entry).toString();
|
||||
if (!name.endsWith(".class")) return;
|
||||
|
||||
var className = name.substring(0, name.length() - 6).replace(File.separatorChar, '/');
|
||||
|
||||
child.accept(className, entry);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Path> getPath(CloseScope scope, String name) {
|
||||
return Arrays.stream(System.getProperty(name).split(File.pathSeparator))
|
||||
.map(Path::of)
|
||||
.filter(Files::exists)
|
||||
.map(file -> {
|
||||
try {
|
||||
return Files.isDirectory(file) ? file : scope.add(FileSystems.newFileSystem(file)).getPath("/");
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static Path getFile(String name) {
|
||||
return Path.of(System.getProperty(name));
|
||||
}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.builder;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
|
||||
/**
|
||||
* A {@link Closeable} implementation which can be used to combine other {@link Closeable} instances.
|
||||
* <p>
|
||||
* This is mostly identical to the version in {@link dan200.computercraft.test.core.CloseScope}. Really they should be
|
||||
* merged, but not sure where/how to do that!
|
||||
*/
|
||||
public final class CloseScope implements Closeable {
|
||||
private final Deque<Closeable> toClose = new ArrayDeque<>();
|
||||
|
||||
public <T extends Closeable> T add(T value) {
|
||||
toClose.addLast(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
Throwable error = null;
|
||||
|
||||
AutoCloseable next;
|
||||
while ((next = toClose.pollLast()) != null) {
|
||||
try {
|
||||
next.close();
|
||||
} catch (Throwable e) {
|
||||
if (error == null) {
|
||||
error = e;
|
||||
} else {
|
||||
error.addSuppressed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error != null) CloseScope.<IOException>throwUnchecked0(error);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T extends Throwable> void throwUnchecked0(Throwable t) throws T {
|
||||
throw (T) t;
|
||||
}
|
||||
}
|
@ -0,0 +1,133 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.builder;
|
||||
|
||||
import org.objectweb.asm.ClassVisitor;
|
||||
import org.objectweb.asm.Label;
|
||||
import org.objectweb.asm.MethodVisitor;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
import static org.objectweb.asm.Opcodes.*;
|
||||
|
||||
/**
|
||||
* Patch Cobalt's {@code LuaState} and CC's {@code CobaltLuaMachine} to self-interrupt.
|
||||
* <p>
|
||||
* In normal CC:T, computers are paused/interrupted asynchronously from the {@code ComputerThread}. However, as
|
||||
* Javascript doesn't (easily) support multi-threaded code, we must find another option.
|
||||
* <p>
|
||||
* Instead, we patch {@code LuaState.isInterrupted()} to periodically return true (every 1024 instructions), and then
|
||||
* patch the interruption callback to refresh the timeout state. This means that the machine runs in very small time
|
||||
* slices which, while quite slow, ensures we never block the UI thread for very long.
|
||||
*/
|
||||
public final class PatchCobalt {
|
||||
private static final String LUA_STATE = "org/squiddev/cobalt/LuaState";
|
||||
private static final String COBALT_MACHINE = "dan200/computercraft/core/lua/CobaltLuaMachine";
|
||||
|
||||
private PatchCobalt() {
|
||||
}
|
||||
|
||||
public static ClassVisitor patch(String name, ClassVisitor visitor) {
|
||||
return switch (name) {
|
||||
case LUA_STATE -> patchLuaState(visitor);
|
||||
case COBALT_MACHINE -> patchCobaltMachine(visitor);
|
||||
default -> visitor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch Cobalt's {@code LuaState.isInterrupted()} to periodically return true.
|
||||
*
|
||||
* @param cv The original class visitor.
|
||||
* @return The transforming class visitor.
|
||||
*/
|
||||
private static ClassVisitor patchLuaState(ClassVisitor cv) {
|
||||
return new ClassVisitor(Opcodes.ASM9, cv) {
|
||||
@Override
|
||||
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
|
||||
super.visit(version, access, name, signature, superName, interfaces);
|
||||
super.visitField(ACC_PRIVATE, "$count", "I", null, null).visitEnd();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
|
||||
var mv = super.visitMethod(access, name, descriptor, signature, exceptions);
|
||||
if (mv == null) return null;
|
||||
|
||||
if (name.equals("isInterrupted")) {
|
||||
mv.visitCode();
|
||||
|
||||
// int x = $count + 1;
|
||||
mv.visitVarInsn(ALOAD, 0);
|
||||
mv.visitFieldInsn(GETFIELD, LUA_STATE, "$count", "I");
|
||||
mv.visitInsn(ICONST_1);
|
||||
mv.visitInsn(IADD);
|
||||
mv.visitLdcInsn(1023);
|
||||
mv.visitInsn(IAND);
|
||||
mv.visitVarInsn(ISTORE, 1);
|
||||
// $count = x;
|
||||
mv.visitVarInsn(ALOAD, 0);
|
||||
mv.visitVarInsn(ILOAD, 1);
|
||||
mv.visitFieldInsn(PUTFIELD, LUA_STATE, "$count", "I");
|
||||
// return x == 0;
|
||||
var label = new Label();
|
||||
mv.visitVarInsn(ILOAD, 1);
|
||||
mv.visitJumpInsn(IFNE, label);
|
||||
mv.visitInsn(ICONST_1);
|
||||
mv.visitInsn(IRETURN);
|
||||
mv.visitLabel(label);
|
||||
mv.visitInsn(ICONST_0);
|
||||
mv.visitInsn(IRETURN);
|
||||
|
||||
mv.visitMaxs(2, 2);
|
||||
mv.visitEnd();
|
||||
|
||||
return null;
|
||||
}
|
||||
return mv;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch {@code CobaltLuaMachine} to call {@code TimeoutState.refresh()} at the head of the function.
|
||||
*
|
||||
* @param cv The original class visitor.
|
||||
* @return The wrapped visitor.
|
||||
*/
|
||||
private static ClassVisitor patchCobaltMachine(ClassVisitor cv) {
|
||||
return new ClassVisitor(ASM9, cv) {
|
||||
private boolean visited = false;
|
||||
|
||||
@Override
|
||||
public @Nullable MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
|
||||
var mv = super.visitMethod(access, name, descriptor, signature, exceptions);
|
||||
if (mv == null) return null;
|
||||
|
||||
if (name.startsWith("lambda$") && descriptor.equals("()Lorg/squiddev/cobalt/interrupt/InterruptAction;")) {
|
||||
visited = true;
|
||||
return new MethodVisitor(api, mv) {
|
||||
@Override
|
||||
public void visitCode() {
|
||||
super.visitCode();
|
||||
mv.visitVarInsn(ALOAD, 0);
|
||||
mv.visitFieldInsn(GETFIELD, COBALT_MACHINE, "timeout", "Ldan200/computercraft/core/computer/TimeoutState;");
|
||||
mv.visitMethodInsn(INVOKEVIRTUAL, "dan200/computercraft/core/computer/TimeoutState", "refresh", "()V", false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return mv;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitEnd() {
|
||||
if (!visited) throw new IllegalStateException("Did not inject .refresh() into CobaltLuaMachine");
|
||||
super.visitEnd();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.builder;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassVisitor;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.commons.ClassRemapper;
|
||||
import org.objectweb.asm.commons.Remapper;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* A class loader which can {@linkplain #addTransformer(BiFunction) transform} and {@linkplain #remapClass(String, String)
|
||||
* remap/rename} classes.
|
||||
* <p>
|
||||
* When loading classes, this behaves much like {@link java.net.URLClassLoader}, loading files from a list of paths.
|
||||
* However, when the TeaVM compiler requests the class bytes for compilation, we run the class through a list of
|
||||
* transformers first, patching the classes to function in a Javascript environment.
|
||||
*/
|
||||
public class TransformingClassLoader extends ClassLoader {
|
||||
private final Map<String, String> remappedClasses = new HashMap<>();
|
||||
private final Map<String, Path> remappedResources = new HashMap<>();
|
||||
private final List<BiFunction<String, ClassVisitor, ClassVisitor>> transformers = new ArrayList<>();
|
||||
|
||||
private final Remapper remapper = new Remapper() {
|
||||
@Override
|
||||
public String map(String internalName) {
|
||||
return remappedClasses.getOrDefault(internalName, internalName);
|
||||
}
|
||||
};
|
||||
|
||||
private final List<Path> classpath;
|
||||
|
||||
// Cache of the last transformed file - TeaVM tends to call getResourceAsStream multiple times.
|
||||
private @Nullable TransformedClass lastFile;
|
||||
|
||||
public TransformingClassLoader(List<Path> classpath) {
|
||||
this.classpath = classpath;
|
||||
|
||||
addTransformer((name, cv) -> new ClassRemapper(cv, remapper));
|
||||
}
|
||||
|
||||
public void addTransformer(BiFunction<String, ClassVisitor, ClassVisitor> transform) {
|
||||
transformers.add(transform);
|
||||
}
|
||||
|
||||
public void remapClass(String from, String to, Path fromLocation) {
|
||||
remappedClasses.put(from, to);
|
||||
remappedResources.put(to + ".class", fromLocation);
|
||||
}
|
||||
|
||||
public void remapClass(String from, String to) {
|
||||
remappedClasses.put(from, to);
|
||||
}
|
||||
|
||||
private @Nullable Path findUnmappedFile(String name) {
|
||||
return classpath.stream().map(x -> x.resolve(name)).filter(Files::exists).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private @Nullable Path findFile(String name) {
|
||||
var path = remappedResources.get(name);
|
||||
return path != null ? path : findUnmappedFile(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> findClass(String name) throws ClassNotFoundException {
|
||||
// findClass is only called at compile time, and so we load the original class, not the remapped one.
|
||||
// Yes, this is super cursed.
|
||||
var path = findUnmappedFile(name.replace('.', '/') + ".class");
|
||||
if (path == null) throw new ClassNotFoundException();
|
||||
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = Files.readAllBytes(path);
|
||||
} catch (IOException e) {
|
||||
throw new ClassNotFoundException("Failed reading " + name, e);
|
||||
}
|
||||
|
||||
return defineClass(name, bytes, 0, bytes.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable InputStream getResourceAsStream(String name) {
|
||||
if (!name.endsWith(".class")) return super.getResourceAsStream(name);
|
||||
|
||||
var lastFile = this.lastFile;
|
||||
if (lastFile != null && lastFile.name().equals(name)) return new ByteArrayInputStream(lastFile.contents());
|
||||
|
||||
var path = findFile(name);
|
||||
if (path == null) return null;
|
||||
|
||||
ClassReader reader;
|
||||
try (var stream = Files.newInputStream(path)) {
|
||||
reader = new ClassReader(stream);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Failed reading " + name, e);
|
||||
}
|
||||
|
||||
var writer = new ClassWriter(reader, 0);
|
||||
var className = reader.getClassName();
|
||||
ClassVisitor sink = writer;
|
||||
for (var transformer : transformers) sink = transformer.apply(className, sink);
|
||||
reader.accept(sink, 0);
|
||||
|
||||
var bytes = writer.toByteArray();
|
||||
this.lastFile = new TransformedClass(name, bytes);
|
||||
return new ByteArrayInputStream(bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable URL findResource(String name) {
|
||||
var path = findFile(name);
|
||||
return path == null ? null : toURL(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Enumeration<URL> findResources(String name) {
|
||||
var path = remappedResources.get(name);
|
||||
return new IteratorEnumeration<>(
|
||||
(path == null ? classpath.stream().map(x -> x.resolve(name)) : Stream.of(path))
|
||||
.filter(Files::exists)
|
||||
.map(TransformingClassLoader::toURL)
|
||||
.iterator()
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("JdkObsolete")
|
||||
private record IteratorEnumeration<T>(Iterator<T> iterator) implements Enumeration<T> {
|
||||
@Override
|
||||
public boolean hasMoreElements() {
|
||||
return iterator.hasNext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public T nextElement() {
|
||||
return iterator.next();
|
||||
}
|
||||
}
|
||||
|
||||
private static URL toURL(Path path) {
|
||||
try {
|
||||
return path.toUri().toURL();
|
||||
} catch (MalformedURLException e) {
|
||||
throw new IllegalStateException("Cannot convert " + path + " to a URL", e);
|
||||
}
|
||||
}
|
||||
|
||||
private record TransformedClass(String name, byte[] contents) {
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
@DefaultQualifier(value = NonNull.class, locations = {
|
||||
TypeUseLocation.RETURN,
|
||||
TypeUseLocation.PARAMETER,
|
||||
TypeUseLocation.FIELD,
|
||||
})
|
||||
package cc.tweaked.web.builder;
|
||||
|
||||
import org.checkerframework.checker.nullness.qual.NonNull;
|
||||
import org.checkerframework.framework.qual.DefaultQualifier;
|
||||
import org.checkerframework.framework.qual.TypeUseLocation;
|
141
projects/web/src/frontend/emu/computer.ts
Normal file
141
projects/web/src/frontend/emu/computer.ts
Normal file
@ -0,0 +1,141 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import { type ComputerActionable, type KeyCode, type LuaValue, Semaphore, TerminalData, lwjgl3Code } from "@squid-dev/cc-web-term";
|
||||
import { type ComputerDisplay, type ComputerHandle, type PeripheralKind, type Side, start } from "./java";
|
||||
|
||||
const colours = "0123456789abcdef";
|
||||
|
||||
/**
|
||||
* A reference to an emulated computer.
|
||||
*
|
||||
* This acts as a bridge between the Java-side computer and our Javascript code,
|
||||
* including the terminal renderer and the main computer component.
|
||||
*/
|
||||
class EmulatedComputer implements ComputerDisplay, ComputerActionable {
|
||||
public readonly terminal: TerminalData = new TerminalData();
|
||||
public readonly semaphore: Semaphore = new Semaphore();
|
||||
private readonly stateChanged: (label: string | null, on: boolean) => void;
|
||||
|
||||
private label: string | null = null;
|
||||
|
||||
private computer?: ComputerHandle;
|
||||
private callbacks: Array<(cb: ComputerHandle) => void> = [];
|
||||
private removed: boolean = false;
|
||||
|
||||
public constructor(stateChange: (label: string | null, on: boolean) => void) {
|
||||
this.stateChanged = stateChange;
|
||||
this.label = null;
|
||||
}
|
||||
|
||||
public getLabel(): string | null {
|
||||
return this.label;
|
||||
}
|
||||
|
||||
public setState(label: string | null, on: boolean): void {
|
||||
if (this.label !== label) this.label = label;
|
||||
this.stateChanged(label, on);
|
||||
}
|
||||
|
||||
public updateTerminal(
|
||||
width: number, height: number,
|
||||
x: number, y: number, blink: boolean, cursorColour: number,
|
||||
): void {
|
||||
this.terminal.resize(width, height);
|
||||
this.terminal.cursorX = x;
|
||||
this.terminal.cursorY = y;
|
||||
this.terminal.cursorBlink = blink;
|
||||
this.terminal.currentFore = colours.charAt(cursorColour);
|
||||
}
|
||||
|
||||
public setTerminalLine(line: number, text: string, fore: string, back: string): void {
|
||||
this.terminal.text[line] = text;
|
||||
this.terminal.fore[line] = fore;
|
||||
this.terminal.back[line] = back;
|
||||
}
|
||||
|
||||
public setPaletteColour(colour: number, r: number, g: number, b: number): void {
|
||||
this.terminal.palette[colours.charAt(colour)] =
|
||||
`rgb(${(r * 0xFF) & 0xFF},${(g * 0xFF) & 0xFF},${(b * 0xFF) & 0xFF})`;
|
||||
}
|
||||
|
||||
public flushTerminal(): void {
|
||||
this.semaphore.signal();
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
start(this)
|
||||
.then(computer => {
|
||||
this.computer = computer;
|
||||
if (this.removed) computer.dispose();
|
||||
for (const callback of this.callbacks) callback(computer);
|
||||
})
|
||||
.catch(e => {
|
||||
const width = this.terminal.sizeX;
|
||||
const fg = "0".repeat(width);
|
||||
const bg = "e".repeat(width);
|
||||
|
||||
const message = `${e}`.replace(/(?![^\n]{1,51}$)([^\n]{1,51})\s/, "$1\n").split("\n");
|
||||
for (let y = 0; y < this.terminal.sizeY; y++) {
|
||||
const text = message[y] ?? "";
|
||||
this.terminal.text[y] = text.length > width ? text.substring(0, width) : text + " ".repeat(width - text.length);
|
||||
this.terminal.fore[y] = fg;
|
||||
this.terminal.back[y] = bg;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public queueEvent(event: string, args: Array<LuaValue>): void {
|
||||
if (this.computer !== undefined) this.computer.event(event, args);
|
||||
}
|
||||
|
||||
public keyDown(key: KeyCode, repeat: boolean): void {
|
||||
const code = lwjgl3Code(key);
|
||||
if (code !== undefined) this.queueEvent("key", [code, repeat]);
|
||||
}
|
||||
|
||||
public keyUp(key: KeyCode): void {
|
||||
const code = lwjgl3Code(key);
|
||||
if (code !== undefined) this.queueEvent("key_up", [code]);
|
||||
}
|
||||
|
||||
public turnOn(): void {
|
||||
this.computer?.turnOn();
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
this.computer?.shutdown();
|
||||
}
|
||||
|
||||
public reboot(): void {
|
||||
this.computer?.reboot();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.removed = true;
|
||||
this.computer?.dispose();
|
||||
}
|
||||
|
||||
public transferFiles(files: Array<{ name: string, contents: ArrayBuffer }>): void {
|
||||
this.computer?.transferFiles(files);
|
||||
}
|
||||
|
||||
public setPeripheral(side: Side, kind: PeripheralKind | null): void {
|
||||
if (this.computer) {
|
||||
this.computer.setPeripheral(side, kind);
|
||||
} else {
|
||||
this.callbacks.push(handler => handler.setPeripheral(side, kind));
|
||||
}
|
||||
}
|
||||
|
||||
public addFile(path: string, contents: string | ArrayBuffer): void {
|
||||
if (this.computer) {
|
||||
this.computer.addFile(path, contents);
|
||||
} else {
|
||||
this.callbacks.push(handler => handler.addFile(path, contents));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EmulatedComputer;
|
51
projects/web/src/frontend/emu/index.tsx
Normal file
51
projects/web/src/frontend/emu/index.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import { type FunctionalComponent, h } from "preact";
|
||||
import type { PeripheralKind, Side } from "./java";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { Terminal } from "@squid-dev/cc-web-term";
|
||||
import EmulatedComputer from "./computer";
|
||||
import termFont from "@squid-dev/cc-web-term/assets/term_font.png";
|
||||
|
||||
export type ComputerProps = {
|
||||
files?: Record<string, string | ArrayBuffer>,
|
||||
peripherals?: Partial<Record<Side, PeripheralKind | null>>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a computer in the world.
|
||||
*
|
||||
* @param props The properties for this component
|
||||
* @returns The resulting JSX element.
|
||||
*/
|
||||
const Computer: FunctionalComponent<ComputerProps> = ({ files, peripherals }) => {
|
||||
const [label, setLabel] = useState<string | null>(null);
|
||||
const [isOn, setOn] = useState(false);
|
||||
const computer = useMemo(() => {
|
||||
const computer = new EmulatedComputer((label, on) => {
|
||||
setLabel(label);
|
||||
setOn(on);
|
||||
});
|
||||
for (const [side, peripheral] of Object.entries(peripherals ?? {})) {
|
||||
computer.setPeripheral(side as Side, peripheral);
|
||||
}
|
||||
for (const [path, contents] of Object.entries(files ?? {})) {
|
||||
computer.addFile(path, contents);
|
||||
}
|
||||
return computer;
|
||||
}, [setLabel, setOn, files, peripherals]);
|
||||
|
||||
useEffect(() => {
|
||||
computer.start();
|
||||
return () => computer.dispose();
|
||||
}, [computer]);
|
||||
|
||||
return <Terminal
|
||||
id={0} label={label} on={isOn} focused={true} font={termFont}
|
||||
computer={computer} terminal={computer.terminal} changed={computer.semaphore}
|
||||
/>;
|
||||
};
|
||||
|
||||
export default Computer;
|
38
projects/web/src/frontend/emu/java.ts
Normal file
38
projects/web/src/frontend/emu/java.ts
Normal file
@ -0,0 +1,38 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import "setimmediate";
|
||||
|
||||
import type { ComputerDisplay, ComputerHandle } from "cct/classes";
|
||||
export type { ComputerDisplay, ComputerHandle, PeripheralKind, Side } from "cct/classes";
|
||||
|
||||
const load = async (): Promise<(computer: ComputerDisplay) => ComputerHandle> => {
|
||||
const [classes, { version, resources }] = await Promise.all([import("cct/classes"), import("cct/resources")]);
|
||||
|
||||
let addComputer: ((computer: ComputerDisplay) => ComputerHandle) | null = null;
|
||||
const encoder = new TextEncoder();
|
||||
window.$javaCallbacks = {
|
||||
setup: add => addComputer = add,
|
||||
modVersion: version,
|
||||
listResources: () => Object.keys(resources),
|
||||
getResource: path => new Int8Array(encoder.encode(resources[path]))
|
||||
};
|
||||
classes.main();
|
||||
|
||||
if (!addComputer) throw new Error("Callbacks.setup was never called");
|
||||
return addComputer;
|
||||
};
|
||||
|
||||
let addComputer: Promise<(computer: ComputerDisplay) => ComputerHandle> | null = null;
|
||||
|
||||
/**
|
||||
* Load our emulator and start a new computer.
|
||||
*
|
||||
* @param computer The display the computer's terminal should be drawn to.
|
||||
* @returns The {@link ComputerHandle} for this computer.
|
||||
*/
|
||||
export const start = (computer: ComputerDisplay): Promise<ComputerHandle> => {
|
||||
if (addComputer == null) addComputer = load();
|
||||
return addComputer.then(f => f(computer));
|
||||
};
|
@ -2,8 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import { Component, Computer, h, render, type PeripheralKind } from "copycat/embed";
|
||||
import type { ComponentChild, FunctionalComponent } from "preact";
|
||||
import { Component, type ComponentChild, type FunctionalComponent, h, render } from "preact";
|
||||
|
||||
import settingsFile from "./mount/.settings";
|
||||
import exampleAudioUrl from "./mount/example.dfpwm";
|
||||
@ -12,6 +11,9 @@ import exampleNfp from "./mount/example.nfp";
|
||||
import exampleNft from "./mount/example.nft";
|
||||
import exprTemplate from "./mount/expr_template.lua";
|
||||
import startupFile from "./mount/startup.lua";
|
||||
import type { PeripheralKind } from "./emu/java";
|
||||
import Computer from "./emu";
|
||||
import "./styles.css";
|
||||
|
||||
const defaultFiles: Record<string, string> = {
|
||||
".settings": settingsFile,
|
||||
@ -25,24 +27,24 @@ const clamp = (value: number, min: number, max: number): number => {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (url: string): Promise<Uint8Array> => {
|
||||
const download = async (url: string): Promise<ArrayBuffer> => {
|
||||
const result = await fetch(url);
|
||||
if (result.status != 200) throw new Error(`${url} responded with ${result.status} ${result.statusText}`);
|
||||
|
||||
return new Uint8Array(await result.arrayBuffer());
|
||||
return await result.arrayBuffer();
|
||||
};
|
||||
|
||||
let dfpwmAudio: Promise<Uint8Array> | null = null;
|
||||
let dfpwmAudio: Promise<ArrayBuffer> | null = null;
|
||||
|
||||
const Click: FunctionalComponent<{ run: () => void }> = ({ run }) =>
|
||||
<button type="button" class="example-run" onClick={run}>Run ᐅ</button>
|
||||
<button type="button" class="example-run" onClick={run}>Run ᐅ</button>;
|
||||
|
||||
type WindowProps = {};
|
||||
|
||||
type Example = {
|
||||
files: Record<string, string | Uint8Array>,
|
||||
files: Record<string, string | ArrayBuffer>,
|
||||
peripheral: PeripheralKind | null,
|
||||
}
|
||||
|
||||
@ -58,7 +60,7 @@ class Window extends Component<WindowProps, WindowState> {
|
||||
private top: number = 0;
|
||||
private dragging?: { downX: number, downY: number, initialX: number, initialY: number };
|
||||
|
||||
private snippets: { [file: string]: string } = {};
|
||||
private snippets: Record<string, string> = {};
|
||||
|
||||
constructor(props: WindowProps, context: unknown) {
|
||||
super(props, context);
|
||||
@ -67,10 +69,10 @@ class Window extends Component<WindowProps, WindowState> {
|
||||
visible: false,
|
||||
example: null,
|
||||
exampleIdx: 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
componentDidMount(): void {
|
||||
const elements = document.querySelectorAll("pre[data-lua-kind]");
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i] as HTMLElement;
|
||||
@ -88,11 +90,11 @@ class Window extends Component<WindowProps, WindowState> {
|
||||
|
||||
const mount = element.getAttribute("data-mount");
|
||||
const peripheral = element.getAttribute("data-peripheral");
|
||||
render(<Click run={this.runExample(example, mount, peripheral)} />, element.parentElement!!);
|
||||
render(<Click run={this.runExample(example, mount, peripheral)} />, element.parentElement!);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(_: WindowProps, { visible }: WindowState) {
|
||||
componentDidUpdate(_: WindowProps, { visible }: WindowState): void {
|
||||
if (!visible && this.state.visible) this.setPosition(this.left, this.top);
|
||||
}
|
||||
|
||||
@ -120,7 +122,7 @@ class Window extends Component<WindowProps, WindowState> {
|
||||
this.top = 20;
|
||||
}
|
||||
|
||||
const files: Record<string, string | Uint8Array> = { "example.lua": example };
|
||||
const files: Record<string, string | ArrayBuffer> = { "example.lua": example };
|
||||
if (mount !== null) {
|
||||
for (const toMount of mount.split(",")) {
|
||||
const [name, path] = toMount.split(":", 2);
|
||||
@ -147,14 +149,14 @@ class Window extends Component<WindowProps, WindowState> {
|
||||
},
|
||||
exampleIdx: exampleIdx + 1,
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private readonly close = () => this.setState({ visible: false });
|
||||
private readonly close = (): void => this.setState({ visible: false });
|
||||
|
||||
// All the dragging code is terrible. However, I've had massive performance
|
||||
// issues doing it other ways, so this'll have to do.
|
||||
private onDown(e: Event, touch: Touch) {
|
||||
private onDown(e: Event, touch: Touch): void {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
@ -168,10 +170,10 @@ class Window extends Component<WindowProps, WindowState> {
|
||||
window.addEventListener("mouseup", this.onUp, true);
|
||||
window.addEventListener("touchend", this.onUp, true);
|
||||
}
|
||||
private readonly onMouseDown = (e: MouseEvent) => this.onDown(e, e);
|
||||
private readonly onTouchDown = (e: TouchEvent) => this.onDown(e, e.touches[0]);
|
||||
private readonly onMouseDown = (e: MouseEvent): void => this.onDown(e, e);
|
||||
private readonly onTouchDown = (e: TouchEvent): void => this.onDown(e, e.touches[0]);
|
||||
|
||||
private onDrag(e: Event, touch: Touch) {
|
||||
private onDrag(e: Event, touch: Touch): void {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
@ -182,11 +184,11 @@ class Window extends Component<WindowProps, WindowState> {
|
||||
dragging.initialX + (touch.clientX - dragging.downX),
|
||||
dragging.initialY + (touch.clientY - dragging.downY),
|
||||
);
|
||||
};
|
||||
private readonly onMouseDrag = (e: MouseEvent) => this.onDrag(e, e);
|
||||
private readonly onTouchDrag = (e: TouchEvent) => this.onDrag(e, e.touches[0]);
|
||||
}
|
||||
private readonly onMouseDrag = (e: MouseEvent): void => this.onDrag(e, e);
|
||||
private readonly onTouchDrag = (e: TouchEvent): void => this.onDrag(e, e.touches[0]);
|
||||
|
||||
private readonly onUp = (e: Event) => {
|
||||
private readonly onUp = (e: Event): void => {
|
||||
e.stopPropagation();
|
||||
|
||||
this.dragging = undefined;
|
||||
@ -195,7 +197,7 @@ class Window extends Component<WindowProps, WindowState> {
|
||||
window.removeEventListener("touchmove", this.onTouchDrag, true);
|
||||
window.removeEventListener("mouseup", this.onUp, true);
|
||||
window.removeEventListener("touchend", this.onUp, true);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly setPosition = (left: number, top: number): void => {
|
||||
const root = this.base as HTMLElement;
|
||||
@ -203,10 +205,10 @@ class Window extends Component<WindowProps, WindowState> {
|
||||
left = this.left = clamp(left, 0, window.innerWidth - root.offsetWidth);
|
||||
top = this.top = clamp(top, 0, window.innerHeight - root.offsetHeight);
|
||||
root.style.transform = `translate(${left}px, ${top}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
const root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
render(<Window />, document.body, root);
|
||||
render(<Window />, root);
|
||||
|
@ -17,7 +17,6 @@ declare module "*.nft" {
|
||||
export default contents;
|
||||
}
|
||||
|
||||
|
||||
declare module "*.settings" {
|
||||
const contents: string;
|
||||
export default contents;
|
||||
@ -33,33 +32,147 @@ declare module "*.dfpwm" {
|
||||
export default contents;
|
||||
}
|
||||
|
||||
declare module "cct/resources" {
|
||||
export const version: string;
|
||||
export const resources: Record<string, string>;
|
||||
}
|
||||
|
||||
declare module "copycat/embed" {
|
||||
import { h, Component, render, ComponentChild } from "preact";
|
||||
declare module "cct/classes" {
|
||||
export const main: () => void;
|
||||
|
||||
export type Side = "up" | "down" | "left" | "right" | "front" | "back";
|
||||
export type PeripheralKind = "speaker";
|
||||
|
||||
export { h, Component, render };
|
||||
/**
|
||||
* Controls a specific computer on the Javascript side. See {@code js/ComputerAccess.java}.
|
||||
*/
|
||||
export interface ComputerDisplay {
|
||||
/**
|
||||
* Set this computer's current state
|
||||
*
|
||||
* @param label This computer's label
|
||||
* @param on If this computer is on right now
|
||||
*/
|
||||
setState(label: string | null, on: boolean): void;
|
||||
|
||||
export type ComputerAccess = unknown;
|
||||
/**
|
||||
* Update the terminal's properties
|
||||
*
|
||||
* @param width The terminal width
|
||||
* @param height The terminal height
|
||||
* @param x The X cursor
|
||||
* @param y The Y cursor
|
||||
* @param blink Whether the cursor is blinking
|
||||
* @param cursorColour The cursor's colour
|
||||
*/
|
||||
updateTerminal(width: number, height: number, x: number, y: number, blink: boolean, cursorColour: number): void;
|
||||
|
||||
export type MainProps = {
|
||||
hdFont?: boolean | string,
|
||||
persistId?: number,
|
||||
files?: { [filename: string]: string | ArrayBuffer },
|
||||
label?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
resolve?: (computer: ComputerAccess) => void,
|
||||
peripherals?: {
|
||||
[side in Side]?: PeripheralKind | null
|
||||
},
|
||||
/**
|
||||
* Set a line on the terminal
|
||||
*
|
||||
* @param line The line index to set
|
||||
* @param text The line's text
|
||||
* @param fore The line's foreground
|
||||
* @param back The line's background
|
||||
*/
|
||||
setTerminalLine(line: number, text: string, fore: string, back: string): void;
|
||||
|
||||
/**
|
||||
* Set the palette colour for a specific index
|
||||
*
|
||||
* @param colour The colour index to set
|
||||
* @param r The red value, between 0 and 1
|
||||
* @param g The green value, between 0 and 1
|
||||
* @param b The blue value, between 0 and 1
|
||||
*/
|
||||
setPaletteColour(colour: number, r: number, g: number, b: number): void;
|
||||
|
||||
/**
|
||||
* Mark the terminal as having changed. Should be called after all other terminal methods.
|
||||
*/
|
||||
flushTerminal(): void;
|
||||
}
|
||||
|
||||
class Computer extends Component<MainProps, unknown> {
|
||||
public render(props: MainProps, state: unknown): ComponentChild;
|
||||
export interface ComputerHandle {
|
||||
/**
|
||||
* Queue an event on the computer.
|
||||
*/
|
||||
event(event: string, args: Array<unknown> | null): void;
|
||||
|
||||
/**
|
||||
* Shut the computer down.
|
||||
*/
|
||||
shutdown(): void;
|
||||
|
||||
/**
|
||||
* Turn the computer on.
|
||||
*/
|
||||
turnOn(): void;
|
||||
|
||||
/**
|
||||
* Reboot the computer.
|
||||
*/
|
||||
reboot(): void;
|
||||
|
||||
/**
|
||||
* Dispose of this computer, marking it as no longer running.
|
||||
*/
|
||||
dispose(): void;
|
||||
|
||||
/**
|
||||
* Transfer some files to this computer.
|
||||
*
|
||||
* @param files A list of files and their contents.
|
||||
*/
|
||||
transferFiles(files: Array<{ name: string, contents: ArrayBuffer }>): void;
|
||||
|
||||
/**
|
||||
* Set a peripheral on a particular side
|
||||
*
|
||||
* @param side The side to set the peripheral on.
|
||||
* @param kind The kind of peripheral. For now, can only be "speaker".
|
||||
*/
|
||||
setPeripheral(side: Side, kind: PeripheralKind | null): void;
|
||||
|
||||
/**
|
||||
* Add a file to this computer's filesystem.
|
||||
* @param path The path of the file.
|
||||
* @param contents The path to the file.
|
||||
*/
|
||||
addFile(path: string, contents: string | ArrayBuffer): void;
|
||||
}
|
||||
|
||||
export { Computer };
|
||||
export interface Callbacks {
|
||||
/**
|
||||
* Get the current callback instance
|
||||
*
|
||||
* @param addComputer A computer to add a new computer.
|
||||
*/
|
||||
setup(addComputer: (computer: ComputerDisplay) => ComputerHandle): void;
|
||||
|
||||
/**
|
||||
* The version of CC: Tweaked currently loaded.
|
||||
*/
|
||||
modVersion: string;
|
||||
|
||||
/**
|
||||
* List all resources available in the ROM.
|
||||
*/
|
||||
listResources(): Array<string>;
|
||||
|
||||
/**
|
||||
* Load a resource from the ROM.
|
||||
*
|
||||
* @param path The path to the resource to load.
|
||||
*/
|
||||
getResource(path: string): Int8Array;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace JSX {
|
||||
export type Element = import("preact").JSX.Element;
|
||||
export type IntrinsicElements = import("preact").JSX.IntrinsicElements;
|
||||
export type ElementClass = import("preact").JSX.ElementClass;
|
||||
}
|
||||
|
||||
declare var $javaCallbacks: import("cct/classes").Callbacks; // eslint-disable-line no-var
|
||||
|
@ -2,7 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import { h, type FunctionComponent, type JSX } from "preact";
|
||||
import { type FunctionComponent, type JSX, h } from "preact";
|
||||
import useExport from "./WithExport";
|
||||
|
||||
const Item: FunctionComponent<{ item: string }> = ({ item }) => {
|
||||
@ -14,12 +14,12 @@ const Item: FunctionComponent<{ item: string }> = ({ item }) => {
|
||||
alt={itemName}
|
||||
title={itemName}
|
||||
className="recipe-icon"
|
||||
/>
|
||||
/>;
|
||||
};
|
||||
|
||||
const EmptyItem: FunctionComponent = () => <span className="recipe-icon " />;
|
||||
|
||||
const Arrow: FunctionComponent<JSX.IntrinsicElements["svg"]> = (props) => <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.513 45.512" {...props}>
|
||||
const Arrow: FunctionComponent<JSX.IntrinsicElements["svg"]> = props => <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.513 45.512" {...props}>
|
||||
<g>
|
||||
<path d="M44.275,19.739L30.211,5.675c-0.909-0.909-2.275-1.18-3.463-0.687c-1.188,0.493-1.959,1.654-1.956,2.938l0.015,5.903
|
||||
l-21.64,0.054C1.414,13.887-0.004,15.312,0,17.065l0.028,11.522c0.002,0.842,0.338,1.648,0.935,2.242s1.405,0.927,2.247,0.925
|
||||
@ -43,7 +43,7 @@ const Recipe: FunctionComponent<{ recipe: string }> = ({ recipe }) => {
|
||||
<Item item={recipeInfo.output} />
|
||||
{recipeInfo.count > 1 && <span className="recipe-count">{recipeInfo.count}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default Recipe;
|
||||
|
@ -2,7 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import { h, createContext, type FunctionComponent, type VNode } from "preact";
|
||||
import { type FunctionComponent, type VNode, createContext, h } from "preact";
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
export type DataExport = {
|
||||
@ -21,7 +21,7 @@ const DataExport = createContext<DataExport>({
|
||||
recipes: {},
|
||||
});
|
||||
|
||||
export const useExport = () => useContext(DataExport);
|
||||
export const useExport = (): DataExport => useContext(DataExport);
|
||||
export default useExport;
|
||||
|
||||
export const WithExport: FunctionComponent<{ data: DataExport, children: VNode }> =
|
||||
|
@ -26,4 +26,4 @@ export const noChildren = function <T>(component: FunctionComponent<T>): Functio
|
||||
};
|
||||
wrapped.displayName = name;
|
||||
return wrapped;
|
||||
}
|
||||
};
|
||||
|
@ -13,17 +13,17 @@
|
||||
import fs from "fs/promises";
|
||||
import { glob } from "glob";
|
||||
import path from "path";
|
||||
import { h, type JSX } from "preact";
|
||||
import { type JSX, h } from "preact";
|
||||
import renderToStaticMarkup from "preact-render-to-string";
|
||||
import * as runtime from "preact/jsx-runtime";
|
||||
import rehypeHighlight, { type Options as HighlightOptions } from "rehype-highlight";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import rehypeParse from "rehype-parse";
|
||||
import rehypeReact, { type Options as ReactOptions } from "rehype-react";
|
||||
import { unified, type Plugin } from "unified";
|
||||
import { unified } from "unified";
|
||||
// Our components
|
||||
import Recipe from "./components/Recipe";
|
||||
import { noChildren } from "./components/support";
|
||||
import { WithExport, type DataExport } from "./components/WithExport";
|
||||
import { type DataExport, WithExport } from "./components/WithExport";
|
||||
|
||||
(async () => {
|
||||
if (process.argv.length !== 5) {
|
||||
@ -43,13 +43,13 @@ import { WithExport, type DataExport } from "./components/WithExport";
|
||||
// Run button into them.
|
||||
["pre"]: (args: JSX.IntrinsicElements["pre"] & { "data-lua-kind"?: undefined }) => {
|
||||
const element = <pre {...args} />;
|
||||
return args["data-lua-kind"] ? <div className="lua-example">{element}</div> : element
|
||||
}
|
||||
return args["data-lua-kind"] ? <div className="lua-example">{element}</div> : element;
|
||||
}
|
||||
} as any,
|
||||
};
|
||||
const processor = unified()
|
||||
.use(rehypeParse, { emitParseErrors: true })
|
||||
.use(rehypeHighlight as unknown as Plugin<[HighlightOptions], import("hast").Root>, { prefix: "" })
|
||||
.use(rehypeHighlight, { prefix: "" })
|
||||
.use(rehypeReact, reactOptions);
|
||||
|
||||
const dataExport = JSON.parse(await fs.readFile(dataFile, "utf-8")) as DataExport;
|
||||
|
209
projects/web/src/main/java/cc/tweaked/web/EmulatedComputer.java
Normal file
209
projects/web/src/main/java/cc/tweaked/web/EmulatedComputer.java
Normal file
@ -0,0 +1,209 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web;
|
||||
|
||||
import cc.tweaked.web.js.ComputerDisplay;
|
||||
import cc.tweaked.web.js.ComputerHandle;
|
||||
import cc.tweaked.web.js.JavascriptConv;
|
||||
import cc.tweaked.web.peripheral.SpeakerPeripheral;
|
||||
import cc.tweaked.web.peripheral.TickablePeripheral;
|
||||
import dan200.computercraft.api.filesystem.WritableMount;
|
||||
import dan200.computercraft.api.peripheral.IPeripheral;
|
||||
import dan200.computercraft.core.ComputerContext;
|
||||
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
|
||||
import dan200.computercraft.core.apis.transfer.TransferredFile;
|
||||
import dan200.computercraft.core.apis.transfer.TransferredFiles;
|
||||
import dan200.computercraft.core.computer.Computer;
|
||||
import dan200.computercraft.core.computer.ComputerEnvironment;
|
||||
import dan200.computercraft.core.computer.ComputerSide;
|
||||
import dan200.computercraft.core.filesystem.MemoryMount;
|
||||
import dan200.computercraft.core.metrics.Metric;
|
||||
import dan200.computercraft.core.metrics.MetricsObserver;
|
||||
import dan200.computercraft.core.terminal.Terminal;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.teavm.jso.JSObject;
|
||||
import org.teavm.jso.core.JSString;
|
||||
import org.teavm.jso.typedarrays.ArrayBuffer;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Manages the core lifecycle of an emulated {@link Computer}.
|
||||
* <p>
|
||||
* This is exposed to Javascript via the {@link ComputerHandle} interface.
|
||||
*/
|
||||
class EmulatedComputer implements ComputerEnvironment, ComputerHandle, MetricsObserver {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(EmulatedComputer.class);
|
||||
|
||||
private static final ComputerSide[] SIDES = ComputerSide.values();
|
||||
private boolean terminalChanged = false;
|
||||
private final Terminal terminal = new Terminal(51, 19, true, () -> terminalChanged = true);
|
||||
private final Computer computer;
|
||||
private final ComputerDisplay computerAccess;
|
||||
private boolean disposed = false;
|
||||
private final MemoryMount mount = new MemoryMount();
|
||||
|
||||
EmulatedComputer(ComputerContext context, ComputerDisplay computerAccess) {
|
||||
this.computerAccess = computerAccess;
|
||||
this.computer = new Computer(context, this, terminal, 0);
|
||||
|
||||
if (!disposed) computer.turnOn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick this computer.
|
||||
*
|
||||
* @return If this computer has been disposed of.
|
||||
*/
|
||||
public boolean tick() {
|
||||
if (disposed && computer.isOn()) computer.unload();
|
||||
|
||||
try {
|
||||
computer.tick();
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("Error when ticking computer", e);
|
||||
}
|
||||
|
||||
if (computer.pollAndResetChanged()) {
|
||||
computerAccess.setState(computer.getLabel(), computer.isOn());
|
||||
}
|
||||
|
||||
for (var side : SIDES) {
|
||||
var peripheral = computer.getEnvironment().getPeripheral(side);
|
||||
if (peripheral instanceof TickablePeripheral toTick) toTick.tick();
|
||||
}
|
||||
|
||||
if (terminalChanged) {
|
||||
terminalChanged = false;
|
||||
computerAccess.updateTerminal(
|
||||
terminal.getWidth(), terminal.getHeight(),
|
||||
terminal.getCursorX(), terminal.getCursorY(),
|
||||
terminal.getCursorBlink(), terminal.getTextColour()
|
||||
);
|
||||
|
||||
for (var i = 0; i < terminal.getHeight(); i++) {
|
||||
computerAccess.setTerminalLine(i,
|
||||
terminal.getLine(i).toString(),
|
||||
terminal.getTextColourLine(i).toString(),
|
||||
terminal.getBackgroundColourLine(i).toString()
|
||||
);
|
||||
}
|
||||
|
||||
var palette = terminal.getPalette();
|
||||
for (var i = 0; i < 16; i++) {
|
||||
var colours = palette.getColour(i);
|
||||
computerAccess.setPaletteColour(15 - i, colours[0], colours[1], colours[2]);
|
||||
}
|
||||
|
||||
computerAccess.flushTerminal();
|
||||
}
|
||||
|
||||
return disposed && !computer.isOn();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDay() {
|
||||
return (int) ((Main.getTicks() + 6000) / 24000) + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getTimeOfDay() {
|
||||
return ((Main.getTicks() + 6000) % 24000) / 1000.0;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public WritableMount createRootMount() {
|
||||
return mount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MetricsObserver getMetrics() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void observe(Metric.Counter counter) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void observe(Metric.Event event, long value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void event(String event, @Nullable JSObject[] args) {
|
||||
computer.queueEvent(event, JavascriptConv.toJava(args));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
computer.shutdown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void turnOn() {
|
||||
computer.turnOn();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reboot() {
|
||||
computer.reboot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
disposed = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void transferFiles(FileContents[] files) {
|
||||
computer.queueEvent(TransferredFiles.EVENT, new Object[]{
|
||||
new TransferredFiles(
|
||||
Arrays.stream(files)
|
||||
.map(x -> new TransferredFile(x.getName(), new ArrayByteChannel(bytesOfBuffer(x.getContents()))))
|
||||
.toList(),
|
||||
() -> {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPeripheral(String sideName, @Nullable String kind) {
|
||||
var side = ComputerSide.valueOfInsensitive(sideName);
|
||||
if (side == null) throw new IllegalArgumentException("Unknown sideName");
|
||||
|
||||
IPeripheral peripheral;
|
||||
if (kind == null) {
|
||||
peripheral = null;
|
||||
} else if (kind.equals("speaker")) {
|
||||
peripheral = new SpeakerPeripheral();
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown peripheral kind");
|
||||
}
|
||||
|
||||
computer.getEnvironment().setPeripheral(side, peripheral);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addFile(String path, JSObject contents) {
|
||||
byte[] bytes;
|
||||
if (JavascriptConv.isArrayBuffer(contents)) {
|
||||
bytes = bytesOfBuffer(contents.cast());
|
||||
} else {
|
||||
JSString string = contents.cast();
|
||||
bytes = string.stringValue().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
mount.addFile(path, bytes);
|
||||
}
|
||||
|
||||
private byte[] bytesOfBuffer(ArrayBuffer buffer) {
|
||||
var oldBytes = JavascriptConv.asByteArray(buffer);
|
||||
return Arrays.copyOf(oldBytes, oldBytes.length);
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web;
|
||||
|
||||
import cc.tweaked.web.js.Callbacks;
|
||||
import dan200.computercraft.api.filesystem.Mount;
|
||||
import dan200.computercraft.core.computer.GlobalEnvironment;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* The {@link GlobalEnvironment} for all {@linkplain EmulatedComputer emulated computers}. This reads resources and
|
||||
* version information from {@linkplain Callbacks the resources module}.
|
||||
*/
|
||||
final class EmulatorEnvironment implements GlobalEnvironment {
|
||||
public static final EmulatorEnvironment INSTANCE = new EmulatorEnvironment();
|
||||
|
||||
private final String version = Callbacks.getModVersion();
|
||||
private @Nullable ResourceMount romMount;
|
||||
private @Nullable byte[] bios;
|
||||
|
||||
private EmulatorEnvironment() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHostString() {
|
||||
return "ComputerCraft " + version + " (tweaked.cc)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUserAgent() {
|
||||
return "computercraft/" + version;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mount createResourceMount(String domain, String subPath) {
|
||||
if (domain.equals("computercraft") && subPath.equals("lua/rom")) {
|
||||
return romMount != null ? romMount : (romMount = new ResourceMount());
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown domain or subpath");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream createResourceFile(String domain, String subPath) {
|
||||
if (domain.equals("computercraft") && subPath.equals("lua/bios.lua")) {
|
||||
var biosContents = bios != null ? bios : (bios = Callbacks.getResource("bios.lua"));
|
||||
return new ByteArrayInputStream(biosContents);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown domain or subpath");
|
||||
}
|
||||
}
|
||||
}
|
44
projects/web/src/main/java/cc/tweaked/web/Main.java
Normal file
44
projects/web/src/main/java/cc/tweaked/web/Main.java
Normal file
@ -0,0 +1,44 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web;
|
||||
|
||||
import cc.tweaked.web.js.Callbacks;
|
||||
import dan200.computercraft.core.ComputerContext;
|
||||
import org.teavm.jso.browser.Window;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The main entrypoint to the emulator.
|
||||
*/
|
||||
public class Main {
|
||||
public static final String CORS_PROXY = "https://copy-cat-cors.vercel.app/?{}";
|
||||
|
||||
private static long ticks;
|
||||
|
||||
public static void main(String[] args) {
|
||||
var context = ComputerContext.builder(EmulatorEnvironment.INSTANCE).build();
|
||||
List<EmulatedComputer> computers = new ArrayList<>();
|
||||
|
||||
Callbacks.setup(access -> {
|
||||
var wrapper = new EmulatedComputer(context, access);
|
||||
computers.add(wrapper);
|
||||
return wrapper;
|
||||
});
|
||||
|
||||
Window.setInterval(() -> {
|
||||
ticks++;
|
||||
var iterator = computers.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
if (iterator.next().tick()) iterator.remove();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
public static long getTicks() {
|
||||
return ticks;
|
||||
}
|
||||
}
|
52
projects/web/src/main/java/cc/tweaked/web/ResourceMount.java
Normal file
52
projects/web/src/main/java/cc/tweaked/web/ResourceMount.java
Normal file
@ -0,0 +1,52 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web;
|
||||
|
||||
import cc.tweaked.web.js.Callbacks;
|
||||
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
|
||||
import dan200.computercraft.core.filesystem.AbstractInMemoryMount;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
|
||||
/**
|
||||
* Mounts in files from JavaScript-supplied resources.
|
||||
*
|
||||
* @see Callbacks#listResources()
|
||||
* @see Callbacks#getResource(String)
|
||||
*/
|
||||
final class ResourceMount extends AbstractInMemoryMount<ResourceMount.FileEntry> {
|
||||
private static final String PREFIX = "rom/";
|
||||
|
||||
ResourceMount() {
|
||||
root = new FileEntry("");
|
||||
for (var file : Callbacks.listResources()) {
|
||||
if (file.startsWith(PREFIX)) getOrCreateChild(root, file.substring(PREFIX.length()), FileEntry::new);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected long getSize(String path, FileEntry file) {
|
||||
return file.isDirectory() ? 0 : getContents(file).length;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SeekableByteChannel openForRead(String path, FileEntry file) {
|
||||
return new ArrayByteChannel(getContents(file));
|
||||
}
|
||||
|
||||
private byte[] getContents(FileEntry file) {
|
||||
return file.contents != null ? file.contents : (file.contents = Callbacks.getResource(PREFIX + file.path));
|
||||
}
|
||||
|
||||
protected static final class FileEntry extends AbstractInMemoryMount.FileEntry<FileEntry> {
|
||||
private final String path;
|
||||
private @Nullable byte[] contents;
|
||||
|
||||
FileEntry(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
}
|
71
projects/web/src/main/java/cc/tweaked/web/js/Callbacks.java
Normal file
71
projects/web/src/main/java/cc/tweaked/web/js/Callbacks.java
Normal file
@ -0,0 +1,71 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.js;
|
||||
|
||||
import org.teavm.jso.JSBody;
|
||||
import org.teavm.jso.JSByRef;
|
||||
import org.teavm.jso.JSFunctor;
|
||||
import org.teavm.jso.JSObject;
|
||||
import org.teavm.jso.browser.TimerHandler;
|
||||
|
||||
/**
|
||||
* Invoke functions in the {@code $javaCallbacks} object. This global is set up by the Javascript code before the
|
||||
* Java code is started.
|
||||
* <p>
|
||||
* This module is a bit of a hack - we should be able to do most of this with module imports/exports. However, handling
|
||||
* those within TeaVM is a bit awkward, so this ends up being much easier.
|
||||
*/
|
||||
public class Callbacks {
|
||||
@JSFunctor
|
||||
@FunctionalInterface
|
||||
public interface AddComputer extends JSObject {
|
||||
ComputerHandle addComputer(ComputerDisplay computer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the {@link AddComputer} function to the Javascript code.
|
||||
*
|
||||
* @param addComputer The function to add a computer.
|
||||
*/
|
||||
@JSBody(params = "setup", script = "return $javaCallbacks.setup(setup);")
|
||||
public static native void setup(AddComputer addComputer);
|
||||
|
||||
/**
|
||||
* Get the version of CC: Tweaked.
|
||||
*
|
||||
* @return The mod's version.
|
||||
*/
|
||||
@JSBody(script = "return $javaCallbacks.modVersion;")
|
||||
public static native String getModVersion();
|
||||
|
||||
/**
|
||||
* List all resources available in the ROM.
|
||||
*
|
||||
* @return All available resources.
|
||||
*/
|
||||
@JSBody(script = "return $javaCallbacks.listResources();")
|
||||
public static native String[] listResources();
|
||||
|
||||
/**
|
||||
* Load a resource from the ROM.
|
||||
*
|
||||
* @param resource The path to the resource to load.
|
||||
* @return The loaded resource.
|
||||
*/
|
||||
@JSByRef
|
||||
@JSBody(params = "name", script = "return $javaCallbacks.getResource(name);")
|
||||
public static native byte[] getResource(String resource);
|
||||
|
||||
/**
|
||||
* Call {@code setImmediate} (or rather a polyfill) to run an asynchronous task.
|
||||
* <p>
|
||||
* While it would be nicer to use something built-in like {@code queueMicrotask}, our computer execution definitely
|
||||
* doesn't count as a microtask, and doing so will stall the UI thread.
|
||||
*
|
||||
* @param task The task to run.
|
||||
*/
|
||||
@JSBody(params = "task", script = "return setImmediate(task);")
|
||||
public static native void setImmediate(TimerHandler task);
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.js;
|
||||
|
||||
import org.teavm.jso.JSObject;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The Javascript-side terminal which displays this computer.
|
||||
*
|
||||
* @see Callbacks.AddComputer#addComputer(ComputerDisplay)
|
||||
*/
|
||||
public interface ComputerDisplay extends JSObject {
|
||||
/**
|
||||
* Set this computer's current state.
|
||||
*
|
||||
* @param label This computer's label
|
||||
* @param on If this computer is on right now
|
||||
*/
|
||||
void setState(@Nullable String label, boolean on);
|
||||
|
||||
/**
|
||||
* Update the terminal's properties.
|
||||
*
|
||||
* @param width The terminal width
|
||||
* @param height The terminal height
|
||||
* @param x The X cursor
|
||||
* @param y The Y cursor
|
||||
* @param blink Whether the cursor is blinking
|
||||
* @param cursorColour The cursor's colour
|
||||
*/
|
||||
void updateTerminal(int width, int height, int x, int y, boolean blink, int cursorColour);
|
||||
|
||||
/**
|
||||
* Set a line on the terminal.
|
||||
*
|
||||
* @param line The line index to set
|
||||
* @param text The line's text
|
||||
* @param fore The line's foreground
|
||||
* @param back The line's background
|
||||
*/
|
||||
void setTerminalLine(int line, String text, String fore, String back);
|
||||
|
||||
/**
|
||||
* Set the palette colour for a specific index.
|
||||
*
|
||||
* @param colour The colour index to set
|
||||
* @param r The red value, between 0 and 1
|
||||
* @param g The green value, between 0 and 1
|
||||
* @param b The blue value, between 0 and 1
|
||||
*/
|
||||
void setPaletteColour(int colour, double r, double g, double b);
|
||||
|
||||
/**
|
||||
* Mark the terminal as having changed. Should be called after all other terminal methods.
|
||||
*/
|
||||
void flushTerminal();
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.js;
|
||||
|
||||
import org.teavm.jso.JSObject;
|
||||
import org.teavm.jso.JSProperty;
|
||||
import org.teavm.jso.core.JSString;
|
||||
import org.teavm.jso.typedarrays.ArrayBuffer;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* A Javascript-facing interface for controlling computers.
|
||||
*/
|
||||
public interface ComputerHandle extends JSObject {
|
||||
/**
|
||||
* Queue an event on the computer.
|
||||
*
|
||||
* @param event The name of the event.
|
||||
* @param args The arguments for this event.
|
||||
*/
|
||||
void event(String event, @Nullable JSObject[] args);
|
||||
|
||||
/**
|
||||
* Shut the computer down.
|
||||
*/
|
||||
void shutdown();
|
||||
|
||||
/**
|
||||
* Turn the computer on.
|
||||
*/
|
||||
void turnOn();
|
||||
|
||||
/**
|
||||
* Reboot the computer.
|
||||
*/
|
||||
void reboot();
|
||||
|
||||
/**
|
||||
* Dispose of this computer, marking it as no longer running.
|
||||
*/
|
||||
void dispose();
|
||||
|
||||
/**
|
||||
* Transfer some files to this computer.
|
||||
*
|
||||
* @param files A list of files and their contents.
|
||||
*/
|
||||
void transferFiles(FileContents[] files);
|
||||
|
||||
/**
|
||||
* Set a peripheral on a particular side.
|
||||
*
|
||||
* @param side The side to set the peripheral on.
|
||||
* @param kind The kind of peripheral. For now, can only be "speaker".
|
||||
*/
|
||||
void setPeripheral(String side, @Nullable String kind);
|
||||
|
||||
/**
|
||||
* Add a file to this computer's filesystem.
|
||||
*
|
||||
* @param path The path of the file.
|
||||
* @param contents The contents of the file, either a {@link JSString} or {@link ArrayBuffer}.
|
||||
*/
|
||||
void addFile(String path, JSObject contents);
|
||||
|
||||
/**
|
||||
* A file to transfer to the computer.
|
||||
*/
|
||||
interface FileContents extends JSObject {
|
||||
@JSProperty
|
||||
String getName();
|
||||
|
||||
@JSProperty
|
||||
ArrayBuffer getContents();
|
||||
}
|
||||
}
|
28
projects/web/src/main/java/cc/tweaked/web/js/Console.java
Normal file
28
projects/web/src/main/java/cc/tweaked/web/js/Console.java
Normal file
@ -0,0 +1,28 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.js;
|
||||
|
||||
import org.teavm.jso.JSBody;
|
||||
import org.teavm.jso.JSObject;
|
||||
|
||||
/**
|
||||
* Wraps Javascript's {@code console} class.
|
||||
*/
|
||||
public final class Console {
|
||||
private Console() {
|
||||
}
|
||||
|
||||
@JSBody(params = "x", script = "console.log(x);")
|
||||
public static native void log(String message);
|
||||
|
||||
@JSBody(params = "x", script = "console.warn(x);")
|
||||
public static native void warn(String message);
|
||||
|
||||
@JSBody(params = "x", script = "console.error(x);")
|
||||
public static native void error(String message);
|
||||
|
||||
@JSBody(params = "x", script = "console.error(x);")
|
||||
public static native void error(JSObject object);
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.js;
|
||||
|
||||
import org.jetbrains.annotations.Contract;
|
||||
import org.teavm.jso.JSBody;
|
||||
import org.teavm.jso.JSByRef;
|
||||
import org.teavm.jso.JSObject;
|
||||
import org.teavm.jso.core.JSBoolean;
|
||||
import org.teavm.jso.core.JSNumber;
|
||||
import org.teavm.jso.core.JSObjects;
|
||||
import org.teavm.jso.core.JSString;
|
||||
import org.teavm.jso.typedarrays.ArrayBuffer;
|
||||
import org.teavm.jso.typedarrays.Int8Array;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Utility methods for converting between Java and Javascript representations.
|
||||
*/
|
||||
public class JavascriptConv {
|
||||
/**
|
||||
* Convert an array of Javascript values to an equivalent array of Java values.
|
||||
*
|
||||
* @param value The value to convert.
|
||||
* @return The converted value.
|
||||
*/
|
||||
@Contract("null -> null; !null -> !null")
|
||||
public static @Nullable Object[] toJava(@Nullable JSObject[] value) {
|
||||
if (value == null) return null;
|
||||
var out = new Object[value.length];
|
||||
for (var i = 0; i < value.length; i++) out[i] = toJava(value[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a primitive Javascript value to a boxed Java object.
|
||||
*
|
||||
* @param value The value to convert.
|
||||
* @return The converted value.
|
||||
*/
|
||||
public static @Nullable Object toJava(@Nullable JSObject value) {
|
||||
if (value == null) return null;
|
||||
return switch (JSObjects.typeOf(value)) {
|
||||
case "string" -> ((JSString) value).stringValue();
|
||||
case "number" -> ((JSNumber) value).doubleValue();
|
||||
case "boolean" -> ((JSBoolean) value).booleanValue();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an arbitrary object is a {@link ArrayBuffer}.
|
||||
*
|
||||
* @param object The object ot check
|
||||
* @return Whether this is an {@link ArrayBuffer}.
|
||||
*/
|
||||
@JSBody(params = "data", script = "return data instanceof ArrayBuffer;")
|
||||
public static native boolean isArrayBuffer(JSObject object);
|
||||
|
||||
/**
|
||||
* Wrap a JS {@link Int8Array} into a {@code byte[]}.
|
||||
*
|
||||
* @param view The array to wrap.
|
||||
* @return The wrapped array.
|
||||
*/
|
||||
@JSByRef
|
||||
@JSBody(params = "x", script = "return x;")
|
||||
public static native byte[] asByteArray(Int8Array view);
|
||||
|
||||
/**
|
||||
* Wrap a JS {@link ArrayBuffer} into a {@code byte[]}.
|
||||
*
|
||||
* @param view The array to wrap.
|
||||
* @return The wrapped array.
|
||||
*/
|
||||
public static byte[] asByteArray(ArrayBuffer view) {
|
||||
return asByteArray(Int8Array.create(view));
|
||||
}
|
||||
}
|
14
projects/web/src/main/java/cc/tweaked/web/package-info.java
Normal file
14
projects/web/src/main/java/cc/tweaked/web/package-info.java
Normal file
@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
@DefaultQualifier(value = NonNull.class, locations = {
|
||||
TypeUseLocation.RETURN,
|
||||
TypeUseLocation.PARAMETER,
|
||||
TypeUseLocation.FIELD,
|
||||
})
|
||||
package cc.tweaked.web;
|
||||
|
||||
import org.checkerframework.checker.nullness.qual.NonNull;
|
||||
import org.checkerframework.framework.qual.DefaultQualifier;
|
||||
import org.checkerframework.framework.qual.TypeUseLocation;
|
@ -0,0 +1,65 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.peripheral;
|
||||
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.api.lua.LuaTable;
|
||||
import org.teavm.jso.webaudio.AudioBuffer;
|
||||
import org.teavm.jso.webaudio.AudioContext;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Optional;
|
||||
|
||||
import static cc.tweaked.web.peripheral.SpeakerPeripheral.SAMPLE_RATE;
|
||||
|
||||
final class AudioState {
|
||||
/**
|
||||
* The minimum size of the client's audio buffer. Once we have less than this on the client, we should send another
|
||||
* batch of audio.
|
||||
*/
|
||||
private static final double CLIENT_BUFFER = 0.5;
|
||||
|
||||
private final AudioContext audioContext;
|
||||
private @Nullable AudioBuffer nextBuffer;
|
||||
private double nextTime;
|
||||
|
||||
AudioState(AudioContext audioContext) {
|
||||
this.audioContext = audioContext;
|
||||
nextTime = audioContext.getCurrentTime();
|
||||
}
|
||||
|
||||
boolean pushBuffer(LuaTable<?, ?> table, int size, Optional<Double> volume) throws LuaException {
|
||||
if (nextBuffer != null) return false;
|
||||
|
||||
var buffer = nextBuffer = audioContext.createBuffer(1, size, SAMPLE_RATE);
|
||||
var contents = buffer.getChannelData(0);
|
||||
|
||||
for (var i = 0; i < size; i++) contents.set(i, table.getInt(i + 1) / 128.0f);
|
||||
|
||||
// So we really should go via DFPWM here, but I do not have enough faith in our performance to do this properly.
|
||||
|
||||
if (shouldSendPending()) playNext();
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean isPlaying() {
|
||||
return nextTime >= audioContext.getCurrentTime();
|
||||
}
|
||||
|
||||
boolean shouldSendPending() {
|
||||
return nextBuffer != null && audioContext.getCurrentTime() >= nextTime - CLIENT_BUFFER;
|
||||
}
|
||||
|
||||
void playNext() {
|
||||
if (nextBuffer == null) throw new NullPointerException("Buffer is null");
|
||||
var source = audioContext.createBufferSource();
|
||||
source.setBuffer(nextBuffer);
|
||||
source.connect(audioContext.getDestination());
|
||||
source.start(nextTime);
|
||||
|
||||
nextTime += nextBuffer.getDuration();
|
||||
nextBuffer = null;
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.peripheral;
|
||||
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.api.lua.LuaFunction;
|
||||
import dan200.computercraft.api.lua.LuaTable;
|
||||
import dan200.computercraft.api.peripheral.IComputerAccess;
|
||||
import dan200.computercraft.api.peripheral.IPeripheral;
|
||||
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||
import org.teavm.jso.webaudio.AudioContext;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Optional;
|
||||
|
||||
import static dan200.computercraft.api.lua.LuaValues.checkFinite;
|
||||
|
||||
/**
|
||||
* A minimal speaker peripheral, which implements {@code playAudio} and nothing else.
|
||||
*/
|
||||
public class SpeakerPeripheral implements TickablePeripheral {
|
||||
public static final int SAMPLE_RATE = 48000;
|
||||
|
||||
private static @MonotonicNonNull AudioContext audioContext;
|
||||
|
||||
private @Nullable IComputerAccess computer;
|
||||
|
||||
private @Nullable AudioState state;
|
||||
|
||||
@Override
|
||||
public String getType() {
|
||||
return "speaker";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void attach(IComputerAccess computer) {
|
||||
this.computer = computer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void detach(IComputerAccess computer) {
|
||||
this.computer = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
if (state != null && state.shouldSendPending()) {
|
||||
state.playNext();
|
||||
if (computer != null) computer.queueEvent("speaker_audio_empty", computer.getAttachmentName());
|
||||
}
|
||||
}
|
||||
|
||||
@LuaFunction
|
||||
public final boolean playNote(String instrumentA, Optional<Double> volumeA, Optional<Double> pitchA) throws LuaException {
|
||||
throw new LuaException("Cannot play notes outside of Minecraft");
|
||||
}
|
||||
|
||||
@LuaFunction
|
||||
public final boolean playSound(String name, Optional<Double> volumeA, Optional<Double> pitchA) throws LuaException {
|
||||
throw new LuaException("Cannot play sounds outside of Minecraft");
|
||||
}
|
||||
|
||||
@LuaFunction(unsafe = true)
|
||||
public final boolean playAudio(LuaTable<?, ?> audio, Optional<Double> volume) throws LuaException {
|
||||
checkFinite(1, volume.orElse(0.0));
|
||||
|
||||
var length = audio.length();
|
||||
if (length <= 0) throw new LuaException("Cannot play empty audio");
|
||||
if (length > 128 * 1024) throw new LuaException("Audio data is too large");
|
||||
|
||||
if (audioContext == null) audioContext = AudioContext.create();
|
||||
if (state == null || !state.isPlaying()) state = new AudioState(audioContext);
|
||||
|
||||
return state.pushBuffer(audio, length, volume);
|
||||
}
|
||||
|
||||
@LuaFunction
|
||||
public final void stop() {
|
||||
// TODO: Not sure how to do this.
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable IPeripheral other) {
|
||||
return other instanceof SpeakerPeripheral;
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.peripheral;
|
||||
|
||||
import dan200.computercraft.api.peripheral.IPeripheral;
|
||||
|
||||
/**
|
||||
* A peripheral which will be updated every time the computer ticks.
|
||||
*/
|
||||
public interface TickablePeripheral extends IPeripheral {
|
||||
void tick();
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.stub;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.nio.channels.WritableByteChannel;
|
||||
|
||||
/**
|
||||
* A stub for {@link java.nio.channels.FileChannel}. This is never constructed, only used in {@code instanceof} checks.
|
||||
*/
|
||||
public abstract class FileChannel implements SeekableByteChannel {
|
||||
private FileChannel() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public abstract FileChannel position(long newPosition) throws IOException;
|
||||
|
||||
public abstract void force(boolean metadata) throws IOException;
|
||||
|
||||
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cc.tweaked.web.stub;
|
||||
|
||||
/**
|
||||
* A no-op stub for {@link java.util.concurrent.locks.ReentrantLock}.
|
||||
*/
|
||||
public class ReentrantLock {
|
||||
public boolean tryLock() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public void unlock() {
|
||||
}
|
||||
|
||||
public void lockInterruptibly() throws InterruptedException {
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core;
|
||||
|
||||
import dan200.computercraft.core.apis.http.options.AddressRule;
|
||||
|
||||
/**
|
||||
* Replaces {@link CoreConfig} with a slightly cut-down version.
|
||||
* <p>
|
||||
* This is mostly required to avoid pulling in {@link AddressRule}.
|
||||
*/
|
||||
public final class TCoreConfig {
|
||||
private TCoreConfig() {
|
||||
}
|
||||
|
||||
public static int maximumFilesOpen = 128;
|
||||
public static boolean disableLua51Features = false;
|
||||
public static String defaultComputerSettings = "";
|
||||
|
||||
public static boolean httpEnabled = true;
|
||||
public static boolean httpWebsocketEnabled = true;
|
||||
public static int httpMaxRequests = 16;
|
||||
public static int httpMaxWebsockets = 4;
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http;
|
||||
|
||||
import dan200.computercraft.core.apis.IAPIEnvironment;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
/**
|
||||
* Replaces {@link CheckUrl} with an implementation which unconditionally returns true.
|
||||
*/
|
||||
public class TCheckUrl extends Resource<TCheckUrl> {
|
||||
private static final String EVENT = "http_check";
|
||||
|
||||
private final IAPIEnvironment environment;
|
||||
private final String address;
|
||||
|
||||
public TCheckUrl(ResourceGroup<TCheckUrl> limiter, IAPIEnvironment environment, String address, URI uri) {
|
||||
super(limiter);
|
||||
this.environment = environment;
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
if (isClosed()) return;
|
||||
environment.queueEvent(EVENT, address, true);
|
||||
}
|
||||
}
|
@ -0,0 +1,156 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.request;
|
||||
|
||||
import cc.tweaked.web.Main;
|
||||
import cc.tweaked.web.js.JavascriptConv;
|
||||
import dan200.computercraft.core.Logging;
|
||||
import dan200.computercraft.core.apis.IAPIEnvironment;
|
||||
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
|
||||
import dan200.computercraft.core.apis.handles.BinaryReadableHandle;
|
||||
import dan200.computercraft.core.apis.handles.EncodedReadableHandle;
|
||||
import dan200.computercraft.core.apis.handles.HandleGeneric;
|
||||
import dan200.computercraft.core.apis.http.HTTPRequestException;
|
||||
import dan200.computercraft.core.apis.http.Resource;
|
||||
import dan200.computercraft.core.apis.http.ResourceGroup;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpHeaders;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.teavm.jso.ajax.XMLHttpRequest;
|
||||
import org.teavm.jso.typedarrays.ArrayBuffer;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.StringReader;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Replaces {@link HttpRequest} with a version which uses AJAX/{@link XMLHttpRequest}.
|
||||
*/
|
||||
public class THttpRequest extends Resource<THttpRequest> {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(THttpRequest.class);
|
||||
private static final String SUCCESS_EVENT = "http_success";
|
||||
private static final String FAILURE_EVENT = "http_failure";
|
||||
|
||||
private final IAPIEnvironment environment;
|
||||
|
||||
private final String address;
|
||||
private final @Nullable String postBuffer;
|
||||
private final HttpHeaders headers;
|
||||
private final boolean binary;
|
||||
|
||||
public THttpRequest(
|
||||
ResourceGroup<THttpRequest> limiter, IAPIEnvironment environment, String address, @Nullable String postText,
|
||||
HttpHeaders headers, boolean binary, boolean followRedirects, int timeout
|
||||
) {
|
||||
super(limiter);
|
||||
this.environment = environment;
|
||||
this.address = address;
|
||||
postBuffer = postText;
|
||||
this.headers = headers;
|
||||
this.binary = binary;
|
||||
|
||||
if (postText != null) {
|
||||
if (!headers.contains(HttpHeaderNames.CONTENT_TYPE)) {
|
||||
headers.set(HttpHeaderNames.CONTENT_TYPE, "application/x-www-form-urlencoded; charset=utf-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static URI checkUri(String address) throws HTTPRequestException {
|
||||
URI url;
|
||||
try {
|
||||
url = new URI(address);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new HTTPRequestException("URL malformed");
|
||||
}
|
||||
|
||||
checkUri(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
public static void checkUri(URI url) throws HTTPRequestException {
|
||||
// Validate the URL
|
||||
if (url.getScheme() == null) throw new HTTPRequestException("Must specify http or https");
|
||||
if (url.getHost() == null) throw new HTTPRequestException("URL malformed");
|
||||
|
||||
var scheme = url.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")) {
|
||||
throw new HTTPRequestException("Invalid protocol '" + scheme + "'");
|
||||
}
|
||||
}
|
||||
|
||||
public void request(URI uri, HttpMethod method) {
|
||||
if (isClosed()) return;
|
||||
|
||||
try {
|
||||
var request = XMLHttpRequest.create();
|
||||
request.setOnReadyStateChange(() -> onResponseStateChange(request));
|
||||
request.setResponseType(binary ? "arraybuffer" : "text");
|
||||
var address = uri.toASCIIString();
|
||||
request.open(method.toString(), Main.CORS_PROXY.isEmpty() ? address : Main.CORS_PROXY.replace("{}", address));
|
||||
for (var iterator = headers.iteratorAsString(); iterator.hasNext(); ) {
|
||||
var header = iterator.next();
|
||||
request.setRequestHeader(header.getKey(), header.getValue());
|
||||
}
|
||||
request.send(postBuffer);
|
||||
checkClosed();
|
||||
} catch (Exception e) {
|
||||
failure("Could not connect");
|
||||
LOG.error(Logging.HTTP_ERROR, "Error in HTTP request", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void onResponseStateChange(XMLHttpRequest request) {
|
||||
if (request.getReadyState() != XMLHttpRequest.DONE) return;
|
||||
if (request.getStatus() == 0) {
|
||||
this.failure("Could not connect");
|
||||
return;
|
||||
}
|
||||
|
||||
HandleGeneric reader;
|
||||
if (binary) {
|
||||
ArrayBuffer buffer = request.getResponse().cast();
|
||||
SeekableByteChannel contents = new ArrayByteChannel(JavascriptConv.asByteArray(buffer));
|
||||
reader = BinaryReadableHandle.of(contents);
|
||||
} else {
|
||||
reader = new EncodedReadableHandle(new BufferedReader(new StringReader(request.getResponseText())));
|
||||
}
|
||||
|
||||
Map<String, String> responseHeaders = new HashMap<>();
|
||||
for (var header : request.getAllResponseHeaders().split("\r\n")) {
|
||||
var index = header.indexOf(':');
|
||||
if (index < 0) continue;
|
||||
|
||||
// Normalise the header (so "content-type" becomes "Content-Type")
|
||||
var upcase = true;
|
||||
var headerBuilder = new StringBuilder(index);
|
||||
for (var i = 0; i < index; i++) {
|
||||
var c = header.charAt(i);
|
||||
headerBuilder.append(upcase ? Character.toUpperCase(c) : c);
|
||||
upcase = c == '-';
|
||||
}
|
||||
responseHeaders.put(headerBuilder.toString(), header.substring(index + 1).trim());
|
||||
}
|
||||
var stream = new HttpResponseHandle(reader, request.getStatus(), request.getStatusText(), responseHeaders);
|
||||
|
||||
if (request.getStatus() >= 200 && request.getStatus() < 400) {
|
||||
if (tryClose()) environment.queueEvent(SUCCESS_EVENT, address, stream);
|
||||
} else {
|
||||
if (tryClose()) environment.queueEvent(FAILURE_EVENT, address, request.getStatusText(), stream);
|
||||
}
|
||||
}
|
||||
|
||||
void failure(String message) {
|
||||
if (tryClose()) environment.queueEvent(FAILURE_EVENT, address, message);
|
||||
}
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.websocket;
|
||||
|
||||
import cc.tweaked.web.js.Console;
|
||||
import cc.tweaked.web.js.JavascriptConv;
|
||||
import com.google.common.base.Strings;
|
||||
import dan200.computercraft.core.apis.IAPIEnvironment;
|
||||
import dan200.computercraft.core.apis.http.Resource;
|
||||
import dan200.computercraft.core.apis.http.ResourceGroup;
|
||||
import dan200.computercraft.core.apis.http.options.Action;
|
||||
import dan200.computercraft.core.apis.http.options.Options;
|
||||
import io.netty.handler.codec.http.HttpHeaders;
|
||||
import org.teavm.jso.typedarrays.Int8Array;
|
||||
import org.teavm.jso.websocket.WebSocket;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* Replaces {@link Websocket} with a version which uses Javascript's built-in {@link WebSocket} client.
|
||||
*/
|
||||
public class TWebsocket extends Resource<TWebsocket> implements WebsocketClient {
|
||||
private final IAPIEnvironment environment;
|
||||
private final URI uri;
|
||||
private final String address;
|
||||
|
||||
private @Nullable WebSocket websocket;
|
||||
|
||||
public TWebsocket(ResourceGroup<TWebsocket> limiter, IAPIEnvironment environment, URI uri, String address, HttpHeaders headers, int timeout) {
|
||||
super(limiter);
|
||||
this.environment = environment;
|
||||
this.uri = uri;
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public void connect() {
|
||||
if (isClosed()) return;
|
||||
|
||||
var client = this.websocket = WebSocket.create(uri.toASCIIString());
|
||||
client.setBinaryType("arraybuffer");
|
||||
client.onOpen(e -> success(Action.ALLOW.toPartial().toOptions()));
|
||||
client.onError(e -> {
|
||||
Console.error(e);
|
||||
failure("Could not connect");
|
||||
});
|
||||
client.onMessage(e -> {
|
||||
if (isClosed()) return;
|
||||
if (JavascriptConv.isArrayBuffer(e.getData())) {
|
||||
var array = Int8Array.create(e.getDataAsArray());
|
||||
var contents = new byte[array.getLength()];
|
||||
for (var i = 0; i < contents.length; i++) contents[i] = array.get(i);
|
||||
environment.queueEvent("websocket_message", address, contents, true);
|
||||
} else {
|
||||
environment.queueEvent("websocket_message", address, e.getDataAsString(), false);
|
||||
}
|
||||
});
|
||||
client.onClose(e -> close(e.getCode(), e.getReason()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendText(String message) {
|
||||
if (websocket == null) return;
|
||||
websocket.send(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendBinary(ByteBuffer message) {
|
||||
if (websocket == null) return;
|
||||
|
||||
var array = Int8Array.create(message.remaining());
|
||||
for (var i = 0; i < array.getLength(); i++) array.set(i, message.get(i));
|
||||
websocket.send(array);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void dispose() {
|
||||
super.dispose();
|
||||
if (websocket != null) {
|
||||
websocket.close();
|
||||
websocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void success(Options options) {
|
||||
if (isClosed()) return;
|
||||
|
||||
var handle = new WebsocketHandle(environment, address, this, options);
|
||||
environment.queueEvent(SUCCESS_EVENT, address, handle);
|
||||
createOwnerReference(handle);
|
||||
|
||||
checkClosed();
|
||||
}
|
||||
|
||||
void failure(String message) {
|
||||
if (tryClose()) environment.queueEvent(FAILURE_EVENT, address, message);
|
||||
}
|
||||
|
||||
void close(int status, String reason) {
|
||||
if (!tryClose()) return;
|
||||
|
||||
environment.queueEvent(CLOSE_EVENT, address, Strings.isNullOrEmpty(reason) ? null : reason, status < 0 ? null : status);
|
||||
}
|
||||
}
|
@ -0,0 +1,137 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.asm;
|
||||
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import com.google.common.cache.CacheLoader;
|
||||
import com.google.common.cache.LoadingCache;
|
||||
import dan200.computercraft.api.lua.ILuaContext;
|
||||
import dan200.computercraft.api.lua.LuaFunction;
|
||||
import dan200.computercraft.api.lua.MethodResult;
|
||||
import dan200.computercraft.api.peripheral.PeripheralType;
|
||||
import dan200.computercraft.core.methods.LuaMethod;
|
||||
import dan200.computercraft.core.methods.NamedMethod;
|
||||
import org.teavm.metaprogramming.*;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Compile-time generation of {@link LuaMethod} methods.
|
||||
*
|
||||
* @see TLuaMethodSupplier
|
||||
* @see StaticGenerator
|
||||
*/
|
||||
@CompileTime
|
||||
public class MethodReflection {
|
||||
@Meta
|
||||
public static native boolean getMethods(Class<?> type, Consumer<NamedMethod<LuaMethod>> make);
|
||||
|
||||
private static void getMethods(ReflectClass<?> klass, Value<Consumer<NamedMethod<LuaMethod>>> make) {
|
||||
var result = getMethodsImpl(klass, make);
|
||||
// Using "unsupportedCase" here causes us to skip generating any code and just return null. While null isn't
|
||||
// a boolean, it's still false-y and thus has the same effect in the generated JS!
|
||||
if (!result) Metaprogramming.unsupportedCase();
|
||||
Metaprogramming.exit(() -> result);
|
||||
}
|
||||
|
||||
private static boolean getMethodsImpl(ReflectClass<?> klass, Value<Consumer<NamedMethod<LuaMethod>>> make) {
|
||||
if (!klass.getName().startsWith("dan200.computercraft.") && !klass.getName().startsWith("cc.tweaked.web.peripheral")) {
|
||||
return false;
|
||||
}
|
||||
if (klass.getName().contains("lambda")) return false;
|
||||
|
||||
Class<?> actualClass;
|
||||
try {
|
||||
actualClass = Metaprogramming.getClassLoader().loadClass(klass.getName());
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
var methods = Internal.getMethods(actualClass);
|
||||
for (var method : methods) {
|
||||
var name = method.name();
|
||||
var nonYielding = method.nonYielding();
|
||||
var actualField = method.method().getField("INSTANCE");
|
||||
|
||||
Metaprogramming.emit(() -> make.get().accept(new NamedMethod<>(name, (LuaMethod) actualField.get(null), nonYielding, null)));
|
||||
}
|
||||
|
||||
return !methods.isEmpty();
|
||||
}
|
||||
|
||||
private static final class Internal {
|
||||
private static final LoadingCache<Class<?>, List<NamedMethod<ReflectClass<LuaMethod>>>> CLASS_CACHE = CacheBuilder
|
||||
.newBuilder()
|
||||
.build(CacheLoader.from(Internal::getMethodsImpl));
|
||||
|
||||
private static final StaticGenerator<LuaMethod> GENERATOR = new StaticGenerator<>(
|
||||
LuaMethod.class, Collections.singletonList(ILuaContext.class), Internal::createClass
|
||||
);
|
||||
|
||||
static List<NamedMethod<ReflectClass<LuaMethod>>> getMethods(Class<?> klass) {
|
||||
try {
|
||||
return CLASS_CACHE.get(klass);
|
||||
} catch (ExecutionException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static ReflectClass<?> createClass(byte[] bytes) {
|
||||
/*
|
||||
StaticGenerator is not declared to be @CompileTime, to ensure it loads in the same module/classloader as
|
||||
other files in this package. This means it can't call Metaprogramming.createClass directly, as that's
|
||||
only available to @CompileTime classes.
|
||||
|
||||
We need to use an explicit call (rather than a MethodReference), as TeaVM doesn't correctly rewrite the
|
||||
latter.
|
||||
*/
|
||||
return Metaprogramming.createClass(bytes);
|
||||
}
|
||||
|
||||
private static List<NamedMethod<ReflectClass<LuaMethod>>> getMethodsImpl(Class<?> klass) {
|
||||
ArrayList<NamedMethod<ReflectClass<LuaMethod>>> methods = null;
|
||||
|
||||
// Find all methods on the current class
|
||||
for (var method : klass.getMethods()) {
|
||||
var annotation = method.getAnnotation(LuaFunction.class);
|
||||
if (annotation == null) continue;
|
||||
|
||||
if (Modifier.isStatic(method.getModifiers())) {
|
||||
System.err.printf("LuaFunction method %s.%s should be an instance method.\n", method.getDeclaringClass(), method.getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
var instance = GENERATOR.getMethod(method).orElse(null);
|
||||
if (instance == null) continue;
|
||||
|
||||
if (methods == null) methods = new ArrayList<>();
|
||||
addMethod(methods, method, annotation, null, instance);
|
||||
}
|
||||
|
||||
if (methods == null) return List.of();
|
||||
methods.trimToSize();
|
||||
return Collections.unmodifiableList(methods);
|
||||
}
|
||||
|
||||
private static void addMethod(List<NamedMethod<ReflectClass<LuaMethod>>> methods, Method method, LuaFunction annotation, @Nullable PeripheralType genericType, ReflectClass<LuaMethod> instance) {
|
||||
var names = annotation.value();
|
||||
var isSimple = method.getReturnType() != MethodResult.class && !annotation.mainThread();
|
||||
if (names.length == 0) {
|
||||
methods.add(new NamedMethod<>(method.getName(), instance, isSimple, genericType));
|
||||
} else {
|
||||
for (var name : names) {
|
||||
methods.add(new NamedMethod<>(name, instance, isSimple, genericType));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,313 @@
|
||||
// SPDX-FileCopyrightText: 2020 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.asm;
|
||||
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import com.google.common.cache.CacheLoader;
|
||||
import com.google.common.cache.LoadingCache;
|
||||
import com.google.common.primitives.Primitives;
|
||||
import com.google.common.reflect.TypeToken;
|
||||
import dan200.computercraft.api.lua.*;
|
||||
import dan200.computercraft.core.methods.LuaMethod;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.MethodVisitor;
|
||||
import org.objectweb.asm.Type;
|
||||
import org.teavm.metaprogramming.ReflectClass;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.objectweb.asm.Opcodes.*;
|
||||
|
||||
/**
|
||||
* The underlying generator for {@link LuaFunction}-annotated methods.
|
||||
* <p>
|
||||
* The constructor {@link StaticGenerator#StaticGenerator(Class, List, Function)} takes in the type of interface to generate (i.e.
|
||||
* {@link LuaMethod}) and the context arguments for this function (in the case of {@link LuaMethod}, this will just be
|
||||
* {@link ILuaContext}).
|
||||
* <p>
|
||||
* The generated class then implements this interface - the {@code apply} method calls the appropriate methods on
|
||||
* {@link IArguments} to extract the arguments, and then calls the original method.
|
||||
*
|
||||
* @param <T> The type of the interface the generated classes implement.
|
||||
*/
|
||||
public final class StaticGenerator<T> {
|
||||
private static final String METHOD_NAME = "apply";
|
||||
private static final String[] EXCEPTIONS = new String[]{Type.getInternalName(LuaException.class)};
|
||||
|
||||
private static final String INTERNAL_METHOD_RESULT = Type.getInternalName(MethodResult.class);
|
||||
private static final String DESC_METHOD_RESULT = Type.getDescriptor(MethodResult.class);
|
||||
|
||||
private static final String INTERNAL_ARGUMENTS = Type.getInternalName(IArguments.class);
|
||||
private static final String DESC_ARGUMENTS = Type.getDescriptor(IArguments.class);
|
||||
|
||||
private static final String INTERNAL_COERCED = Type.getInternalName(Coerced.class);
|
||||
|
||||
private final Class<T> base;
|
||||
private final List<Class<?>> context;
|
||||
|
||||
private final String[] interfaces;
|
||||
private final String methodDesc;
|
||||
private final String classPrefix;
|
||||
|
||||
private final Function<byte[], ReflectClass<?>> createClass;
|
||||
|
||||
private final LoadingCache<Method, Optional<ReflectClass<T>>> methodCache = CacheBuilder
|
||||
.newBuilder()
|
||||
.build(CacheLoader.from(catching(this::build, Optional.empty())));
|
||||
|
||||
public StaticGenerator(Class<T> base, List<Class<?>> context, Function<byte[], ReflectClass<?>> createClass) {
|
||||
this.base = base;
|
||||
this.context = context;
|
||||
this.createClass = createClass;
|
||||
|
||||
interfaces = new String[]{Type.getInternalName(base)};
|
||||
|
||||
var methodDesc = new StringBuilder().append("(Ljava/lang/Object;");
|
||||
for (var klass : context) methodDesc.append(Type.getDescriptor(klass));
|
||||
methodDesc.append(DESC_ARGUMENTS).append(")").append(DESC_METHOD_RESULT);
|
||||
this.methodDesc = methodDesc.toString();
|
||||
|
||||
classPrefix = StaticGenerator.class.getPackageName() + "." + base.getSimpleName() + "$";
|
||||
}
|
||||
|
||||
public Optional<ReflectClass<T>> getMethod(Method method) {
|
||||
return methodCache.getUnchecked(method);
|
||||
}
|
||||
|
||||
private Optional<ReflectClass<T>> build(Method method) {
|
||||
var name = method.getDeclaringClass().getName() + "." + method.getName();
|
||||
var modifiers = method.getModifiers();
|
||||
|
||||
// Instance methods must be final - this prevents them being overridden and potentially exposed twice.
|
||||
if (!Modifier.isStatic(modifiers) && !Modifier.isFinal(modifiers)) {
|
||||
System.err.printf("Lua Method %s should be final.\n", name);
|
||||
}
|
||||
|
||||
if (!Modifier.isPublic(modifiers)) {
|
||||
System.err.printf("Lua Method %s should be a public method.\n", name);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (!Modifier.isPublic(method.getDeclaringClass().getModifiers())) {
|
||||
System.err.printf("Lua Method %s should be on a public class.\n", name);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
var exceptions = method.getExceptionTypes();
|
||||
for (var exception : exceptions) {
|
||||
if (exception != LuaException.class) {
|
||||
System.err.printf("Lua Method %s cannot throw %s.\n", name, exception.getName());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
var annotation = method.getAnnotation(LuaFunction.class);
|
||||
if (annotation.unsafe() && annotation.mainThread()) {
|
||||
System.err.printf("Lua Method %s cannot use unsafe and mainThread.\n", name);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// We have some rather ugly handling of static methods in both here and the main generate function. Static methods
|
||||
// only come from generic sources, so this should be safe.
|
||||
var target = Modifier.isStatic(modifiers) ? method.getParameterTypes()[0] : method.getDeclaringClass();
|
||||
|
||||
try {
|
||||
var bytes = generate(classPrefix + method.getDeclaringClass().getSimpleName() + "$" + method.getName(), target, method, annotation.unsafe());
|
||||
if (bytes == null) return Optional.empty();
|
||||
|
||||
return Optional.of(createClass.apply(bytes).asSubclass(base));
|
||||
} catch (ClassFormatError | RuntimeException e) {
|
||||
System.err.printf("Error generating %s\n", name);
|
||||
e.printStackTrace();
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private byte[] generate(String className, Class<?> target, Method targetMethod, boolean unsafe) {
|
||||
var internalName = className.replace(".", "/");
|
||||
|
||||
// Construct a public final class which extends Object and implements MethodInstance.Delegate
|
||||
var cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
|
||||
cw.visit(V17, ACC_PUBLIC | ACC_FINAL, internalName, null, "java/lang/Object", interfaces);
|
||||
cw.visitSource("CC generated method", null);
|
||||
|
||||
cw.visitField(ACC_PUBLIC | ACC_STATIC | ACC_FINAL, "INSTANCE", "L" + internalName + ";", null, null).visitEnd();
|
||||
|
||||
{ // Constructor just invokes super.
|
||||
var mw = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
|
||||
mw.visitCode();
|
||||
mw.visitVarInsn(ALOAD, 0);
|
||||
mw.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
|
||||
mw.visitInsn(RETURN);
|
||||
mw.visitMaxs(0, 0);
|
||||
mw.visitEnd();
|
||||
}
|
||||
|
||||
// Static initialiser sets the INSTANCE field.
|
||||
{
|
||||
var mw = cw.visitMethod(ACC_STATIC, "<clinit>", "()V", null, null);
|
||||
mw.visitCode();
|
||||
mw.visitTypeInsn(NEW, internalName);
|
||||
mw.visitInsn(DUP);
|
||||
mw.visitMethodInsn(INVOKESPECIAL, internalName, "<init>", "()V", false);
|
||||
mw.visitFieldInsn(PUTSTATIC, internalName, "INSTANCE", "L" + internalName + ";");
|
||||
mw.visitInsn(RETURN);
|
||||
mw.visitMaxs(0, 0);
|
||||
mw.visitEnd();
|
||||
}
|
||||
|
||||
{
|
||||
var mw = cw.visitMethod(ACC_PUBLIC, METHOD_NAME, methodDesc, null, EXCEPTIONS);
|
||||
mw.visitCode();
|
||||
|
||||
// If we're an instance method, load the target as the first argument.
|
||||
if (!Modifier.isStatic(targetMethod.getModifiers())) {
|
||||
mw.visitVarInsn(ALOAD, 1);
|
||||
mw.visitTypeInsn(CHECKCAST, Type.getInternalName(target));
|
||||
}
|
||||
|
||||
var argIndex = 0;
|
||||
for (var genericArg : targetMethod.getGenericParameterTypes()) {
|
||||
var loadedArg = loadArg(mw, target, targetMethod, unsafe, genericArg, argIndex);
|
||||
if (loadedArg == null) return null;
|
||||
if (loadedArg) argIndex++;
|
||||
}
|
||||
|
||||
mw.visitMethodInsn(
|
||||
Modifier.isStatic(targetMethod.getModifiers()) ? INVOKESTATIC : INVOKEVIRTUAL,
|
||||
Type.getInternalName(targetMethod.getDeclaringClass()), targetMethod.getName(),
|
||||
Type.getMethodDescriptor(targetMethod), false
|
||||
);
|
||||
|
||||
// We allow a reasonable amount of flexibility on the return value's type. Alongside the obvious MethodResult,
|
||||
// we convert basic types into an immediate result.
|
||||
var ret = targetMethod.getReturnType();
|
||||
if (ret != MethodResult.class) {
|
||||
if (ret == void.class) {
|
||||
mw.visitMethodInsn(INVOKESTATIC, INTERNAL_METHOD_RESULT, "of", "()" + DESC_METHOD_RESULT, false);
|
||||
} else if (ret.isPrimitive()) {
|
||||
var boxed = Primitives.wrap(ret);
|
||||
mw.visitMethodInsn(INVOKESTATIC, Type.getInternalName(boxed), "valueOf", "(" + Type.getDescriptor(ret) + ")" + Type.getDescriptor(boxed), false);
|
||||
mw.visitMethodInsn(INVOKESTATIC, INTERNAL_METHOD_RESULT, "of", "(Ljava/lang/Object;)" + DESC_METHOD_RESULT, false);
|
||||
} else if (ret == Object[].class) {
|
||||
mw.visitMethodInsn(INVOKESTATIC, INTERNAL_METHOD_RESULT, "of", "([Ljava/lang/Object;)" + DESC_METHOD_RESULT, false);
|
||||
} else {
|
||||
mw.visitMethodInsn(INVOKESTATIC, INTERNAL_METHOD_RESULT, "of", "(Ljava/lang/Object;)" + DESC_METHOD_RESULT, false);
|
||||
}
|
||||
}
|
||||
|
||||
mw.visitInsn(ARETURN);
|
||||
|
||||
mw.visitMaxs(0, 0);
|
||||
mw.visitEnd();
|
||||
}
|
||||
|
||||
cw.visitEnd();
|
||||
|
||||
return cw.toByteArray();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Boolean loadArg(MethodVisitor mw, Class<?> target, Method method, boolean unsafe, java.lang.reflect.Type genericArg, int argIndex) {
|
||||
if (genericArg == target) {
|
||||
mw.visitVarInsn(ALOAD, 1);
|
||||
mw.visitTypeInsn(CHECKCAST, Type.getInternalName(target));
|
||||
return false;
|
||||
}
|
||||
|
||||
var arg = Reflect.getRawType(method, genericArg, true);
|
||||
if (arg == null) return null;
|
||||
|
||||
if (arg == IArguments.class) {
|
||||
mw.visitVarInsn(ALOAD, 2 + context.size());
|
||||
return false;
|
||||
}
|
||||
|
||||
var idx = context.indexOf(arg);
|
||||
if (idx >= 0) {
|
||||
mw.visitVarInsn(ALOAD, 2 + idx);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (arg == Coerced.class) {
|
||||
var klass = Reflect.getRawType(method, TypeToken.of(genericArg).resolveType(Reflect.COERCED_IN).getType(), false);
|
||||
if (klass == null) return null;
|
||||
|
||||
if (klass == String.class) {
|
||||
mw.visitTypeInsn(NEW, INTERNAL_COERCED);
|
||||
mw.visitInsn(DUP);
|
||||
mw.visitVarInsn(ALOAD, 2 + context.size());
|
||||
Reflect.loadInt(mw, argIndex);
|
||||
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "getStringCoerced", "(I)Ljava/lang/String;", true);
|
||||
mw.visitMethodInsn(INVOKESPECIAL, INTERNAL_COERCED, "<init>", "(Ljava/lang/Object;)V", false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (arg == Optional.class) {
|
||||
var klass = Reflect.getRawType(method, TypeToken.of(genericArg).resolveType(Reflect.OPTIONAL_IN).getType(), false);
|
||||
if (klass == null) return null;
|
||||
|
||||
if (Enum.class.isAssignableFrom(klass) && klass != Enum.class) {
|
||||
mw.visitVarInsn(ALOAD, 2 + context.size());
|
||||
Reflect.loadInt(mw, argIndex);
|
||||
mw.visitLdcInsn(Type.getType(klass));
|
||||
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "optEnum", "(ILjava/lang/Class;)Ljava/util/Optional;", true);
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = Reflect.getLuaName(Primitives.unwrap(klass), unsafe);
|
||||
if (name != null) {
|
||||
mw.visitVarInsn(ALOAD, 2 + context.size());
|
||||
Reflect.loadInt(mw, argIndex);
|
||||
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "opt" + name, "(I)Ljava/util/Optional;", true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Enum.class.isAssignableFrom(arg) && arg != Enum.class) {
|
||||
mw.visitVarInsn(ALOAD, 2 + context.size());
|
||||
Reflect.loadInt(mw, argIndex);
|
||||
mw.visitLdcInsn(Type.getType(arg));
|
||||
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "getEnum", "(ILjava/lang/Class;)Ljava/lang/Enum;", true);
|
||||
mw.visitTypeInsn(CHECKCAST, Type.getInternalName(arg));
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = arg == Object.class ? "" : Reflect.getLuaName(arg, unsafe);
|
||||
if (name != null) {
|
||||
if (Reflect.getRawType(method, genericArg, false) == null) return null;
|
||||
|
||||
mw.visitVarInsn(ALOAD, 2 + context.size());
|
||||
Reflect.loadInt(mw, argIndex);
|
||||
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "get" + name, "(I)" + Type.getDescriptor(arg), true);
|
||||
return true;
|
||||
}
|
||||
|
||||
System.err.printf("Unknown parameter type %s for method %s.%s.\n",
|
||||
arg.getName(), method.getDeclaringClass().getName(), method.getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("Guava")
|
||||
static <T, U> com.google.common.base.Function<T, U> catching(Function<T, U> function, U def) {
|
||||
return x -> {
|
||||
try {
|
||||
return function.apply(x);
|
||||
} catch (Exception | LinkageError e) {
|
||||
// LinkageError due to possible codegen bugs and NoClassDefFoundError. The latter occurs when fetching
|
||||
// methods on a class which references non-existent (i.e. client-only) types.
|
||||
e.printStackTrace();
|
||||
return def;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.asm;
|
||||
|
||||
import dan200.computercraft.core.methods.LuaMethod;
|
||||
import dan200.computercraft.core.methods.MethodSupplier;
|
||||
import dan200.computercraft.core.methods.ObjectSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Replaces {@link LuaMethodSupplier} with a version which uses {@link MethodReflection} to fabricate the classes.
|
||||
*/
|
||||
public final class TLuaMethodSupplier implements MethodSupplier<LuaMethod> {
|
||||
static final TLuaMethodSupplier INSTANCE = new TLuaMethodSupplier();
|
||||
|
||||
private TLuaMethodSupplier() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean forEachSelfMethod(Object object, UntargetedConsumer<LuaMethod> consumer) {
|
||||
return MethodReflection.getMethods(object.getClass(), method -> consumer.accept(method.name(), method.method(), method));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean forEachMethod(Object object, TargetedConsumer<LuaMethod> consumer) {
|
||||
var hasMethods = MethodReflection.getMethods(object.getClass(), method -> consumer.accept(object, method.name(), method.method(), method));
|
||||
|
||||
if (object instanceof ObjectSource source) {
|
||||
for (var extra : source.getExtra()) {
|
||||
hasMethods |= MethodReflection.getMethods(extra.getClass(), method -> consumer.accept(extra, method.name(), method.method(), method));
|
||||
}
|
||||
}
|
||||
|
||||
return hasMethods;
|
||||
}
|
||||
|
||||
public static MethodSupplier<LuaMethod> create(List<GenericMethod> genericMethods) {
|
||||
return INSTANCE;
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.asm;
|
||||
|
||||
import dan200.computercraft.api.peripheral.IComputerAccess;
|
||||
import dan200.computercraft.core.methods.LuaMethod;
|
||||
import dan200.computercraft.core.methods.MethodSupplier;
|
||||
import dan200.computercraft.core.methods.PeripheralMethod;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Replaces {@link PeripheralMethodSupplier} with a version which lifts {@link LuaMethod}s to {@link PeripheralMethod}.
|
||||
* As none of our peripherals need {@link IComputerAccess}, this is entirely safe.
|
||||
*/
|
||||
public final class TPeripheralMethodSupplier implements MethodSupplier<PeripheralMethod> {
|
||||
static final TPeripheralMethodSupplier INSTANCE = new TPeripheralMethodSupplier();
|
||||
|
||||
private TPeripheralMethodSupplier() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean forEachSelfMethod(Object object, UntargetedConsumer<PeripheralMethod> consumer) {
|
||||
return TLuaMethodSupplier.INSTANCE.forEachSelfMethod(object, (name, method, info) -> consumer.accept(name, cast(method), null));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean forEachMethod(Object object, TargetedConsumer<PeripheralMethod> consumer) {
|
||||
return TLuaMethodSupplier.INSTANCE.forEachMethod(object, (target, name, method, info) -> consumer.accept(target, name, cast(method), null));
|
||||
}
|
||||
|
||||
private static PeripheralMethod cast(LuaMethod method) {
|
||||
return (target, context, computer, args) -> method.apply(target, context, args);
|
||||
}
|
||||
|
||||
public static MethodSupplier<PeripheralMethod> create(List<GenericMethod> genericMethods) {
|
||||
return INSTANCE;
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.computer;
|
||||
|
||||
import cc.tweaked.web.js.Callbacks;
|
||||
import org.teavm.jso.browser.TimerHandler;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* A reimplementation of {@link ComputerThread} which, well, avoids any threading!
|
||||
* <p>
|
||||
* This instead just exucutes work as soon as possible via {@link Callbacks#setImmediate(TimerHandler)}. Timeouts are
|
||||
* instead handled via polling, see {@link cc.tweaked.web.builder.PatchCobalt}.
|
||||
*/
|
||||
public class TComputerThread {
|
||||
private static final ArrayDeque<ComputerExecutor> executors = new ArrayDeque<>();
|
||||
private final TimerHandler callback = this::workOnce;
|
||||
|
||||
public TComputerThread(int threads) {
|
||||
}
|
||||
|
||||
public void queue(ComputerExecutor executor) {
|
||||
if (executor.onComputerQueue) throw new IllegalStateException("Cannot queue already queued executor");
|
||||
executor.onComputerQueue = true;
|
||||
|
||||
if (executors.isEmpty()) Callbacks.setImmediate(callback);
|
||||
executors.add(executor);
|
||||
}
|
||||
|
||||
private void workOnce() {
|
||||
var executor = executors.poll();
|
||||
if (executor == null) throw new IllegalStateException("Working, but executor is null");
|
||||
if (!executor.onComputerQueue) throw new IllegalArgumentException("Working but not on queue");
|
||||
|
||||
executor.beforeWork();
|
||||
try {
|
||||
executor.work();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
if (executor.afterWork()) executors.push(executor);
|
||||
if (!executors.isEmpty()) Callbacks.setImmediate(callback);
|
||||
}
|
||||
|
||||
public boolean hasPendingWork() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public long scaledPeriod() {
|
||||
return 50 * 1_000_000L;
|
||||
}
|
||||
|
||||
public boolean stop(long timeout, TimeUnit unit) {
|
||||
return true;
|
||||
}
|
||||
}
|
@ -0,0 +1,154 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package io.netty.handler.codec.http;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* A replacement for {@link DefaultHttpHeaders}.
|
||||
* <p>
|
||||
* The default implementation does additional conversion for dates, which ends up pulling in a lot of code we can't
|
||||
* compile.
|
||||
*/
|
||||
public class TDefaultHttpHeaders extends HttpHeaders {
|
||||
private final Map<String, String> map = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public String get(String name) {
|
||||
return map.get(normalise(name));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getAll(String name) {
|
||||
var value = get(name);
|
||||
return value == null ? List.of() : List.of(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map.Entry<String, String>> entries() {
|
||||
return List.copyOf(map.entrySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String name) {
|
||||
return get(name) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Deprecated
|
||||
public Iterator<Map.Entry<String, String>> iterator() {
|
||||
return map.entrySet().iterator();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Iterator<Map.Entry<CharSequence, CharSequence>> iteratorCharSequence() {
|
||||
return (Iterator<Map.Entry<CharSequence, CharSequence>>) (Iterator<?>) map.entrySet().iterator();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return map.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return map.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> names() {
|
||||
return map.keySet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders add(String name, Object value) {
|
||||
return set(name, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders set(String name, Object value) {
|
||||
map.put(normalise(name), (String) value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders remove(String name) {
|
||||
map.remove(normalise(name));
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders clear() {
|
||||
map.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
//region Uncalled/unsupported methods
|
||||
@Override
|
||||
public Integer getInt(CharSequence name) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(CharSequence name, int defaultValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Short getShort(CharSequence name) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public short getShort(CharSequence name, short defaultValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getTimeMillis(CharSequence name) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimeMillis(CharSequence name, long defaultValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders add(String name, Iterable<?> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders addInt(CharSequence name, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders addShort(CharSequence name, short value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders set(String name, Iterable<?> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders setInt(CharSequence name, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders setShort(CharSequence name, short value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
//endregion
|
||||
|
||||
private static String normalise(String string) {
|
||||
return string.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
55
projects/web/src/main/java/io/netty/util/TAsciiString.java
Normal file
55
projects/web/src/main/java/io/netty/util/TAsciiString.java
Normal file
@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package io.netty.util;
|
||||
|
||||
import org.teavm.interop.NoSideEffects;
|
||||
|
||||
/**
|
||||
* A replacement for {@link AsciiString} which just wraps a normal string.
|
||||
* <p>
|
||||
* {@link AsciiString} relies heavily on Netty's low-level (and often unsafe!) code, which doesn't run on Javascript.
|
||||
*/
|
||||
public final class TAsciiString implements CharSequence {
|
||||
private final String value;
|
||||
|
||||
private TAsciiString(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@NoSideEffects
|
||||
public static TAsciiString cached(String value) {
|
||||
return new TAsciiString(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return value.length();
|
||||
}
|
||||
|
||||
@Override
|
||||
public char charAt(int index) {
|
||||
return value.charAt(index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence subSequence(int start, int end) {
|
||||
return value.subSequence(start, end);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
return this == o || (o instanceof TAsciiString other && value.equals(other.value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return value.hashCode();
|
||||
}
|
||||
}
|
99
projects/web/src/main/java/org/slf4j/TLoggerFactory.java
Normal file
99
projects/web/src/main/java/org/slf4j/TLoggerFactory.java
Normal file
@ -0,0 +1,99 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package org.slf4j;
|
||||
|
||||
import cc.tweaked.web.js.Console;
|
||||
import org.slf4j.event.Level;
|
||||
import org.slf4j.helpers.AbstractLogger;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A replacement for SLF4J's {@link LoggerFactory}, which skips service loading, returning a logger which prints to the
|
||||
* JS console.
|
||||
*/
|
||||
public final class TLoggerFactory {
|
||||
private static final Logger INSTANCE = new LoggerImpl();
|
||||
|
||||
private TLoggerFactory() {
|
||||
}
|
||||
|
||||
public static Logger getLogger(Class<?> klass) {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private static final class LoggerImpl extends AbstractLogger {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 3442920913507872371L;
|
||||
|
||||
@Override
|
||||
protected String getFullyQualifiedCallerName() {
|
||||
return "logger";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleNormalizedLoggingCall(Level level, Marker marker, String msg, Object[] arguments, Throwable throwable) {
|
||||
if (arguments != null) msg += " " + Arrays.toString(arguments);
|
||||
switch (level) {
|
||||
case TRACE, DEBUG, INFO -> Console.log(msg);
|
||||
case WARN -> Console.warn(msg);
|
||||
case ERROR -> Console.error(msg);
|
||||
}
|
||||
|
||||
if (throwable != null) throwable.printStackTrace();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTraceEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTraceEnabled(Marker marker) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDebugEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDebugEnabled(Marker marker) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInfoEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInfoEnabled(Marker marker) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWarnEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWarnEnabled(Marker marker) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isErrorEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isErrorEnabled(Marker marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
69
projects/web/src/main/java/org/slf4j/TMarkerFactory.java
Normal file
69
projects/web/src/main/java/org/slf4j/TMarkerFactory.java
Normal file
@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package org.slf4j;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
|
||||
/**
|
||||
* A replacement for SLF4J's {@link MarkerFactory}, which skips service loading and always uses a constant
|
||||
* {@link Marker}.
|
||||
*/
|
||||
public final class TMarkerFactory {
|
||||
private static final Marker INSTANCE = new MarkerImpl();
|
||||
|
||||
private TMarkerFactory() {
|
||||
}
|
||||
|
||||
public static Marker getMarker(String name) {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private static final class MarkerImpl implements Marker {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 6353565105632304410L;
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "unnamed";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void add(Marker reference) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean remove(Marker reference) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Deprecated
|
||||
public boolean hasChildren() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasReferences() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<Marker> iterator() {
|
||||
return Collections.emptyIterator();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(Marker other) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String name) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user