mirror of
https://github.com/SquidDev-CC/CC-Tweaked
synced 2025-04-23 03:03:19 +00:00
Update to CC:T 1.115.0
- Sync Lua files - Backport our Netty HTTP library.
This commit is contained in:
parent
fbf64a0404
commit
22c094192b
@ -6,7 +6,7 @@
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.0.1
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
18
LICENSES/MIT.txt
Normal file
18
LICENSES/MIT.txt
Normal file
@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) <year> <copyright holders>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
@ -4,11 +4,13 @@
|
||||
|
||||
import com.diffplug.gradle.spotless.FormatExtension
|
||||
import com.diffplug.spotless.LineEnding
|
||||
import net.fabricmc.loom.LoomGradleExtension
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.voldeloom)
|
||||
alias(libs.plugins.spotless)
|
||||
id("com.gradleup.shadow") version "8.3.5"
|
||||
}
|
||||
|
||||
val modVersion: String by extra
|
||||
@ -76,14 +78,16 @@ dependencies {
|
||||
|
||||
compileOnly("com.google.code.findbugs:jsr305:3.0.2")
|
||||
compileOnly("org.jetbrains:annotations:24.0.1")
|
||||
compileOnly(libs.jspecify)
|
||||
modImplementation("maven.modrinth:computercraft:1.50")
|
||||
"shade"("cc.tweaked:cobalt")
|
||||
"shade"(libs.bundles.netty)
|
||||
|
||||
"buildTools"("cc.tweaked.cobalt:build-tools")
|
||||
}
|
||||
|
||||
// Point compileJava to emit to classes/uninstrumentedJava/main, and then add a task to instrument these classes,
|
||||
// saving them back to the the original class directory. This is held together with so much string :(.
|
||||
// saving them back to the original class directory. This is held together with so much string :(.
|
||||
val mainSource = sourceSets.main.get()
|
||||
val javaClassesDir = mainSource.java.classesDirectory.get()
|
||||
val untransformedClasses = project.layout.buildDirectory.dir("classes/uninstrumentedJava/main")
|
||||
@ -119,7 +123,16 @@ tasks.withType(AbstractArchiveTask::class.java).configureEach {
|
||||
fileMode = Integer.valueOf("664", 8)
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
// Override remapJarForRelease, and then manually configure all tasks to have the right classifiers.
|
||||
tasks.remapJarForRelease {
|
||||
archiveClassifier = ""
|
||||
input = tasks.shadowJar.flatMap { it.archiveFile }
|
||||
}
|
||||
|
||||
tasks.jar { archiveClassifier = "dev-slim" }
|
||||
|
||||
tasks.shadowJar {
|
||||
archiveClassifier = "dev"
|
||||
manifest {
|
||||
attributes(
|
||||
"FMLCorePlugin" to "cc.tweaked.patch.CorePlugin",
|
||||
@ -127,7 +140,17 @@ tasks.jar {
|
||||
)
|
||||
}
|
||||
|
||||
from(configurations["shade"].map { if (it.isDirectory) it else zipTree(it) })
|
||||
configurations = listOf(project.configurations["shade"])
|
||||
relocate("io.netty", "cc.tweaked.vendor.netty")
|
||||
minimize()
|
||||
}
|
||||
|
||||
project.afterEvaluate {
|
||||
// Remove tasks.jar from the runtime classpath and add shadowJar instead.
|
||||
val field = LoomGradleExtension::class.java.getDeclaredField("unmappedModsBuilt")
|
||||
field.isAccessible = true
|
||||
(field.get(volde) as MutableList<*>).clear()
|
||||
volde.addUnmappedMod(tasks.shadowJar.get().archiveFile.get().asFile.toPath())
|
||||
}
|
||||
|
||||
tasks.processResources {
|
||||
|
@ -4,6 +4,6 @@
|
||||
|
||||
org.gradle.jvmargs=-Xmx3G
|
||||
|
||||
modVersion=1.109.6
|
||||
modVersion=1.115.1
|
||||
|
||||
mcVersion=1.4.7
|
||||
|
@ -5,24 +5,18 @@
|
||||
[versions]
|
||||
forge = "1.4.7-6.6.2.534"
|
||||
|
||||
asm = "9.3"
|
||||
kotlin = "1.8.10"
|
||||
jspecify = "1.0.0"
|
||||
netty = "4.1.119.Final"
|
||||
|
||||
[libraries]
|
||||
asm = { module = "org.ow2.asm:asm", version.ref = "asm" }
|
||||
asm-analysis = { module = "org.ow2.asm:asm-analysis", version.ref = "asm" }
|
||||
asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm" }
|
||||
asm-tree = { module = "org.ow2.asm:asm-tree", version.ref = "asm" }
|
||||
asm-util = { module = "org.ow2.asm:asm-util", version.ref = "asm" }
|
||||
jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" }
|
||||
|
||||
kotlin-platform = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" }
|
||||
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||
netty-codec = { module = "io.netty:netty-codec", version.ref = "netty" }
|
||||
netty-http = { module = "io.netty:netty-codec-http", version.ref = "netty" }
|
||||
|
||||
[bundles]
|
||||
asm = ["asm", "asm-analysis", "asm-commons", "asm-tree", "asm-util"]
|
||||
kotlin = ["kotlin-stdlib"]
|
||||
netty = ["netty-codec", "netty-http"]
|
||||
|
||||
[plugins]
|
||||
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
spotless = { id = "com.diffplug.spotless", version = "6.19.0" }
|
||||
voldeloom = { id = "agency.highlysuspect.voldeloom", version = "2.4-SNAPSHOT" }
|
||||
|
@ -30,6 +30,7 @@ public class ClassTransformer implements IClassTransformer {
|
||||
BasicRemapper.builder()
|
||||
.remapType("dan200/computer/core/apis/FSAPI", "dan200/computercraft/core/apis/FSAPI")
|
||||
.remapType("dan200/computer/core/apis/OSAPI", "dan200/computercraft/core/apis/OSAPI")
|
||||
.remapType("dan200/computer/core/apis/HTTPAPI", "dan200/computercraft/core/apis/HTTPAPI")
|
||||
.remapType("dan200/computer/core/apis/TermAPI", "dan200/computercraft/core/apis/TermAPI")
|
||||
.build().toMethodTransform()
|
||||
)
|
||||
|
@ -195,6 +195,19 @@ public abstract class IArguments {
|
||||
return LuaValues.encode(getString(index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the argument, converting it to the raw-byte representation of its string by following Lua conventions.
|
||||
* <p>
|
||||
* This is equivalent to {@link #getStringCoerced(int)}, but then
|
||||
*
|
||||
* @param index The argument number.
|
||||
* @return The argument's value. This is a <em>read only</em> buffer.
|
||||
* @throws LuaException If the argument cannot be converted to Java.
|
||||
*/
|
||||
public ByteBuffer getBytesCoerced(int index) throws LuaException {
|
||||
return LuaValues.encode(getStringCoerced(index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a string argument as an enum value.
|
||||
*
|
||||
|
461
src/main/java/dan200/computercraft/api/lua/LuaTable.java
Normal file
461
src/main/java/dan200/computercraft/api/lua/LuaTable.java
Normal file
@ -0,0 +1,461 @@
|
||||
// SPDX-FileCopyrightText: 2021 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.api.lua;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static dan200.computercraft.api.lua.LuaValues.*;
|
||||
|
||||
/**
|
||||
* A view of a Lua table.
|
||||
* <p>
|
||||
* Much like {@link IArguments}, this allows for convenient parsing of fields from a Lua table.
|
||||
*
|
||||
* @param <K> The type of keys in a table, will typically be a wildcard.
|
||||
* @param <V> The type of values in a table, will typically be a wildcard.
|
||||
* @see ObjectArguments
|
||||
*/
|
||||
public abstract class LuaTable<K, V> implements Map<K, V> {
|
||||
/**
|
||||
* Compute the length of the array part of this table.
|
||||
*
|
||||
* @return This table's length.
|
||||
*/
|
||||
public int length() {
|
||||
var size = 0;
|
||||
while (containsKey((double) (size + 1))) size++;
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as a double.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value.
|
||||
* @throws LuaException If the value is not a number.
|
||||
* @see #getFiniteDouble(int) if you require this to be finite (i.e. not infinite or NaN).
|
||||
* @since 1.116
|
||||
*/
|
||||
public double getDouble(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (!(value instanceof Number number)) throw badTableItem(index, "number", getType(value));
|
||||
return number.doubleValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as a double.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value.
|
||||
* @throws LuaException If the value is not a number.
|
||||
* @see #getFiniteDouble(String) if you require this to be finite (i.e. not infinite or NaN).
|
||||
* @since 1.116
|
||||
*/
|
||||
public double getDouble(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (!(value instanceof Number number)) throw badField(key, "number", getType(value));
|
||||
return number.doubleValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as an integer.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value.
|
||||
* @throws LuaException If the value is not an integer.
|
||||
*/
|
||||
public long getLong(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (!(value instanceof Number number)) throw badTableItem(index, "number", getType(value));
|
||||
checkFiniteIndex(index, number.doubleValue());
|
||||
return number.longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as an integer.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value.
|
||||
* @throws LuaException If the value is not an integer.
|
||||
*/
|
||||
public long getLong(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (!(value instanceof Number number)) throw badField(key, "number", getType(value));
|
||||
checkFiniteField(key, number.doubleValue());
|
||||
return number.longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as an integer.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value.
|
||||
* @throws LuaException If the value is not an integer.
|
||||
*/
|
||||
public int getInt(int index) throws LuaException {
|
||||
return (int) getLong(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as an integer.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value.
|
||||
* @throws LuaException If the value is not an integer.
|
||||
*/
|
||||
public int getInt(String key) throws LuaException {
|
||||
return (int) getLong(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an argument as a finite number (not infinite or NaN).
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value.
|
||||
* @throws LuaException If the value is not finite.
|
||||
* @since 1.116
|
||||
*/
|
||||
public double getFiniteDouble(int index) throws LuaException {
|
||||
return checkFiniteIndex(index, getDouble(index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an argument as a finite number (not infinite or NaN).
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value.
|
||||
* @throws LuaException If the value is not finite.
|
||||
* @since 1.116
|
||||
*/
|
||||
public double getFiniteDouble(String key) throws LuaException {
|
||||
return checkFiniteField(key, getDouble(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as a boolean.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value.
|
||||
* @throws LuaException If the value is not a boolean.
|
||||
* @since 1.116
|
||||
*/
|
||||
public boolean getBoolean(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (!(value instanceof Boolean bool)) throw badTableItem(index, "boolean", getType(value));
|
||||
return bool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as a boolean.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value.
|
||||
* @throws LuaException If the value is not a boolean.
|
||||
* @since 1.116
|
||||
*/
|
||||
public boolean getBoolean(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (!(value instanceof Boolean bool)) throw badField(key, "boolean", getType(value));
|
||||
return bool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as a string.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value.
|
||||
* @throws LuaException If the value is not a string.
|
||||
* @since 1.116
|
||||
*/
|
||||
public String getString(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (!(value instanceof String string)) throw badTableItem(index, "string", getType(value));
|
||||
return string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as a string.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value.
|
||||
* @throws LuaException If the value is not a string.
|
||||
* @since 1.116
|
||||
*/
|
||||
public String getString(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (!(value instanceof String string)) throw badField(key, "string", getType(value));
|
||||
return string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as a table.
|
||||
* <p>
|
||||
* The returned table may be converted into a {@link LuaTable} (using {@link ObjectLuaTable}) for easier parsing of
|
||||
* table keys.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value.
|
||||
* @throws LuaException If the value is not a table.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Map<?, ?> getTable(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (!(value instanceof Map<?, ?> table)) throw badTableItem(index, "table", getType(value));
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as a table.
|
||||
* <p>
|
||||
* The returned table may be converted into a {@link LuaTable} (using {@link ObjectLuaTable}) for easier parsing of
|
||||
* table keys.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value.
|
||||
* @throws LuaException If the value is not a table.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Map<?, ?> getTable(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (!(value instanceof Map<?, ?> table)) throw badField(key, "table", getType(value));
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as a double.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not a number.
|
||||
* @see #getFiniteDouble(int) if you require this to be finite (i.e. not infinite or NaN).
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Double> optDouble(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof Number number)) throw badTableItem(index, "number", getType(value));
|
||||
return Optional.of(number.doubleValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as a double.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not a number.
|
||||
* @see #getFiniteDouble(String) if you require this to be finite (i.e. not infinite or NaN).
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Double> optDouble(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof Number number)) throw badField(key, "number", getType(value));
|
||||
return Optional.of(number.doubleValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as an integer.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not an integer.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Long> optLong(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof Number number)) throw badTableItem(index, "number", getType(value));
|
||||
checkFiniteIndex(index, number.doubleValue());
|
||||
return Optional.of(number.longValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as an integer.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not an integer.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Long> optLong(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof Number number)) throw badField(key, "number", getType(value));
|
||||
checkFiniteField(key, number.doubleValue());
|
||||
return Optional.of(number.longValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as an integer.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not an integer.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Integer> optInt(int index) throws LuaException {
|
||||
return optLong(index).map(Long::intValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as an integer.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not an integer.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Integer> optInt(String key) throws LuaException {
|
||||
return optLong(key).map(Long::intValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an argument as a finite number (not infinite or NaN).
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not finite.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Double> optFiniteDouble(int index) throws LuaException {
|
||||
var value = optDouble(index);
|
||||
if (value.isPresent()) checkFiniteIndex(index, value.get());
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an argument as a finite number (not infinite or NaN).
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not finite.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Double> optFiniteDouble(String key) throws LuaException {
|
||||
var value = optDouble(key);
|
||||
if (value.isPresent()) checkFiniteField(key, value.get());
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as a boolean.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not a boolean.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Boolean> optBoolean(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof Boolean bool)) throw badTableItem(index, "boolean", getType(value));
|
||||
return Optional.of(bool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as a boolean.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not a boolean.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Boolean> optBoolean(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof Boolean bool)) throw badField(key, "boolean", getType(value));
|
||||
return Optional.of(bool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as a double.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not a string.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<String> optString(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof String string)) throw badTableItem(index, "string", getType(value));
|
||||
return Optional.of(string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as a string.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not a string.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<String> optString(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof String string)) throw badField(key, "string", getType(value));
|
||||
return Optional.of(string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array entry as a table.
|
||||
* <p>
|
||||
* The returned table may be converted into a {@link LuaTable} (using {@link ObjectLuaTable}) for easier parsing of
|
||||
* table keys.
|
||||
*
|
||||
* @param index The index in the table, starting at 1.
|
||||
* @return The entry's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not a table.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Map<?, ?>> optTable(int index) throws LuaException {
|
||||
Object value = get((double) index);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof Map<?, ?> table)) throw badTableItem(index, "table", getType(value));
|
||||
return Optional.of(table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table entry as a table.
|
||||
* <p>
|
||||
* The returned table may be converted into a {@link LuaTable} (using {@link ObjectLuaTable}) for easier parsing of
|
||||
* table keys.
|
||||
*
|
||||
* @param key The name of the field in the table.
|
||||
* @return The field's value, or {@link Optional#empty()} if not present.
|
||||
* @throws LuaException If the value is not a table.
|
||||
* @since 1.116
|
||||
*/
|
||||
public Optional<Map<?, ?>> optTable(String key) throws LuaException {
|
||||
Object value = get(key);
|
||||
if (value == null) return Optional.empty();
|
||||
if (!(value instanceof Map<?, ?> table)) throw badField(key, "table", getType(value));
|
||||
return Optional.of(table);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public V put(K o, V o2) {
|
||||
throw new UnsupportedOperationException("Cannot modify LuaTable");
|
||||
}
|
||||
|
||||
@Override
|
||||
public V remove(Object o) {
|
||||
throw new UnsupportedOperationException("Cannot modify LuaTable");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putAll(Map<? extends K, ? extends V> map) {
|
||||
throw new UnsupportedOperationException("Cannot modify LuaTable");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
throw new UnsupportedOperationException("Cannot modify LuaTable");
|
||||
}
|
||||
}
|
@ -4,7 +4,8 @@
|
||||
|
||||
package dan200.computercraft.api.lua;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Map;
|
||||
|
||||
@ -96,7 +97,7 @@ public final class LuaValues {
|
||||
* @return The constructed exception, which should be thrown immediately.
|
||||
*/
|
||||
public static LuaException badTableItem(int index, String expected, String actual) {
|
||||
return new LuaException("table item #" + index + " is not " + expected + " (got " + actual + ")");
|
||||
return new LuaException("bad item #" + index + " (" + expected + " expected, got " + actual + ")");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -108,7 +109,7 @@ public final class LuaValues {
|
||||
* @return The constructed exception, which should be thrown immediately.
|
||||
*/
|
||||
public static LuaException badField(String key, String expected, String actual) {
|
||||
return new LuaException("field " + key + " is not " + expected + " (got " + actual + ")");
|
||||
return new LuaException("bad field '" + key + "' (" + expected + " expected, got " + actual + ")");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -137,6 +138,16 @@ public final class LuaValues {
|
||||
return value;
|
||||
}
|
||||
|
||||
static double checkFiniteIndex(int index, double value) throws LuaException {
|
||||
if (!Double.isFinite(value)) throw badTableItem(index, "number", getNumericType(value));
|
||||
return value;
|
||||
}
|
||||
|
||||
static double checkFiniteField(String key, double value) throws LuaException {
|
||||
if (!Double.isFinite(value)) throw badField(key, "number", getNumericType(value));
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a string is a valid enum value.
|
||||
*
|
||||
|
@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: 2021 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.api.lua;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* An implementation of {@link LuaTable} based on a standard Java {@link Map}.
|
||||
*/
|
||||
public class ObjectLuaTable extends LuaTable<Object, Object> {
|
||||
private final Map<Object, Object> map;
|
||||
|
||||
public ObjectLuaTable(Map<?, ?> map) {
|
||||
this.map = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return map.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return map.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsKey(Object o) {
|
||||
return map.containsKey(o);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsValue(Object o) {
|
||||
return map.containsKey(o);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Object get(Object o) {
|
||||
return map.get(o);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Object> keySet() {
|
||||
return map.keySet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Object> values() {
|
||||
return map.values();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Entry<Object, Object>> entrySet() {
|
||||
return map.entrySet();
|
||||
}
|
||||
}
|
217
src/main/java/dan200/computercraft/core/apis/HTTPAPI.java
Normal file
217
src/main/java/dan200/computercraft/core/apis/HTTPAPI.java
Normal file
@ -0,0 +1,217 @@
|
||||
// Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
|
||||
//
|
||||
// SPDX-License-Identifier: LicenseRef-CCPL
|
||||
|
||||
package dan200.computercraft.core.apis;
|
||||
|
||||
import dan200.ComputerCraft;
|
||||
import dan200.computer.core.IAPIEnvironment;
|
||||
import dan200.computer.core.ILuaAPI;
|
||||
import dan200.computercraft.api.lua.*;
|
||||
import dan200.computercraft.core.apis.http.*;
|
||||
import dan200.computercraft.core.apis.http.request.HttpRequest;
|
||||
import dan200.computercraft.core.apis.http.websocket.Websocket;
|
||||
import dan200.computercraft.core.apis.http.websocket.WebsocketClient;
|
||||
import io.netty.handler.codec.http.DefaultHttpHeaders;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpHeaders;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static dan200.computercraft.core.util.ArgumentHelpers.assertBetween;
|
||||
|
||||
/**
|
||||
* Placeholder description, please ignore.
|
||||
*
|
||||
* @cc.module http
|
||||
* @hidden
|
||||
*/
|
||||
public class HTTPAPI implements ILuaAPI {
|
||||
private static final double DEFAULT_TIMEOUT = 30;
|
||||
private static final double MAX_TIMEOUT = 60;
|
||||
|
||||
private final IAPIEnvironment apiEnvironment;
|
||||
|
||||
private final ResourceGroup<CheckUrl> checkUrls = new ResourceGroup<>(() -> ResourceGroup.DEFAULT_LIMIT);
|
||||
private final ResourceGroup<HttpRequest> requests = new ResourceQueue<>(() -> 16);
|
||||
private final ResourceGroup<Websocket> websockets = new ResourceGroup<>(() -> 4);
|
||||
|
||||
public HTTPAPI(IAPIEnvironment environment) {
|
||||
apiEnvironment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getNames() {
|
||||
return new String[]{ "http" };
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startup() {
|
||||
checkUrls.startup();
|
||||
requests.startup();
|
||||
websockets.startup();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
checkUrls.shutdown();
|
||||
requests.shutdown();
|
||||
websockets.shutdown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void advance(double dt) {
|
||||
// It's rather ugly to run this here, but we need to clean up
|
||||
// resources as often as possible to reduce blocking.
|
||||
Resource.cleanup();
|
||||
}
|
||||
|
||||
@LuaFunction
|
||||
public final Object[] request(IArguments args) throws LuaException {
|
||||
String address, requestMethod;
|
||||
ByteBuffer postBody;
|
||||
Map<?, ?> headerTable;
|
||||
boolean binary, redirect;
|
||||
Optional<Double> timeoutArg;
|
||||
|
||||
if (args.get(0) instanceof Map) {
|
||||
var options = new ObjectLuaTable(args.getTable(0));
|
||||
address = options.getString("url");
|
||||
postBody = options.optString("body").map(LuaValues::encode).orElse(null);
|
||||
headerTable = options.optTable("headers").orElse(Collections.emptyMap());
|
||||
binary = options.optBoolean("binary").orElse(false);
|
||||
requestMethod = options.optString("method").orElse(null);
|
||||
redirect = options.optBoolean("redirect").orElse(true);
|
||||
timeoutArg = options.optFiniteDouble("timeout");
|
||||
} else {
|
||||
// Get URL and post information
|
||||
address = args.getString(0);
|
||||
postBody = args.optBytes(1).orElse(null);
|
||||
headerTable = args.optTable(2, Collections.emptyMap());
|
||||
binary = args.optBoolean(3, false);
|
||||
requestMethod = null;
|
||||
redirect = true;
|
||||
timeoutArg = Optional.empty();
|
||||
}
|
||||
|
||||
var headers = getHeaders(headerTable);
|
||||
var timeout = getTimeout(timeoutArg);
|
||||
|
||||
HttpMethod httpMethod;
|
||||
if (requestMethod == null) {
|
||||
httpMethod = postBody == null ? HttpMethod.GET : HttpMethod.POST;
|
||||
} else {
|
||||
httpMethod = HttpMethod.valueOf(requestMethod.toUpperCase(Locale.ROOT));
|
||||
if (httpMethod == null || requestMethod.equalsIgnoreCase("CONNECT")) {
|
||||
throw new LuaException("Unsupported HTTP method");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
var uri = HttpRequest.checkUri(address);
|
||||
var request = new HttpRequest(requests, apiEnvironment, address, postBody, headers, binary, redirect, timeout);
|
||||
|
||||
// Make the request
|
||||
if (!request.queue(r -> r.request(uri, httpMethod))) {
|
||||
throw new LuaException("Too many ongoing HTTP requests");
|
||||
}
|
||||
|
||||
return new Object[]{ true };
|
||||
} catch (HTTPRequestException e) {
|
||||
return new Object[]{ false, e.getMessage() };
|
||||
}
|
||||
}
|
||||
|
||||
@LuaFunction
|
||||
public final Object[] checkURL(String address) throws LuaException {
|
||||
try {
|
||||
var uri = HttpRequest.checkUri(address);
|
||||
if (!new CheckUrl(checkUrls, apiEnvironment, address, uri).queue(CheckUrl::run)) {
|
||||
throw new LuaException("Too many ongoing checkUrl calls");
|
||||
}
|
||||
|
||||
return new Object[]{ true };
|
||||
} catch (HTTPRequestException e) {
|
||||
return new Object[]{ false, e.getMessage() };
|
||||
}
|
||||
}
|
||||
|
||||
@LuaFunction
|
||||
public final Object[] websocket(IArguments args) throws LuaException {
|
||||
String address;
|
||||
Map<?, ?> headerTable;
|
||||
Optional<Double> timeoutArg;
|
||||
|
||||
if (args.get(0) instanceof Map) {
|
||||
var options = new ObjectLuaTable(args.getTable(0));
|
||||
address = options.getString("url");
|
||||
headerTable = options.optTable("headers").orElse(Collections.emptyMap());
|
||||
timeoutArg = options.optFiniteDouble("timeout");
|
||||
} else {
|
||||
address = args.getString(0);
|
||||
headerTable = args.optTable(1, Collections.emptyMap());
|
||||
timeoutArg = Optional.empty();
|
||||
}
|
||||
|
||||
var headers = getHeaders(headerTable);
|
||||
var timeout = getTimeout(timeoutArg);
|
||||
|
||||
try {
|
||||
var uri = WebsocketClient.Support.parseUri(address);
|
||||
if (!new Websocket(websockets, apiEnvironment, uri, address, headers, timeout).queue(Websocket::connect)) {
|
||||
throw new LuaException("Too many websockets already open");
|
||||
}
|
||||
|
||||
return new Object[]{ true };
|
||||
} catch (HTTPRequestException e) {
|
||||
return new Object[]{ false, e.getMessage() };
|
||||
}
|
||||
}
|
||||
|
||||
private HttpHeaders getHeaders(Map<?, ?> headerTable) throws LuaException {
|
||||
HttpHeaders headers = new DefaultHttpHeaders();
|
||||
for (Map.Entry<?, ?> entry : headerTable.entrySet()) {
|
||||
var value = entry.getValue();
|
||||
if (entry.getKey() instanceof String && value instanceof String) {
|
||||
try {
|
||||
headers.add((String) entry.getKey(), value);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new LuaException(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!headers.contains(HttpHeaderNames.USER_AGENT)) {
|
||||
headers.set(HttpHeaderNames.USER_AGENT, "computercraft/" + ComputerCraft.getVersion());
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the timeout value, asserting it is in range.
|
||||
*
|
||||
* @param timeoutArg The (optional) timeout, in seconds.
|
||||
* @return The parsed timeout value, in milliseconds.
|
||||
* @throws LuaException If the timeout is in-range.
|
||||
*/
|
||||
private static int getTimeout(Optional<Double> timeoutArg) throws LuaException {
|
||||
double timeout = timeoutArg.orElse(DEFAULT_TIMEOUT);
|
||||
assertBetween(timeout, 0, MAX_TIMEOUT, "timeout out of range (%s)");
|
||||
return (int) (timeout * 1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getMethodNames() {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object[] callMethod(int i, Object[] objects) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
@ -0,0 +1,327 @@
|
||||
// SPDX-FileCopyrightText: 2017 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.handles;
|
||||
|
||||
import dan200.computercraft.api.lua.Coerced;
|
||||
import dan200.computercraft.api.lua.IArguments;
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.api.lua.LuaFunction;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.Buffer;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The base class for all file handle types.
|
||||
*/
|
||||
public abstract class AbstractHandle {
|
||||
private static final int BUFFER_SIZE = 8192;
|
||||
|
||||
private final SeekableByteChannel channel;
|
||||
private boolean closed;
|
||||
protected final boolean binary;
|
||||
|
||||
private final ByteBuffer single = ByteBuffer.allocate(1);
|
||||
|
||||
protected AbstractHandle(SeekableByteChannel channel, boolean binary) {
|
||||
this.channel = channel;
|
||||
this.binary = binary;
|
||||
}
|
||||
|
||||
protected void checkOpen() throws LuaException {
|
||||
if (closed) throw new LuaException("attempt to use a closed file");
|
||||
}
|
||||
|
||||
/**
|
||||
* Close this file, freeing any resources it uses.
|
||||
* <p>
|
||||
* Once a file is closed it may no longer be read or written to.
|
||||
*
|
||||
* @throws LuaException If the file has already been closed.
|
||||
*/
|
||||
@LuaFunction
|
||||
public final void close() throws LuaException {
|
||||
checkOpen();
|
||||
try {
|
||||
closed = true;
|
||||
channel.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a new position within the file, changing where bytes are written to. The new position is an offset
|
||||
* given by {@code offset}, relative to a start position determined by {@code whence}:
|
||||
* <p>
|
||||
* - {@code "set"}: {@code offset} is relative to the beginning of the file.
|
||||
* - {@code "cur"}: Relative to the current position. This is the default.
|
||||
* - {@code "end"}: Relative to the end of the file.
|
||||
* <p>
|
||||
* In case of success, {@code seek} returns the new file position from the beginning of the file.
|
||||
*
|
||||
* @param whence Where the offset is relative to.
|
||||
* @param offset The offset to seek to.
|
||||
* @return The new position.
|
||||
* @throws LuaException If the file has been closed.
|
||||
* @cc.treturn [1] number The new position.
|
||||
* @cc.treturn [2] nil If seeking failed.
|
||||
* @cc.treturn string The reason seeking failed.
|
||||
* @cc.since 1.80pr1.9
|
||||
*/
|
||||
public Object @Nullable [] seek(Optional<String> whence, Optional<Long> offset) throws LuaException {
|
||||
checkOpen();
|
||||
long actualOffset = offset.orElse(0L);
|
||||
try {
|
||||
switch (whence.orElse("cur")) {
|
||||
case "set" -> channel.position(actualOffset);
|
||||
case "cur" -> channel.position(channel.position() + actualOffset);
|
||||
case "end" -> channel.position(channel.size() + actualOffset);
|
||||
default -> throw new LuaException("bad argument #1 to 'seek' (invalid option '" + whence + "'");
|
||||
}
|
||||
|
||||
return new Object[]{channel.position()};
|
||||
} catch (IllegalArgumentException e) {
|
||||
return new Object[]{null, "Position is negative"};
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a number of bytes from this file.
|
||||
*
|
||||
* @param countArg The number of bytes to read. This may be 0 to determine we are at the end of the file. When
|
||||
* absent, a single byte will be read.
|
||||
* @return The read bytes.
|
||||
* @throws LuaException When trying to read a negative number of bytes.
|
||||
* @throws LuaException If the file has been closed.
|
||||
* @cc.treturn [1] nil If we are at the end of the file.
|
||||
* @cc.treturn [2] number The value of the byte read. This is returned if the file is opened in binary mode and
|
||||
* {@code count} is absent
|
||||
* @cc.treturn [3] string The bytes read as a string. This is returned when the {@code count} is given.
|
||||
* @cc.changed 1.80pr1 Now accepts an integer argument to read multiple bytes, returning a string instead of a number.
|
||||
*/
|
||||
public Object @Nullable [] read(Optional<Integer> countArg) throws LuaException {
|
||||
checkOpen();
|
||||
try {
|
||||
if (binary && !countArg.isPresent()) {
|
||||
clear(single);
|
||||
var b = channel.read(single);
|
||||
return b == -1 ? null : new Object[]{single.get(0) & 0xFF};
|
||||
} else {
|
||||
int count = countArg.orElse(1);
|
||||
if (count < 0) throw new LuaException("Cannot read a negative number of bytes");
|
||||
if (count == 0) return channel.position() >= channel.size() ? null : new Object[]{""};
|
||||
|
||||
if (count <= BUFFER_SIZE) {
|
||||
var buffer = ByteBuffer.allocate(count);
|
||||
|
||||
var read = channel.read(buffer);
|
||||
if (read < 0) return null;
|
||||
flip(buffer);
|
||||
return new Object[]{buffer};
|
||||
} else {
|
||||
// Read the initial set of characters, failing if none are read.
|
||||
var buffer = ByteBuffer.allocate(BUFFER_SIZE);
|
||||
var read = channel.read(buffer);
|
||||
if (read < 0) return null;
|
||||
flip(buffer);
|
||||
|
||||
// If we failed to read "enough" here, let's just abort
|
||||
if (read >= count || read < BUFFER_SIZE) return new Object[]{buffer};
|
||||
|
||||
// Build up an array of ByteBuffers. Hopefully this means we can perform less allocation
|
||||
// than doubling up the buffer each time.
|
||||
var totalRead = read;
|
||||
List<ByteBuffer> parts = new ArrayList<>(4);
|
||||
parts.add(buffer);
|
||||
while (read >= BUFFER_SIZE && totalRead < count) {
|
||||
buffer = ByteBuffer.allocateDirect(Math.min(BUFFER_SIZE, count - totalRead));
|
||||
read = channel.read(buffer);
|
||||
if (read < 0) break;
|
||||
flip(buffer);
|
||||
|
||||
totalRead += read;
|
||||
assert read == buffer.remaining();
|
||||
parts.add(buffer);
|
||||
}
|
||||
|
||||
// Now just copy all the bytes across!
|
||||
var bytes = new byte[totalRead];
|
||||
var pos = 0;
|
||||
for (var part : parts) {
|
||||
var length = part.remaining();
|
||||
part.get(bytes, pos, length);
|
||||
pos += length;
|
||||
}
|
||||
assert pos == totalRead;
|
||||
return new Object[]{bytes};
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the remainder of the file.
|
||||
*
|
||||
* @return The remaining contents of the file, or {@code null} in the event of an error.
|
||||
* @throws LuaException If the file has been closed.
|
||||
* @cc.treturn string|nil The remaining contents of the file, or {@code nil} in the event of an error.
|
||||
* @cc.since 1.80pr1
|
||||
*/
|
||||
public Object @Nullable [] readAll() throws LuaException {
|
||||
checkOpen();
|
||||
try {
|
||||
var expected = 32;
|
||||
expected = Math.max(expected, (int) (channel.size() - channel.position()));
|
||||
var stream = new ByteArrayOutputStream(expected);
|
||||
|
||||
var buf = ByteBuffer.allocate(8192);
|
||||
while (true) {
|
||||
clear(buf);
|
||||
var r = channel.read(buf);
|
||||
if (r == -1) break;
|
||||
|
||||
stream.write(buf.array(), 0, r);
|
||||
}
|
||||
return new Object[]{stream.toByteArray()};
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a line from the file.
|
||||
*
|
||||
* @param withTrailingArg Whether to include the newline characters with the returned string. Defaults to {@code false}.
|
||||
* @return The read string.
|
||||
* @throws LuaException If the file has been closed.
|
||||
* @cc.treturn string|nil The read line or {@code nil} if at the end of the file.
|
||||
* @cc.since 1.80pr1.9
|
||||
* @cc.changed 1.81.0 `\r` is now stripped.
|
||||
*/
|
||||
public Object @Nullable [] readLine(Optional<Boolean> withTrailingArg) throws LuaException {
|
||||
checkOpen();
|
||||
boolean withTrailing = withTrailingArg.orElse(false);
|
||||
try {
|
||||
var stream = new ByteArrayOutputStream();
|
||||
|
||||
boolean readAnything = false, readRc = false;
|
||||
while (true) {
|
||||
clear(single);
|
||||
var read = channel.read(single);
|
||||
if (read <= 0) {
|
||||
// Nothing else to read, and we saw no \n. Return the array. If we saw a \r, then add it
|
||||
// back.
|
||||
if (readRc) stream.write('\r');
|
||||
return readAnything ? new Object[]{stream.toByteArray()} : null;
|
||||
}
|
||||
|
||||
readAnything = true;
|
||||
|
||||
var chr = single.get(0);
|
||||
if (chr == '\n') {
|
||||
if (withTrailing) {
|
||||
if (readRc) stream.write('\r');
|
||||
stream.write(chr);
|
||||
}
|
||||
return new Object[]{stream.toByteArray()};
|
||||
} else {
|
||||
// We want to skip \r\n, but obviously need to include cases where \r is not followed by \n.
|
||||
// Note, this behaviour is non-standard compliant (strictly speaking we should have no
|
||||
// special logic for \r), but we preserve compatibility with EncodedReadableHandle and
|
||||
// previous behaviour of the io library.
|
||||
if (readRc) stream.write('\r');
|
||||
readRc = chr == '\r';
|
||||
if (!readRc) stream.write(chr);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Write a string or byte to the file.
|
||||
*
|
||||
* @param arguments The value to write.
|
||||
* @throws LuaException If the file has been closed.
|
||||
* @cc.tparam [1] string contents The string to write.
|
||||
* @cc.tparam [2] number charcode The byte to write, if the file was opened in binary mode.
|
||||
* @cc.changed 1.80pr1 Now accepts a string to write multiple bytes.
|
||||
*/
|
||||
public void write(IArguments arguments) throws LuaException {
|
||||
checkOpen();
|
||||
try {
|
||||
var arg = arguments.get(0);
|
||||
if (binary && arg instanceof Number n) {
|
||||
var number = n.intValue();
|
||||
writeSingle((byte) number);
|
||||
} else {
|
||||
channel.write(arguments.getBytesCoerced(0));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new LuaException(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a string of characters to the file, following them with a new line character.
|
||||
*
|
||||
* @param text The text to write to the file.
|
||||
* @throws LuaException If the file has been closed.
|
||||
*/
|
||||
public void writeLine(Coerced<ByteBuffer> text) throws LuaException {
|
||||
checkOpen();
|
||||
try {
|
||||
channel.write(text.value());
|
||||
writeSingle((byte) '\n');
|
||||
} catch (IOException e) {
|
||||
throw new LuaException(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void writeSingle(byte value) throws IOException {
|
||||
clear(single);
|
||||
single.put(value);
|
||||
flip(single);
|
||||
channel.write(single);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current file without closing it.
|
||||
*
|
||||
* @throws LuaException If the file has been closed.
|
||||
*/
|
||||
public void flush() throws LuaException {
|
||||
checkOpen();
|
||||
try {
|
||||
// Technically this is not needed
|
||||
if (channel instanceof FileChannel channel) channel.force(false);
|
||||
} catch (IOException e) {
|
||||
throw new LuaException(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// The ByteBuffer.clear():ByteBuffer overrides don't exist on Java 8, so add some wrapper functions to avoid that.
|
||||
|
||||
private static void clear(Buffer buffer) {
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
private static void flip(Buffer buffer) {
|
||||
buffer.flip();
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
|
||||
// SPDX-FileCopyrightText: 2018 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.handles;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.nio.channels.NonWritableChannelException;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A seekable, readable byte channel which is backed by a simple byte array.
|
||||
*/
|
||||
public class ArrayByteChannel implements SeekableByteChannel {
|
||||
private boolean closed = false;
|
||||
private int position = 0;
|
||||
|
||||
private final byte[] backing;
|
||||
|
||||
public ArrayByteChannel(byte[] backing) {
|
||||
this.backing = backing;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(ByteBuffer destination) throws ClosedChannelException {
|
||||
if (closed) throw new ClosedChannelException();
|
||||
Objects.requireNonNull(destination, "destination");
|
||||
|
||||
if (position >= backing.length) return -1;
|
||||
|
||||
var remaining = Math.min(backing.length - position, destination.remaining());
|
||||
destination.put(backing, position, remaining);
|
||||
position += remaining;
|
||||
return remaining;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int write(ByteBuffer src) throws ClosedChannelException {
|
||||
if (closed) throw new ClosedChannelException();
|
||||
throw new NonWritableChannelException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long position() throws ClosedChannelException {
|
||||
if (closed) throw new ClosedChannelException();
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SeekableByteChannel position(long newPosition) throws ClosedChannelException {
|
||||
if (closed) throw new ClosedChannelException();
|
||||
if (newPosition < 0 || newPosition > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("Position out of bounds");
|
||||
}
|
||||
position = (int) newPosition;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long size() throws ClosedChannelException {
|
||||
if (closed) throw new ClosedChannelException();
|
||||
return backing.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SeekableByteChannel truncate(long size) throws ClosedChannelException {
|
||||
if (closed) throw new ClosedChannelException();
|
||||
throw new NonWritableChannelException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOpen() {
|
||||
return !closed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
// SPDX-FileCopyrightText: 2018 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.handles;
|
||||
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.api.lua.LuaFunction;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A file handle opened for reading with {@link dan200.computercraft.core.apis.FSAPI#open(String, String)}.
|
||||
*
|
||||
* @cc.module fs.ReadHandle
|
||||
*/
|
||||
public class ReadHandle extends AbstractHandle {
|
||||
public ReadHandle(SeekableByteChannel channel, boolean binary) {
|
||||
super(channel, binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@LuaFunction
|
||||
public final Object @Nullable [] read(Optional<Integer> countArg) throws LuaException {
|
||||
return super.read(countArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@LuaFunction
|
||||
public final Object @Nullable [] readAll() throws LuaException {
|
||||
return super.readAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@LuaFunction
|
||||
public final Object @Nullable [] readLine(Optional<Boolean> withTrailingArg) throws LuaException {
|
||||
return super.readLine(withTrailingArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@LuaFunction
|
||||
public final Object @Nullable [] seek(Optional<String> whence, Optional<Long> offset) throws LuaException {
|
||||
return super.seek(whence, offset);
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http;
|
||||
|
||||
import dan200.computer.core.IAPIEnvironment;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
/**
|
||||
* Checks a URL using {@link NetworkUtils#getAddress(String, int, boolean)}}
|
||||
* <p>
|
||||
* This requires a DNS lookup, and so needs to occur off-thread.
|
||||
*/
|
||||
public class CheckUrl extends Resource<CheckUrl> {
|
||||
private static final String EVENT = "http_check";
|
||||
|
||||
private @Nullable Future<?> future;
|
||||
|
||||
private final IAPIEnvironment environment;
|
||||
private final String address;
|
||||
private final URI uri;
|
||||
|
||||
public CheckUrl(ResourceGroup<CheckUrl> limiter, IAPIEnvironment environment, String address, URI uri) {
|
||||
super(limiter);
|
||||
this.environment = environment;
|
||||
this.address = address;
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
if (isClosed()) return;
|
||||
future = NetworkUtils.EXECUTOR.submit(this::doRun);
|
||||
checkClosed();
|
||||
}
|
||||
|
||||
private void doRun() {
|
||||
if (isClosed()) return;
|
||||
|
||||
try {
|
||||
var ssl = uri.getScheme().equalsIgnoreCase("https");
|
||||
var netAddress = NetworkUtils.getAddress(uri, ssl);
|
||||
NetworkUtils.getOptions(uri.getHost(), netAddress);
|
||||
|
||||
if (tryClose()) environment.queueEvent(EVENT, new Object[]{ address, true });
|
||||
} catch (HTTPRequestException e) {
|
||||
if (tryClose()) {
|
||||
environment.queueEvent(EVENT, new Object[]{ address, false, NetworkUtils.toFriendlyError(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void dispose() {
|
||||
super.dispose();
|
||||
future = closeFuture(future);
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
// Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
|
||||
//
|
||||
// SPDX-License-Identifier: LicenseRef-CCPL
|
||||
|
||||
package dan200.computercraft.core.apis.http;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
public class HTTPRequestException extends Exception {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 7591208619422744652L;
|
||||
|
||||
public HTTPRequestException(String s) {
|
||||
super(s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Throwable fillInStackTrace() {
|
||||
return this;
|
||||
}
|
||||
}
|
@ -0,0 +1,196 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http;
|
||||
|
||||
import dan200.computercraft.core.apis.http.options.Action;
|
||||
import dan200.computercraft.core.apis.http.options.Options;
|
||||
import dan200.computercraft.core.util.ThreadUtils;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ConnectTimeoutException;
|
||||
import io.netty.channel.EventLoopGroup;
|
||||
import io.netty.channel.nio.NioEventLoopGroup;
|
||||
import io.netty.channel.socket.SocketChannel;
|
||||
import io.netty.handler.codec.DecoderException;
|
||||
import io.netty.handler.codec.TooLongFrameException;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketHandshakeException;
|
||||
import io.netty.handler.ssl.SslContext;
|
||||
import io.netty.handler.ssl.SslContextBuilder;
|
||||
import io.netty.handler.ssl.SslHandler;
|
||||
import io.netty.handler.timeout.ReadTimeoutException;
|
||||
import io.netty.handler.traffic.AbstractTrafficShapingHandler;
|
||||
import io.netty.handler.traffic.GlobalTrafficShapingHandler;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static cc.tweaked.CCTweaked.LOG;
|
||||
|
||||
/**
|
||||
* Just a shared object for executing simple HTTP related tasks.
|
||||
*/
|
||||
public final class NetworkUtils {
|
||||
public static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(4, ThreadUtils.lowPriorityFactory("Network"));
|
||||
public static final EventLoopGroup LOOP_GROUP = new NioEventLoopGroup(4, ThreadUtils.lowPriorityFactory("Netty"));
|
||||
|
||||
private static final AbstractTrafficShapingHandler SHAPING_HANDLER = new GlobalTrafficShapingHandler(
|
||||
EXECUTOR, 32 * 1024 * 1024, 32 * 1024 * 1024
|
||||
);
|
||||
|
||||
static {
|
||||
EXECUTOR.setKeepAliveTime(60, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private NetworkUtils() {
|
||||
}
|
||||
|
||||
private static final Object sslLock = new Object();
|
||||
private static @Nullable SslContext sslContext;
|
||||
private static boolean triedSslContext = false;
|
||||
|
||||
private static @Nullable SslContext makeSslContext() {
|
||||
if (triedSslContext) return sslContext;
|
||||
synchronized (sslLock) {
|
||||
if (triedSslContext) return sslContext;
|
||||
|
||||
triedSslContext = true;
|
||||
try {
|
||||
return sslContext = SslContextBuilder.forClient().build();
|
||||
} catch (SSLException e) {
|
||||
LOG.log(Level.SEVERE, "Cannot construct SSL context", e);
|
||||
return sslContext = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static SslContext getSslContext() throws HTTPRequestException {
|
||||
var ssl = makeSslContext();
|
||||
if (ssl == null) throw new HTTPRequestException("Could not create a secure connection");
|
||||
return ssl;
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
SHAPING_HANDLER.trafficCounter().resetCumulativeTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link InetSocketAddress} from a {@link java.net.URI}.
|
||||
* <p>
|
||||
* Note, this may require a DNS lookup, and so should not be executed on the main CC thread.
|
||||
*
|
||||
* @param uri The URI to fetch.
|
||||
* @param ssl Whether to connect with SSL. This is used to find the default port if not otherwise specified.
|
||||
* @return The resolved address.
|
||||
* @throws HTTPRequestException If the host is not malformed.
|
||||
*/
|
||||
public static InetSocketAddress getAddress(URI uri, boolean ssl) throws HTTPRequestException {
|
||||
return getAddress(uri.getHost(), uri.getPort(), ssl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link InetSocketAddress} from the resolved {@code host} and port.
|
||||
* <p>
|
||||
* Note, this may require a DNS lookup, and so should not be executed on the main CC thread.
|
||||
*
|
||||
* @param host The host to resolve.
|
||||
* @param port The port, or -1 if not defined.
|
||||
* @param ssl Whether to connect with SSL. This is used to find the default port if not otherwise specified.
|
||||
* @return The resolved address.
|
||||
* @throws HTTPRequestException If the host is not malformed.
|
||||
*/
|
||||
public static InetSocketAddress getAddress(String host, int port, boolean ssl) throws HTTPRequestException {
|
||||
if (port < 0) port = ssl ? 443 : 80;
|
||||
var socketAddress = new InetSocketAddress(host, port);
|
||||
if (socketAddress.isUnresolved()) throw new HTTPRequestException("Unknown host");
|
||||
return socketAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get options for a specific domain.
|
||||
*
|
||||
* @param host The host to resolve.
|
||||
* @param address The address, resolved by {@link #getAddress(String, int, boolean)}.
|
||||
* @return The options for this host.
|
||||
* @throws HTTPRequestException If the host is not permitted
|
||||
*/
|
||||
public static Options getOptions(String host, InetSocketAddress address) throws HTTPRequestException {
|
||||
return new Options(Action.ALLOW, 4 * 1024 * 1024, 16 * 1024 * 1024, 128 * 1024, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an SSL handler for the remote host.
|
||||
*
|
||||
* @param ch The channel the handler will be added to.
|
||||
* @param sslContext The SSL context, if present.
|
||||
* @param timeout The timeout on this channel.
|
||||
* @param peerHost The host to connect to.
|
||||
* @param peerPort The port to connect to.
|
||||
* @return The SSL handler.
|
||||
* @see io.netty.handler.ssl.SslHandler
|
||||
*/
|
||||
private static SslHandler makeSslHandler(SocketChannel ch, SslContext sslContext, int timeout, String peerHost, int peerPort) {
|
||||
var handler = sslContext.newHandler(ch.alloc(), peerHost, peerPort);
|
||||
if (timeout > 0) handler.setHandshakeTimeoutMillis(timeout);
|
||||
return handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up some basic properties of the channel. This adds a timeout, the traffic shaping handler, and the SSL
|
||||
* handler.
|
||||
*
|
||||
* @param ch The channel to initialise.
|
||||
* @param uri The URI to connect to.
|
||||
* @param socketAddress The address of the socket to connect to.
|
||||
* @param sslContext The SSL context, if present.
|
||||
* @param proxy The proxy handler, if present.
|
||||
* @param timeout The timeout on this channel.
|
||||
* @see io.netty.channel.ChannelInitializer
|
||||
*/
|
||||
public static void initChannel(SocketChannel ch, URI uri, InetSocketAddress socketAddress, @Nullable SslContext sslContext, @Nullable Consumer<SocketChannel> proxy, int timeout) {
|
||||
if (timeout > 0) ch.config().setConnectTimeoutMillis(timeout);
|
||||
|
||||
var p = ch.pipeline();
|
||||
p.addLast(SHAPING_HANDLER);
|
||||
|
||||
if (proxy != null) proxy.accept(ch);
|
||||
|
||||
if (sslContext != null) {
|
||||
p.addLast(makeSslHandler(ch, sslContext, timeout, uri.getHost(), socketAddress.getPort()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a {@link ByteBuf} into a byte array.
|
||||
*
|
||||
* @param buffer The buffer to read.
|
||||
* @return The resulting bytes.
|
||||
*/
|
||||
public static byte[] toBytes(ByteBuf buffer) {
|
||||
var bytes = new byte[buffer.readableBytes()];
|
||||
buffer.readBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static String toFriendlyError(Throwable cause) {
|
||||
if (cause instanceof WebSocketHandshakeException || cause instanceof HTTPRequestException) {
|
||||
var message = cause.getMessage();
|
||||
return message == null ? "Could not connect" : message;
|
||||
} else if (cause instanceof TooLongFrameException) {
|
||||
return "Message is too large";
|
||||
} else if (cause instanceof ReadTimeoutException || cause instanceof ConnectTimeoutException) {
|
||||
return "Timed out";
|
||||
} else if (cause instanceof SSLHandshakeException || (cause instanceof DecoderException && cause.getCause() instanceof SSLHandshakeException)) {
|
||||
return "Could not create a secure connection";
|
||||
} else {
|
||||
return "Could not connect";
|
||||
}
|
||||
}
|
||||
}
|
139
src/main/java/dan200/computercraft/core/apis/http/Resource.java
Normal file
139
src/main/java/dan200/computercraft/core/apis/http/Resource.java
Normal file
@ -0,0 +1,139 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http;
|
||||
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.Reference;
|
||||
import java.lang.ref.ReferenceQueue;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* A holder for one or more resources, with a lifetime.
|
||||
*
|
||||
* @param <T> The type of this resource. Should be the class extending from {@link Resource}.
|
||||
*/
|
||||
public abstract class Resource<T extends Resource<T>> implements Closeable {
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final ResourceGroup<T> limiter;
|
||||
|
||||
protected Resource(ResourceGroup<T> limiter) {
|
||||
this.limiter = limiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this resource is closed.
|
||||
*
|
||||
* @return Whether this resource is closed.
|
||||
*/
|
||||
public final boolean isClosed() {
|
||||
return closed.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this has been cancelled. If so, it'll clean up any existing resources and cancel any pending futures.
|
||||
*
|
||||
* @return Whether this resource has been closed.
|
||||
*/
|
||||
public final boolean checkClosed() {
|
||||
if (!closed.get()) return false;
|
||||
dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to close the current resource.
|
||||
*
|
||||
* @return Whether this was successfully closed, or {@code false} if it has already been closed.
|
||||
*/
|
||||
protected final boolean tryClose() {
|
||||
if (closed.getAndSet(true)) return false;
|
||||
dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up any pending resources
|
||||
* <p>
|
||||
* Note, this may be called multiple times, and so should be thread-safe and
|
||||
* avoid any major side effects.
|
||||
*/
|
||||
protected void dispose() {
|
||||
@SuppressWarnings("unchecked")
|
||||
var thisT = (T) this;
|
||||
limiter.release(thisT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link WeakReference} which will close {@code this} when collected.
|
||||
*
|
||||
* @param <R> The object we are wrapping in a reference.
|
||||
* @param object The object to reference to
|
||||
* @return The weak reference.
|
||||
*/
|
||||
protected <R> WeakReference<R> createOwnerReference(R object) {
|
||||
return new CloseReference<>(this, object);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void close() {
|
||||
tryClose();
|
||||
}
|
||||
|
||||
public final boolean queue(Consumer<T> task) {
|
||||
@SuppressWarnings("unchecked")
|
||||
var thisT = (T) this;
|
||||
return limiter.queue(thisT, () -> task.accept(thisT));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
protected static <T extends Closeable> T closeCloseable(@Nullable T closeable) {
|
||||
try {
|
||||
if (closeable != null) closeable.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
protected static ChannelFuture closeChannel(@Nullable ChannelFuture future) {
|
||||
if (future != null) {
|
||||
future.cancel(false);
|
||||
var channel = future.channel();
|
||||
if (channel != null && channel.isOpen()) channel.close();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
protected static <T extends Future<?>> T closeFuture(@Nullable T future) {
|
||||
if (future != null) future.cancel(true);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private static final ReferenceQueue<Object> QUEUE = new ReferenceQueue<>();
|
||||
|
||||
private static class CloseReference<T> extends WeakReference<T> {
|
||||
final Resource<?> resource;
|
||||
|
||||
CloseReference(Resource<?> resource, T referent) {
|
||||
super(referent, QUEUE);
|
||||
this.resource = resource;
|
||||
}
|
||||
}
|
||||
|
||||
public static void cleanup() {
|
||||
Reference<?> reference;
|
||||
while ((reference = QUEUE.poll()) != null) ((CloseReference<?>) reference).resource.close();
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* A collection of {@link Resource}s, with an upper bound on capacity.
|
||||
*
|
||||
* @param <T> The type of the resource this group manages.
|
||||
*/
|
||||
public class ResourceGroup<T extends Resource<T>> {
|
||||
public static final int DEFAULT_LIMIT = 512;
|
||||
|
||||
final IntSupplier limit;
|
||||
|
||||
boolean active = false;
|
||||
|
||||
final Set<T> resources = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
public ResourceGroup(IntSupplier limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
public void startup() {
|
||||
active = true;
|
||||
}
|
||||
|
||||
public synchronized void shutdown() {
|
||||
active = false;
|
||||
|
||||
for (var resource : resources) resource.close();
|
||||
resources.clear();
|
||||
|
||||
Resource.cleanup();
|
||||
}
|
||||
|
||||
|
||||
public final boolean queue(T resource, Runnable setup) {
|
||||
return queue(() -> {
|
||||
setup.run();
|
||||
return resource;
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized boolean queue(Supplier<T> resource) {
|
||||
Resource.cleanup();
|
||||
if (!active) return false;
|
||||
|
||||
var limit = this.limit.getAsInt();
|
||||
if (limit <= 0 || resources.size() < limit) {
|
||||
resources.add(resource.get());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public synchronized void release(T resource) {
|
||||
resources.remove(resource);
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* A {@link ResourceGroup} which will queue items when the group at capacity.
|
||||
*
|
||||
* @param <T> The type of the resource this queue manages.
|
||||
*/
|
||||
public class ResourceQueue<T extends Resource<T>> extends ResourceGroup<T> {
|
||||
private final ArrayDeque<Supplier<T>> pending = new ArrayDeque<>();
|
||||
|
||||
public ResourceQueue(IntSupplier limit) {
|
||||
super(limit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void shutdown() {
|
||||
super.shutdown();
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean queue(Supplier<T> resource) {
|
||||
if (!active) return false;
|
||||
if (super.queue(resource)) return true;
|
||||
if (pending.size() > DEFAULT_LIMIT) return false;
|
||||
|
||||
pending.add(resource);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void release(T resource) {
|
||||
super.release(resource);
|
||||
|
||||
if (!active) return;
|
||||
|
||||
var limit = this.limit.getAsInt();
|
||||
if (limit <= 0 || resources.size() < limit) {
|
||||
var next = pending.poll();
|
||||
if (next != null) resources.add(next.get());
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2020 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.options;
|
||||
|
||||
public enum Action {
|
||||
ALLOW,
|
||||
DENY;
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
// SPDX-FileCopyrightText: 2020 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.options;
|
||||
|
||||
/**
|
||||
* Options for a given HTTP request or websocket, which control its resource constraints.
|
||||
*
|
||||
* @param action Whether to {@link Action#ALLOW} or {@link Action#DENY} this request.
|
||||
* @param maxUpload The maximum size of the HTTP request.
|
||||
* @param maxDownload The maximum size of the HTTP response.
|
||||
* @param websocketMessage The maximum size of a websocket message (outgoing and incoming).
|
||||
* @param useProxy Whether to use the configured proxy.
|
||||
*/
|
||||
public record Options(Action action, long maxUpload, long maxDownload, int websocketMessage, boolean useProxy) {
|
||||
}
|
@ -0,0 +1,211 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.request;
|
||||
|
||||
import dan200.computer.core.IAPIEnvironment;
|
||||
import dan200.computercraft.core.apis.http.HTTPRequestException;
|
||||
import dan200.computercraft.core.apis.http.NetworkUtils;
|
||||
import dan200.computercraft.core.apis.http.Resource;
|
||||
import dan200.computercraft.core.apis.http.ResourceGroup;
|
||||
import io.netty.bootstrap.Bootstrap;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.socket.SocketChannel;
|
||||
import io.netty.channel.socket.nio.NioSocketChannel;
|
||||
import io.netty.handler.codec.http.*;
|
||||
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static cc.tweaked.CCTweaked.LOG;
|
||||
|
||||
/**
|
||||
* Represents an in-progress HTTP request.
|
||||
*/
|
||||
public class HttpRequest extends Resource<HttpRequest> {
|
||||
private static final String SUCCESS_EVENT = "http_success";
|
||||
private static final String FAILURE_EVENT = "http_failure";
|
||||
|
||||
private static final int MAX_REDIRECTS = 16;
|
||||
|
||||
private @Nullable Future<?> executorFuture;
|
||||
private @Nullable ChannelFuture connectFuture;
|
||||
private @Nullable HttpRequestHandler currentRequest;
|
||||
|
||||
private final IAPIEnvironment environment;
|
||||
|
||||
private final String address;
|
||||
private final ByteBuf postBuffer;
|
||||
private final HttpHeaders headers;
|
||||
private final boolean binary;
|
||||
private final int timeout;
|
||||
|
||||
final AtomicInteger redirects;
|
||||
|
||||
public HttpRequest(
|
||||
ResourceGroup<HttpRequest> limiter, IAPIEnvironment environment, String address, @Nullable ByteBuffer postBody,
|
||||
HttpHeaders headers, boolean binary, boolean followRedirects, int timeout
|
||||
) {
|
||||
super(limiter);
|
||||
this.environment = environment;
|
||||
this.address = address;
|
||||
postBuffer = postBody != null
|
||||
? Unpooled.wrappedBuffer(postBody)
|
||||
: Unpooled.buffer(0);
|
||||
this.headers = headers;
|
||||
this.binary = binary;
|
||||
redirects = new AtomicInteger(followRedirects ? MAX_REDIRECTS : 0);
|
||||
this.timeout = timeout;
|
||||
|
||||
if (postBody != null) {
|
||||
if (!headers.contains(HttpHeaderNames.CONTENT_TYPE)) {
|
||||
headers.set(HttpHeaderNames.CONTENT_TYPE, "application/x-www-form-urlencoded; charset=utf-8");
|
||||
}
|
||||
|
||||
if (!headers.contains(HttpHeaderNames.CONTENT_LENGTH)) {
|
||||
headers.set(HttpHeaderNames.CONTENT_LENGTH, postBuffer.readableBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IAPIEnvironment environment() {
|
||||
return environment;
|
||||
}
|
||||
|
||||
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;
|
||||
executorFuture = NetworkUtils.EXECUTOR.submit(() -> doRequest(uri, method));
|
||||
checkClosed();
|
||||
}
|
||||
|
||||
private void doRequest(URI uri, HttpMethod method) {
|
||||
// If we're cancelled, abort.
|
||||
if (isClosed()) return;
|
||||
|
||||
try {
|
||||
var ssl = uri.getScheme().equalsIgnoreCase("https");
|
||||
var socketAddress = NetworkUtils.getAddress(uri, ssl);
|
||||
var options = NetworkUtils.getOptions(uri.getHost(), socketAddress);
|
||||
var sslContext = ssl ? NetworkUtils.getSslContext() : null;
|
||||
|
||||
// getAddress may have a slight delay, so let's perform another cancellation check.
|
||||
if (isClosed()) return;
|
||||
|
||||
var requestBody = getHeaderSize(headers) + postBuffer.capacity();
|
||||
if (options.maxUpload() != 0 && requestBody > options.maxUpload()) {
|
||||
failure("Request body is too large");
|
||||
return;
|
||||
}
|
||||
|
||||
var handler = currentRequest = new HttpRequestHandler(this, uri, method, options);
|
||||
connectFuture = new Bootstrap()
|
||||
.group(NetworkUtils.LOOP_GROUP)
|
||||
.channelFactory(NioSocketChannel::new)
|
||||
.handler(new ChannelInitializer<SocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(SocketChannel ch) {
|
||||
NetworkUtils.initChannel(ch, uri, socketAddress, sslContext, null, timeout);
|
||||
|
||||
var p = ch.pipeline();
|
||||
if (timeout > 0) p.addLast(new ReadTimeoutHandler(timeout, TimeUnit.MILLISECONDS));
|
||||
|
||||
p.addLast(
|
||||
new HttpClientCodec(),
|
||||
new HttpContentDecompressor(),
|
||||
handler
|
||||
);
|
||||
}
|
||||
})
|
||||
.remoteAddress(socketAddress)
|
||||
.connect()
|
||||
.addListener(c -> {
|
||||
if (!c.isSuccess()) failure(NetworkUtils.toFriendlyError(c.cause()));
|
||||
});
|
||||
|
||||
// Do an additional check for cancellation
|
||||
checkClosed();
|
||||
} catch (HTTPRequestException e) {
|
||||
failure(NetworkUtils.toFriendlyError(e));
|
||||
} catch (Exception e) {
|
||||
failure(NetworkUtils.toFriendlyError(e));
|
||||
LOG.log(Level.SEVERE, "Error in HTTP request", e);
|
||||
}
|
||||
}
|
||||
|
||||
void failure(String message) {
|
||||
if (tryClose()) environment.queueEvent(FAILURE_EVENT, new Object[]{ address, message });
|
||||
}
|
||||
|
||||
void failure(String message, HttpResponseHandle object) {
|
||||
if (tryClose()) environment.queueEvent(FAILURE_EVENT, new Object[]{ address, message, object });
|
||||
}
|
||||
|
||||
void success(HttpResponseHandle object) {
|
||||
if (tryClose()) environment.queueEvent(SUCCESS_EVENT, new Object[]{ address, object });
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void dispose() {
|
||||
super.dispose();
|
||||
|
||||
executorFuture = closeFuture(executorFuture);
|
||||
connectFuture = closeChannel(connectFuture);
|
||||
currentRequest = closeCloseable(currentRequest);
|
||||
}
|
||||
|
||||
public static long getHeaderSize(HttpHeaders headers) {
|
||||
long size = 0;
|
||||
for (var header : headers) {
|
||||
size += header.getKey() == null ? 0 : header.getKey().length();
|
||||
size += header.getValue() == null ? 0 : header.getValue().length() + 1;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
public ByteBuf body() {
|
||||
return postBuffer;
|
||||
}
|
||||
|
||||
public HttpHeaders headers() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public boolean isBinary() {
|
||||
return binary;
|
||||
}
|
||||
}
|
@ -0,0 +1,215 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.request;
|
||||
|
||||
import dan200.computercraft.core.apis.http.HTTPRequestException;
|
||||
import dan200.computercraft.core.apis.http.NetworkUtils;
|
||||
import dan200.computercraft.core.apis.http.options.Options;
|
||||
import io.netty.buffer.CompositeByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import io.netty.handler.codec.http.*;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class HttpRequestHandler extends SimpleChannelInboundHandler<HttpObject> implements Closeable {
|
||||
/**
|
||||
* Same as {@link io.netty.handler.codec.MessageAggregator}.
|
||||
*/
|
||||
private static final int DEFAULT_MAX_COMPOSITE_BUFFER_COMPONENTS = 1024;
|
||||
|
||||
private static final byte[] EMPTY_BYTES = new byte[0];
|
||||
|
||||
private final HttpRequest request;
|
||||
private boolean closed = false;
|
||||
|
||||
private final URI uri;
|
||||
private final HttpMethod method;
|
||||
private final Options options;
|
||||
|
||||
private @Nullable Charset responseCharset;
|
||||
private final HttpHeaders responseHeaders = new DefaultHttpHeaders();
|
||||
private @Nullable HttpResponseStatus responseStatus;
|
||||
private @Nullable CompositeByteBuf responseBody;
|
||||
|
||||
HttpRequestHandler(HttpRequest request, URI uri, HttpMethod method, Options options) {
|
||||
this.request = request;
|
||||
|
||||
this.uri = uri;
|
||||
this.method = method;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelActive(ChannelHandlerContext ctx) throws Exception {
|
||||
if (request.checkClosed()) return;
|
||||
|
||||
var body = request.body();
|
||||
body.resetReaderIndex().retain();
|
||||
|
||||
var requestUri = uri.getRawPath();
|
||||
if (uri.getRawQuery() != null) requestUri += "?" + uri.getRawQuery();
|
||||
|
||||
FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, requestUri, body);
|
||||
request.setMethod(method);
|
||||
request.headers().set(this.request.headers());
|
||||
|
||||
// We force some headers to be always applied
|
||||
if (!request.headers().contains(HttpHeaderNames.ACCEPT_CHARSET)) {
|
||||
request.headers().set(HttpHeaderNames.ACCEPT_CHARSET, "UTF-8");
|
||||
}
|
||||
request.headers().set(HttpHeaderNames.HOST, uri.getPort() < 0 ? uri.getHost() : uri.getHost() + ":" + uri.getPort());
|
||||
request.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
|
||||
|
||||
ctx.channel().writeAndFlush(request);
|
||||
|
||||
super.channelActive(ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
|
||||
if (!closed) request.failure("Could not connect");
|
||||
super.channelInactive(ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead0(ChannelHandlerContext ctx, HttpObject message) {
|
||||
if (closed || request.checkClosed()) return;
|
||||
|
||||
if (message instanceof HttpResponse response) {
|
||||
|
||||
if (request.redirects.get() > 0) {
|
||||
var redirect = getRedirect(response.status(), response.headers());
|
||||
if (redirect != null && !uri.equals(redirect) && request.redirects.getAndDecrement() > 0) {
|
||||
// If we have a redirect, and don't end up at the same place, then follow it.
|
||||
|
||||
// We mark ourselves as disposed first though, to avoid firing events when the channel
|
||||
// becomes inactive or disposed.
|
||||
closed = true;
|
||||
ctx.close();
|
||||
|
||||
try {
|
||||
HttpRequest.checkUri(redirect);
|
||||
} catch (HTTPRequestException e) {
|
||||
// If we cannot visit this uri, then fail.
|
||||
request.failure(NetworkUtils.toFriendlyError(e));
|
||||
return;
|
||||
}
|
||||
|
||||
request.request(redirect, response.status().code() == 303 ? HttpMethod.GET : method);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
responseCharset = HttpUtil.getCharset(response, StandardCharsets.UTF_8);
|
||||
responseStatus = response.status();
|
||||
responseHeaders.add(response.headers());
|
||||
}
|
||||
|
||||
if (message instanceof HttpContent content) {
|
||||
|
||||
if (responseBody == null) {
|
||||
responseBody = ctx.alloc().compositeBuffer(DEFAULT_MAX_COMPOSITE_BUFFER_COMPONENTS);
|
||||
}
|
||||
|
||||
var partial = content.content();
|
||||
if (partial.isReadable()) {
|
||||
// If we've read more than we're allowed to handle, abort as soon as possible.
|
||||
if (options.maxDownload() != 0 && responseBody.readableBytes() + partial.readableBytes() > options.maxDownload()) {
|
||||
closed = true;
|
||||
ctx.close();
|
||||
|
||||
request.failure("Response is too large");
|
||||
return;
|
||||
}
|
||||
|
||||
responseBody.addComponent(true, partial.retain());
|
||||
}
|
||||
|
||||
if (message instanceof LastHttpContent last) {
|
||||
responseHeaders.add(last.trailingHeaders());
|
||||
|
||||
// Set the content length, if not already given.
|
||||
if (responseHeaders.contains(HttpHeaderNames.CONTENT_LENGTH)) {
|
||||
responseHeaders.set(HttpHeaderNames.CONTENT_LENGTH, responseBody.readableBytes());
|
||||
}
|
||||
|
||||
ctx.close();
|
||||
sendResponse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
|
||||
ctx.close();
|
||||
request.failure(NetworkUtils.toFriendlyError(cause));
|
||||
}
|
||||
|
||||
private void sendResponse() {
|
||||
Objects.requireNonNull(responseStatus, "Status has not been set");
|
||||
Objects.requireNonNull(responseCharset, "Charset has not been set");
|
||||
|
||||
// Read the ByteBuf into a channel.
|
||||
var body = responseBody;
|
||||
var bytes = body == null ? EMPTY_BYTES : NetworkUtils.toBytes(body);
|
||||
|
||||
// Decode the headers
|
||||
var status = responseStatus;
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
for (var header : responseHeaders) {
|
||||
var existing = headers.get(header.getKey());
|
||||
headers.put(header.getKey(), existing == null ? header.getValue() : existing + "," + header.getValue());
|
||||
}
|
||||
|
||||
// Prepare to queue an event
|
||||
var stream = new HttpResponseHandle(bytes, request.isBinary(), status.code(), status.reasonPhrase(), headers);
|
||||
|
||||
if (status.code() >= 200 && status.code() < 400) {
|
||||
request.success(stream);
|
||||
} else {
|
||||
request.failure(status.reasonPhrase(), stream);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the redirect from this response.
|
||||
*
|
||||
* @param status The status of the HTTP response.
|
||||
* @param headers The headers of the HTTP response.
|
||||
* @return The URI to redirect to, or {@code null} if no redirect should occur.
|
||||
*/
|
||||
@Nullable
|
||||
private URI getRedirect(HttpResponseStatus status, HttpHeaders headers) {
|
||||
var code = status.code();
|
||||
if (code < 300 || code > 307 || code == 304 || code == 306) return null;
|
||||
|
||||
var location = headers.get(HttpHeaderNames.LOCATION);
|
||||
if (location == null) return null;
|
||||
|
||||
try {
|
||||
return uri.resolve(new URI(location));
|
||||
} catch (IllegalArgumentException | URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
if (responseBody != null) {
|
||||
responseBody.release();
|
||||
responseBody = null;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.request;
|
||||
|
||||
import dan200.computercraft.api.lua.IArguments;
|
||||
import dan200.computercraft.api.lua.LuaFunction;
|
||||
import dan200.computercraft.core.apis.HTTPAPI;
|
||||
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
|
||||
import dan200.computercraft.core.apis.handles.ReadHandle;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A http response. This provides the same methods as a {@link ReadHandle file}, though provides several request
|
||||
* specific methods.
|
||||
*
|
||||
* @cc.module http.Response
|
||||
* @see HTTPAPI#request(IArguments) On how to make a http request.
|
||||
*/
|
||||
public class HttpResponseHandle extends ReadHandle {
|
||||
private final int responseCode;
|
||||
private final String responseStatus;
|
||||
private final Map<String, String> responseHeaders;
|
||||
|
||||
public HttpResponseHandle(byte[] buffer, boolean isBinary, int responseCode, String responseStatus, Map<String, String> responseHeaders) {
|
||||
super(new ArrayByteChannel(buffer), isBinary);
|
||||
this.responseCode = responseCode;
|
||||
this.responseStatus = responseStatus;
|
||||
this.responseHeaders = responseHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the response code and response message returned by the server.
|
||||
*
|
||||
* @return The response code and message.
|
||||
* @cc.treturn number The response code (i.e. 200)
|
||||
* @cc.treturn string The response message (i.e. "OK")
|
||||
* @cc.changed 1.80pr1.13 Added response message return value.
|
||||
*/
|
||||
@LuaFunction
|
||||
public final Object[] getResponseCode() {
|
||||
return new Object[]{responseCode, responseStatus};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a table containing the response's headers, in a format similar to that required by {@link HTTPAPI#request}.
|
||||
* If multiple headers are sent with the same name, they will be combined with a comma.
|
||||
*
|
||||
* @return The response's headers.
|
||||
* @cc.usage Make a request to [example.tweaked.cc](https://example.tweaked.cc), and print the
|
||||
* returned headers.
|
||||
* <pre>{@code
|
||||
* local request = http.get("https://example.tweaked.cc")
|
||||
* print(textutils.serialize(request.getResponseHeaders()))
|
||||
* -- => {
|
||||
* -- [ "Content-Type" ] = "text/plain; charset=utf8",
|
||||
* -- [ "content-length" ] = 17,
|
||||
* -- ...
|
||||
* -- }
|
||||
* request.close()
|
||||
* }</pre>
|
||||
*/
|
||||
@LuaFunction
|
||||
public final Map<String, String> getResponseHeaders() {
|
||||
return responseHeaders;
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.websocket;
|
||||
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpHeaders;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker13;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketVersion;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
/**
|
||||
* A version of {@link WebSocketClientHandshaker13} which doesn't add the {@link HttpHeaderNames#ORIGIN} header to the
|
||||
* original HTTP request.
|
||||
*/
|
||||
class NoOriginWebSocketHandshaker extends WebSocketClientHandshaker13 {
|
||||
NoOriginWebSocketHandshaker(URI webSocketURL, WebSocketVersion version, String subprotocol, boolean allowExtensions, HttpHeaders customHeaders, int maxFramePayloadLength) {
|
||||
super(webSocketURL, version, subprotocol, allowExtensions, customHeaders, maxFramePayloadLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected FullHttpRequest newHandshakeRequest() {
|
||||
var request = super.newHandshakeRequest();
|
||||
var headers = request.headers();
|
||||
if (!customHeaders.contains(HttpHeaderNames.ORIGIN)) headers.remove(HttpHeaderNames.ORIGIN);
|
||||
return request;
|
||||
}
|
||||
}
|
@ -0,0 +1,192 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.websocket;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
import dan200.computer.core.IAPIEnvironment;
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.core.apis.http.*;
|
||||
import dan200.computercraft.core.apis.http.options.Options;
|
||||
import dan200.computercraft.core.util.AtomicHelpers;
|
||||
import io.netty.bootstrap.Bootstrap;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.socket.SocketChannel;
|
||||
import io.netty.channel.socket.nio.NioSocketChannel;
|
||||
import io.netty.handler.codec.http.HttpClientCodec;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpHeaders;
|
||||
import io.netty.handler.codec.http.HttpObjectAggregator;
|
||||
import io.netty.handler.codec.http.websocketx.*;
|
||||
import io.netty.util.concurrent.GenericFutureListener;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static cc.tweaked.CCTweaked.LOG;
|
||||
|
||||
/**
|
||||
* Provides functionality to verify and connect to a remote websocket.
|
||||
*/
|
||||
public class Websocket extends Resource<Websocket> implements WebsocketClient {
|
||||
/**
|
||||
* We declare the maximum size to be 2^30 bytes. While messages can be much longer, we set an arbitrary limit as
|
||||
* working with larger messages (especially within a Lua VM) is absurd.
|
||||
*/
|
||||
public static final int MAX_MESSAGE_SIZE = 1 << 30;
|
||||
|
||||
private @Nullable Future<?> executorFuture;
|
||||
private @Nullable ChannelFuture channelFuture;
|
||||
|
||||
private final IAPIEnvironment environment;
|
||||
private final URI uri;
|
||||
private final String address;
|
||||
private final HttpHeaders headers;
|
||||
private final int timeout;
|
||||
|
||||
private final AtomicInteger inFlight = new AtomicInteger(0);
|
||||
private final GenericFutureListener<? extends io.netty.util.concurrent.Future<? super Void>> onSend = f -> inFlight.decrementAndGet();
|
||||
|
||||
public Websocket(ResourceGroup<Websocket> limiter, IAPIEnvironment environment, URI uri, String address, HttpHeaders headers, int timeout) {
|
||||
super(limiter);
|
||||
this.environment = environment;
|
||||
this.uri = uri;
|
||||
this.address = address;
|
||||
this.headers = headers;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public void connect() {
|
||||
if (isClosed()) return;
|
||||
executorFuture = NetworkUtils.EXECUTOR.submit(this::doConnect);
|
||||
checkClosed();
|
||||
}
|
||||
|
||||
private void doConnect() {
|
||||
// If we're cancelled, abort.
|
||||
if (isClosed()) return;
|
||||
|
||||
try {
|
||||
var ssl = uri.getScheme().equalsIgnoreCase("wss");
|
||||
var socketAddress = NetworkUtils.getAddress(uri, ssl);
|
||||
var options = NetworkUtils.getOptions(uri.getHost(), socketAddress);
|
||||
var sslContext = ssl ? NetworkUtils.getSslContext() : null;
|
||||
|
||||
// getAddress may have a slight delay, so let's perform another cancellation check.
|
||||
if (isClosed()) return;
|
||||
|
||||
channelFuture = new Bootstrap()
|
||||
.group(NetworkUtils.LOOP_GROUP)
|
||||
.channel(NioSocketChannel.class)
|
||||
.handler(new ChannelInitializer<SocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(SocketChannel ch) {
|
||||
NetworkUtils.initChannel(ch, uri, socketAddress, sslContext, null, timeout);
|
||||
|
||||
var subprotocol = headers.get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL);
|
||||
var handshaker = new NoOriginWebSocketHandshaker(
|
||||
uri, WebSocketVersion.V13, subprotocol, true, headers,
|
||||
options.websocketMessage() <= 0 ? MAX_MESSAGE_SIZE : options.websocketMessage()
|
||||
);
|
||||
|
||||
var p = ch.pipeline();
|
||||
p.addLast(
|
||||
new HttpClientCodec(),
|
||||
new HttpObjectAggregator(8192),
|
||||
WebsocketCompressionHandler.INSTANCE,
|
||||
new WebSocketClientProtocolHandler(handshaker, false, timeout),
|
||||
new WebsocketHandler(Websocket.this, options)
|
||||
);
|
||||
}
|
||||
})
|
||||
.remoteAddress(socketAddress)
|
||||
.connect()
|
||||
.addListener(c -> {
|
||||
if (!c.isSuccess()) failure(NetworkUtils.toFriendlyError(c.cause()));
|
||||
});
|
||||
|
||||
// Do an additional check for cancellation
|
||||
checkClosed();
|
||||
} catch (HTTPRequestException e) {
|
||||
failure(NetworkUtils.toFriendlyError(e));
|
||||
} catch (Exception e) {
|
||||
failure(NetworkUtils.toFriendlyError(e));
|
||||
LOG.log(Level.SEVERE, "Error in websocket", e);
|
||||
}
|
||||
}
|
||||
|
||||
void success(Options options) {
|
||||
if (isClosed()) return;
|
||||
|
||||
var handle = new WebsocketHandle(environment, address, this, options);
|
||||
environment().queueEvent(SUCCESS_EVENT, new Object[]{ address, handle });
|
||||
createOwnerReference(handle);
|
||||
|
||||
checkClosed();
|
||||
}
|
||||
|
||||
void failure(String message) {
|
||||
if (tryClose()) environment.queueEvent(FAILURE_EVENT, new Object[]{ address, message });
|
||||
}
|
||||
|
||||
void close(int status, String reason) {
|
||||
if (tryClose()) {
|
||||
environment.queueEvent(CLOSE_EVENT, new Object[]{ address,
|
||||
Strings.isNullOrEmpty(reason) ? null : reason,
|
||||
status < 0 ? null : status });
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void dispose() {
|
||||
super.dispose();
|
||||
|
||||
executorFuture = closeFuture(executorFuture);
|
||||
channelFuture = closeChannel(channelFuture);
|
||||
}
|
||||
|
||||
IAPIEnvironment environment() {
|
||||
return environment;
|
||||
}
|
||||
|
||||
String address() {
|
||||
return address;
|
||||
}
|
||||
|
||||
private @Nullable Channel channel() {
|
||||
var channel = channelFuture;
|
||||
return channel == null ? null : channel.channel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendText(String message) throws LuaException {
|
||||
sendMessage(new TextWebSocketFrame(message), message.length());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendBinary(ByteBuffer message) throws LuaException {
|
||||
long size = message.remaining();
|
||||
sendMessage(new BinaryWebSocketFrame(Unpooled.wrappedBuffer(message)), size);
|
||||
}
|
||||
|
||||
private void sendMessage(WebSocketFrame frame, long size) throws LuaException {
|
||||
var channel = channel();
|
||||
if (channel == null) return;
|
||||
|
||||
// Grow the number of in-flight requests, aborting if we've hit the limit. This is then decremented when the
|
||||
// promise finishes.
|
||||
if (!AtomicHelpers.incrementToLimit(inFlight, ResourceQueue.DEFAULT_LIMIT)) {
|
||||
throw new LuaException("Too many ongoing websocket messages");
|
||||
}
|
||||
|
||||
channel.writeAndFlush(frame).addListener(onSend);
|
||||
}
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.websocket;
|
||||
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.core.apis.http.HTTPRequestException;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* A client-side websocket, which can be used to send messages to a remote server.
|
||||
* <p>
|
||||
* {@link WebsocketHandle} wraps this into a Lua-compatible interface.
|
||||
*/
|
||||
public interface WebsocketClient extends Closeable {
|
||||
String SUCCESS_EVENT = "websocket_success";
|
||||
String FAILURE_EVENT = "websocket_failure";
|
||||
String CLOSE_EVENT = "websocket_closed";
|
||||
String MESSAGE_EVENT = "websocket_message";
|
||||
|
||||
/**
|
||||
* Determine whether this websocket is closed.
|
||||
*
|
||||
* @return Whether this websocket is closed.
|
||||
*/
|
||||
boolean isClosed();
|
||||
|
||||
/**
|
||||
* Close this websocket.
|
||||
*/
|
||||
@Override
|
||||
void close();
|
||||
|
||||
/**
|
||||
* Send a text websocket frame.
|
||||
*
|
||||
* @param message The message to send.
|
||||
* @throws LuaException If the message could not be sent.
|
||||
*/
|
||||
void sendText(String message) throws LuaException;
|
||||
|
||||
/**
|
||||
* Send a binary websocket frame.
|
||||
*
|
||||
* @param message The message to send.
|
||||
* @throws LuaException If the message could not be sent.
|
||||
*/
|
||||
void sendBinary(ByteBuffer message) throws LuaException;
|
||||
|
||||
class Support {
|
||||
/**
|
||||
* Parse an address, ensuring it is a valid websocket URI.
|
||||
*
|
||||
* @param address The address to parse.
|
||||
* @return The parsed URI.
|
||||
* @throws HTTPRequestException If the address is not valid.
|
||||
*/
|
||||
public static URI parseUri(String address) throws HTTPRequestException {
|
||||
URI uri = null;
|
||||
try {
|
||||
uri = new URI(address);
|
||||
} catch (URISyntaxException ignored) {
|
||||
// Fall through to the case below
|
||||
}
|
||||
|
||||
if (uri == null || uri.getHost() == null) {
|
||||
try {
|
||||
uri = new URI("ws://" + address);
|
||||
} catch (URISyntaxException ignored) {
|
||||
// Fall through to the case below
|
||||
}
|
||||
}
|
||||
|
||||
if (uri == null || uri.getHost() == null) throw new HTTPRequestException("URL malformed");
|
||||
|
||||
var scheme = uri.getScheme();
|
||||
if (scheme == null) {
|
||||
try {
|
||||
uri = new URI("ws://" + uri);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new HTTPRequestException("URL malformed");
|
||||
}
|
||||
} else if (!scheme.equalsIgnoreCase("wss") && !scheme.equalsIgnoreCase("ws")) {
|
||||
throw new HTTPRequestException("Invalid scheme '" + scheme + "'");
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
// SPDX-FileCopyrightText: 2021 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.websocket;
|
||||
|
||||
import io.netty.channel.ChannelHandler;
|
||||
import io.netty.handler.codec.compression.ZlibCodecFactory;
|
||||
import io.netty.handler.codec.http.websocketx.extensions.WebSocketClientExtensionHandler;
|
||||
import io.netty.handler.codec.http.websocketx.extensions.compression.DeflateFrameClientExtensionHandshaker;
|
||||
import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateClientExtensionHandshaker;
|
||||
import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketClientCompressionHandler;
|
||||
|
||||
import static io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE;
|
||||
|
||||
/**
|
||||
* An alternative to {@link WebSocketClientCompressionHandler} which supports the {@code client_no_context_takeover}
|
||||
* extension. Makes CC <em>slightly</em> more flexible.
|
||||
*/
|
||||
@ChannelHandler.Sharable
|
||||
final class WebsocketCompressionHandler extends WebSocketClientExtensionHandler {
|
||||
public static final WebsocketCompressionHandler INSTANCE = new WebsocketCompressionHandler();
|
||||
|
||||
private WebsocketCompressionHandler() {
|
||||
super(
|
||||
new PerMessageDeflateClientExtensionHandshaker(
|
||||
6, ZlibCodecFactory.isSupportingWindowSizeAndMemLevel(), MAX_WINDOW_SIZE,
|
||||
true, false
|
||||
),
|
||||
new DeflateFrameClientExtensionHandshaker(false),
|
||||
new DeflateFrameClientExtensionHandshaker(true)
|
||||
);
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.websocket;
|
||||
|
||||
import dan200.computer.core.IAPIEnvironment;
|
||||
import dan200.computercraft.api.lua.Coerced;
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.api.lua.LuaFunction;
|
||||
import dan200.computercraft.core.apis.http.options.Options;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CharsetDecoder;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A websocket, which can be used to send and receive messages with a web server.
|
||||
*
|
||||
* @cc.module http.Websocket
|
||||
* @see dan200.computercraft.core.apis.HTTPAPI#websocket On how to open a websocket.
|
||||
*/
|
||||
public class WebsocketHandle {
|
||||
private static final ThreadLocal<CharsetDecoder> DECODER = ThreadLocal.withInitial(() -> StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPLACE));
|
||||
|
||||
private final IAPIEnvironment environment;
|
||||
private final String address;
|
||||
private final WebsocketClient websocket;
|
||||
private final Options options;
|
||||
|
||||
public WebsocketHandle(IAPIEnvironment environment, String address, WebsocketClient websocket, Options options) {
|
||||
this.environment = environment;
|
||||
this.address = address;
|
||||
this.websocket = websocket;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
// TODO: Can we implement receive()?
|
||||
|
||||
/**
|
||||
* Send a websocket message to the connected server.
|
||||
*
|
||||
* @param message The message to send.
|
||||
* @param binary Whether this message should be treated as a binary message.
|
||||
* @throws LuaException If the message is too large.
|
||||
* @throws LuaException If the websocket has been closed.
|
||||
* @cc.changed 1.81.0 Added argument for binary mode.
|
||||
*/
|
||||
@LuaFunction
|
||||
public final void send(Coerced<ByteBuffer> message, Optional<Boolean> binary) throws LuaException {
|
||||
checkOpen();
|
||||
|
||||
var text = message.value();
|
||||
if (options.websocketMessage() != 0 && text.remaining() > options.websocketMessage()) {
|
||||
throw new LuaException("Message is too large");
|
||||
}
|
||||
|
||||
if (binary.orElse(false)) {
|
||||
websocket.sendBinary(text);
|
||||
} else {
|
||||
try {
|
||||
websocket.sendText(DECODER.get().decode(text).toString());
|
||||
} catch (CharacterCodingException e) {
|
||||
// This shouldn't happen, but worth mentioning.
|
||||
throw new LuaException("Message is not valid UTF8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close this websocket. This will terminate the connection, meaning messages can no longer be sent or received
|
||||
* along it.
|
||||
*/
|
||||
@LuaFunction
|
||||
public final void close() {
|
||||
websocket.close();
|
||||
}
|
||||
|
||||
private void checkOpen() throws LuaException {
|
||||
if (websocket.isClosed()) throw new LuaException("attempt to use a closed file");
|
||||
}
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.apis.http.websocket;
|
||||
|
||||
import dan200.computercraft.core.apis.http.NetworkUtils;
|
||||
import dan200.computercraft.core.apis.http.options.Options;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import io.netty.handler.codec.http.FullHttpResponse;
|
||||
import io.netty.handler.codec.http.websocketx.*;
|
||||
import io.netty.util.CharsetUtil;
|
||||
|
||||
import static dan200.computercraft.core.apis.http.websocket.WebsocketClient.MESSAGE_EVENT;
|
||||
|
||||
class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
|
||||
private final Websocket websocket;
|
||||
private final Options options;
|
||||
private boolean handshakeComplete = false;
|
||||
|
||||
WebsocketHandler(Websocket websocket, Options options) {
|
||||
this.websocket = websocket;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
|
||||
fail("Connection closed");
|
||||
super.channelInactive(ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
|
||||
if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE) {
|
||||
websocket.success(options);
|
||||
handshakeComplete = true;
|
||||
} else if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_TIMEOUT) {
|
||||
websocket.failure("Timed out");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead0(ChannelHandlerContext ctx, Object msg) {
|
||||
if (websocket.isClosed()) return;
|
||||
|
||||
if (msg instanceof FullHttpResponse response) {
|
||||
throw new IllegalStateException("Unexpected FullHttpResponse (getStatus=" + response.status() + ", content=" + response.content().toString(CharsetUtil.UTF_8) + ')');
|
||||
}
|
||||
|
||||
var frame = (WebSocketFrame) msg;
|
||||
if (frame instanceof TextWebSocketFrame textFrame) {
|
||||
var data = NetworkUtils.toBytes(textFrame.content());
|
||||
|
||||
websocket.environment().queueEvent(MESSAGE_EVENT, new Object[]{ websocket.address(), data, false });
|
||||
} else if (frame instanceof BinaryWebSocketFrame) {
|
||||
var data = NetworkUtils.toBytes(frame.content());
|
||||
|
||||
websocket.environment().queueEvent(MESSAGE_EVENT, new Object[]{ websocket.address(), data, true });
|
||||
} else if (frame instanceof CloseWebSocketFrame closeFrame) {
|
||||
websocket.close(closeFrame.statusCode(), closeFrame.reasonText());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
|
||||
ctx.close();
|
||||
|
||||
fail(NetworkUtils.toFriendlyError(cause));
|
||||
}
|
||||
|
||||
private void fail(String message) {
|
||||
if (handshakeComplete) {
|
||||
websocket.close(-1, message);
|
||||
} else {
|
||||
websocket.failure(message);
|
||||
}
|
||||
}
|
||||
}
|
@ -20,6 +20,7 @@ import org.objectweb.asm.Type;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@ -272,6 +273,14 @@ public final class Generator<T> {
|
||||
mw.visitMethodInsn(INVOKEVIRTUAL, INTERNAL_ARGUMENTS, "getStringCoerced", "(I)Ljava/lang/String;");
|
||||
mw.visitMethodInsn(INVOKESPECIAL, INTERNAL_COERCED, "<init>", "(Ljava/lang/Object;)V");
|
||||
return true;
|
||||
} else if (klass == ByteBuffer.class) {
|
||||
mw.visitTypeInsn(NEW, INTERNAL_COERCED);
|
||||
mw.visitInsn(DUP);
|
||||
mw.visitVarInsn(ALOAD, 2 + context.size());
|
||||
Reflect.loadInt(mw, argIndex);
|
||||
mw.visitMethodInsn(INVOKEVIRTUAL, INTERNAL_ARGUMENTS, "getBytesCoerced", "(I)Ljava/nio/ByteBuffer;");
|
||||
mw.visitMethodInsn(INVOKESPECIAL, INTERNAL_COERCED, "<init>", "(Ljava/lang/Object;)V");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,6 +12,7 @@ import dan200.computer.core.ILuaMachine;
|
||||
import dan200.computer.core.ILuaObject;
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.core.asm.Methods;
|
||||
import dan200.computercraft.core.lua.errorinfo.ErrorInfoLib;
|
||||
import org.squiddev.cobalt.*;
|
||||
import org.squiddev.cobalt.compiler.LoadState;
|
||||
import org.squiddev.cobalt.function.LuaFunction;
|
||||
@ -59,6 +60,7 @@ public class CobaltLuaMachine implements ILuaMachine {
|
||||
try {
|
||||
CoreLibraries.debugGlobals(state);
|
||||
Bit32Lib.add(state, globals);
|
||||
ErrorInfoLib.add(state);
|
||||
|
||||
globals.rawset("_HOST", valueOf("ComputerCraft " + ComputerCraft.getVersion() + " (" + Loader.instance().getMCVersionString() + ")"));
|
||||
globals.rawset("_CC_DEFAULT_SETTINGS", valueOf(""));
|
||||
|
@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: 2009-2011 Luaj.org, 2015-2020 SquidDev
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package dan200.computercraft.core.lua.errorinfo;
|
||||
|
||||
import org.squiddev.cobalt.Prototype;
|
||||
|
||||
import static org.squiddev.cobalt.Lua.*;
|
||||
|
||||
/**
|
||||
* Extracted parts of Cobalt's {@link org.squiddev.cobalt.debug.DebugHelpers}.
|
||||
*/
|
||||
final class DebugHelpers {
|
||||
private DebugHelpers() {
|
||||
}
|
||||
|
||||
private static int filterPc(int pc, int jumpTarget) {
|
||||
return pc < jumpTarget ? -1 : pc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the PC where a register was last set.
|
||||
* <p>
|
||||
* This makes some assumptions about the structure of the bytecode, namely that there are no back edges within the
|
||||
* CFG. As a result, this is only valid for temporary values, and not locals.
|
||||
*
|
||||
* @param pt The function prototype.
|
||||
* @param lastPc The PC to work back from.
|
||||
* @param reg The register.
|
||||
* @return The last instruction where the register was set, or {@code -1} if not defined.
|
||||
*/
|
||||
static int findSetReg(Prototype pt, int lastPc, int reg) {
|
||||
var lastInsn = -1; // Last instruction that changed "reg";
|
||||
var jumpTarget = 0; // Any code before this address is conditional
|
||||
|
||||
for (var pc = 0; pc < lastPc; pc++) {
|
||||
var i = pt.code[pc];
|
||||
var op = GET_OPCODE(i);
|
||||
var a = GETARG_A(i);
|
||||
switch (op) {
|
||||
case OP_LOADNIL -> {
|
||||
var b = GETARG_B(i);
|
||||
if (a <= reg && reg <= a + b) lastInsn = filterPc(pc, jumpTarget);
|
||||
}
|
||||
case OP_TFORCALL -> {
|
||||
if (a >= a + 2) lastInsn = filterPc(pc, jumpTarget);
|
||||
}
|
||||
case OP_CALL, OP_TAILCALL -> {
|
||||
if (reg >= a) lastInsn = filterPc(pc, jumpTarget);
|
||||
}
|
||||
case OP_JMP -> {
|
||||
var dest = pc + 1 + GETARG_sBx(i);
|
||||
// If jump is forward and doesn't skip lastPc, update jump target
|
||||
if (pc < dest && dest <= lastPc && dest > jumpTarget) jumpTarget = dest;
|
||||
}
|
||||
default -> {
|
||||
if (testAMode(op) && reg == a) lastInsn = filterPc(pc, jumpTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lastInsn;
|
||||
}
|
||||
}
|
@ -0,0 +1,222 @@
|
||||
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.lua.errorinfo;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.squiddev.cobalt.*;
|
||||
import org.squiddev.cobalt.debug.DebugFrame;
|
||||
import org.squiddev.cobalt.function.LuaFunction;
|
||||
import org.squiddev.cobalt.function.RegisteredFunction;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.squiddev.cobalt.Lua.*;
|
||||
import static org.squiddev.cobalt.debug.DebugFrame.FLAG_ANY_HOOK;
|
||||
|
||||
/**
|
||||
* Provides additional info about an error.
|
||||
* <p>
|
||||
* This is currently an internal and deeply unstable module. It's not clear if doing this via bytecode (rather than an
|
||||
* AST) is the correct approach and/or, what the correct design is.
|
||||
*/
|
||||
public class ErrorInfoLib {
|
||||
private static final int MAX_DEPTH = 8;
|
||||
|
||||
private static final RegisteredFunction[] functions = new RegisteredFunction[]{
|
||||
RegisteredFunction.ofV("info_for_nil", ErrorInfoLib::getInfoForNil),
|
||||
};
|
||||
|
||||
public static void add(LuaState state) throws LuaError {
|
||||
state.registry().getSubTable(Constants.LOADED).rawset("cc.internal.error_info", RegisteredFunction.bind(functions));
|
||||
}
|
||||
|
||||
private static Varargs getInfoForNil(LuaState state, Varargs args) throws LuaError {
|
||||
var thread = args.arg(1).checkThread();
|
||||
var level = args.arg(2).checkInteger();
|
||||
|
||||
var context = getInfoForNil(state, thread, level);
|
||||
return context == null ? Constants.NIL : ValueFactory.varargsOf(
|
||||
ValueFactory.valueOf(context.op()), ValueFactory.valueOf(context.source().isGlobal()),
|
||||
context.source().table(), context.source().key()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get some additional information about an {@code attempt to $OP (a nil value)} error. This often occurs as a
|
||||
* result of a misspelled local, global or table index, and so we attempt to detect those cases.
|
||||
*
|
||||
* @param state The current Lua state.
|
||||
* @param thread The thread which has errored.
|
||||
* @param level The level where the error occurred. We currently expect this to always be 0.
|
||||
* @return Some additional information about the error, where available.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static @Nullable NilInfo getInfoForNil(LuaState state, LuaThread thread, int level) {
|
||||
var frame = thread.getDebugState().getFrame(level);
|
||||
if (frame == null || frame.closure == null || (frame.flags & FLAG_ANY_HOOK) != 0) return null;
|
||||
|
||||
var prototype = frame.closure.getPrototype();
|
||||
var pc = frame.pc;
|
||||
var insn = prototype.code[pc];
|
||||
|
||||
// Find what operation we're doing that errored.
|
||||
return switch (GET_OPCODE(insn)) {
|
||||
case OP_CALL, OP_TAILCALL ->
|
||||
NilInfo.of("call", resolveValueSource(state, frame, prototype, pc, GETARG_A(insn), 0));
|
||||
case OP_GETTABLE, OP_SETTABLE, OP_SELF ->
|
||||
NilInfo.of("index", resolveValueSource(state, frame, prototype, pc, GETARG_A(insn), 0));
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about an {@code attempt to $OP (a nil value)} error.
|
||||
*
|
||||
* @param op The operation we tried to perform.
|
||||
* @param source The expression that resulted in a nil value.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
record NilInfo(String op, ValueSource source) {
|
||||
public static @Nullable NilInfo of(String op, @Nullable ValueSource values) {
|
||||
return values == null ? null : new NilInfo(op, values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A partially-reconstructed Lua expression. This currently only is used for table indexing ({@code table[key]}.
|
||||
*
|
||||
* @param isGlobal Whether this is a global table access. This is a best-effort guess, and does not distinguish between
|
||||
* {@code foo} and {@code _ENV.foo}.
|
||||
* @param table The table being indexed.
|
||||
* @param key The key we tried to index.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
record ValueSource(boolean isGlobal, LuaValue table, LuaString key) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to partially reconstruct a Lua expression from the current debug state.
|
||||
*
|
||||
* @param state The current Lua state.
|
||||
* @param frame The current debug frame.
|
||||
* @param prototype The current function.
|
||||
* @param pc The current program counter.
|
||||
* @param register The register where this value was stored.
|
||||
* @param depth The current depth. Starts at 0, and aborts once reaching {@link #MAX_DEPTH}.
|
||||
* @return The reconstructed expression, or {@code null} if not available.
|
||||
*/
|
||||
@SuppressWarnings("NullTernary")
|
||||
private static @Nullable ValueSource resolveValueSource(LuaState state, DebugFrame frame, Prototype prototype, int pc, int register, int depth) {
|
||||
if (depth > MAX_DEPTH) return null;
|
||||
if (prototype.getLocalName(register + 1, pc) != null) return null;
|
||||
|
||||
// Find where this register was set. If unknown, then abort.
|
||||
pc = DebugHelpers.findSetReg(prototype, pc, register);
|
||||
if (pc == -1) return null;
|
||||
|
||||
var insn = prototype.code[pc];
|
||||
return switch (GET_OPCODE(insn)) {
|
||||
case OP_MOVE -> {
|
||||
var a = GETARG_A(insn);
|
||||
var b = GETARG_B(insn); // move from `b' to `a'
|
||||
yield b < a ? resolveValueSource(state, frame, prototype, pc, register, depth + 1) : null; // Resolve 'b' .
|
||||
}
|
||||
case OP_GETTABUP, OP_GETTABLE, OP_SELF -> {
|
||||
var tableIndex = GETARG_B(insn);
|
||||
var keyIndex = GETARG_C(insn);
|
||||
// We're only interested in expressions of the form "foo.bar". Showing a "did you mean" hint for
|
||||
// "foo[i]" isn't very useful!
|
||||
if (!ISK(keyIndex)) yield null;
|
||||
|
||||
var key = prototype.constants[INDEXK(keyIndex)];
|
||||
if (key.type() != Constants.TSTRING) yield null;
|
||||
|
||||
var table = GET_OPCODE(insn) == OP_GETTABUP
|
||||
? frame.closure.getUpvalue(tableIndex).getValue()
|
||||
: evaluate(state, frame, prototype, pc, tableIndex, depth);
|
||||
if (table == null) yield null;
|
||||
|
||||
var isGlobal = GET_OPCODE(insn) == OP_GETTABUP && Objects.equals(prototype.getUpvalueName(tableIndex), Constants.ENV);
|
||||
yield new ValueSource(isGlobal, table, (LuaString) key);
|
||||
}
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconstruct the value of a register.
|
||||
*
|
||||
* @param state The current Lua state.
|
||||
* @param frame The current debug frame.
|
||||
* @param prototype The current function
|
||||
* @param pc The PC to evaluate at.
|
||||
* @param register The register to evaluate.
|
||||
* @param depth The current depth. Starts at 0, and aborts once reaching {@link #MAX_DEPTH}.
|
||||
* @return The reconstructed value, or {@code null} if unavailable.
|
||||
*/
|
||||
@SuppressWarnings("NullTernary")
|
||||
private static @Nullable LuaValue evaluate(LuaState state, DebugFrame frame, Prototype prototype, int pc, int register, int depth) {
|
||||
if (depth >= MAX_DEPTH) return null;
|
||||
|
||||
// If this is a local, then return its contents.
|
||||
if (prototype.getLocalName(register + 1, pc) != null) return frame.stack[register];
|
||||
|
||||
// Otherwise find where this register was set. If unknown, then abort.
|
||||
pc = DebugHelpers.findSetReg(prototype, pc, register);
|
||||
if (pc == -1) return null;
|
||||
|
||||
var insn = prototype.code[pc];
|
||||
var opcode = GET_OPCODE(insn);
|
||||
return switch (opcode) {
|
||||
case OP_MOVE -> {
|
||||
var a = GETARG_A(insn);
|
||||
var b = GETARG_B(insn); // move from `b' to `a'
|
||||
yield b < a ? evaluate(state, frame, prototype, pc, register, depth + 1) : null; // Resolve 'b'.
|
||||
}
|
||||
// Load constants
|
||||
case OP_LOADK -> prototype.constants[GETARG_Bx(insn)];
|
||||
case OP_LOADKX -> prototype.constants[GETARG_Ax(prototype.code[pc + 1])];
|
||||
case OP_LOADBOOL -> GETARG_B(insn) == 0 ? Constants.FALSE : Constants.TRUE;
|
||||
case OP_LOADNIL -> Constants.NIL;
|
||||
// Upvalues and tables.
|
||||
case OP_GETUPVAL -> frame.closure.getUpvalue(GETARG_B(insn)).getValue();
|
||||
case OP_GETTABLE, OP_GETTABUP -> {
|
||||
var table = opcode == OP_GETTABUP
|
||||
? frame.closure.getUpvalue(GETARG_B(insn)).getValue()
|
||||
: evaluate(state, frame, prototype, pc, GETARG_B(insn), depth + 1);
|
||||
if (table == null) yield null;
|
||||
|
||||
var key = evaluateK(state, frame, prototype, pc, GETARG_C(insn), depth + 1);
|
||||
yield key == null ? null : safeIndex(state, table, key);
|
||||
}
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private static @Nullable LuaValue evaluateK(LuaState state, DebugFrame frame, Prototype prototype, int pc, int registerOrConstant, int depth) {
|
||||
return ISK(registerOrConstant) ? prototype.constants[INDEXK(registerOrConstant)] : evaluate(state, frame, prototype, pc, registerOrConstant, depth + 1);
|
||||
}
|
||||
|
||||
private static @Nullable LuaValue safeIndex(LuaState state, LuaValue table, LuaValue key) {
|
||||
var loop = 0;
|
||||
do {
|
||||
LuaValue metatable;
|
||||
if (table instanceof LuaTable tbl) {
|
||||
var res = tbl.rawget(key);
|
||||
if (!res.isNil() || (metatable = tbl.metatag(state, CachedMetamethod.INDEX)).isNil()) return res;
|
||||
} else if ((metatable = table.metatag(state, CachedMetamethod.INDEX)).isNil()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (metatable instanceof LuaFunction) return null;
|
||||
|
||||
table = metatable;
|
||||
}
|
||||
while (++loop < Constants.MAXTAGLOOP);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.util;
|
||||
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
|
||||
/**
|
||||
* A few helpers for working with arguments.
|
||||
* <p>
|
||||
* This should really be moved into the public API. However, until I have settled on a suitable format, we'll keep it
|
||||
* where it is used.
|
||||
*/
|
||||
public class ArgumentHelpers {
|
||||
public static void assertBetween(double value, double min, double max, String message) throws LuaException {
|
||||
if (value < min || value > max || Double.isNaN(value)) {
|
||||
throw new LuaException(String.format(message, "between " + min + " and " + max));
|
||||
}
|
||||
}
|
||||
|
||||
public static void assertBetween(int value, int min, int max, String message) throws LuaException {
|
||||
if (value < min || value > max) {
|
||||
throw new LuaException(String.format(message, "between " + min + " and " + max));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.util;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public final class AtomicHelpers {
|
||||
private AtomicHelpers() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A version of {@link AtomicInteger#getAndIncrement()}, which increments until a limit is reached.
|
||||
*
|
||||
* @param atomic The atomic to increment.
|
||||
* @param limit The maximum value of {@code value}.
|
||||
* @return Whether the value was sucessfully incremented.
|
||||
*/
|
||||
public static boolean incrementToLimit(AtomicInteger atomic, int limit) {
|
||||
int value;
|
||||
do {
|
||||
value = atomic.get();
|
||||
if (value >= limit) return false;
|
||||
} while (!atomic.compareAndSet(value, value + 1));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: 2018 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.util;
|
||||
|
||||
import com.google.common.util.concurrent.ThreadFactoryBuilder;
|
||||
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static cc.tweaked.CCTweaked.LOG;
|
||||
|
||||
/**
|
||||
* Provides some utilities to create thread groups.
|
||||
*/
|
||||
public final class ThreadUtils {
|
||||
private static final ThreadGroup baseGroup = new ThreadGroup("ComputerCraft");
|
||||
|
||||
/**
|
||||
* A lower thread priority (though not the minimum), used for most of ComputerCraft's threads.
|
||||
* <p>
|
||||
* The Minecraft thread typically runs on a higher priority thread anyway, but this ensures we don't dominate other,
|
||||
* more critical work.
|
||||
*
|
||||
* @see Thread#setPriority(int)
|
||||
*/
|
||||
public static final int LOWER_PRIORITY = (Thread.MIN_PRIORITY + Thread.NORM_PRIORITY) / 2;
|
||||
|
||||
private ThreadUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base thread group, that all off-thread ComputerCraft activities are run on.
|
||||
*
|
||||
* @return The ComputerCraft group.
|
||||
*/
|
||||
public static ThreadGroup group() {
|
||||
return baseGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link ThreadFactoryBuilder}, which constructs threads under a group of the given {@code name}.
|
||||
* <p>
|
||||
* Each thread will be of the format {@code ComputerCraft-<name>-<number>}, and belong to a group
|
||||
* called {@code ComputerCraft-<name>} (which in turn will be a child group of the main {@code ComputerCraft} group.
|
||||
*
|
||||
* @param name The name for the thread group and child threads.
|
||||
* @return The constructed thread factory builder, which may be extended with other properties.
|
||||
* @see #factory(String)
|
||||
*/
|
||||
public static ThreadFactoryBuilder builder(String name) {
|
||||
var group = new ThreadGroup(baseGroup, baseGroup.getName() + "-" + name);
|
||||
return new ThreadFactoryBuilder()
|
||||
.setDaemon(true)
|
||||
.setNameFormat(group.getName().replace("%", "%%") + "-%d")
|
||||
.setUncaughtExceptionHandler((t, e) -> LOG.log(Level.SEVERE, "Exception in thread " + t.getName(), e))
|
||||
.setThreadFactory(x -> new Thread(group, x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link ThreadFactory}, which constructs threads under a group of the given {@code name}.
|
||||
* <p>
|
||||
* Each thread will be of the format {@code ComputerCraft-<name>-<number>}, and belong to a group
|
||||
* called {@code ComputerCraft-<name>} (which in turn will be a child group of the main {@code ComputerCraft} group.
|
||||
*
|
||||
* @param name The name for the thread group and child threads.
|
||||
* @return The constructed thread factory.
|
||||
* @see #builder(String)
|
||||
*/
|
||||
public static ThreadFactory factory(String name) {
|
||||
return builder(name).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link ThreadFactory}, which constructs threads under a group of the given {@code name}. This is the
|
||||
* same as {@link #factory(String)}, but threads will be created with a {@linkplain #LOWER_PRIORITY lower priority}.
|
||||
*
|
||||
* @param name The name for the thread group and child threads.
|
||||
* @return The constructed thread factory.
|
||||
* @see #builder(String)
|
||||
*/
|
||||
public static ThreadFactory lowPriorityFactory(String name) {
|
||||
return builder(name).setPriority(LOWER_PRIORITY).build();
|
||||
}
|
||||
}
|
@ -223,16 +223,21 @@ end
|
||||
--- Returns true if a path is mounted to the parent filesystem.
|
||||
--
|
||||
-- The root filesystem "/" is considered a mount, along with disk folders and
|
||||
-- the rom folder. Other programs (such as network shares) can exstend this to
|
||||
-- make other mount types by correctly assigning their return value for getDrive.
|
||||
-- the rom folder.
|
||||
--
|
||||
-- @tparam string path The path to check.
|
||||
-- @treturn boolean If the path is mounted, rather than a normal file/folder.
|
||||
-- @throws If the path does not exist.
|
||||
-- @see getDrive
|
||||
-- @since 1.87.0
|
||||
function fs.isDriveRoot(sPath)
|
||||
expect(1, sPath, "string")
|
||||
function fs.isDriveRoot(path)
|
||||
expect(1, path, "string")
|
||||
|
||||
local parent = fs.getDir(path)
|
||||
|
||||
-- Force the root directory to be a mount.
|
||||
return fs.getDir(sPath) == ".." or fs.getDrive(sPath) ~= fs.getDrive(fs.getDir(sPath))
|
||||
if parent == ".." then return true end
|
||||
|
||||
local drive = fs.getDrive(path)
|
||||
return drive ~= nil and drive ~= fs.getDrive(parent)
|
||||
end
|
||||
|
@ -23,7 +23,7 @@ just the same. For example, you might build a GPS cluster according to [this
|
||||
tutorial][1], using z to account for height, or you might use y to account for
|
||||
height in the way that Minecraft's debug screen displays.
|
||||
|
||||
[1]: http://www.computercraft.info/forums2/index.php?/topic/3088-how-to-guide-gps-global-position-system/
|
||||
[1]: https://ccf.squiddev.cc/forums2/index.php?/topic/3088-how-to-guide-gps-global-position-system/
|
||||
|
||||
@module gps
|
||||
@since 1.31
|
||||
@ -196,6 +196,8 @@ function locate(_nTimeout, _bDebug)
|
||||
modem.close(CHANNEL_GPS)
|
||||
end
|
||||
|
||||
os.cancelTimer(timeout)
|
||||
|
||||
-- Return the response
|
||||
if pos1 and pos2 then
|
||||
if _bDebug then
|
||||
|
@ -14,28 +14,82 @@ local expect = dofile("rom/modules/main/cc/expect.lua").expect
|
||||
local native = http
|
||||
local nativeHTTPRequest = http.request
|
||||
|
||||
request = http.request
|
||||
local methods = {
|
||||
GET = true, POST = true, HEAD = true,
|
||||
OPTIONS = true, PUT = true, DELETE = true,
|
||||
PATCH = true, TRACE = true,
|
||||
}
|
||||
|
||||
local function check_key(options, key, ty, opt)
|
||||
local value = options[key]
|
||||
local valueTy = type(value)
|
||||
|
||||
if (value ~= nil or not opt) and valueTy ~= ty then
|
||||
error(("bad field '%s' (%s expected, got %s"):format(key, ty, valueTy), 4)
|
||||
end
|
||||
end
|
||||
|
||||
local function check_request_options(options, body)
|
||||
check_key(options, "url", "string")
|
||||
if body == false then
|
||||
check_key(options, "body", "nil")
|
||||
else
|
||||
check_key(options, "body", "string", not body)
|
||||
end
|
||||
check_key(options, "headers", "table", true)
|
||||
check_key(options, "method", "string", true)
|
||||
check_key(options, "redirect", "boolean", true)
|
||||
check_key(options, "timeout", "number", true)
|
||||
|
||||
if options.method and not methods[options.method] then
|
||||
error("Unsupported HTTP method", 3)
|
||||
end
|
||||
end
|
||||
|
||||
local function wrap_request(_url, ...)
|
||||
nativeHTTPRequest(...)
|
||||
while true do
|
||||
local event, param1, param2, param3 = os.pullEvent()
|
||||
if event == "http_success" and param1 == _url then
|
||||
return param2
|
||||
elseif event == "http_failure" and param1 == _url then
|
||||
return nil, param2, param3
|
||||
local ok, err = nativeHTTPRequest(...)
|
||||
if ok then
|
||||
while true do
|
||||
local event, param1, param2, param3 = os.pullEvent()
|
||||
if event == "http_success" and param1 == _url then
|
||||
return param2
|
||||
elseif event == "http_failure" and param1 == _url then
|
||||
return nil, param2, param3
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil, err
|
||||
end
|
||||
|
||||
--[[- Make a HTTP GET request to the given url.
|
||||
|
||||
@tparam string url The url to request
|
||||
@tparam[opt] { [string] = string } headers Additional headers to send as part
|
||||
of this request.
|
||||
@tparam[opt=false] boolean binary Whether the [response handle][`fs.ReadHandle`]
|
||||
should be opened in binary mode.
|
||||
|
||||
@tparam[2] {
|
||||
url = string, headers? = { [string] = string },
|
||||
binary? = boolean, method? = string, redirect? = boolean,
|
||||
timeout? = number,
|
||||
} request Options for the request. See [`http.request`] for details on how
|
||||
these options behave.
|
||||
|
||||
@treturn Response The resulting http response, which can be read from.
|
||||
@treturn[2] nil When the http request failed, such as in the event of a 404
|
||||
error or connection timeout.
|
||||
@treturn string A message detailing why the request failed.
|
||||
@treturn Response|nil The failing http response, if available.
|
||||
|
||||
@changed 1.63 Added argument for headers.
|
||||
@changed 1.80pr1 Response handles are now returned on error if available.
|
||||
@changed 1.80pr1 Added argument for binary handles.
|
||||
@changed 1.80pr1.6 Added support for table argument.
|
||||
@changed 1.86.0 Added PATCH and TRACE methods.
|
||||
@changed 1.105.0 Added support for custom timeouts.
|
||||
@changed 1.109.0 The returned response now reads the body as raw bytes, rather
|
||||
than decoding from UTF-8.
|
||||
|
||||
@usage Make a request to [example.tweaked.cc](https://example.tweaked.cc),
|
||||
and print the returned page.
|
||||
@ -47,25 +101,287 @@ print(request.readAll())
|
||||
request.close()
|
||||
```
|
||||
]]
|
||||
function get(_url)
|
||||
function get(_url, _headers, _binary)
|
||||
if type(_url) == "table" then
|
||||
check_request_options(_url, false)
|
||||
return wrap_request(_url.url, _url)
|
||||
end
|
||||
|
||||
expect(1, _url, "string")
|
||||
return wrap_request(_url, _url)
|
||||
expect(2, _headers, "table", "nil")
|
||||
expect(3, _binary, "boolean", "nil")
|
||||
return wrap_request(_url, _url, nil, _headers, _binary)
|
||||
end
|
||||
|
||||
--[[- Make a HTTP POST request to the given url.
|
||||
|
||||
@tparam string url The url to request
|
||||
@tparam string body The body of the POST request.
|
||||
@tparam[opt] { [string] = string } headers Additional headers to send as part
|
||||
of this request.
|
||||
@tparam[opt=false] boolean binary Whether the [response handle][`fs.ReadHandle`]
|
||||
should be opened in binary mode.
|
||||
|
||||
@tparam[2] {
|
||||
url = string, body? = string, headers? = { [string] = string },
|
||||
binary? = boolean, method? = string, redirect? = boolean,
|
||||
timeout? = number,
|
||||
} request Options for the request. See [`http.request`] for details on how
|
||||
these options behave.
|
||||
|
||||
@treturn Response The resulting http response, which can be read from.
|
||||
@treturn[2] nil When the http request failed, such as in the event of a 404
|
||||
error or connection timeout.
|
||||
@treturn string A message detailing why the request failed.
|
||||
@treturn Response|nil The failing http response, if available.
|
||||
|
||||
@since 1.31
|
||||
@changed 1.63 Added argument for headers.
|
||||
@changed 1.80pr1 Response handles are now returned on error if available.
|
||||
@changed 1.80pr1 Added argument for binary handles.
|
||||
@changed 1.80pr1.6 Added support for table argument.
|
||||
@changed 1.86.0 Added PATCH and TRACE methods.
|
||||
@changed 1.105.0 Added support for custom timeouts.
|
||||
@changed 1.109.0 The returned response now reads the body as raw bytes, rather
|
||||
than decoding from UTF-8.
|
||||
]]
|
||||
function post(_url, _post)
|
||||
function post(_url, _post, _headers, _binary)
|
||||
if type(_url) == "table" then
|
||||
check_request_options(_url, true)
|
||||
return wrap_request(_url.url, _url)
|
||||
end
|
||||
|
||||
expect(1, _url, "string")
|
||||
expect(2, _post, "string")
|
||||
return wrap_request(_url, _url, _post)
|
||||
expect(3, _headers, "table", "nil")
|
||||
expect(4, _binary, "boolean", "nil")
|
||||
return wrap_request(_url, _url, _post, _headers, _binary)
|
||||
end
|
||||
|
||||
--[[- Asynchronously make a HTTP request to the given url.
|
||||
|
||||
This returns immediately, a [`http_success`] or [`http_failure`] will be queued
|
||||
once the request has completed.
|
||||
|
||||
@tparam string url The url to request
|
||||
@tparam[opt] string body An optional string containing the body of the
|
||||
request. If specified, a `POST` request will be made instead.
|
||||
@tparam[opt] { [string] = string } headers Additional headers to send as part
|
||||
of this request.
|
||||
@tparam[opt=false] boolean binary Whether the [response handle][`fs.ReadHandle`]
|
||||
should be opened in binary mode.
|
||||
|
||||
@tparam[2] {
|
||||
url = string, body? = string, headers? = { [string] = string },
|
||||
binary? = boolean, method? = string, redirect? = boolean,
|
||||
timeout? = number,
|
||||
} request Options for the request.
|
||||
|
||||
This table form is an expanded version of the previous syntax. All arguments
|
||||
from above are passed in as fields instead (for instance,
|
||||
`http.request("https://example.com")` becomes `http.request { url =
|
||||
"https://example.com" }`).
|
||||
This table also accepts several additional options:
|
||||
|
||||
- `method`: Which HTTP method to use, for instance `"PATCH"` or `"DELETE"`.
|
||||
- `redirect`: Whether to follow HTTP redirects. Defaults to true.
|
||||
- `timeout`: The connection timeout, in seconds.
|
||||
|
||||
@see http.get For a synchronous way to make GET requests.
|
||||
@see http.post For a synchronous way to make POST requests.
|
||||
|
||||
@changed 1.63 Added argument for headers.
|
||||
@changed 1.80pr1 Added argument for binary handles.
|
||||
@changed 1.80pr1.6 Added support for table argument.
|
||||
@changed 1.86.0 Added PATCH and TRACE methods.
|
||||
@changed 1.105.0 Added support for custom timeouts.
|
||||
@changed 1.109.0 The returned response now reads the body as raw bytes, rather
|
||||
than decoding from UTF-8.
|
||||
]]
|
||||
function request(_url, _post, _headers, _binary)
|
||||
local url
|
||||
if type(_url) == "table" then
|
||||
check_request_options(_url)
|
||||
url = _url.url
|
||||
else
|
||||
expect(1, _url, "string")
|
||||
expect(2, _post, "string", "nil")
|
||||
expect(3, _headers, "table", "nil")
|
||||
expect(4, _binary, "boolean", "nil")
|
||||
url = _url
|
||||
end
|
||||
|
||||
local ok, err = nativeHTTPRequest(_url, _post, _headers, _binary)
|
||||
if not ok then
|
||||
os.queueEvent("http_failure", url, err)
|
||||
end
|
||||
|
||||
-- Return true/false for legacy reasons. Undocumented, as it shouldn't be relied on.
|
||||
return ok, err
|
||||
end
|
||||
|
||||
local nativeCheckURL = native.checkURL
|
||||
|
||||
--[[- Asynchronously determine whether a URL can be requested.
|
||||
|
||||
If this returns `true`, one should also listen for [`http_check`] which will
|
||||
container further information about whether the URL is allowed or not.
|
||||
|
||||
@tparam string url The URL to check.
|
||||
@treturn true When this url is not invalid. This does not imply that it is
|
||||
allowed - see the comment above.
|
||||
@treturn[2] false When this url is invalid.
|
||||
@treturn string A reason why this URL is not valid (for instance, if it is
|
||||
malformed, or blocked).
|
||||
|
||||
@see http.checkURL For a synchronous version.
|
||||
]]
|
||||
checkURLAsync = nativeCheckURL
|
||||
|
||||
--[[- Determine whether a URL can be requested.
|
||||
|
||||
If this returns `true`, one should also listen for [`http_check`] which will
|
||||
container further information about whether the URL is allowed or not.
|
||||
|
||||
@tparam string url The URL to check.
|
||||
@treturn true When this url is valid and can be requested via [`http.request`].
|
||||
@treturn[2] false When this url is invalid.
|
||||
@treturn string A reason why this URL is not valid (for instance, if it is
|
||||
malformed, or blocked).
|
||||
|
||||
@see http.checkURLAsync For an asynchronous version.
|
||||
|
||||
@usage
|
||||
```lua
|
||||
print(http.checkURL("https://example.tweaked.cc/"))
|
||||
-- => true
|
||||
print(http.checkURL("http://localhost/"))
|
||||
-- => false Domain not permitted
|
||||
print(http.checkURL("not a url"))
|
||||
-- => false URL malformed
|
||||
```
|
||||
]]
|
||||
function checkURL(_url)
|
||||
expect(1, _url, "string")
|
||||
local ok, err = nativeCheckURL(_url)
|
||||
if not ok then return ok, err end
|
||||
|
||||
while true do
|
||||
local _, url, ok, err = os.pullEvent("http_check")
|
||||
if url == _url then return ok, err end
|
||||
end
|
||||
end
|
||||
|
||||
local nativeWebsocket = native.websocket
|
||||
|
||||
local function check_websocket_options(options, body)
|
||||
check_key(options, "url", "string")
|
||||
check_key(options, "headers", "table", true)
|
||||
check_key(options, "timeout", "number", true)
|
||||
end
|
||||
|
||||
|
||||
--[[- Asynchronously open a websocket.
|
||||
|
||||
This returns immediately, a [`websocket_success`] or [`websocket_failure`]
|
||||
will be queued once the request has completed.
|
||||
|
||||
@tparam[1] string url The websocket url to connect to. This should have the
|
||||
`ws://` or `wss://` protocol.
|
||||
@tparam[1, opt] { [string] = string } headers Additional headers to send as part
|
||||
of the initial websocket connection.
|
||||
|
||||
@tparam[2] {
|
||||
url = string, headers? = { [string] = string }, timeout ?= number,
|
||||
} request Options for the websocket. See [`http.websocket`] for details on how
|
||||
these options behave.
|
||||
|
||||
@since 1.80pr1.3
|
||||
@changed 1.95.3 Added User-Agent to default headers.
|
||||
@changed 1.105.0 Added support for table argument and custom timeout.
|
||||
@changed 1.109.0 Non-binary websocket messages now use the raw bytes rather than
|
||||
using UTF-8.
|
||||
@see websocket_success
|
||||
@see websocket_failure
|
||||
]]
|
||||
function websocketAsync(url, headers)
|
||||
local actual_url
|
||||
if type(url) == "table" then
|
||||
check_websocket_options(url)
|
||||
actual_url = url.url
|
||||
else
|
||||
expect(1, url, "string")
|
||||
expect(2, headers, "table", "nil")
|
||||
actual_url = url
|
||||
end
|
||||
|
||||
local ok, err = nativeWebsocket(url, headers)
|
||||
if not ok then
|
||||
os.queueEvent("websocket_failure", actual_url, err)
|
||||
end
|
||||
|
||||
-- Return true/false for legacy reasons. Undocumented, as it shouldn't be relied on.
|
||||
return ok, err
|
||||
end
|
||||
|
||||
--[[- Open a websocket.
|
||||
|
||||
@tparam[1] string url The websocket url to connect to. This should have the
|
||||
`ws://` or `wss://` protocol.
|
||||
@tparam[1,opt] { [string] = string } headers Additional headers to send as part
|
||||
of the initial websocket connection.
|
||||
|
||||
@tparam[2] {
|
||||
url = string, headers? = { [string] = string }, timeout ?= number,
|
||||
} request Options for the websocket.
|
||||
|
||||
This table form is an expanded version of the previous syntax. All arguments
|
||||
from above are passed in as fields instead (for instance,
|
||||
`http.websocket("https://example.com")` becomes `http.websocket { url =
|
||||
"https://example.com" }`).
|
||||
This table also accepts the following additional options:
|
||||
|
||||
- `timeout`: The connection timeout, in seconds.
|
||||
|
||||
@treturn Websocket The websocket connection.
|
||||
@treturn[2] false If the websocket connection failed.
|
||||
@treturn string An error message describing why the connection failed.
|
||||
|
||||
@since 1.80pr1.1
|
||||
@changed 1.80pr1.3 No longer asynchronous.
|
||||
@changed 1.95.3 Added User-Agent to default headers.
|
||||
@changed 1.105.0 Added support for table argument and custom timeout.
|
||||
@changed 1.109.0 Non-binary websocket messages now use the raw bytes rather than
|
||||
using UTF-8.
|
||||
|
||||
@usage Connect to an echo websocket and send a message.
|
||||
|
||||
local ws = assert(http.websocket("wss://example.tweaked.cc/echo"))
|
||||
ws.send("Hello!") -- Send a message
|
||||
print(ws.receive()) -- And receive the reply
|
||||
ws.close()
|
||||
|
||||
]]
|
||||
function websocket(url, headers)
|
||||
local actual_url
|
||||
if type(url) == "table" then
|
||||
check_websocket_options(url)
|
||||
actual_url = url.url
|
||||
else
|
||||
expect(1, url, "string")
|
||||
expect(2, headers, "table", "nil")
|
||||
actual_url = url
|
||||
end
|
||||
|
||||
local ok, err = nativeWebsocket(url, headers)
|
||||
if not ok then return ok, err end
|
||||
|
||||
while true do
|
||||
local event, url, param = os.pullEvent( )
|
||||
if event == "websocket_success" and url == actual_url then
|
||||
return param
|
||||
elseif event == "websocket_failure" and url == actual_url then
|
||||
return false, param
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -2,7 +2,7 @@
|
||||
--
|
||||
-- SPDX-License-Identifier: LicenseRef-CCPL
|
||||
|
||||
--- Constants for all keyboard "key codes", as queued by the @{key} event.
|
||||
--- Constants for all keyboard "key codes", as queued by the [`key`] event.
|
||||
--
|
||||
-- These values are not guaranteed to remain the same between versions. It is
|
||||
-- recommended that you use the constants provided by this file, rather than
|
||||
|
@ -47,12 +47,22 @@ local function sortCoords(startX, startY, endX, endY)
|
||||
return minX, maxX, minY, maxY
|
||||
end
|
||||
|
||||
--- Parses an image from a multi-line string
|
||||
--
|
||||
-- @tparam string image The string containing the raw-image data.
|
||||
-- @treturn table The parsed image data, suitable for use with
|
||||
-- [`paintutils.drawImage`].
|
||||
-- @since 1.80pr1
|
||||
--[=[- Parses an image from a multi-line string
|
||||
|
||||
@tparam string image The string containing the raw-image data.
|
||||
@treturn table The parsed image data, suitable for use with [`paintutils.drawImage`].
|
||||
@usage Parse an image from a string, and draw it.
|
||||
|
||||
local image = paintutils.parseImage([[
|
||||
e e
|
||||
|
||||
e e
|
||||
eeee
|
||||
]])
|
||||
paintutils.drawImage(image, term.getCursorPos())
|
||||
|
||||
@since 1.80pr1
|
||||
]=]
|
||||
function parseImage(image)
|
||||
expect(1, image, "string")
|
||||
local tImage = {}
|
||||
|
@ -39,60 +39,55 @@ the other.
|
||||
@since 1.2
|
||||
]]
|
||||
|
||||
local exception = dofile("rom/modules/main/cc/internal/tiny_require.lua")("cc.internal.exception")
|
||||
|
||||
local function create(...)
|
||||
local tFns = table.pack(...)
|
||||
local tCos = {}
|
||||
for i = 1, tFns.n, 1 do
|
||||
local fn = tFns[i]
|
||||
local barrier_ctx = { co = coroutine.running() }
|
||||
|
||||
local functions = table.pack(...)
|
||||
local threads = {}
|
||||
for i = 1, functions.n, 1 do
|
||||
local fn = functions[i]
|
||||
if type(fn) ~= "function" then
|
||||
error("bad argument #" .. i .. " (function expected, got " .. type(fn) .. ")", 3)
|
||||
end
|
||||
|
||||
tCos[i] = coroutine.create(fn)
|
||||
threads[i] = { co = coroutine.create(function() return exception.try_barrier(barrier_ctx, fn) end), filter = nil }
|
||||
end
|
||||
|
||||
return tCos
|
||||
return threads
|
||||
end
|
||||
|
||||
local function runUntilLimit(_routines, _limit)
|
||||
local count = #_routines
|
||||
local function runUntilLimit(threads, limit)
|
||||
local count = #threads
|
||||
if count < 1 then return 0 end
|
||||
local living = count
|
||||
|
||||
local tFilters = {}
|
||||
local eventData = { n = 0 }
|
||||
local event = { n = 0 }
|
||||
while true do
|
||||
for n = 1, count do
|
||||
local r = _routines[n]
|
||||
if r then
|
||||
if tFilters[r] == nil or tFilters[r] == eventData[1] or eventData[1] == "terminate" then
|
||||
local ok, param = coroutine.resume(r, table.unpack(eventData, 1, eventData.n))
|
||||
if not ok then
|
||||
error(param, 0)
|
||||
else
|
||||
tFilters[r] = param
|
||||
end
|
||||
if coroutine.status(r) == "dead" then
|
||||
_routines[n] = nil
|
||||
living = living - 1
|
||||
if living <= _limit then
|
||||
return n
|
||||
end
|
||||
for i = 1, count do
|
||||
local thread = threads[i]
|
||||
if thread and (thread.filter == nil or thread.filter == event[1] or event[1] == "terminate") then
|
||||
local ok, param = coroutine.resume(thread.co, table.unpack(event, 1, event.n))
|
||||
if ok then
|
||||
thread.filter = param
|
||||
elseif type(param) == "string" and exception.can_wrap_errors() then
|
||||
error(exception.make_exception(param, thread.co))
|
||||
else
|
||||
error(param, 0)
|
||||
end
|
||||
|
||||
if coroutine.status(thread.co) == "dead" then
|
||||
threads[i] = false
|
||||
living = living - 1
|
||||
if living <= limit then
|
||||
return i
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for n = 1, count do
|
||||
local r = _routines[n]
|
||||
if r and coroutine.status(r) == "dead" then
|
||||
_routines[n] = nil
|
||||
living = living - 1
|
||||
if living <= _limit then
|
||||
return n
|
||||
end
|
||||
end
|
||||
end
|
||||
eventData = table.pack(os.pullEventRaw())
|
||||
|
||||
event = table.pack(os.pullEventRaw())
|
||||
end
|
||||
end
|
||||
|
||||
@ -120,8 +115,8 @@ from the [`parallel.waitForAny`] call.
|
||||
print("Everything done!")
|
||||
]]
|
||||
function waitForAny(...)
|
||||
local routines = create(...)
|
||||
return runUntilLimit(routines, #routines - 1)
|
||||
local threads = create(...)
|
||||
return runUntilLimit(threads, #threads - 1)
|
||||
end
|
||||
|
||||
--[[- Switches between execution of the functions, until all of them are
|
||||
@ -144,6 +139,6 @@ from the [`parallel.waitForAll`] call.
|
||||
print("Everything done!")
|
||||
]]
|
||||
function waitForAll(...)
|
||||
local routines = create(...)
|
||||
return runUntilLimit(routines, 0)
|
||||
local threads = create(...)
|
||||
return runUntilLimit(threads, 0)
|
||||
end
|
||||
|
@ -149,7 +149,7 @@ function isOpen(modem)
|
||||
end
|
||||
|
||||
--[[- Allows a computer or turtle with an attached modem to send a message
|
||||
intended for a sycomputer with a specific ID. At least one such modem must first
|
||||
intended for a computer with a specific ID. At least one such modem must first
|
||||
be [opened][`rednet.open`] before sending is possible.
|
||||
|
||||
Assuming the target was in range and also had a correctly opened modem, the
|
||||
@ -298,6 +298,7 @@ function receive(protocol_filter, timeout)
|
||||
-- Return the first matching rednet_message
|
||||
local sender_id, message, protocol = p1, p2, p3
|
||||
if protocol_filter == nil or protocol == protocol_filter then
|
||||
if timer then os.cancelTimer(timer) end
|
||||
return sender_id, message, protocol
|
||||
end
|
||||
elseif event == "timer" then
|
||||
@ -431,6 +432,7 @@ function lookup(protocol, hostname)
|
||||
if hostname == nil then
|
||||
table.insert(results, sender_id)
|
||||
elseif message.sHostname == hostname then
|
||||
os.cancelTimer(timer)
|
||||
return sender_id
|
||||
end
|
||||
end
|
||||
@ -440,6 +442,9 @@ function lookup(protocol, hostname)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
os.cancelTimer(timer)
|
||||
|
||||
if results then
|
||||
return table.unpack(results)
|
||||
end
|
||||
|
@ -19,7 +19,7 @@ When a computer starts, it reads the current value of settings from the
|
||||
settings.define("my.setting", {
|
||||
description = "An example setting",
|
||||
default = 123,
|
||||
type = number,
|
||||
type = "number",
|
||||
})
|
||||
print("my.setting = " .. settings.get("my.setting")) -- 123
|
||||
|
||||
@ -61,7 +61,7 @@ for _, v in ipairs(valid_types) do valid_types[v] = true end
|
||||
-- - `default`: A default value, which is returned by [`settings.get`] if the
|
||||
-- setting has not been changed.
|
||||
-- - `type`: Require values to be of this type. [Setting][`set`] the value to another type
|
||||
-- will error.
|
||||
-- will error. Must be one of: `"number"`, `"string"`, `"boolean"`, or `"table"`.
|
||||
-- @since 1.87.0
|
||||
function define(name, options)
|
||||
expect(1, name, "string")
|
||||
@ -183,7 +183,7 @@ function unset(name)
|
||||
end
|
||||
|
||||
--- Resets the value of all settings. Equivalent to calling [`settings.unset`]
|
||||
--- on every setting.
|
||||
-- on every setting.
|
||||
--
|
||||
-- @see settings.unset
|
||||
function clear()
|
||||
@ -213,16 +213,16 @@ end
|
||||
-- Existing settings will be merged with any pre-existing ones. Conflicting
|
||||
-- entries will be overwritten, but any others will be preserved.
|
||||
--
|
||||
-- @tparam[opt] string sPath The file to load from, defaulting to `.settings`.
|
||||
-- @tparam[opt=".settings"] string path The file to load from.
|
||||
-- @treturn boolean Whether settings were successfully read from this
|
||||
-- file. Reasons for failure may include the file not existing or being
|
||||
-- corrupted.
|
||||
--
|
||||
-- @see settings.save
|
||||
-- @changed 1.87.0 `sPath` is now optional.
|
||||
function load(sPath)
|
||||
expect(1, sPath, "string", "nil")
|
||||
local file = fs.open(sPath or ".settings", "r")
|
||||
-- @changed 1.87.0 `path` is now optional.
|
||||
function load(path)
|
||||
expect(1, path, "string", "nil")
|
||||
local file = fs.open(path or ".settings", "r")
|
||||
if not file then
|
||||
return false
|
||||
end
|
||||
@ -255,14 +255,14 @@ end
|
||||
-- This will entirely overwrite the pre-existing file. Settings defined in the
|
||||
-- file, but not currently loaded will be removed.
|
||||
--
|
||||
-- @tparam[opt] string sPath The path to save settings to, defaulting to `.settings`.
|
||||
-- @tparam[opt=".settings"] string path The path to save settings to.
|
||||
-- @treturn boolean If the settings were successfully saved.
|
||||
--
|
||||
-- @see settings.load
|
||||
-- @changed 1.87.0 `sPath` is now optional.
|
||||
function save(sPath)
|
||||
expect(1, sPath, "string", "nil")
|
||||
local file = fs.open(sPath or ".settings", "w")
|
||||
-- @changed 1.87.0 `path` is now optional.
|
||||
function save(path)
|
||||
expect(1, path, "string", "nil")
|
||||
local file = fs.open(path or ".settings", "w")
|
||||
if not file then
|
||||
return false
|
||||
end
|
||||
|
@ -7,9 +7,11 @@
|
||||
-- @module textutils
|
||||
-- @since 1.2
|
||||
|
||||
local expect = dofile("rom/modules/main/cc/expect.lua")
|
||||
local require = dofile("rom/modules/main/cc/internal/tiny_require.lua")
|
||||
|
||||
local expect = require("cc.expect")
|
||||
local expect, field = expect.expect, expect.field
|
||||
local wrap = dofile("rom/modules/main/cc/strings.lua").wrap
|
||||
local wrap = require("cc.strings").wrap
|
||||
|
||||
--- Slowly writes string text at current cursor position,
|
||||
-- character-by-character.
|
||||
@ -847,13 +849,32 @@ unserialise = unserialize -- GB version
|
||||
|
||||
--[[- Returns a JSON representation of the given data.
|
||||
|
||||
This function attempts to guess whether a table is a JSON array or
|
||||
object. However, empty tables are assumed to be empty objects - use
|
||||
[`textutils.empty_json_array`] to mark an empty array.
|
||||
|
||||
This is largely intended for interacting with various functions from the
|
||||
[`commands`] API, though may also be used in making [`http`] requests.
|
||||
|
||||
Lua has a rather different data model to Javascript/JSON. As a result, some Lua
|
||||
values do not serialise cleanly into JSON.
|
||||
|
||||
- Lua tables can contain arbitrary key-value pairs, but JSON only accepts arrays,
|
||||
and objects (which require a string key). When serialising a table, if it only
|
||||
has numeric keys, then it will be treated as an array. Otherwise, the table will
|
||||
be serialised to an object using the string keys. Non-string keys (such as numbers
|
||||
or tables) will be dropped.
|
||||
|
||||
A consequence of this is that an empty table will always be serialised to an object,
|
||||
not an array. [`textutils.empty_json_array`] may be used to express an empty array.
|
||||
|
||||
- Lua strings are an a sequence of raw bytes, and do not have any specific encoding.
|
||||
However, JSON strings must be valid unicode. By default, non-ASCII characters in a
|
||||
string are serialised to their unicode code point (for instance, `"\xfe"` is
|
||||
converted to `"\u00fe"`). The `unicode_strings` option may be set to treat all input
|
||||
strings as UTF-8.
|
||||
|
||||
- Lua does not distinguish between missing keys (`undefined` in JS) and ones explicitly
|
||||
set to `null`. As a result `{ x = nil }` is serialised to `{}`. [`textutils.json_null`]
|
||||
may be used to get an explicit null value (`{ x = textutils.json_null }` will serialise
|
||||
to `{"x": null}`).
|
||||
|
||||
@param[1] t The value to serialise. Like [`textutils.serialise`], this should not
|
||||
contain recursive tables or functions.
|
||||
@tparam[1,opt] {
|
||||
@ -921,22 +942,21 @@ unserialiseJSON = unserialise_json
|
||||
-- @since 1.31
|
||||
function urlEncode(str)
|
||||
expect(1, str, "string")
|
||||
if str then
|
||||
str = string.gsub(str, "\n", "\r\n")
|
||||
str = string.gsub(str, "([^A-Za-z0-9 %-%_%.])", function(c)
|
||||
local n = string.byte(c)
|
||||
if n < 128 then
|
||||
-- ASCII
|
||||
return string.format("%%%02X", n)
|
||||
else
|
||||
-- Non-ASCII (encode as UTF-8)
|
||||
return
|
||||
string.format("%%%02X", 192 + bit32.band(bit32.arshift(n, 6), 31)) ..
|
||||
string.format("%%%02X", 128 + bit32.band(n, 63))
|
||||
end
|
||||
end)
|
||||
str = string.gsub(str, " ", "+")
|
||||
end
|
||||
local gsub, byte, format, band, arshift = string.gsub, string.byte, string.format, bit32.band, bit32.arshift
|
||||
|
||||
str = gsub(str, "\n", "\r\n")
|
||||
str = gsub(str, "[^A-Za-z0-9%-%_%.]", function(c)
|
||||
if c == " " then return "+" end
|
||||
|
||||
local n = byte(c)
|
||||
if n < 128 then
|
||||
-- ASCII
|
||||
return format("%%%02X", n)
|
||||
else
|
||||
-- Non-ASCII (encode as UTF-8)
|
||||
return format("%%%02X%%%02X", 192 + band(arshift(n, 6), 31), 128 + band(n, 63))
|
||||
end
|
||||
end)
|
||||
return str
|
||||
end
|
||||
|
||||
|
@ -13,6 +13,11 @@
|
||||
-- @module vector
|
||||
-- @since 1.31
|
||||
|
||||
local getmetatable = getmetatable
|
||||
local expect = dofile("rom/modules/main/cc/expect.lua").expect
|
||||
|
||||
local vmetatable
|
||||
|
||||
--- A 3-dimensional vector, with `x`, `y`, and `z` values.
|
||||
--
|
||||
-- This is suitable for representing both position and directional vectors.
|
||||
@ -27,6 +32,9 @@ local vector = {
|
||||
-- @usage v1:add(v2)
|
||||
-- @usage v1 + v2
|
||||
add = function(self, o)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
if getmetatable(o) ~= vmetatable then expect(2, o, "vector") end
|
||||
|
||||
return vector.new(
|
||||
self.x + o.x,
|
||||
self.y + o.y,
|
||||
@ -42,6 +50,9 @@ local vector = {
|
||||
-- @usage v1:sub(v2)
|
||||
-- @usage v1 - v2
|
||||
sub = function(self, o)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
if getmetatable(o) ~= vmetatable then expect(2, o, "vector") end
|
||||
|
||||
return vector.new(
|
||||
self.x - o.x,
|
||||
self.y - o.y,
|
||||
@ -52,30 +63,36 @@ local vector = {
|
||||
--- Multiplies a vector by a scalar value.
|
||||
--
|
||||
-- @tparam Vector self The vector to multiply.
|
||||
-- @tparam number m The scalar value to multiply with.
|
||||
-- @tparam number factor The scalar value to multiply with.
|
||||
-- @treturn Vector A vector with value `(x * m, y * m, z * m)`.
|
||||
-- @usage v:mul(3)
|
||||
-- @usage v * 3
|
||||
mul = function(self, m)
|
||||
-- @usage vector.new(1, 2, 3):mul(3)
|
||||
-- @usage vector.new(1, 2, 3) * 3
|
||||
mul = function(self, factor)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
expect(2, factor, "number")
|
||||
|
||||
return vector.new(
|
||||
self.x * m,
|
||||
self.y * m,
|
||||
self.z * m
|
||||
self.x * factor,
|
||||
self.y * factor,
|
||||
self.z * factor
|
||||
)
|
||||
end,
|
||||
|
||||
--- Divides a vector by a scalar value.
|
||||
--
|
||||
-- @tparam Vector self The vector to divide.
|
||||
-- @tparam number m The scalar value to divide by.
|
||||
-- @tparam number factor The scalar value to divide by.
|
||||
-- @treturn Vector A vector with value `(x / m, y / m, z / m)`.
|
||||
-- @usage v:div(3)
|
||||
-- @usage v / 3
|
||||
div = function(self, m)
|
||||
-- @usage vector.new(1, 2, 3):div(3)
|
||||
-- @usage vector.new(1, 2, 3) / 3
|
||||
div = function(self, factor)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
expect(2, factor, "number")
|
||||
|
||||
return vector.new(
|
||||
self.x / m,
|
||||
self.y / m,
|
||||
self.z / m
|
||||
self.x / factor,
|
||||
self.y / factor,
|
||||
self.z / factor
|
||||
)
|
||||
end,
|
||||
|
||||
@ -83,8 +100,9 @@ local vector = {
|
||||
--
|
||||
-- @tparam Vector self The vector to negate.
|
||||
-- @treturn Vector The negated vector.
|
||||
-- @usage -v
|
||||
-- @usage -vector.new(1, 2, 3)
|
||||
unm = function(self)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
return vector.new(
|
||||
-self.x,
|
||||
-self.y,
|
||||
@ -96,9 +114,12 @@ local vector = {
|
||||
--
|
||||
-- @tparam Vector self The first vector to compute the dot product of.
|
||||
-- @tparam Vector o The second vector to compute the dot product of.
|
||||
-- @treturn Vector The dot product of `self` and `o`.
|
||||
-- @treturn number The dot product of `self` and `o`.
|
||||
-- @usage v1:dot(v2)
|
||||
dot = function(self, o)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
if getmetatable(o) ~= vmetatable then expect(2, o, "vector") end
|
||||
|
||||
return self.x * o.x + self.y * o.y + self.z * o.z
|
||||
end,
|
||||
|
||||
@ -109,6 +130,9 @@ local vector = {
|
||||
-- @treturn Vector The cross product of `self` and `o`.
|
||||
-- @usage v1:cross(v2)
|
||||
cross = function(self, o)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
if getmetatable(o) ~= vmetatable then expect(2, o, "vector") end
|
||||
|
||||
return vector.new(
|
||||
self.y * o.z - self.z * o.y,
|
||||
self.z * o.x - self.x * o.z,
|
||||
@ -120,6 +144,7 @@ local vector = {
|
||||
-- @tparam Vector self This vector.
|
||||
-- @treturn number The length of this vector.
|
||||
length = function(self)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
|
||||
end,
|
||||
|
||||
@ -141,6 +166,9 @@ local vector = {
|
||||
-- nearest 0.5.
|
||||
-- @treturn Vector The rounded vector.
|
||||
round = function(self, tolerance)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
expect(2, tolerance, "number", "nil")
|
||||
|
||||
tolerance = tolerance or 1.0
|
||||
return vector.new(
|
||||
math.floor((self.x + tolerance * 0.5) / tolerance) * tolerance,
|
||||
@ -156,6 +184,8 @@ local vector = {
|
||||
-- @usage v:tostring()
|
||||
-- @usage tostring(v)
|
||||
tostring = function(self)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
|
||||
return self.x .. "," .. self.y .. "," .. self.z
|
||||
end,
|
||||
|
||||
@ -165,11 +195,15 @@ local vector = {
|
||||
-- @tparam Vector other The second vector to compare to.
|
||||
-- @treturn boolean Whether or not the vectors are equal.
|
||||
equals = function(self, other)
|
||||
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||
if getmetatable(other) ~= vmetatable then expect(2, other, "vector") end
|
||||
|
||||
return self.x == other.x and self.y == other.y and self.z == other.z
|
||||
end,
|
||||
}
|
||||
|
||||
local vmetatable = {
|
||||
vmetatable = {
|
||||
__name = "vector",
|
||||
__index = vector,
|
||||
__add = vector.add,
|
||||
__sub = vector.sub,
|
||||
|
@ -1,3 +1,160 @@
|
||||
# New features in CC: Tweaked 1.115.1
|
||||
|
||||
* Update various translations (cyb3r, kevk2156, teamer337, yakku).
|
||||
* Support Fabric's item lookup API for registering media providers.
|
||||
|
||||
Several bug fixes:
|
||||
* Fix crashes on Create 6.0 (ellellie).
|
||||
* Fix `speaker.playAudio` not updating speaker volume.
|
||||
* Resize pocket lectern textures to fix issues with generating mipmaps.
|
||||
|
||||
# New features in CC: Tweaked 1.115.0
|
||||
|
||||
* Support placing pocket computers on lecterns.
|
||||
* Suggest alternative table keys on `nil` errors.
|
||||
* Errors from inside `parallel` functions now have source information attached.
|
||||
* Expose printout contents to the Java API.
|
||||
|
||||
Several bug fixes:
|
||||
* Ignore unrepresentable characters in `char`/`paste` events.
|
||||
|
||||
# New features in CC: Tweaked 1.114.4
|
||||
|
||||
* Allow typing/pasting any character in the CC charset.
|
||||
|
||||
Several bug fixes:
|
||||
* Fix command computers being exposed as peripherals (Forge only).
|
||||
* Fix command computers having NBT set when placed in a Create contraption.
|
||||
* Use correct bounding box when checking for entities in turtle movement.
|
||||
|
||||
# New features in CC: Tweaked 1.114.3
|
||||
|
||||
* `wget` now prints the error that occurred, rather than a generic "Failed" (tizu69).
|
||||
* Update several translations.
|
||||
|
||||
Several bug fixes:
|
||||
* Fix `fs.isDriveRoot` returning true for non-existent files.
|
||||
* Fix possible memory leak when sending terminal contents.
|
||||
|
||||
# New features in CC: Tweaked 1.114.2
|
||||
|
||||
One bug fix:
|
||||
* Fix OpenGL errors when rendering empty monitors.
|
||||
|
||||
# New features in CC: Tweaked 1.114.1
|
||||
|
||||
Several bug fixes:
|
||||
* Fix monitor touch events only firing from one monitor.
|
||||
* Fix crash when lectern has no item.
|
||||
* Fix cursor not blinking on monitors.
|
||||
|
||||
# New features in CC: Tweaked 1.114.0
|
||||
|
||||
* Add redstone relay peripheral.
|
||||
* Add support for `math.atan(y, x)`.
|
||||
* Update several translations.
|
||||
|
||||
Several bug fixes:
|
||||
* Fix pocket upgrades not appearing after crafting.
|
||||
* Cancel `rednet.receive` and `Websocket.receive` timers after a message is received.
|
||||
* Fix several issues with parsing and printing large doubles.
|
||||
* Fix in-hand pocket computer being blank after changing dimension.
|
||||
|
||||
# New features in CC: Tweaked 1.113.1
|
||||
|
||||
* Update Japanese translation (konumatakaki).
|
||||
* Improve performance of `textutils.urlEncode`.
|
||||
|
||||
Several bug fixes:
|
||||
* Fix overflow when converting recursive objects from Java to Lua.
|
||||
* Fix websocket compression not working under Forge.
|
||||
|
||||
# New features in CC: Tweaked 1.113.0
|
||||
|
||||
* Allow placing printed pages and books in lecterns.
|
||||
|
||||
Several bug fixes:
|
||||
* Various documentation fixes (MCJack123)
|
||||
* Fix computers and turtles not being dropped when exploded with TNT.
|
||||
* Fix crash when turtles are broken while mining a block.
|
||||
* Fix pocket computer terminals not updating when in the off-hand.
|
||||
|
||||
# New features in CC: Tweaked 1.112.0
|
||||
|
||||
* Report a custom error when using `!` instead of `not`.
|
||||
* Update several translations (zyxkad, MineKID-LP).
|
||||
* Add `cc.strings.split` function.
|
||||
|
||||
Several bug fixes:
|
||||
* Fix `drive.getAudioTitle` returning `nil` when no disk is inserted.
|
||||
* Preserve item data when upgrading pocket computers.
|
||||
* Add missing bounds check to `cc.strings.wrap` (Lupus950).
|
||||
* Fix modems not moving with Create contraptions.
|
||||
|
||||
# New features in CC: Tweaked 1.111.0
|
||||
|
||||
* Update several translations (Ale32bit).
|
||||
* Split up turtle textures into individual textures.
|
||||
* Add `r+`/`w+` support to the `io` library.
|
||||
* Warn when capabilities are not registered and Optifine is installed.
|
||||
|
||||
Several bug fixes:
|
||||
* Allow planks to be used for building in "adventure" (dan200).
|
||||
* Fix `disk.getAudioTitle()` returning untranslated strings for some modded discs.
|
||||
* Fix crash when right clicking turtles in spectator.
|
||||
|
||||
# New features in CC: Tweaked 1.110.3
|
||||
|
||||
* Update several translations (PatriikPlays).
|
||||
|
||||
Several bug fixes:
|
||||
* Fix some errors missing source positions.
|
||||
* Correctly handle multiple threads sending websocket messages at once.
|
||||
|
||||
# New features in CC: Tweaked 1.110.2
|
||||
|
||||
* Add `speaker sound` command (fatboychummy).
|
||||
|
||||
Several bug fixes:
|
||||
* Improve error when calling `speaker play` with no path (fatboychummy).
|
||||
* Prevent playing music discs with `speaker.playSound`.
|
||||
* Various documentation fixes (cyberbit).
|
||||
* Fix generic peripherals not being able to transfer to some inventories on Forge.
|
||||
* Fix rare crash when holding a pocket computer.
|
||||
* Fix modems breaking when moved by Create.
|
||||
* Fix crash when rendering a turtle through an Immersive Portals portal.
|
||||
|
||||
# New features in CC: Tweaked 1.110.1
|
||||
|
||||
Several bug fixes:
|
||||
* Fix computers not turning on after they're unloaded/not-ticked for a while.
|
||||
* Fix networking cables sometimes not connecting on Forge.
|
||||
|
||||
# New features in CC: Tweaked 1.110.0
|
||||
|
||||
* Add a new `@c[...]` syntax for selecting computers in the `/computercraft` command.
|
||||
* Remove custom breaking progress of modems on Forge.
|
||||
|
||||
Several bug fixes:
|
||||
* Fix client and server DFPWM transcoders getting out of sync.
|
||||
* Fix `turtle.suck` reporting incorrect error when failing to suck items.
|
||||
* Fix pocket computers displaying state (blinking, modem light) for the wrong computer.
|
||||
* Fix crash when wrapping an invalid BE as a generic peripheral.
|
||||
* Chest peripherals now reattach when a chest is converted into a double chest.
|
||||
* Fix `speaker` program not resolving files relative to the current directory.
|
||||
* Skip main-thread tasks if the peripheral is detached.
|
||||
* Fix internal Lua VM errors if yielding inside `__tostring`.
|
||||
|
||||
# New features in CC: Tweaked 1.109.7
|
||||
|
||||
* Improve performance of removing and unloading wired cables/modems.
|
||||
|
||||
Several bug fixes:
|
||||
* Fix monitors sometimes not updating on the client when chunks are unloaded and reloaded.
|
||||
* `colour.toBlit` correctly errors on out-of-bounds values.
|
||||
* Round non-standard colours in `window`, like `term.native()` does.
|
||||
* Fix the client monitor rendering both the current and outdated contents.
|
||||
|
||||
# New features in CC: Tweaked 1.109.6
|
||||
|
||||
* Improve several Lua parser error messages.
|
||||
@ -724,7 +881,7 @@ And several bug fixes:
|
||||
# New features in CC: Tweaked 1.86.2
|
||||
|
||||
* Fix peripheral.getMethods returning an empty table.
|
||||
* Update to Minecraft 1.15.2. This is currently alpha-quality and so is missing missing features and may be unstable.
|
||||
* Update to Minecraft 1.15.2. This is currently alpha-quality and so is missing features and may be unstable.
|
||||
|
||||
# New features in CC: Tweaked 1.86.1
|
||||
|
||||
@ -1340,7 +1497,7 @@ And several bug fixes:
|
||||
* Turtles can now compare items in their inventories
|
||||
* Turtles can place signs with text on them with `turtle.place( [signText] )`
|
||||
* Turtles now optionally require fuel items to move, and can refuel themselves
|
||||
* The size of the the turtle inventory has been increased to 16
|
||||
* The size of the turtle inventory has been increased to 16
|
||||
* The size of the turtle screen has been increased
|
||||
* New turtle functions: `turtle.compareTo( [slotNum] )`, `turtle.craft()`, `turtle.attack()`, `turtle.attackUp()`, `turtle.attackDown()`, `turtle.dropUp()`, `turtle.dropDown()`, `turtle.getFuelLevel()`, `turtle.refuel()`
|
||||
* New disk function: disk.getID()
|
||||
|
@ -1,9 +1,11 @@
|
||||
New features in CC: Tweaked 1.109.6
|
||||
New features in CC: Tweaked 1.115.1
|
||||
|
||||
* Improve several Lua parser error messages.
|
||||
* Allow addon mods to register `require`able modules.
|
||||
* Update various translations (cyb3r, kevk2156, teamer337, yakku).
|
||||
* Support Fabric's item lookup API for registering media providers.
|
||||
|
||||
Several bug fixes:
|
||||
* Fix weak tables becoming malformed when keys are GCed.
|
||||
* Fix crashes on Create 6.0 (ellellie).
|
||||
* Fix `speaker.playAudio` not updating speaker volume.
|
||||
* Resize pocket lectern textures to fix issues with generating mipmaps.
|
||||
|
||||
Type "help changelog" to see the full version history.
|
||||
|
@ -6,14 +6,13 @@
|
||||
Convert between streams of DFPWM audio data and a list of amplitudes.
|
||||
|
||||
DFPWM (Dynamic Filter Pulse Width Modulation) is an audio codec designed by GreaseMonkey. It's a relatively compact
|
||||
format compared to raw PCM data, only using 1 bit per sample, but is simple enough to simple enough to encode and decode
|
||||
in real time.
|
||||
format compared to raw PCM data, only using 1 bit per sample, but is simple enough to encode and decode in real time.
|
||||
|
||||
Typically DFPWM audio is read from [the filesystem][`fs.BinaryReadHandle`] or a [a web request][`http.Response`] as a
|
||||
string, and converted a format suitable for [`speaker.playAudio`].
|
||||
Typically DFPWM audio is read from [the filesystem][`fs.ReadHandle`] or a [a web request][`http.Response`] as a string,
|
||||
and converted a format suitable for [`speaker.playAudio`].
|
||||
|
||||
## Encoding and decoding files
|
||||
This modules exposes two key functions, [`make_decoder`] and [`make_encoder`], which construct a new decoder or encoder.
|
||||
This module exposes two key functions, [`make_decoder`] and [`make_encoder`], which construct a new decoder or encoder.
|
||||
The returned encoder/decoder is itself a function, which converts between the two kinds of data.
|
||||
|
||||
These encoders and decoders have lots of hidden state, so you should be careful to use the same encoder or decoder for
|
||||
@ -21,9 +20,9 @@ a specific audio stream. Typically you will want to create a decoder for each st
|
||||
for each one you write.
|
||||
|
||||
## Converting audio to DFPWM
|
||||
DFPWM is not a popular file format and so standard audio processing tools will not have an option to export to it.
|
||||
DFPWM is not a popular file format and so standard audio processing tools may not have an option to export to it.
|
||||
Instead, you can convert audio files online using [music.madefor.cc], the [LionRay Wav Converter][LionRay] Java
|
||||
application or development builds of [FFmpeg].
|
||||
application or [FFmpeg] 5.1 or later.
|
||||
|
||||
[music.madefor.cc]: https://music.madefor.cc/ "DFPWM audio converter for Computronics and CC: Tweaked"
|
||||
[LionRay]: https://github.com/gamax92/LionRay/ "LionRay Wav Converter "
|
||||
@ -211,7 +210,7 @@ end
|
||||
|
||||
--[[- A convenience function for encoding a complete file of audio at once.
|
||||
|
||||
This should only be used for complete pieces of audio. If you are writing writing multiple chunks to the same place,
|
||||
This should only be used for complete pieces of audio. If you are writing multiple chunks to the same place,
|
||||
you should use an encoder returned by [`make_encoder`] instead.
|
||||
|
||||
@tparam { number... } input The table of amplitude data.
|
||||
|
@ -118,8 +118,8 @@ end
|
||||
--- Expect a number to be within a specific range.
|
||||
--
|
||||
-- @tparam number num The value to check.
|
||||
-- @tparam number min The minimum value, if nil then `-math.huge` is used.
|
||||
-- @tparam number max The maximum value, if nil then `math.huge` is used.
|
||||
-- @tparam[opt=-math.huge] number min The minimum value.
|
||||
-- @tparam[opt=math.huge] number max The maximum value.
|
||||
-- @return The given `value`.
|
||||
-- @throws If the value is outside of the allowed range.
|
||||
-- @since 1.96.0
|
||||
|
@ -89,7 +89,7 @@ end
|
||||
--
|
||||
-- @tparam table image An image, as returned from [`load`] or [`parse`].
|
||||
-- @tparam number xPos The x position to start drawing at.
|
||||
-- @tparam number xPos The y position to start drawing at.
|
||||
-- @tparam number yPos The y position to start drawing at.
|
||||
-- @tparam[opt] term.Redirect target The terminal redirect to draw to. Defaults to the
|
||||
-- current terminal.
|
||||
local function draw(image, xPos, yPos, target)
|
||||
|
@ -0,0 +1,167 @@
|
||||
-- SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
|
||||
--
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
--[[- Internal tools for diagnosing errors and suggesting fixes.
|
||||
|
||||
> [!DANGER]
|
||||
> This is an internal module and SHOULD NOT be used in your own code. It may
|
||||
> be removed or changed at any time.
|
||||
|
||||
@local
|
||||
]]
|
||||
|
||||
local debug, type, rawget = debug, type, rawget
|
||||
local sub, lower, find, min, abs = string.sub, string.lower, string.find, math.min, math.abs
|
||||
|
||||
--[[- Compute the Optimal String Distance between two strings.
|
||||
|
||||
@tparam string str_a The first string.
|
||||
@tparam string str_b The second string.
|
||||
@treturn number|nil The distance between two strings, or nil if they are two far
|
||||
apart.
|
||||
]]
|
||||
local function osa_distance(str_a, str_b, threshold)
|
||||
local len_a, len_b = #str_a, #str_b
|
||||
|
||||
-- If the two strings are too different in length, then bail now.
|
||||
if abs(len_a - len_b) > threshold then return end
|
||||
|
||||
-- Zero-initialise our distance table.
|
||||
local d = {}
|
||||
for i = 1, (len_a + 1) * (len_b + 1) do d[i] = 0 end
|
||||
|
||||
-- Then fill the first row and column
|
||||
local function idx(a, b) return a * (len_a + 1) + b + 1 end
|
||||
for i = 0, len_a do d[idx(i, 0)] = i end
|
||||
for j = 0, len_b do d[idx(0, j)] = j end
|
||||
|
||||
-- Then compute our distance
|
||||
for i = 1, len_a do
|
||||
local char_a = sub(str_a, i, i)
|
||||
for j = 1, len_b do
|
||||
local char_b = sub(str_b, j, j)
|
||||
|
||||
local sub_cost
|
||||
if char_a == char_b then
|
||||
sub_cost = 0
|
||||
elseif lower(char_a) == lower(char_b) then
|
||||
sub_cost = 0.5
|
||||
else
|
||||
sub_cost = 1
|
||||
end
|
||||
|
||||
local new_cost = min(
|
||||
d[idx(i - 1, j)] + 1, -- Deletion
|
||||
d[idx(i, j - 1)] + 1, -- Insertion,
|
||||
d[idx(i - 1, j - 1)] + sub_cost -- Substitution
|
||||
)
|
||||
|
||||
-- Transposition
|
||||
if i > 1 and j > 1 and char_a == sub(str_b, j - 1, j - 1) and char_b == sub(str_a, i - 1, i - 1) then
|
||||
local trans_cost = d[idx(i - 2, j - 2)] + 1
|
||||
if trans_cost < new_cost then new_cost = trans_cost end
|
||||
end
|
||||
|
||||
d[idx(i, j)] = new_cost
|
||||
end
|
||||
end
|
||||
|
||||
local result = d[idx(len_a, len_b)]
|
||||
if result <= threshold then return result else return nil end
|
||||
end
|
||||
|
||||
--- Check whether this suggestion is useful.
|
||||
local function useful_suggestion(str)
|
||||
local len = #str
|
||||
return len > 0 and len < 32 and find(str, "^[%a_]%w*$")
|
||||
end
|
||||
|
||||
local function get_suggestions(is_global, value, key, thread, frame_offset)
|
||||
if not useful_suggestion(key) then return end
|
||||
|
||||
-- Pick a maximum number of edits. We're more lenient on longer strings, but
|
||||
-- still only allow two mistakes.
|
||||
local threshold = #key >= 5 and 2 or 1
|
||||
|
||||
-- Find all items in the table, and see if they seem similar.
|
||||
local suggestions = {}
|
||||
local function process_suggestion(k)
|
||||
if type(k) ~= "string" or not useful_suggestion(k) then return end
|
||||
|
||||
local distance = osa_distance(k, key, threshold)
|
||||
if distance then
|
||||
if distance < threshold then
|
||||
-- If this is better than any existing match, then prefer it.
|
||||
suggestions = { k }
|
||||
threshold = distance
|
||||
else
|
||||
-- Otherwise distance==threshold, and so just add it.
|
||||
suggestions[#suggestions + 1] = k
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
while type(value) == "table" do
|
||||
for k in next, value do process_suggestion(k) end
|
||||
|
||||
local mt = debug.getmetatable(value)
|
||||
if mt == nil then break end
|
||||
value = rawget(mt, "__index")
|
||||
end
|
||||
|
||||
-- If we're attempting to lookup a global, then also suggest any locals and
|
||||
-- upvalues. Our upvalues will be incomplete, but maybe a little useful?
|
||||
if is_global then
|
||||
for i = 1, 200 do
|
||||
local name = debug.getlocal(thread, frame_offset, i)
|
||||
if not name then break end
|
||||
process_suggestion(name)
|
||||
end
|
||||
|
||||
local func = debug.getinfo(thread, frame_offset, "f").func
|
||||
for i = 1, 255 do
|
||||
local name = debug.getupvalue(func, i)
|
||||
if not name then break end
|
||||
process_suggestion(name)
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(suggestions)
|
||||
|
||||
return suggestions
|
||||
end
|
||||
|
||||
--[[- Get a tip to display at the end of an error.
|
||||
|
||||
@tparam string err The error message.
|
||||
@tparam coroutine thread The current thread.
|
||||
@tparam number frame_offset The offset into the thread where the current frame exists
|
||||
@return An optional message to append to the error.
|
||||
]]
|
||||
local function get_tip(err, thread, frame_offset)
|
||||
local nil_op = err:match("^attempt to (%l+) .* %(a nil value%)")
|
||||
if not nil_op then return end
|
||||
|
||||
local has_error_info, error_info = pcall(require, "cc.internal.error_info")
|
||||
if not has_error_info then return end
|
||||
local op, is_global, table, key = error_info.info_for_nil(thread, frame_offset)
|
||||
if op == nil or op ~= nil_op then return end
|
||||
|
||||
local suggestions = get_suggestions(is_global, table, key, thread, frame_offset)
|
||||
if not suggestions or next(suggestions) == nil then return end
|
||||
|
||||
local pretty = require "cc.pretty"
|
||||
local msg = "Did you mean: "
|
||||
|
||||
local n_suggestions = min(3, #suggestions)
|
||||
for i = 1, n_suggestions do
|
||||
if i > 1 then
|
||||
if i == n_suggestions then msg = msg .. " or " else msg = msg .. ", " end
|
||||
end
|
||||
msg = msg .. pretty.text(suggestions[i], colours.lightGrey)
|
||||
end
|
||||
return msg .. "?"
|
||||
end
|
||||
|
||||
return { get_tip = get_tip }
|
@ -12,7 +12,7 @@
|
||||
]]
|
||||
|
||||
local expect = require "cc.expect".expect
|
||||
local error_printer = require "cc.internal.error_printer"
|
||||
local type, debug, coroutine = type, debug, coroutine
|
||||
|
||||
local function find_frame(thread, file, line)
|
||||
-- Scan the first 16 frames for something interesting.
|
||||
@ -21,18 +21,136 @@ local function find_frame(thread, file, line)
|
||||
if not frame then break end
|
||||
|
||||
if frame.short_src == file and frame.what ~= "C" and frame.currentline == line then
|
||||
return frame
|
||||
return offset, frame
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--[[- Check whether this error is an exception.
|
||||
|
||||
Currently we don't provide a stable API for throwing (and propagating) rich
|
||||
errors, like those supported by this module. In lieu of that, we describe the
|
||||
exception protocol, which may be used by user-written coroutine managers to
|
||||
throw exceptions which are pretty-printed by the shell:
|
||||
|
||||
An exception is any table with:
|
||||
- The `"exception"` type
|
||||
- A string `message` field,
|
||||
- And a coroutine `thread` fields.
|
||||
|
||||
To throw such an exception, the inner loop of your coroutine manager may look
|
||||
something like this:
|
||||
|
||||
```lua
|
||||
local ok, result = coroutine.resume(co, table.unpack(event, 1, event.n))
|
||||
if not ok then
|
||||
-- Rethrow non-string errors directly
|
||||
if type(result) ~= "string" then error(result, 0) end
|
||||
-- Otherwise, wrap it into an exception.
|
||||
error(setmetatable({ message = result, thread = co }, {
|
||||
__name = "exception",
|
||||
__tostring = function(self) return self.message end,
|
||||
}))
|
||||
end
|
||||
```
|
||||
|
||||
@param exn Some error object
|
||||
@treturn boolean Whether this error is an exception.
|
||||
]]
|
||||
local function is_exception(exn)
|
||||
if type(exn) ~= "table" then return false end
|
||||
|
||||
local mt = getmetatable(exn)
|
||||
return mt and mt.__name == "exception" and type(rawget(exn, "message")) == "string" and type(rawget(exn, "thread")) == "thread"
|
||||
end
|
||||
|
||||
local exn_mt = {
|
||||
__name = "exception",
|
||||
__tostring = function(self) return self.message end,
|
||||
}
|
||||
|
||||
--[[- Create a new exception from a message and thread.
|
||||
|
||||
@tparam string message The exception message.
|
||||
@tparam coroutine thread The coroutine the error occurred on.
|
||||
@return The constructed exception.
|
||||
]]
|
||||
local function make_exception(message, thread)
|
||||
return setmetatable({ message = message, thread = thread }, exn_mt)
|
||||
end
|
||||
|
||||
--[[- A marker function for [`try`] and the wider exception machinery.
|
||||
|
||||
This function is typically the first function on the call stack. It acts as both
|
||||
a signifier that this function is exception aware, and allows us to store
|
||||
additional information for the exception machinery on the call stack.
|
||||
|
||||
@see can_wrap_errors
|
||||
]]
|
||||
local try_barrier = debug.getregistry().cc_try_barrier
|
||||
if not try_barrier then
|
||||
-- We define an extra "bounce" function to prevent f(...) being treated as a
|
||||
-- tail call, and so ensure the barrier remains on the stack.
|
||||
local function bounce(...) return ... end
|
||||
|
||||
--- @tparam { co = coroutine, can_wrap ?= boolean } parent The parent coroutine.
|
||||
-- @tparam function f The function to call.
|
||||
-- @param ... The arguments to this function.
|
||||
try_barrier = function(parent, f, ...) return bounce(f(...)) end
|
||||
|
||||
debug.getregistry().cc_try_barrier = try_barrier
|
||||
end
|
||||
|
||||
-- Functions that act as a barrier for exceptions.
|
||||
local pcall_functions = { [pcall] = true, [xpcall] = true, [load] = true }
|
||||
|
||||
--[[- Check to see whether we can wrap errors into an exception.
|
||||
|
||||
This scans the current thread (up to a limit), and any parent threads, to
|
||||
determine if there is a pcall anywhere on the callstack. If not, then we know
|
||||
the error message is not observed by user code, and so may be wrapped into an
|
||||
exception.
|
||||
|
||||
@tparam[opt] coroutine The thread to check. Defaults to the current thread.
|
||||
@treturn boolean Whether we can wrap errors into exceptions.
|
||||
]]
|
||||
local function can_wrap_errors(thread)
|
||||
if not thread then thread = coroutine.running() end
|
||||
|
||||
for offset = 0, 31 do
|
||||
local frame = debug.getinfo(thread, offset, "f")
|
||||
if not frame then return false end
|
||||
|
||||
local func = frame.func
|
||||
if func == try_barrier then
|
||||
-- If we've a try barrier, then extract the parent coroutine and
|
||||
-- check if it can wrap errors.
|
||||
local _, parent = debug.getlocal(thread, offset, 1)
|
||||
if type(parent) ~= "table" or type(parent.co) ~= "thread" then return false end
|
||||
|
||||
local result = parent.can_wrap
|
||||
if result == nil then
|
||||
result = can_wrap_errors(parent.co)
|
||||
parent.can_wrap = result
|
||||
end
|
||||
|
||||
return result
|
||||
elseif pcall_functions[func] then
|
||||
-- If we're a pcall, then abort.
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--[[- Attempt to call the provided function `func` with the provided arguments.
|
||||
|
||||
@tparam function func The function to call.
|
||||
@param ... Arguments to this function.
|
||||
|
||||
@treturn[1] true If the function ran successfully.
|
||||
@return[1] ... The return values of the function.
|
||||
@return[1] ... The return values of the function.
|
||||
|
||||
@treturn[2] false If the function failed.
|
||||
@return[2] The error message
|
||||
@ -41,8 +159,8 @@ end
|
||||
local function try(func, ...)
|
||||
expect(1, func, "function")
|
||||
|
||||
local co = coroutine.create(func)
|
||||
local result = table.pack(coroutine.resume(co, ...))
|
||||
local co = coroutine.create(try_barrier)
|
||||
local result = table.pack(coroutine.resume(co, { co = co, can_wrap = true }, func, ...))
|
||||
|
||||
while coroutine.status(co) ~= "dead" do
|
||||
local event = table.pack(os.pullEventRaw(result[2]))
|
||||
@ -51,8 +169,14 @@ local function try(func, ...)
|
||||
end
|
||||
end
|
||||
|
||||
if not result[1] then return false, result[2], co end
|
||||
return table.unpack(result, 1, result.n)
|
||||
if result[1] then
|
||||
return table.unpack(result, 1, result.n)
|
||||
elseif is_exception(result[2]) then
|
||||
local exn = result[2]
|
||||
return false, rawget(exn, "message"), rawget(exn, "thread")
|
||||
else
|
||||
return false, result[2], co
|
||||
end
|
||||
end
|
||||
|
||||
--[[- Report additional context about an error.
|
||||
@ -67,11 +191,11 @@ local function report(err, thread, source_map)
|
||||
|
||||
if type(err) ~= "string" then return end
|
||||
|
||||
local file, line = err:match("^([^:]+):(%d+):")
|
||||
local file, line, err = err:match("^([^:]+):(%d+): (.*)")
|
||||
if not file then return end
|
||||
line = tonumber(line)
|
||||
|
||||
local frame = find_frame(thread, file, line)
|
||||
local frame_offset, frame = find_frame(thread, file, line)
|
||||
if not frame or not frame.currentcolumn then return end
|
||||
|
||||
local column = frame.currentcolumn
|
||||
@ -108,16 +232,22 @@ local function report(err, thread, source_map)
|
||||
-- Could not determine the line. Bail.
|
||||
if not line_contents or #line_contents == "" then return end
|
||||
|
||||
error_printer({
|
||||
require("cc.internal.error_printer")({
|
||||
get_pos = function() return line, column end,
|
||||
get_line = function() return line_contents end,
|
||||
}, {
|
||||
{ tag = "annotate", start_pos = column, end_pos = column, msg = "" },
|
||||
require "cc.internal.error_hints".get_tip(err, thread, frame_offset),
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
return {
|
||||
make_exception = make_exception,
|
||||
|
||||
try_barrier = try_barrier,
|
||||
can_wrap_errors = can_wrap_errors,
|
||||
|
||||
try = try,
|
||||
report = report,
|
||||
}
|
||||
|
@ -284,6 +284,23 @@ function errors.wrong_ne(start_pos, end_pos)
|
||||
}
|
||||
end
|
||||
|
||||
--[[- `!` was used instead of `not`.
|
||||
|
||||
@tparam number start_pos The start position of the token.
|
||||
@tparam number end_pos The end position of the token.
|
||||
@return The resulting parse error.
|
||||
]]
|
||||
function errors.wrong_not(start_pos, end_pos)
|
||||
expect(1, start_pos, "number")
|
||||
expect(2, end_pos, "number")
|
||||
|
||||
return {
|
||||
"Unexpected character.",
|
||||
annotate(start_pos, end_pos),
|
||||
"Tip: Replace this with " .. code("not") .. " to negate a boolean.",
|
||||
}
|
||||
end
|
||||
|
||||
--[[- An unexpected character was used.
|
||||
|
||||
@tparam number pos The position of this character.
|
||||
|
@ -327,6 +327,9 @@ local function lex_token(context, str, pos)
|
||||
elseif contents == "!=" or contents == "<>" then
|
||||
context.report(errors.wrong_ne, pos, end_pos)
|
||||
return tokens.NE, end_pos
|
||||
elseif contents == "!" then
|
||||
context.report(errors.wrong_not, pos, end_pos)
|
||||
return tokens.NOT, end_pos
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -0,0 +1,37 @@
|
||||
-- SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
|
||||
--
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
--[[- A minimal implementation of require.
|
||||
|
||||
This is intended for use with APIs, and other internal code which is not run in
|
||||
the [`shell`] environment. This allows us to avoid some of the overhead of
|
||||
loading the full [`cc.require`] module.
|
||||
|
||||
> [!DANGER]
|
||||
> This is an internal module and SHOULD NOT be used in your own code. It may
|
||||
> be removed or changed at any time.
|
||||
|
||||
@local
|
||||
|
||||
@tparam string name The module to require.
|
||||
@return The required module.
|
||||
]]
|
||||
|
||||
local loaded = {}
|
||||
local env = setmetatable({}, { __index = _G })
|
||||
local function require(name)
|
||||
local result = loaded[name]
|
||||
if result then return result end
|
||||
|
||||
local path = "rom/modules/main/" .. name:gsub("%.", "/")
|
||||
if fs.exists(path .. ".lua") then
|
||||
result = assert(loadfile(path .. ".lua", nil, env))()
|
||||
else
|
||||
result = assert(loadfile(path .. "/init.lua", nil, env))()
|
||||
end
|
||||
loaded[name] = result
|
||||
return result
|
||||
end
|
||||
env.require = require
|
||||
return require
|
@ -8,7 +8,8 @@
|
||||
-- @since 1.95.0
|
||||
-- @see textutils For additional string related utilities.
|
||||
|
||||
local expect = (require and require("cc.expect") or dofile("rom/modules/main/cc/expect.lua")).expect
|
||||
local expect = require("cc.expect")
|
||||
local expect, range = expect.expect, expect.range
|
||||
|
||||
--[[- Wraps a block of text, so that each line fits within the given width.
|
||||
|
||||
@ -32,7 +33,7 @@ local function wrap(text, width)
|
||||
expect(1, text, "string")
|
||||
expect(2, width, "number", "nil")
|
||||
width = width or term.getSize()
|
||||
|
||||
range(width, 1)
|
||||
|
||||
local lines, lines_n, current_line = {}, 0, ""
|
||||
local function push_line()
|
||||
@ -109,7 +110,63 @@ local function ensure_width(line, width)
|
||||
return line
|
||||
end
|
||||
|
||||
--[[- Split a string into parts, each separated by a deliminator.
|
||||
|
||||
For instance, splitting the string `"a b c"` with the deliminator `" "`, would
|
||||
return a table with three strings: `"a"`, `"b"`, and `"c"`.
|
||||
|
||||
By default, the deliminator is given as a [Lua pattern][pattern]. Passing `true`
|
||||
to the `plain` argument will cause the deliminator to be treated as a litteral
|
||||
string.
|
||||
|
||||
[pattern]: https://www.lua.org/manual/5.3/manual.html#6.4.1
|
||||
|
||||
@tparam string str The string to split.
|
||||
@tparam string deliminator The pattern to split this string on.
|
||||
@tparam[opt=false] boolean plain Treat the deliminator as a plain string, rather than a pattern.
|
||||
@tparam[opt] number limit The maximum number of elements in the returned list.
|
||||
@treturn { string... } The list of split strings.
|
||||
|
||||
@usage Split a string into words.
|
||||
|
||||
require "cc.strings".split("This is a sentence.", "%s+")
|
||||
|
||||
@usage Split a string by "-" into at most 3 elements.
|
||||
|
||||
require "cc.strings".split("a-separated-string-of-sorts", "-", true, 3)
|
||||
|
||||
@see table.concat To join strings together.
|
||||
|
||||
@since 1.112.0
|
||||
]]
|
||||
local function split(str, deliminator, plain, limit)
|
||||
expect(1, str, "string")
|
||||
expect(2, deliminator, "string")
|
||||
expect(3, plain, "boolean", "nil")
|
||||
expect(4, limit, "number", "nil")
|
||||
|
||||
local out, out_n, pos = {}, 0, 1
|
||||
while not limit or out_n < limit - 1 do
|
||||
local start, finish = str:find(deliminator, pos, plain)
|
||||
if not start then break end
|
||||
|
||||
out_n = out_n + 1
|
||||
out[out_n] = str:sub(pos, start - 1)
|
||||
|
||||
-- Require us to advance by at least one character.
|
||||
if finish < start then error("separator is empty", 2) end
|
||||
|
||||
pos = finish + 1
|
||||
end
|
||||
|
||||
if pos == 1 then return { str } end
|
||||
|
||||
out[out_n + 1] = str:sub(pos)
|
||||
return out
|
||||
end
|
||||
|
||||
return {
|
||||
wrap = wrap,
|
||||
ensure_width = ensure_width,
|
||||
split = split,
|
||||
}
|
||||
|
@ -108,6 +108,7 @@ local items = {
|
||||
},
|
||||
["some planks"] = {
|
||||
aliases = { "planks", "wooden planks", "wood planks" },
|
||||
material = true,
|
||||
desc = "You could easily craft these planks into sticks.",
|
||||
},
|
||||
["some sticks"] = {
|
||||
|
@ -43,14 +43,18 @@ if cmd == "stop" then
|
||||
for _, speaker in pairs(get_speakers(name)) do speaker.stop() end
|
||||
elseif cmd == "play" then
|
||||
local _, file, name = ...
|
||||
if not file then
|
||||
error("Usage: speaker play <file or url> [speaker]", 0)
|
||||
end
|
||||
|
||||
local speaker = get_speakers(name)[1]
|
||||
|
||||
local handle, err
|
||||
if http and file:match("^https?://") then
|
||||
print("Downloading...")
|
||||
handle, err = http.get{ url = file, binary = true }
|
||||
handle, err = http.get(file)
|
||||
else
|
||||
handle, err = fs.open(file, "rb")
|
||||
handle, err = fs.open(shell.resolve(file), "r")
|
||||
end
|
||||
|
||||
if not handle then
|
||||
@ -128,9 +132,47 @@ elseif cmd == "play" then
|
||||
end
|
||||
|
||||
handle.close()
|
||||
elseif cmd == "sound" then
|
||||
local _, sound, volume, pitch, name = ...
|
||||
|
||||
if not sound then
|
||||
error("Usage: speaker sound <sound> [volume] [pitch] [speaker]", 0)
|
||||
return
|
||||
end
|
||||
|
||||
if volume then
|
||||
volume = tonumber(volume)
|
||||
if not volume then
|
||||
error("Volume must be a number", 0)
|
||||
end
|
||||
if volume < 0 or volume > 3 then
|
||||
error("Volume must be between 0 and 3", 0)
|
||||
end
|
||||
end
|
||||
|
||||
if pitch then
|
||||
pitch = tonumber(pitch)
|
||||
if not pitch then
|
||||
error("Pitch must be a number", 0)
|
||||
end
|
||||
if pitch < 0 or pitch > 2 then
|
||||
error("Pitch must be between 0 and 2", 0)
|
||||
end
|
||||
end
|
||||
|
||||
local speaker = get_speakers(name)[1]
|
||||
|
||||
if speaker.playSound(sound, volume, pitch) then
|
||||
print(("Played sound %q on speaker %q with volume %s and pitch %s."):format(
|
||||
sound, peripheral.getName(speaker), volume or 1, pitch or 1
|
||||
))
|
||||
else
|
||||
error(("Could not play sound %q"):format(sound), 0)
|
||||
end
|
||||
else
|
||||
local programName = arg[0] or fs.getName(shell.getRunningProgram())
|
||||
print("Usage:")
|
||||
print(programName .. " play <file or url> [speaker]")
|
||||
print(programName .. " sound <sound> [volume] [pitch] [speaker]")
|
||||
print(programName .. " stop [speaker]")
|
||||
end
|
||||
|
@ -56,6 +56,14 @@ local function get(url)
|
||||
)
|
||||
|
||||
if response then
|
||||
-- If spam protection is activated, we get redirected to /paste with Content-Type: text/html
|
||||
local headers = response.getResponseHeaders()
|
||||
if not headers["Content-Type"] or not headers["Content-Type"]:find("^text/plain") then
|
||||
io.stderr:write("Failed.\n")
|
||||
print("Pastebin blocked the download due to spam protection. Please complete the captcha in a web browser: https://pastebin.com/" .. textutils.urlEncode(paste))
|
||||
return
|
||||
end
|
||||
|
||||
print("Success.")
|
||||
|
||||
local sResponse = response.readAll()
|
||||
|
@ -35,13 +35,20 @@ local function getFilename(sUrl)
|
||||
return sUrl:match("/([^/]+)$")
|
||||
end
|
||||
|
||||
local function get(sUrl)
|
||||
write("Connecting to " .. sUrl .. "... ")
|
||||
local function get(url)
|
||||
-- Check if the URL is valid
|
||||
local ok, err = http.checkURL(url)
|
||||
if not ok then
|
||||
printError(err or "Invalid URL.")
|
||||
return
|
||||
end
|
||||
|
||||
local response = http.get(sUrl , nil , true)
|
||||
write("Connecting to " .. url .. "... ")
|
||||
|
||||
local response, err = http.get(url)
|
||||
if not response then
|
||||
print("Failed.")
|
||||
return nil
|
||||
printError(err)
|
||||
return
|
||||
end
|
||||
|
||||
print("Success.")
|
||||
|
@ -31,7 +31,7 @@ setmetatable(tEnv, { __index = _ENV })
|
||||
do
|
||||
local make_package = require "cc.require".make
|
||||
local dir = shell.dir()
|
||||
_ENV.require, _ENV.package = make_package(_ENV, dir)
|
||||
tEnv.require, tEnv.package = make_package(tEnv, dir)
|
||||
end
|
||||
|
||||
if term.isColour() then
|
||||
@ -104,7 +104,7 @@ while running do
|
||||
end
|
||||
else
|
||||
printError(results[2])
|
||||
require "cc.internal.exception".report(results[2], results[3], chunk_map)
|
||||
exception.report(results[2], results[3], chunk_map)
|
||||
end
|
||||
else
|
||||
local parser = require "cc.internal.syntax"
|
||||
|
@ -117,7 +117,7 @@ shell.setCompletionFunction("rom/programs/fun/dj.lua", completion.build(
|
||||
completion.peripheral
|
||||
))
|
||||
shell.setCompletionFunction("rom/programs/fun/speaker.lua", completion.build(
|
||||
{ completion.choice, { "play ", "stop " } },
|
||||
{ completion.choice, { "play ", "sound ", "stop " } },
|
||||
function(shell, text, previous)
|
||||
if previous[2] == "play" then return completion.file(shell, text, previous, true)
|
||||
elseif previous[2] == "stop" then return completion.peripheral(shell, text, previous, false)
|
||||
|
2
vendor/Cobalt
vendored
2
vendor/Cobalt
vendored
@ -1 +1 @@
|
||||
Subproject commit 6536189750811a301cff560099dc2ce4ad34316e
|
||||
Subproject commit 4842fa31a12072c630e236cc81496537bb3736ae
|
Loading…
x
Reference in New Issue
Block a user