mirror of
https://github.com/SquidDev-CC/CC-Tweaked
synced 2025-07-07 04:22:53 +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
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.0.1
|
rev: v5.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- 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.gradle.spotless.FormatExtension
|
||||||
import com.diffplug.spotless.LineEnding
|
import com.diffplug.spotless.LineEnding
|
||||||
|
import net.fabricmc.loom.LoomGradleExtension
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.voldeloom)
|
alias(libs.plugins.voldeloom)
|
||||||
alias(libs.plugins.spotless)
|
alias(libs.plugins.spotless)
|
||||||
|
id("com.gradleup.shadow") version "8.3.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
val modVersion: String by extra
|
val modVersion: String by extra
|
||||||
@ -76,14 +78,16 @@ dependencies {
|
|||||||
|
|
||||||
compileOnly("com.google.code.findbugs:jsr305:3.0.2")
|
compileOnly("com.google.code.findbugs:jsr305:3.0.2")
|
||||||
compileOnly("org.jetbrains:annotations:24.0.1")
|
compileOnly("org.jetbrains:annotations:24.0.1")
|
||||||
|
compileOnly(libs.jspecify)
|
||||||
modImplementation("maven.modrinth:computercraft:1.50")
|
modImplementation("maven.modrinth:computercraft:1.50")
|
||||||
"shade"("cc.tweaked:cobalt")
|
"shade"("cc.tweaked:cobalt")
|
||||||
|
"shade"(libs.bundles.netty)
|
||||||
|
|
||||||
"buildTools"("cc.tweaked.cobalt:build-tools")
|
"buildTools"("cc.tweaked.cobalt:build-tools")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Point compileJava to emit to classes/uninstrumentedJava/main, and then add a task to instrument these classes,
|
// 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 mainSource = sourceSets.main.get()
|
||||||
val javaClassesDir = mainSource.java.classesDirectory.get()
|
val javaClassesDir = mainSource.java.classesDirectory.get()
|
||||||
val untransformedClasses = project.layout.buildDirectory.dir("classes/uninstrumentedJava/main")
|
val untransformedClasses = project.layout.buildDirectory.dir("classes/uninstrumentedJava/main")
|
||||||
@ -119,7 +123,16 @@ tasks.withType(AbstractArchiveTask::class.java).configureEach {
|
|||||||
fileMode = Integer.valueOf("664", 8)
|
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 {
|
manifest {
|
||||||
attributes(
|
attributes(
|
||||||
"FMLCorePlugin" to "cc.tweaked.patch.CorePlugin",
|
"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 {
|
tasks.processResources {
|
||||||
|
@ -4,6 +4,6 @@
|
|||||||
|
|
||||||
org.gradle.jvmargs=-Xmx3G
|
org.gradle.jvmargs=-Xmx3G
|
||||||
|
|
||||||
modVersion=1.109.6
|
modVersion=1.115.1
|
||||||
|
|
||||||
mcVersion=1.4.7
|
mcVersion=1.4.7
|
||||||
|
@ -5,24 +5,18 @@
|
|||||||
[versions]
|
[versions]
|
||||||
forge = "1.4.7-6.6.2.534"
|
forge = "1.4.7-6.6.2.534"
|
||||||
|
|
||||||
asm = "9.3"
|
jspecify = "1.0.0"
|
||||||
kotlin = "1.8.10"
|
netty = "4.1.119.Final"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
asm = { module = "org.ow2.asm:asm", version.ref = "asm" }
|
jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" }
|
||||||
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" }
|
|
||||||
|
|
||||||
kotlin-platform = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" }
|
netty-codec = { module = "io.netty:netty-codec", version.ref = "netty" }
|
||||||
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
netty-http = { module = "io.netty:netty-codec-http", version.ref = "netty" }
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
asm = ["asm", "asm-analysis", "asm-commons", "asm-tree", "asm-util"]
|
netty = ["netty-codec", "netty-http"]
|
||||||
kotlin = ["kotlin-stdlib"]
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
|
||||||
spotless = { id = "com.diffplug.spotless", version = "6.19.0" }
|
spotless = { id = "com.diffplug.spotless", version = "6.19.0" }
|
||||||
voldeloom = { id = "agency.highlysuspect.voldeloom", version = "2.4-SNAPSHOT" }
|
voldeloom = { id = "agency.highlysuspect.voldeloom", version = "2.4-SNAPSHOT" }
|
||||||
|
@ -30,6 +30,7 @@ public class ClassTransformer implements IClassTransformer {
|
|||||||
BasicRemapper.builder()
|
BasicRemapper.builder()
|
||||||
.remapType("dan200/computer/core/apis/FSAPI", "dan200/computercraft/core/apis/FSAPI")
|
.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/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")
|
.remapType("dan200/computer/core/apis/TermAPI", "dan200/computercraft/core/apis/TermAPI")
|
||||||
.build().toMethodTransform()
|
.build().toMethodTransform()
|
||||||
)
|
)
|
||||||
|
@ -195,6 +195,19 @@ public abstract class IArguments {
|
|||||||
return LuaValues.encode(getString(index));
|
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.
|
* 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;
|
package dan200.computercraft.api.lua;
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -96,7 +97,7 @@ public final class LuaValues {
|
|||||||
* @return The constructed exception, which should be thrown immediately.
|
* @return The constructed exception, which should be thrown immediately.
|
||||||
*/
|
*/
|
||||||
public static LuaException badTableItem(int index, String expected, String actual) {
|
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.
|
* @return The constructed exception, which should be thrown immediately.
|
||||||
*/
|
*/
|
||||||
public static LuaException badField(String key, String expected, String actual) {
|
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;
|
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.
|
* 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.Method;
|
||||||
import java.lang.reflect.Modifier;
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
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(INVOKEVIRTUAL, INTERNAL_ARGUMENTS, "getStringCoerced", "(I)Ljava/lang/String;");
|
||||||
mw.visitMethodInsn(INVOKESPECIAL, INTERNAL_COERCED, "<init>", "(Ljava/lang/Object;)V");
|
mw.visitMethodInsn(INVOKESPECIAL, INTERNAL_COERCED, "<init>", "(Ljava/lang/Object;)V");
|
||||||
return true;
|
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.computer.core.ILuaObject;
|
||||||
import dan200.computercraft.api.lua.LuaException;
|
import dan200.computercraft.api.lua.LuaException;
|
||||||
import dan200.computercraft.core.asm.Methods;
|
import dan200.computercraft.core.asm.Methods;
|
||||||
|
import dan200.computercraft.core.lua.errorinfo.ErrorInfoLib;
|
||||||
import org.squiddev.cobalt.*;
|
import org.squiddev.cobalt.*;
|
||||||
import org.squiddev.cobalt.compiler.LoadState;
|
import org.squiddev.cobalt.compiler.LoadState;
|
||||||
import org.squiddev.cobalt.function.LuaFunction;
|
import org.squiddev.cobalt.function.LuaFunction;
|
||||||
@ -59,6 +60,7 @@ public class CobaltLuaMachine implements ILuaMachine {
|
|||||||
try {
|
try {
|
||||||
CoreLibraries.debugGlobals(state);
|
CoreLibraries.debugGlobals(state);
|
||||||
Bit32Lib.add(state, globals);
|
Bit32Lib.add(state, globals);
|
||||||
|
ErrorInfoLib.add(state);
|
||||||
|
|
||||||
globals.rawset("_HOST", valueOf("ComputerCraft " + ComputerCraft.getVersion() + " (" + Loader.instance().getMCVersionString() + ")"));
|
globals.rawset("_HOST", valueOf("ComputerCraft " + ComputerCraft.getVersion() + " (" + Loader.instance().getMCVersionString() + ")"));
|
||||||
globals.rawset("_CC_DEFAULT_SETTINGS", valueOf(""));
|
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.
|
--- Returns true if a path is mounted to the parent filesystem.
|
||||||
--
|
--
|
||||||
-- The root filesystem "/" is considered a mount, along with disk folders and
|
-- 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
|
-- the rom folder.
|
||||||
-- make other mount types by correctly assigning their return value for getDrive.
|
|
||||||
--
|
--
|
||||||
-- @tparam string path The path to check.
|
-- @tparam string path The path to check.
|
||||||
-- @treturn boolean If the path is mounted, rather than a normal file/folder.
|
-- @treturn boolean If the path is mounted, rather than a normal file/folder.
|
||||||
-- @throws If the path does not exist.
|
-- @throws If the path does not exist.
|
||||||
-- @see getDrive
|
-- @see getDrive
|
||||||
-- @since 1.87.0
|
-- @since 1.87.0
|
||||||
function fs.isDriveRoot(sPath)
|
function fs.isDriveRoot(path)
|
||||||
expect(1, sPath, "string")
|
expect(1, path, "string")
|
||||||
|
|
||||||
|
local parent = fs.getDir(path)
|
||||||
|
|
||||||
-- Force the root directory to be a mount.
|
-- 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
|
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
|
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.
|
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
|
@module gps
|
||||||
@since 1.31
|
@since 1.31
|
||||||
@ -196,6 +196,8 @@ function locate(_nTimeout, _bDebug)
|
|||||||
modem.close(CHANNEL_GPS)
|
modem.close(CHANNEL_GPS)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
os.cancelTimer(timeout)
|
||||||
|
|
||||||
-- Return the response
|
-- Return the response
|
||||||
if pos1 and pos2 then
|
if pos1 and pos2 then
|
||||||
if _bDebug then
|
if _bDebug then
|
||||||
|
@ -14,28 +14,82 @@ local expect = dofile("rom/modules/main/cc/expect.lua").expect
|
|||||||
local native = http
|
local native = http
|
||||||
local nativeHTTPRequest = http.request
|
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, ...)
|
local function wrap_request(_url, ...)
|
||||||
nativeHTTPRequest(...)
|
local ok, err = nativeHTTPRequest(...)
|
||||||
while true do
|
if ok then
|
||||||
local event, param1, param2, param3 = os.pullEvent()
|
while true do
|
||||||
if event == "http_success" and param1 == _url then
|
local event, param1, param2, param3 = os.pullEvent()
|
||||||
return param2
|
if event == "http_success" and param1 == _url then
|
||||||
elseif event == "http_failure" and param1 == _url then
|
return param2
|
||||||
return nil, param2, param3
|
elseif event == "http_failure" and param1 == _url then
|
||||||
|
return nil, param2, param3
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
return nil, err
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[- Make a HTTP GET request to the given url.
|
--[[- Make a HTTP GET request to the given url.
|
||||||
|
|
||||||
@tparam string url The url to request
|
@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 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
|
@treturn[2] nil When the http request failed, such as in the event of a 404
|
||||||
error or connection timeout.
|
error or connection timeout.
|
||||||
@treturn string A message detailing why the request failed.
|
@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),
|
@usage Make a request to [example.tweaked.cc](https://example.tweaked.cc),
|
||||||
and print the returned page.
|
and print the returned page.
|
||||||
@ -47,25 +101,287 @@ print(request.readAll())
|
|||||||
request.close()
|
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")
|
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
|
end
|
||||||
|
|
||||||
--[[- Make a HTTP POST request to the given url.
|
--[[- Make a HTTP POST request to the given url.
|
||||||
|
|
||||||
@tparam string url The url to request
|
@tparam string url The url to request
|
||||||
@tparam string body The body of the POST 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 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
|
@treturn[2] nil When the http request failed, such as in the event of a 404
|
||||||
error or connection timeout.
|
error or connection timeout.
|
||||||
@treturn string A message detailing why the request failed.
|
@treturn string A message detailing why the request failed.
|
||||||
|
@treturn Response|nil The failing http response, if available.
|
||||||
|
|
||||||
@since 1.31
|
@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(1, _url, "string")
|
||||||
expect(2, _post, "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
|
end
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
--
|
--
|
||||||
-- SPDX-License-Identifier: LicenseRef-CCPL
|
-- 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
|
-- 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
|
-- 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
|
return minX, maxX, minY, maxY
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Parses an image from a multi-line string
|
--[=[- Parses an image from a multi-line string
|
||||||
--
|
|
||||||
-- @tparam string image The string containing the raw-image data.
|
@tparam string image The string containing the raw-image data.
|
||||||
-- @treturn table The parsed image data, suitable for use with
|
@treturn table The parsed image data, suitable for use with [`paintutils.drawImage`].
|
||||||
-- [`paintutils.drawImage`].
|
@usage Parse an image from a string, and draw it.
|
||||||
-- @since 1.80pr1
|
|
||||||
|
local image = paintutils.parseImage([[
|
||||||
|
e e
|
||||||
|
|
||||||
|
e e
|
||||||
|
eeee
|
||||||
|
]])
|
||||||
|
paintutils.drawImage(image, term.getCursorPos())
|
||||||
|
|
||||||
|
@since 1.80pr1
|
||||||
|
]=]
|
||||||
function parseImage(image)
|
function parseImage(image)
|
||||||
expect(1, image, "string")
|
expect(1, image, "string")
|
||||||
local tImage = {}
|
local tImage = {}
|
||||||
|
@ -39,60 +39,55 @@ the other.
|
|||||||
@since 1.2
|
@since 1.2
|
||||||
]]
|
]]
|
||||||
|
|
||||||
|
local exception = dofile("rom/modules/main/cc/internal/tiny_require.lua")("cc.internal.exception")
|
||||||
|
|
||||||
local function create(...)
|
local function create(...)
|
||||||
local tFns = table.pack(...)
|
local barrier_ctx = { co = coroutine.running() }
|
||||||
local tCos = {}
|
|
||||||
for i = 1, tFns.n, 1 do
|
local functions = table.pack(...)
|
||||||
local fn = tFns[i]
|
local threads = {}
|
||||||
|
for i = 1, functions.n, 1 do
|
||||||
|
local fn = functions[i]
|
||||||
if type(fn) ~= "function" then
|
if type(fn) ~= "function" then
|
||||||
error("bad argument #" .. i .. " (function expected, got " .. type(fn) .. ")", 3)
|
error("bad argument #" .. i .. " (function expected, got " .. type(fn) .. ")", 3)
|
||||||
end
|
end
|
||||||
|
|
||||||
tCos[i] = coroutine.create(fn)
|
threads[i] = { co = coroutine.create(function() return exception.try_barrier(barrier_ctx, fn) end), filter = nil }
|
||||||
end
|
end
|
||||||
|
|
||||||
return tCos
|
return threads
|
||||||
end
|
end
|
||||||
|
|
||||||
local function runUntilLimit(_routines, _limit)
|
local function runUntilLimit(threads, limit)
|
||||||
local count = #_routines
|
local count = #threads
|
||||||
if count < 1 then return 0 end
|
if count < 1 then return 0 end
|
||||||
local living = count
|
local living = count
|
||||||
|
|
||||||
local tFilters = {}
|
local event = { n = 0 }
|
||||||
local eventData = { n = 0 }
|
|
||||||
while true do
|
while true do
|
||||||
for n = 1, count do
|
for i = 1, count do
|
||||||
local r = _routines[n]
|
local thread = threads[i]
|
||||||
if r then
|
if thread and (thread.filter == nil or thread.filter == event[1] or event[1] == "terminate") then
|
||||||
if tFilters[r] == nil or tFilters[r] == eventData[1] or eventData[1] == "terminate" then
|
local ok, param = coroutine.resume(thread.co, table.unpack(event, 1, event.n))
|
||||||
local ok, param = coroutine.resume(r, table.unpack(eventData, 1, eventData.n))
|
if ok then
|
||||||
if not ok then
|
thread.filter = param
|
||||||
error(param, 0)
|
elseif type(param) == "string" and exception.can_wrap_errors() then
|
||||||
else
|
error(exception.make_exception(param, thread.co))
|
||||||
tFilters[r] = param
|
else
|
||||||
end
|
error(param, 0)
|
||||||
if coroutine.status(r) == "dead" then
|
end
|
||||||
_routines[n] = nil
|
|
||||||
living = living - 1
|
if coroutine.status(thread.co) == "dead" then
|
||||||
if living <= _limit then
|
threads[i] = false
|
||||||
return n
|
living = living - 1
|
||||||
end
|
if living <= limit then
|
||||||
|
return i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
for n = 1, count do
|
|
||||||
local r = _routines[n]
|
event = table.pack(os.pullEventRaw())
|
||||||
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())
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -120,8 +115,8 @@ from the [`parallel.waitForAny`] call.
|
|||||||
print("Everything done!")
|
print("Everything done!")
|
||||||
]]
|
]]
|
||||||
function waitForAny(...)
|
function waitForAny(...)
|
||||||
local routines = create(...)
|
local threads = create(...)
|
||||||
return runUntilLimit(routines, #routines - 1)
|
return runUntilLimit(threads, #threads - 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[- Switches between execution of the functions, until all of them are
|
--[[- Switches between execution of the functions, until all of them are
|
||||||
@ -144,6 +139,6 @@ from the [`parallel.waitForAll`] call.
|
|||||||
print("Everything done!")
|
print("Everything done!")
|
||||||
]]
|
]]
|
||||||
function waitForAll(...)
|
function waitForAll(...)
|
||||||
local routines = create(...)
|
local threads = create(...)
|
||||||
return runUntilLimit(routines, 0)
|
return runUntilLimit(threads, 0)
|
||||||
end
|
end
|
||||||
|
@ -149,7 +149,7 @@ function isOpen(modem)
|
|||||||
end
|
end
|
||||||
|
|
||||||
--[[- Allows a computer or turtle with an attached modem to send a message
|
--[[- 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.
|
be [opened][`rednet.open`] before sending is possible.
|
||||||
|
|
||||||
Assuming the target was in range and also had a correctly opened modem, the
|
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
|
-- Return the first matching rednet_message
|
||||||
local sender_id, message, protocol = p1, p2, p3
|
local sender_id, message, protocol = p1, p2, p3
|
||||||
if protocol_filter == nil or protocol == protocol_filter then
|
if protocol_filter == nil or protocol == protocol_filter then
|
||||||
|
if timer then os.cancelTimer(timer) end
|
||||||
return sender_id, message, protocol
|
return sender_id, message, protocol
|
||||||
end
|
end
|
||||||
elseif event == "timer" then
|
elseif event == "timer" then
|
||||||
@ -431,6 +432,7 @@ function lookup(protocol, hostname)
|
|||||||
if hostname == nil then
|
if hostname == nil then
|
||||||
table.insert(results, sender_id)
|
table.insert(results, sender_id)
|
||||||
elseif message.sHostname == hostname then
|
elseif message.sHostname == hostname then
|
||||||
|
os.cancelTimer(timer)
|
||||||
return sender_id
|
return sender_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -440,6 +442,9 @@ function lookup(protocol, hostname)
|
|||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
os.cancelTimer(timer)
|
||||||
|
|
||||||
if results then
|
if results then
|
||||||
return table.unpack(results)
|
return table.unpack(results)
|
||||||
end
|
end
|
||||||
|
@ -19,7 +19,7 @@ When a computer starts, it reads the current value of settings from the
|
|||||||
settings.define("my.setting", {
|
settings.define("my.setting", {
|
||||||
description = "An example setting",
|
description = "An example setting",
|
||||||
default = 123,
|
default = 123,
|
||||||
type = number,
|
type = "number",
|
||||||
})
|
})
|
||||||
print("my.setting = " .. settings.get("my.setting")) -- 123
|
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
|
-- - `default`: A default value, which is returned by [`settings.get`] if the
|
||||||
-- setting has not been changed.
|
-- setting has not been changed.
|
||||||
-- - `type`: Require values to be of this type. [Setting][`set`] the value to another type
|
-- - `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
|
-- @since 1.87.0
|
||||||
function define(name, options)
|
function define(name, options)
|
||||||
expect(1, name, "string")
|
expect(1, name, "string")
|
||||||
@ -183,7 +183,7 @@ function unset(name)
|
|||||||
end
|
end
|
||||||
|
|
||||||
--- Resets the value of all settings. Equivalent to calling [`settings.unset`]
|
--- Resets the value of all settings. Equivalent to calling [`settings.unset`]
|
||||||
--- on every setting.
|
-- on every setting.
|
||||||
--
|
--
|
||||||
-- @see settings.unset
|
-- @see settings.unset
|
||||||
function clear()
|
function clear()
|
||||||
@ -213,16 +213,16 @@ end
|
|||||||
-- Existing settings will be merged with any pre-existing ones. Conflicting
|
-- Existing settings will be merged with any pre-existing ones. Conflicting
|
||||||
-- entries will be overwritten, but any others will be preserved.
|
-- 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
|
-- @treturn boolean Whether settings were successfully read from this
|
||||||
-- file. Reasons for failure may include the file not existing or being
|
-- file. Reasons for failure may include the file not existing or being
|
||||||
-- corrupted.
|
-- corrupted.
|
||||||
--
|
--
|
||||||
-- @see settings.save
|
-- @see settings.save
|
||||||
-- @changed 1.87.0 `sPath` is now optional.
|
-- @changed 1.87.0 `path` is now optional.
|
||||||
function load(sPath)
|
function load(path)
|
||||||
expect(1, sPath, "string", "nil")
|
expect(1, path, "string", "nil")
|
||||||
local file = fs.open(sPath or ".settings", "r")
|
local file = fs.open(path or ".settings", "r")
|
||||||
if not file then
|
if not file then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
@ -255,14 +255,14 @@ end
|
|||||||
-- This will entirely overwrite the pre-existing file. Settings defined in the
|
-- This will entirely overwrite the pre-existing file. Settings defined in the
|
||||||
-- file, but not currently loaded will be removed.
|
-- 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.
|
-- @treturn boolean If the settings were successfully saved.
|
||||||
--
|
--
|
||||||
-- @see settings.load
|
-- @see settings.load
|
||||||
-- @changed 1.87.0 `sPath` is now optional.
|
-- @changed 1.87.0 `path` is now optional.
|
||||||
function save(sPath)
|
function save(path)
|
||||||
expect(1, sPath, "string", "nil")
|
expect(1, path, "string", "nil")
|
||||||
local file = fs.open(sPath or ".settings", "w")
|
local file = fs.open(path or ".settings", "w")
|
||||||
if not file then
|
if not file then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
@ -7,9 +7,11 @@
|
|||||||
-- @module textutils
|
-- @module textutils
|
||||||
-- @since 1.2
|
-- @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 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,
|
--- Slowly writes string text at current cursor position,
|
||||||
-- character-by-character.
|
-- character-by-character.
|
||||||
@ -847,13 +849,32 @@ unserialise = unserialize -- GB version
|
|||||||
|
|
||||||
--[[- Returns a JSON representation of the given data.
|
--[[- 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
|
This is largely intended for interacting with various functions from the
|
||||||
[`commands`] API, though may also be used in making [`http`] requests.
|
[`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
|
@param[1] t The value to serialise. Like [`textutils.serialise`], this should not
|
||||||
contain recursive tables or functions.
|
contain recursive tables or functions.
|
||||||
@tparam[1,opt] {
|
@tparam[1,opt] {
|
||||||
@ -921,22 +942,21 @@ unserialiseJSON = unserialise_json
|
|||||||
-- @since 1.31
|
-- @since 1.31
|
||||||
function urlEncode(str)
|
function urlEncode(str)
|
||||||
expect(1, str, "string")
|
expect(1, str, "string")
|
||||||
if str then
|
local gsub, byte, format, band, arshift = string.gsub, string.byte, string.format, bit32.band, bit32.arshift
|
||||||
str = string.gsub(str, "\n", "\r\n")
|
|
||||||
str = string.gsub(str, "([^A-Za-z0-9 %-%_%.])", function(c)
|
str = gsub(str, "\n", "\r\n")
|
||||||
local n = string.byte(c)
|
str = gsub(str, "[^A-Za-z0-9%-%_%.]", function(c)
|
||||||
if n < 128 then
|
if c == " " then return "+" end
|
||||||
-- ASCII
|
|
||||||
return string.format("%%%02X", n)
|
local n = byte(c)
|
||||||
else
|
if n < 128 then
|
||||||
-- Non-ASCII (encode as UTF-8)
|
-- ASCII
|
||||||
return
|
return format("%%%02X", n)
|
||||||
string.format("%%%02X", 192 + bit32.band(bit32.arshift(n, 6), 31)) ..
|
else
|
||||||
string.format("%%%02X", 128 + bit32.band(n, 63))
|
-- Non-ASCII (encode as UTF-8)
|
||||||
end
|
return format("%%%02X%%%02X", 192 + band(arshift(n, 6), 31), 128 + band(n, 63))
|
||||||
end)
|
end
|
||||||
str = string.gsub(str, " ", "+")
|
end)
|
||||||
end
|
|
||||||
return str
|
return str
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -13,6 +13,11 @@
|
|||||||
-- @module vector
|
-- @module vector
|
||||||
-- @since 1.31
|
-- @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.
|
--- A 3-dimensional vector, with `x`, `y`, and `z` values.
|
||||||
--
|
--
|
||||||
-- This is suitable for representing both position and directional vectors.
|
-- This is suitable for representing both position and directional vectors.
|
||||||
@ -27,6 +32,9 @@ local vector = {
|
|||||||
-- @usage v1:add(v2)
|
-- @usage v1:add(v2)
|
||||||
-- @usage v1 + v2
|
-- @usage v1 + v2
|
||||||
add = function(self, o)
|
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(
|
return vector.new(
|
||||||
self.x + o.x,
|
self.x + o.x,
|
||||||
self.y + o.y,
|
self.y + o.y,
|
||||||
@ -42,6 +50,9 @@ local vector = {
|
|||||||
-- @usage v1:sub(v2)
|
-- @usage v1:sub(v2)
|
||||||
-- @usage v1 - v2
|
-- @usage v1 - v2
|
||||||
sub = function(self, o)
|
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(
|
return vector.new(
|
||||||
self.x - o.x,
|
self.x - o.x,
|
||||||
self.y - o.y,
|
self.y - o.y,
|
||||||
@ -52,30 +63,36 @@ local vector = {
|
|||||||
--- Multiplies a vector by a scalar value.
|
--- Multiplies a vector by a scalar value.
|
||||||
--
|
--
|
||||||
-- @tparam Vector self The vector to multiply.
|
-- @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)`.
|
-- @treturn Vector A vector with value `(x * m, y * m, z * m)`.
|
||||||
-- @usage v:mul(3)
|
-- @usage vector.new(1, 2, 3):mul(3)
|
||||||
-- @usage v * 3
|
-- @usage vector.new(1, 2, 3) * 3
|
||||||
mul = function(self, m)
|
mul = function(self, factor)
|
||||||
|
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||||
|
expect(2, factor, "number")
|
||||||
|
|
||||||
return vector.new(
|
return vector.new(
|
||||||
self.x * m,
|
self.x * factor,
|
||||||
self.y * m,
|
self.y * factor,
|
||||||
self.z * m
|
self.z * factor
|
||||||
)
|
)
|
||||||
end,
|
end,
|
||||||
|
|
||||||
--- Divides a vector by a scalar value.
|
--- Divides a vector by a scalar value.
|
||||||
--
|
--
|
||||||
-- @tparam Vector self The vector to divide.
|
-- @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)`.
|
-- @treturn Vector A vector with value `(x / m, y / m, z / m)`.
|
||||||
-- @usage v:div(3)
|
-- @usage vector.new(1, 2, 3):div(3)
|
||||||
-- @usage v / 3
|
-- @usage vector.new(1, 2, 3) / 3
|
||||||
div = function(self, m)
|
div = function(self, factor)
|
||||||
|
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||||
|
expect(2, factor, "number")
|
||||||
|
|
||||||
return vector.new(
|
return vector.new(
|
||||||
self.x / m,
|
self.x / factor,
|
||||||
self.y / m,
|
self.y / factor,
|
||||||
self.z / m
|
self.z / factor
|
||||||
)
|
)
|
||||||
end,
|
end,
|
||||||
|
|
||||||
@ -83,8 +100,9 @@ local vector = {
|
|||||||
--
|
--
|
||||||
-- @tparam Vector self The vector to negate.
|
-- @tparam Vector self The vector to negate.
|
||||||
-- @treturn Vector The negated vector.
|
-- @treturn Vector The negated vector.
|
||||||
-- @usage -v
|
-- @usage -vector.new(1, 2, 3)
|
||||||
unm = function(self)
|
unm = function(self)
|
||||||
|
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||||
return vector.new(
|
return vector.new(
|
||||||
-self.x,
|
-self.x,
|
||||||
-self.y,
|
-self.y,
|
||||||
@ -96,9 +114,12 @@ local vector = {
|
|||||||
--
|
--
|
||||||
-- @tparam Vector self The first vector to compute the dot product of.
|
-- @tparam Vector self The first vector to compute the dot product of.
|
||||||
-- @tparam Vector o The second 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)
|
-- @usage v1:dot(v2)
|
||||||
dot = function(self, o)
|
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
|
return self.x * o.x + self.y * o.y + self.z * o.z
|
||||||
end,
|
end,
|
||||||
|
|
||||||
@ -109,6 +130,9 @@ local vector = {
|
|||||||
-- @treturn Vector The cross product of `self` and `o`.
|
-- @treturn Vector The cross product of `self` and `o`.
|
||||||
-- @usage v1:cross(v2)
|
-- @usage v1:cross(v2)
|
||||||
cross = function(self, o)
|
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(
|
return vector.new(
|
||||||
self.y * o.z - self.z * o.y,
|
self.y * o.z - self.z * o.y,
|
||||||
self.z * o.x - self.x * o.z,
|
self.z * o.x - self.x * o.z,
|
||||||
@ -120,6 +144,7 @@ local vector = {
|
|||||||
-- @tparam Vector self This vector.
|
-- @tparam Vector self This vector.
|
||||||
-- @treturn number The length of this vector.
|
-- @treturn number The length of this vector.
|
||||||
length = function(self)
|
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)
|
return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
|
||||||
end,
|
end,
|
||||||
|
|
||||||
@ -141,6 +166,9 @@ local vector = {
|
|||||||
-- nearest 0.5.
|
-- nearest 0.5.
|
||||||
-- @treturn Vector The rounded vector.
|
-- @treturn Vector The rounded vector.
|
||||||
round = function(self, tolerance)
|
round = function(self, tolerance)
|
||||||
|
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||||
|
expect(2, tolerance, "number", "nil")
|
||||||
|
|
||||||
tolerance = tolerance or 1.0
|
tolerance = tolerance or 1.0
|
||||||
return vector.new(
|
return vector.new(
|
||||||
math.floor((self.x + tolerance * 0.5) / tolerance) * tolerance,
|
math.floor((self.x + tolerance * 0.5) / tolerance) * tolerance,
|
||||||
@ -156,6 +184,8 @@ local vector = {
|
|||||||
-- @usage v:tostring()
|
-- @usage v:tostring()
|
||||||
-- @usage tostring(v)
|
-- @usage tostring(v)
|
||||||
tostring = function(self)
|
tostring = function(self)
|
||||||
|
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
|
||||||
|
|
||||||
return self.x .. "," .. self.y .. "," .. self.z
|
return self.x .. "," .. self.y .. "," .. self.z
|
||||||
end,
|
end,
|
||||||
|
|
||||||
@ -165,11 +195,15 @@ local vector = {
|
|||||||
-- @tparam Vector other The second vector to compare to.
|
-- @tparam Vector other The second vector to compare to.
|
||||||
-- @treturn boolean Whether or not the vectors are equal.
|
-- @treturn boolean Whether or not the vectors are equal.
|
||||||
equals = function(self, other)
|
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
|
return self.x == other.x and self.y == other.y and self.z == other.z
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
local vmetatable = {
|
vmetatable = {
|
||||||
|
__name = "vector",
|
||||||
__index = vector,
|
__index = vector,
|
||||||
__add = vector.add,
|
__add = vector.add,
|
||||||
__sub = vector.sub,
|
__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
|
# New features in CC: Tweaked 1.109.6
|
||||||
|
|
||||||
* Improve several Lua parser error messages.
|
* Improve several Lua parser error messages.
|
||||||
@ -724,7 +881,7 @@ And several bug fixes:
|
|||||||
# New features in CC: Tweaked 1.86.2
|
# New features in CC: Tweaked 1.86.2
|
||||||
|
|
||||||
* Fix peripheral.getMethods returning an empty table.
|
* 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
|
# 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 now compare items in their inventories
|
||||||
* Turtles can place signs with text on them with `turtle.place( [signText] )`
|
* Turtles can place signs with text on them with `turtle.place( [signText] )`
|
||||||
* Turtles now optionally require fuel items to move, and can refuel themselves
|
* 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
|
* 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 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()
|
* 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.
|
* Update various translations (cyb3r, kevk2156, teamer337, yakku).
|
||||||
* Allow addon mods to register `require`able modules.
|
* Support Fabric's item lookup API for registering media providers.
|
||||||
|
|
||||||
Several bug fixes:
|
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.
|
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.
|
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
|
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
|
format compared to raw PCM data, only using 1 bit per sample, but is simple enough to encode and decode in real time.
|
||||||
in real time.
|
|
||||||
|
|
||||||
Typically DFPWM audio is read from [the filesystem][`fs.BinaryReadHandle`] or a [a web request][`http.Response`] as a
|
Typically DFPWM audio is read from [the filesystem][`fs.ReadHandle`] or a [a web request][`http.Response`] as a string,
|
||||||
string, and converted a format suitable for [`speaker.playAudio`].
|
and converted a format suitable for [`speaker.playAudio`].
|
||||||
|
|
||||||
## Encoding and decoding files
|
## 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.
|
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
|
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.
|
for each one you write.
|
||||||
|
|
||||||
## Converting audio to DFPWM
|
## 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
|
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"
|
[music.madefor.cc]: https://music.madefor.cc/ "DFPWM audio converter for Computronics and CC: Tweaked"
|
||||||
[LionRay]: https://github.com/gamax92/LionRay/ "LionRay Wav Converter "
|
[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.
|
--[[- 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.
|
you should use an encoder returned by [`make_encoder`] instead.
|
||||||
|
|
||||||
@tparam { number... } input The table of amplitude data.
|
@tparam { number... } input The table of amplitude data.
|
||||||
|
@ -118,8 +118,8 @@ end
|
|||||||
--- Expect a number to be within a specific range.
|
--- Expect a number to be within a specific range.
|
||||||
--
|
--
|
||||||
-- @tparam number num The value to check.
|
-- @tparam number num The value to check.
|
||||||
-- @tparam number min The minimum value, if nil then `-math.huge` is used.
|
-- @tparam[opt=-math.huge] number min The minimum value.
|
||||||
-- @tparam number max The maximum value, if nil then `math.huge` is used.
|
-- @tparam[opt=math.huge] number max The maximum value.
|
||||||
-- @return The given `value`.
|
-- @return The given `value`.
|
||||||
-- @throws If the value is outside of the allowed range.
|
-- @throws If the value is outside of the allowed range.
|
||||||
-- @since 1.96.0
|
-- @since 1.96.0
|
||||||
|
@ -89,7 +89,7 @@ end
|
|||||||
--
|
--
|
||||||
-- @tparam table image An image, as returned from [`load`] or [`parse`].
|
-- @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 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
|
-- @tparam[opt] term.Redirect target The terminal redirect to draw to. Defaults to the
|
||||||
-- current terminal.
|
-- current terminal.
|
||||||
local function draw(image, xPos, yPos, target)
|
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 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)
|
local function find_frame(thread, file, line)
|
||||||
-- Scan the first 16 frames for something interesting.
|
-- 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 not frame then break end
|
||||||
|
|
||||||
if frame.short_src == file and frame.what ~= "C" and frame.currentline == line then
|
if frame.short_src == file and frame.what ~= "C" and frame.currentline == line then
|
||||||
return frame
|
return offset, frame
|
||||||
end
|
end
|
||||||
end
|
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.
|
--[[- Attempt to call the provided function `func` with the provided arguments.
|
||||||
|
|
||||||
@tparam function func The function to call.
|
@tparam function func The function to call.
|
||||||
@param ... Arguments to this function.
|
@param ... Arguments to this function.
|
||||||
|
|
||||||
@treturn[1] true If the function ran successfully.
|
@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.
|
@treturn[2] false If the function failed.
|
||||||
@return[2] The error message
|
@return[2] The error message
|
||||||
@ -41,8 +159,8 @@ end
|
|||||||
local function try(func, ...)
|
local function try(func, ...)
|
||||||
expect(1, func, "function")
|
expect(1, func, "function")
|
||||||
|
|
||||||
local co = coroutine.create(func)
|
local co = coroutine.create(try_barrier)
|
||||||
local result = table.pack(coroutine.resume(co, ...))
|
local result = table.pack(coroutine.resume(co, { co = co, can_wrap = true }, func, ...))
|
||||||
|
|
||||||
while coroutine.status(co) ~= "dead" do
|
while coroutine.status(co) ~= "dead" do
|
||||||
local event = table.pack(os.pullEventRaw(result[2]))
|
local event = table.pack(os.pullEventRaw(result[2]))
|
||||||
@ -51,8 +169,14 @@ local function try(func, ...)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if not result[1] then return false, result[2], co end
|
if result[1] then
|
||||||
return table.unpack(result, 1, result.n)
|
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
|
end
|
||||||
|
|
||||||
--[[- Report additional context about an error.
|
--[[- Report additional context about an error.
|
||||||
@ -67,11 +191,11 @@ local function report(err, thread, source_map)
|
|||||||
|
|
||||||
if type(err) ~= "string" then return end
|
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
|
if not file then return end
|
||||||
line = tonumber(line)
|
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
|
if not frame or not frame.currentcolumn then return end
|
||||||
|
|
||||||
local column = frame.currentcolumn
|
local column = frame.currentcolumn
|
||||||
@ -108,16 +232,22 @@ local function report(err, thread, source_map)
|
|||||||
-- Could not determine the line. Bail.
|
-- Could not determine the line. Bail.
|
||||||
if not line_contents or #line_contents == "" then return end
|
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_pos = function() return line, column end,
|
||||||
get_line = function() return line_contents end,
|
get_line = function() return line_contents end,
|
||||||
}, {
|
}, {
|
||||||
{ tag = "annotate", start_pos = column, end_pos = column, msg = "" },
|
{ tag = "annotate", start_pos = column, end_pos = column, msg = "" },
|
||||||
|
require "cc.internal.error_hints".get_tip(err, thread, frame_offset),
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
make_exception = make_exception,
|
||||||
|
|
||||||
|
try_barrier = try_barrier,
|
||||||
|
can_wrap_errors = can_wrap_errors,
|
||||||
|
|
||||||
try = try,
|
try = try,
|
||||||
report = report,
|
report = report,
|
||||||
}
|
}
|
||||||
|
@ -284,6 +284,23 @@ function errors.wrong_ne(start_pos, end_pos)
|
|||||||
}
|
}
|
||||||
end
|
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.
|
--[[- An unexpected character was used.
|
||||||
|
|
||||||
@tparam number pos The position of this character.
|
@tparam number pos The position of this character.
|
||||||
|
@ -327,6 +327,9 @@ local function lex_token(context, str, pos)
|
|||||||
elseif contents == "!=" or contents == "<>" then
|
elseif contents == "!=" or contents == "<>" then
|
||||||
context.report(errors.wrong_ne, pos, end_pos)
|
context.report(errors.wrong_ne, pos, end_pos)
|
||||||
return tokens.NE, 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
|
||||||
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
|
-- @since 1.95.0
|
||||||
-- @see textutils For additional string related utilities.
|
-- @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.
|
--[[- 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(1, text, "string")
|
||||||
expect(2, width, "number", "nil")
|
expect(2, width, "number", "nil")
|
||||||
width = width or term.getSize()
|
width = width or term.getSize()
|
||||||
|
range(width, 1)
|
||||||
|
|
||||||
local lines, lines_n, current_line = {}, 0, ""
|
local lines, lines_n, current_line = {}, 0, ""
|
||||||
local function push_line()
|
local function push_line()
|
||||||
@ -109,7 +110,63 @@ local function ensure_width(line, width)
|
|||||||
return line
|
return line
|
||||||
end
|
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 {
|
return {
|
||||||
wrap = wrap,
|
wrap = wrap,
|
||||||
ensure_width = ensure_width,
|
ensure_width = ensure_width,
|
||||||
|
split = split,
|
||||||
}
|
}
|
||||||
|
@ -108,6 +108,7 @@ local items = {
|
|||||||
},
|
},
|
||||||
["some planks"] = {
|
["some planks"] = {
|
||||||
aliases = { "planks", "wooden planks", "wood planks" },
|
aliases = { "planks", "wooden planks", "wood planks" },
|
||||||
|
material = true,
|
||||||
desc = "You could easily craft these planks into sticks.",
|
desc = "You could easily craft these planks into sticks.",
|
||||||
},
|
},
|
||||||
["some sticks"] = {
|
["some sticks"] = {
|
||||||
|
@ -43,14 +43,18 @@ if cmd == "stop" then
|
|||||||
for _, speaker in pairs(get_speakers(name)) do speaker.stop() end
|
for _, speaker in pairs(get_speakers(name)) do speaker.stop() end
|
||||||
elseif cmd == "play" then
|
elseif cmd == "play" then
|
||||||
local _, file, name = ...
|
local _, file, name = ...
|
||||||
|
if not file then
|
||||||
|
error("Usage: speaker play <file or url> [speaker]", 0)
|
||||||
|
end
|
||||||
|
|
||||||
local speaker = get_speakers(name)[1]
|
local speaker = get_speakers(name)[1]
|
||||||
|
|
||||||
local handle, err
|
local handle, err
|
||||||
if http and file:match("^https?://") then
|
if http and file:match("^https?://") then
|
||||||
print("Downloading...")
|
print("Downloading...")
|
||||||
handle, err = http.get{ url = file, binary = true }
|
handle, err = http.get(file)
|
||||||
else
|
else
|
||||||
handle, err = fs.open(file, "rb")
|
handle, err = fs.open(shell.resolve(file), "r")
|
||||||
end
|
end
|
||||||
|
|
||||||
if not handle then
|
if not handle then
|
||||||
@ -128,9 +132,47 @@ elseif cmd == "play" then
|
|||||||
end
|
end
|
||||||
|
|
||||||
handle.close()
|
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
|
else
|
||||||
local programName = arg[0] or fs.getName(shell.getRunningProgram())
|
local programName = arg[0] or fs.getName(shell.getRunningProgram())
|
||||||
print("Usage:")
|
print("Usage:")
|
||||||
print(programName .. " play <file or url> [speaker]")
|
print(programName .. " play <file or url> [speaker]")
|
||||||
|
print(programName .. " sound <sound> [volume] [pitch] [speaker]")
|
||||||
print(programName .. " stop [speaker]")
|
print(programName .. " stop [speaker]")
|
||||||
end
|
end
|
||||||
|
@ -56,6 +56,14 @@ local function get(url)
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response then
|
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.")
|
print("Success.")
|
||||||
|
|
||||||
local sResponse = response.readAll()
|
local sResponse = response.readAll()
|
||||||
|
@ -35,13 +35,20 @@ local function getFilename(sUrl)
|
|||||||
return sUrl:match("/([^/]+)$")
|
return sUrl:match("/([^/]+)$")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function get(sUrl)
|
local function get(url)
|
||||||
write("Connecting to " .. sUrl .. "... ")
|
-- 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
|
if not response then
|
||||||
print("Failed.")
|
printError(err)
|
||||||
return nil
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
print("Success.")
|
print("Success.")
|
||||||
|
@ -31,7 +31,7 @@ setmetatable(tEnv, { __index = _ENV })
|
|||||||
do
|
do
|
||||||
local make_package = require "cc.require".make
|
local make_package = require "cc.require".make
|
||||||
local dir = shell.dir()
|
local dir = shell.dir()
|
||||||
_ENV.require, _ENV.package = make_package(_ENV, dir)
|
tEnv.require, tEnv.package = make_package(tEnv, dir)
|
||||||
end
|
end
|
||||||
|
|
||||||
if term.isColour() then
|
if term.isColour() then
|
||||||
@ -104,7 +104,7 @@ while running do
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
printError(results[2])
|
printError(results[2])
|
||||||
require "cc.internal.exception".report(results[2], results[3], chunk_map)
|
exception.report(results[2], results[3], chunk_map)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
local parser = require "cc.internal.syntax"
|
local parser = require "cc.internal.syntax"
|
||||||
|
@ -117,7 +117,7 @@ shell.setCompletionFunction("rom/programs/fun/dj.lua", completion.build(
|
|||||||
completion.peripheral
|
completion.peripheral
|
||||||
))
|
))
|
||||||
shell.setCompletionFunction("rom/programs/fun/speaker.lua", completion.build(
|
shell.setCompletionFunction("rom/programs/fun/speaker.lua", completion.build(
|
||||||
{ completion.choice, { "play ", "stop " } },
|
{ completion.choice, { "play ", "sound ", "stop " } },
|
||||||
function(shell, text, previous)
|
function(shell, text, previous)
|
||||||
if previous[2] == "play" then return completion.file(shell, text, previous, true)
|
if previous[2] == "play" then return completion.file(shell, text, previous, true)
|
||||||
elseif previous[2] == "stop" then return completion.peripheral(shell, text, previous, false)
|
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