1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-04-23 03:03:19 +00:00

Update to CC:T 1.115.0

- Sync Lua files
 - Backport our Netty HTTP library.
This commit is contained in:
Jonathan Coates 2025-04-06 13:44:46 +01:00
parent fbf64a0404
commit 22c094192b
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
66 changed files with 4478 additions and 179 deletions

View File

@ -6,7 +6,7 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer

18
LICENSES/MIT.txt Normal file
View 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.

View File

@ -4,11 +4,13 @@
import com.diffplug.gradle.spotless.FormatExtension
import com.diffplug.spotless.LineEnding
import net.fabricmc.loom.LoomGradleExtension
import java.nio.charset.StandardCharsets
plugins {
alias(libs.plugins.voldeloom)
alias(libs.plugins.spotless)
id("com.gradleup.shadow") version "8.3.5"
}
val modVersion: String by extra
@ -76,14 +78,16 @@ dependencies {
compileOnly("com.google.code.findbugs:jsr305:3.0.2")
compileOnly("org.jetbrains:annotations:24.0.1")
compileOnly(libs.jspecify)
modImplementation("maven.modrinth:computercraft:1.50")
"shade"("cc.tweaked:cobalt")
"shade"(libs.bundles.netty)
"buildTools"("cc.tweaked.cobalt:build-tools")
}
// Point compileJava to emit to classes/uninstrumentedJava/main, and then add a task to instrument these classes,
// saving them back to the the original class directory. This is held together with so much string :(.
// saving them back to the original class directory. This is held together with so much string :(.
val mainSource = sourceSets.main.get()
val javaClassesDir = mainSource.java.classesDirectory.get()
val untransformedClasses = project.layout.buildDirectory.dir("classes/uninstrumentedJava/main")
@ -119,7 +123,16 @@ tasks.withType(AbstractArchiveTask::class.java).configureEach {
fileMode = Integer.valueOf("664", 8)
}
tasks.jar {
// Override remapJarForRelease, and then manually configure all tasks to have the right classifiers.
tasks.remapJarForRelease {
archiveClassifier = ""
input = tasks.shadowJar.flatMap { it.archiveFile }
}
tasks.jar { archiveClassifier = "dev-slim" }
tasks.shadowJar {
archiveClassifier = "dev"
manifest {
attributes(
"FMLCorePlugin" to "cc.tweaked.patch.CorePlugin",
@ -127,7 +140,17 @@ tasks.jar {
)
}
from(configurations["shade"].map { if (it.isDirectory) it else zipTree(it) })
configurations = listOf(project.configurations["shade"])
relocate("io.netty", "cc.tweaked.vendor.netty")
minimize()
}
project.afterEvaluate {
// Remove tasks.jar from the runtime classpath and add shadowJar instead.
val field = LoomGradleExtension::class.java.getDeclaredField("unmappedModsBuilt")
field.isAccessible = true
(field.get(volde) as MutableList<*>).clear()
volde.addUnmappedMod(tasks.shadowJar.get().archiveFile.get().asFile.toPath())
}
tasks.processResources {

View File

@ -4,6 +4,6 @@
org.gradle.jvmargs=-Xmx3G
modVersion=1.109.6
modVersion=1.115.1
mcVersion=1.4.7

View File

@ -5,24 +5,18 @@
[versions]
forge = "1.4.7-6.6.2.534"
asm = "9.3"
kotlin = "1.8.10"
jspecify = "1.0.0"
netty = "4.1.119.Final"
[libraries]
asm = { module = "org.ow2.asm:asm", version.ref = "asm" }
asm-analysis = { module = "org.ow2.asm:asm-analysis", version.ref = "asm" }
asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm" }
asm-tree = { module = "org.ow2.asm:asm-tree", version.ref = "asm" }
asm-util = { module = "org.ow2.asm:asm-util", version.ref = "asm" }
jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" }
kotlin-platform = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
netty-codec = { module = "io.netty:netty-codec", version.ref = "netty" }
netty-http = { module = "io.netty:netty-codec-http", version.ref = "netty" }
[bundles]
asm = ["asm", "asm-analysis", "asm-commons", "asm-tree", "asm-util"]
kotlin = ["kotlin-stdlib"]
netty = ["netty-codec", "netty-http"]
[plugins]
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
spotless = { id = "com.diffplug.spotless", version = "6.19.0" }
voldeloom = { id = "agency.highlysuspect.voldeloom", version = "2.4-SNAPSHOT" }

View File

@ -30,6 +30,7 @@ public class ClassTransformer implements IClassTransformer {
BasicRemapper.builder()
.remapType("dan200/computer/core/apis/FSAPI", "dan200/computercraft/core/apis/FSAPI")
.remapType("dan200/computer/core/apis/OSAPI", "dan200/computercraft/core/apis/OSAPI")
.remapType("dan200/computer/core/apis/HTTPAPI", "dan200/computercraft/core/apis/HTTPAPI")
.remapType("dan200/computer/core/apis/TermAPI", "dan200/computercraft/core/apis/TermAPI")
.build().toMethodTransform()
)

View File

@ -195,6 +195,19 @@ public abstract class IArguments {
return LuaValues.encode(getString(index));
}
/**
* Get the argument, converting it to the raw-byte representation of its string by following Lua conventions.
* <p>
* This is equivalent to {@link #getStringCoerced(int)}, but then
*
* @param index The argument number.
* @return The argument's value. This is a <em>read only</em> buffer.
* @throws LuaException If the argument cannot be converted to Java.
*/
public ByteBuffer getBytesCoerced(int index) throws LuaException {
return LuaValues.encode(getStringCoerced(index));
}
/**
* Get a string argument as an enum value.
*

View 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");
}
}

View File

@ -4,7 +4,8 @@
package dan200.computercraft.api.lua;
import javax.annotation.Nullable;
import org.jspecify.annotations.Nullable;
import java.nio.ByteBuffer;
import java.util.Map;
@ -96,7 +97,7 @@ public final class LuaValues {
* @return The constructed exception, which should be thrown immediately.
*/
public static LuaException badTableItem(int index, String expected, String actual) {
return new LuaException("table item #" + index + " is not " + expected + " (got " + actual + ")");
return new LuaException("bad item #" + index + " (" + expected + " expected, got " + actual + ")");
}
/**
@ -108,7 +109,7 @@ public final class LuaValues {
* @return The constructed exception, which should be thrown immediately.
*/
public static LuaException badField(String key, String expected, String actual) {
return new LuaException("field " + key + " is not " + expected + " (got " + actual + ")");
return new LuaException("bad field '" + key + "' (" + expected + " expected, got " + actual + ")");
}
/**
@ -137,6 +138,16 @@ public final class LuaValues {
return value;
}
static double checkFiniteIndex(int index, double value) throws LuaException {
if (!Double.isFinite(value)) throw badTableItem(index, "number", getNumericType(value));
return value;
}
static double checkFiniteField(String key, double value) throws LuaException {
if (!Double.isFinite(value)) throw badField(key, "number", getNumericType(value));
return value;
}
/**
* Ensure a string is a valid enum value.
*

View File

@ -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();
}
}

View 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();
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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";
}
}
}

View 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();
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}
}

View File

@ -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;
}

View File

@ -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) {
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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)
);
}
}

View File

@ -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");
}
}

View 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);
}
}
}

View File

@ -20,6 +20,7 @@ import org.objectweb.asm.Type;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -272,6 +273,14 @@ public final class Generator<T> {
mw.visitMethodInsn(INVOKEVIRTUAL, INTERNAL_ARGUMENTS, "getStringCoerced", "(I)Ljava/lang/String;");
mw.visitMethodInsn(INVOKESPECIAL, INTERNAL_COERCED, "<init>", "(Ljava/lang/Object;)V");
return true;
} else if (klass == ByteBuffer.class) {
mw.visitTypeInsn(NEW, INTERNAL_COERCED);
mw.visitInsn(DUP);
mw.visitVarInsn(ALOAD, 2 + context.size());
Reflect.loadInt(mw, argIndex);
mw.visitMethodInsn(INVOKEVIRTUAL, INTERNAL_ARGUMENTS, "getBytesCoerced", "(I)Ljava/nio/ByteBuffer;");
mw.visitMethodInsn(INVOKESPECIAL, INTERNAL_COERCED, "<init>", "(Ljava/lang/Object;)V");
return true;
}
}

View File

@ -12,6 +12,7 @@ import dan200.computer.core.ILuaMachine;
import dan200.computer.core.ILuaObject;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.core.asm.Methods;
import dan200.computercraft.core.lua.errorinfo.ErrorInfoLib;
import org.squiddev.cobalt.*;
import org.squiddev.cobalt.compiler.LoadState;
import org.squiddev.cobalt.function.LuaFunction;
@ -59,6 +60,7 @@ public class CobaltLuaMachine implements ILuaMachine {
try {
CoreLibraries.debugGlobals(state);
Bit32Lib.add(state, globals);
ErrorInfoLib.add(state);
globals.rawset("_HOST", valueOf("ComputerCraft " + ComputerCraft.getVersion() + " (" + Loader.instance().getMCVersionString() + ")"));
globals.rawset("_CC_DEFAULT_SETTINGS", valueOf(""));

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -223,16 +223,21 @@ end
--- Returns true if a path is mounted to the parent filesystem.
--
-- The root filesystem "/" is considered a mount, along with disk folders and
-- the rom folder. Other programs (such as network shares) can exstend this to
-- make other mount types by correctly assigning their return value for getDrive.
-- the rom folder.
--
-- @tparam string path The path to check.
-- @treturn boolean If the path is mounted, rather than a normal file/folder.
-- @throws If the path does not exist.
-- @see getDrive
-- @since 1.87.0
function fs.isDriveRoot(sPath)
expect(1, sPath, "string")
function fs.isDriveRoot(path)
expect(1, path, "string")
local parent = fs.getDir(path)
-- Force the root directory to be a mount.
return fs.getDir(sPath) == ".." or fs.getDrive(sPath) ~= fs.getDrive(fs.getDir(sPath))
if parent == ".." then return true end
local drive = fs.getDrive(path)
return drive ~= nil and drive ~= fs.getDrive(parent)
end

View File

@ -23,7 +23,7 @@ just the same. For example, you might build a GPS cluster according to [this
tutorial][1], using z to account for height, or you might use y to account for
height in the way that Minecraft's debug screen displays.
[1]: http://www.computercraft.info/forums2/index.php?/topic/3088-how-to-guide-gps-global-position-system/
[1]: https://ccf.squiddev.cc/forums2/index.php?/topic/3088-how-to-guide-gps-global-position-system/
@module gps
@since 1.31
@ -196,6 +196,8 @@ function locate(_nTimeout, _bDebug)
modem.close(CHANNEL_GPS)
end
os.cancelTimer(timeout)
-- Return the response
if pos1 and pos2 then
if _bDebug then

View File

@ -14,28 +14,82 @@ local expect = dofile("rom/modules/main/cc/expect.lua").expect
local native = http
local nativeHTTPRequest = http.request
request = http.request
local methods = {
GET = true, POST = true, HEAD = true,
OPTIONS = true, PUT = true, DELETE = true,
PATCH = true, TRACE = true,
}
local function check_key(options, key, ty, opt)
local value = options[key]
local valueTy = type(value)
if (value ~= nil or not opt) and valueTy ~= ty then
error(("bad field '%s' (%s expected, got %s"):format(key, ty, valueTy), 4)
end
end
local function check_request_options(options, body)
check_key(options, "url", "string")
if body == false then
check_key(options, "body", "nil")
else
check_key(options, "body", "string", not body)
end
check_key(options, "headers", "table", true)
check_key(options, "method", "string", true)
check_key(options, "redirect", "boolean", true)
check_key(options, "timeout", "number", true)
if options.method and not methods[options.method] then
error("Unsupported HTTP method", 3)
end
end
local function wrap_request(_url, ...)
nativeHTTPRequest(...)
while true do
local event, param1, param2, param3 = os.pullEvent()
if event == "http_success" and param1 == _url then
return param2
elseif event == "http_failure" and param1 == _url then
return nil, param2, param3
local ok, err = nativeHTTPRequest(...)
if ok then
while true do
local event, param1, param2, param3 = os.pullEvent()
if event == "http_success" and param1 == _url then
return param2
elseif event == "http_failure" and param1 == _url then
return nil, param2, param3
end
end
end
return nil, err
end
--[[- Make a HTTP GET request to the given url.
@tparam string url The url to request
@tparam[opt] { [string] = string } headers Additional headers to send as part
of this request.
@tparam[opt=false] boolean binary Whether the [response handle][`fs.ReadHandle`]
should be opened in binary mode.
@tparam[2] {
url = string, headers? = { [string] = string },
binary? = boolean, method? = string, redirect? = boolean,
timeout? = number,
} request Options for the request. See [`http.request`] for details on how
these options behave.
@treturn Response The resulting http response, which can be read from.
@treturn[2] nil When the http request failed, such as in the event of a 404
error or connection timeout.
@treturn string A message detailing why the request failed.
@treturn Response|nil The failing http response, if available.
@changed 1.63 Added argument for headers.
@changed 1.80pr1 Response handles are now returned on error if available.
@changed 1.80pr1 Added argument for binary handles.
@changed 1.80pr1.6 Added support for table argument.
@changed 1.86.0 Added PATCH and TRACE methods.
@changed 1.105.0 Added support for custom timeouts.
@changed 1.109.0 The returned response now reads the body as raw bytes, rather
than decoding from UTF-8.
@usage Make a request to [example.tweaked.cc](https://example.tweaked.cc),
and print the returned page.
@ -47,25 +101,287 @@ print(request.readAll())
request.close()
```
]]
function get(_url)
function get(_url, _headers, _binary)
if type(_url) == "table" then
check_request_options(_url, false)
return wrap_request(_url.url, _url)
end
expect(1, _url, "string")
return wrap_request(_url, _url)
expect(2, _headers, "table", "nil")
expect(3, _binary, "boolean", "nil")
return wrap_request(_url, _url, nil, _headers, _binary)
end
--[[- Make a HTTP POST request to the given url.
@tparam string url The url to request
@tparam string body The body of the POST request.
@tparam[opt] { [string] = string } headers Additional headers to send as part
of this request.
@tparam[opt=false] boolean binary Whether the [response handle][`fs.ReadHandle`]
should be opened in binary mode.
@tparam[2] {
url = string, body? = string, headers? = { [string] = string },
binary? = boolean, method? = string, redirect? = boolean,
timeout? = number,
} request Options for the request. See [`http.request`] for details on how
these options behave.
@treturn Response The resulting http response, which can be read from.
@treturn[2] nil When the http request failed, such as in the event of a 404
error or connection timeout.
@treturn string A message detailing why the request failed.
@treturn Response|nil The failing http response, if available.
@since 1.31
@changed 1.63 Added argument for headers.
@changed 1.80pr1 Response handles are now returned on error if available.
@changed 1.80pr1 Added argument for binary handles.
@changed 1.80pr1.6 Added support for table argument.
@changed 1.86.0 Added PATCH and TRACE methods.
@changed 1.105.0 Added support for custom timeouts.
@changed 1.109.0 The returned response now reads the body as raw bytes, rather
than decoding from UTF-8.
]]
function post(_url, _post)
function post(_url, _post, _headers, _binary)
if type(_url) == "table" then
check_request_options(_url, true)
return wrap_request(_url.url, _url)
end
expect(1, _url, "string")
expect(2, _post, "string")
return wrap_request(_url, _url, _post)
expect(3, _headers, "table", "nil")
expect(4, _binary, "boolean", "nil")
return wrap_request(_url, _url, _post, _headers, _binary)
end
--[[- Asynchronously make a HTTP request to the given url.
This returns immediately, a [`http_success`] or [`http_failure`] will be queued
once the request has completed.
@tparam string url The url to request
@tparam[opt] string body An optional string containing the body of the
request. If specified, a `POST` request will be made instead.
@tparam[opt] { [string] = string } headers Additional headers to send as part
of this request.
@tparam[opt=false] boolean binary Whether the [response handle][`fs.ReadHandle`]
should be opened in binary mode.
@tparam[2] {
url = string, body? = string, headers? = { [string] = string },
binary? = boolean, method? = string, redirect? = boolean,
timeout? = number,
} request Options for the request.
This table form is an expanded version of the previous syntax. All arguments
from above are passed in as fields instead (for instance,
`http.request("https://example.com")` becomes `http.request { url =
"https://example.com" }`).
This table also accepts several additional options:
- `method`: Which HTTP method to use, for instance `"PATCH"` or `"DELETE"`.
- `redirect`: Whether to follow HTTP redirects. Defaults to true.
- `timeout`: The connection timeout, in seconds.
@see http.get For a synchronous way to make GET requests.
@see http.post For a synchronous way to make POST requests.
@changed 1.63 Added argument for headers.
@changed 1.80pr1 Added argument for binary handles.
@changed 1.80pr1.6 Added support for table argument.
@changed 1.86.0 Added PATCH and TRACE methods.
@changed 1.105.0 Added support for custom timeouts.
@changed 1.109.0 The returned response now reads the body as raw bytes, rather
than decoding from UTF-8.
]]
function request(_url, _post, _headers, _binary)
local url
if type(_url) == "table" then
check_request_options(_url)
url = _url.url
else
expect(1, _url, "string")
expect(2, _post, "string", "nil")
expect(3, _headers, "table", "nil")
expect(4, _binary, "boolean", "nil")
url = _url
end
local ok, err = nativeHTTPRequest(_url, _post, _headers, _binary)
if not ok then
os.queueEvent("http_failure", url, err)
end
-- Return true/false for legacy reasons. Undocumented, as it shouldn't be relied on.
return ok, err
end
local nativeCheckURL = native.checkURL
--[[- Asynchronously determine whether a URL can be requested.
If this returns `true`, one should also listen for [`http_check`] which will
container further information about whether the URL is allowed or not.
@tparam string url The URL to check.
@treturn true When this url is not invalid. This does not imply that it is
allowed - see the comment above.
@treturn[2] false When this url is invalid.
@treturn string A reason why this URL is not valid (for instance, if it is
malformed, or blocked).
@see http.checkURL For a synchronous version.
]]
checkURLAsync = nativeCheckURL
--[[- Determine whether a URL can be requested.
If this returns `true`, one should also listen for [`http_check`] which will
container further information about whether the URL is allowed or not.
@tparam string url The URL to check.
@treturn true When this url is valid and can be requested via [`http.request`].
@treturn[2] false When this url is invalid.
@treturn string A reason why this URL is not valid (for instance, if it is
malformed, or blocked).
@see http.checkURLAsync For an asynchronous version.
@usage
```lua
print(http.checkURL("https://example.tweaked.cc/"))
-- => true
print(http.checkURL("http://localhost/"))
-- => false Domain not permitted
print(http.checkURL("not a url"))
-- => false URL malformed
```
]]
function checkURL(_url)
expect(1, _url, "string")
local ok, err = nativeCheckURL(_url)
if not ok then return ok, err end
while true do
local _, url, ok, err = os.pullEvent("http_check")
if url == _url then return ok, err end
end
end
local nativeWebsocket = native.websocket
local function check_websocket_options(options, body)
check_key(options, "url", "string")
check_key(options, "headers", "table", true)
check_key(options, "timeout", "number", true)
end
--[[- Asynchronously open a websocket.
This returns immediately, a [`websocket_success`] or [`websocket_failure`]
will be queued once the request has completed.
@tparam[1] string url The websocket url to connect to. This should have the
`ws://` or `wss://` protocol.
@tparam[1, opt] { [string] = string } headers Additional headers to send as part
of the initial websocket connection.
@tparam[2] {
url = string, headers? = { [string] = string }, timeout ?= number,
} request Options for the websocket. See [`http.websocket`] for details on how
these options behave.
@since 1.80pr1.3
@changed 1.95.3 Added User-Agent to default headers.
@changed 1.105.0 Added support for table argument and custom timeout.
@changed 1.109.0 Non-binary websocket messages now use the raw bytes rather than
using UTF-8.
@see websocket_success
@see websocket_failure
]]
function websocketAsync(url, headers)
local actual_url
if type(url) == "table" then
check_websocket_options(url)
actual_url = url.url
else
expect(1, url, "string")
expect(2, headers, "table", "nil")
actual_url = url
end
local ok, err = nativeWebsocket(url, headers)
if not ok then
os.queueEvent("websocket_failure", actual_url, err)
end
-- Return true/false for legacy reasons. Undocumented, as it shouldn't be relied on.
return ok, err
end
--[[- Open a websocket.
@tparam[1] string url The websocket url to connect to. This should have the
`ws://` or `wss://` protocol.
@tparam[1,opt] { [string] = string } headers Additional headers to send as part
of the initial websocket connection.
@tparam[2] {
url = string, headers? = { [string] = string }, timeout ?= number,
} request Options for the websocket.
This table form is an expanded version of the previous syntax. All arguments
from above are passed in as fields instead (for instance,
`http.websocket("https://example.com")` becomes `http.websocket { url =
"https://example.com" }`).
This table also accepts the following additional options:
- `timeout`: The connection timeout, in seconds.
@treturn Websocket The websocket connection.
@treturn[2] false If the websocket connection failed.
@treturn string An error message describing why the connection failed.
@since 1.80pr1.1
@changed 1.80pr1.3 No longer asynchronous.
@changed 1.95.3 Added User-Agent to default headers.
@changed 1.105.0 Added support for table argument and custom timeout.
@changed 1.109.0 Non-binary websocket messages now use the raw bytes rather than
using UTF-8.
@usage Connect to an echo websocket and send a message.
local ws = assert(http.websocket("wss://example.tweaked.cc/echo"))
ws.send("Hello!") -- Send a message
print(ws.receive()) -- And receive the reply
ws.close()
]]
function websocket(url, headers)
local actual_url
if type(url) == "table" then
check_websocket_options(url)
actual_url = url.url
else
expect(1, url, "string")
expect(2, headers, "table", "nil")
actual_url = url
end
local ok, err = nativeWebsocket(url, headers)
if not ok then return ok, err end
while true do
local event, url, param = os.pullEvent( )
if event == "websocket_success" and url == actual_url then
return param
elseif event == "websocket_failure" and url == actual_url then
return false, param
end
end
end

View File

@ -2,7 +2,7 @@
--
-- SPDX-License-Identifier: LicenseRef-CCPL
--- Constants for all keyboard "key codes", as queued by the @{key} event.
--- Constants for all keyboard "key codes", as queued by the [`key`] event.
--
-- These values are not guaranteed to remain the same between versions. It is
-- recommended that you use the constants provided by this file, rather than

View File

@ -47,12 +47,22 @@ local function sortCoords(startX, startY, endX, endY)
return minX, maxX, minY, maxY
end
--- Parses an image from a multi-line string
--
-- @tparam string image The string containing the raw-image data.
-- @treturn table The parsed image data, suitable for use with
-- [`paintutils.drawImage`].
-- @since 1.80pr1
--[=[- Parses an image from a multi-line string
@tparam string image The string containing the raw-image data.
@treturn table The parsed image data, suitable for use with [`paintutils.drawImage`].
@usage Parse an image from a string, and draw it.
local image = paintutils.parseImage([[
e e
e e
eeee
]])
paintutils.drawImage(image, term.getCursorPos())
@since 1.80pr1
]=]
function parseImage(image)
expect(1, image, "string")
local tImage = {}

View File

@ -39,60 +39,55 @@ the other.
@since 1.2
]]
local exception = dofile("rom/modules/main/cc/internal/tiny_require.lua")("cc.internal.exception")
local function create(...)
local tFns = table.pack(...)
local tCos = {}
for i = 1, tFns.n, 1 do
local fn = tFns[i]
local barrier_ctx = { co = coroutine.running() }
local functions = table.pack(...)
local threads = {}
for i = 1, functions.n, 1 do
local fn = functions[i]
if type(fn) ~= "function" then
error("bad argument #" .. i .. " (function expected, got " .. type(fn) .. ")", 3)
end
tCos[i] = coroutine.create(fn)
threads[i] = { co = coroutine.create(function() return exception.try_barrier(barrier_ctx, fn) end), filter = nil }
end
return tCos
return threads
end
local function runUntilLimit(_routines, _limit)
local count = #_routines
local function runUntilLimit(threads, limit)
local count = #threads
if count < 1 then return 0 end
local living = count
local tFilters = {}
local eventData = { n = 0 }
local event = { n = 0 }
while true do
for n = 1, count do
local r = _routines[n]
if r then
if tFilters[r] == nil or tFilters[r] == eventData[1] or eventData[1] == "terminate" then
local ok, param = coroutine.resume(r, table.unpack(eventData, 1, eventData.n))
if not ok then
error(param, 0)
else
tFilters[r] = param
end
if coroutine.status(r) == "dead" then
_routines[n] = nil
living = living - 1
if living <= _limit then
return n
end
for i = 1, count do
local thread = threads[i]
if thread and (thread.filter == nil or thread.filter == event[1] or event[1] == "terminate") then
local ok, param = coroutine.resume(thread.co, table.unpack(event, 1, event.n))
if ok then
thread.filter = param
elseif type(param) == "string" and exception.can_wrap_errors() then
error(exception.make_exception(param, thread.co))
else
error(param, 0)
end
if coroutine.status(thread.co) == "dead" then
threads[i] = false
living = living - 1
if living <= limit then
return i
end
end
end
end
for n = 1, count do
local r = _routines[n]
if r and coroutine.status(r) == "dead" then
_routines[n] = nil
living = living - 1
if living <= _limit then
return n
end
end
end
eventData = table.pack(os.pullEventRaw())
event = table.pack(os.pullEventRaw())
end
end
@ -120,8 +115,8 @@ from the [`parallel.waitForAny`] call.
print("Everything done!")
]]
function waitForAny(...)
local routines = create(...)
return runUntilLimit(routines, #routines - 1)
local threads = create(...)
return runUntilLimit(threads, #threads - 1)
end
--[[- Switches between execution of the functions, until all of them are
@ -144,6 +139,6 @@ from the [`parallel.waitForAll`] call.
print("Everything done!")
]]
function waitForAll(...)
local routines = create(...)
return runUntilLimit(routines, 0)
local threads = create(...)
return runUntilLimit(threads, 0)
end

View File

@ -149,7 +149,7 @@ function isOpen(modem)
end
--[[- Allows a computer or turtle with an attached modem to send a message
intended for a sycomputer with a specific ID. At least one such modem must first
intended for a computer with a specific ID. At least one such modem must first
be [opened][`rednet.open`] before sending is possible.
Assuming the target was in range and also had a correctly opened modem, the
@ -298,6 +298,7 @@ function receive(protocol_filter, timeout)
-- Return the first matching rednet_message
local sender_id, message, protocol = p1, p2, p3
if protocol_filter == nil or protocol == protocol_filter then
if timer then os.cancelTimer(timer) end
return sender_id, message, protocol
end
elseif event == "timer" then
@ -431,6 +432,7 @@ function lookup(protocol, hostname)
if hostname == nil then
table.insert(results, sender_id)
elseif message.sHostname == hostname then
os.cancelTimer(timer)
return sender_id
end
end
@ -440,6 +442,9 @@ function lookup(protocol, hostname)
break
end
end
os.cancelTimer(timer)
if results then
return table.unpack(results)
end

View File

@ -19,7 +19,7 @@ When a computer starts, it reads the current value of settings from the
settings.define("my.setting", {
description = "An example setting",
default = 123,
type = number,
type = "number",
})
print("my.setting = " .. settings.get("my.setting")) -- 123
@ -61,7 +61,7 @@ for _, v in ipairs(valid_types) do valid_types[v] = true end
-- - `default`: A default value, which is returned by [`settings.get`] if the
-- setting has not been changed.
-- - `type`: Require values to be of this type. [Setting][`set`] the value to another type
-- will error.
-- will error. Must be one of: `"number"`, `"string"`, `"boolean"`, or `"table"`.
-- @since 1.87.0
function define(name, options)
expect(1, name, "string")
@ -183,7 +183,7 @@ function unset(name)
end
--- Resets the value of all settings. Equivalent to calling [`settings.unset`]
--- on every setting.
-- on every setting.
--
-- @see settings.unset
function clear()
@ -213,16 +213,16 @@ end
-- Existing settings will be merged with any pre-existing ones. Conflicting
-- entries will be overwritten, but any others will be preserved.
--
-- @tparam[opt] string sPath The file to load from, defaulting to `.settings`.
-- @tparam[opt=".settings"] string path The file to load from.
-- @treturn boolean Whether settings were successfully read from this
-- file. Reasons for failure may include the file not existing or being
-- corrupted.
--
-- @see settings.save
-- @changed 1.87.0 `sPath` is now optional.
function load(sPath)
expect(1, sPath, "string", "nil")
local file = fs.open(sPath or ".settings", "r")
-- @changed 1.87.0 `path` is now optional.
function load(path)
expect(1, path, "string", "nil")
local file = fs.open(path or ".settings", "r")
if not file then
return false
end
@ -255,14 +255,14 @@ end
-- This will entirely overwrite the pre-existing file. Settings defined in the
-- file, but not currently loaded will be removed.
--
-- @tparam[opt] string sPath The path to save settings to, defaulting to `.settings`.
-- @tparam[opt=".settings"] string path The path to save settings to.
-- @treturn boolean If the settings were successfully saved.
--
-- @see settings.load
-- @changed 1.87.0 `sPath` is now optional.
function save(sPath)
expect(1, sPath, "string", "nil")
local file = fs.open(sPath or ".settings", "w")
-- @changed 1.87.0 `path` is now optional.
function save(path)
expect(1, path, "string", "nil")
local file = fs.open(path or ".settings", "w")
if not file then
return false
end

View File

@ -7,9 +7,11 @@
-- @module textutils
-- @since 1.2
local expect = dofile("rom/modules/main/cc/expect.lua")
local require = dofile("rom/modules/main/cc/internal/tiny_require.lua")
local expect = require("cc.expect")
local expect, field = expect.expect, expect.field
local wrap = dofile("rom/modules/main/cc/strings.lua").wrap
local wrap = require("cc.strings").wrap
--- Slowly writes string text at current cursor position,
-- character-by-character.
@ -847,13 +849,32 @@ unserialise = unserialize -- GB version
--[[- Returns a JSON representation of the given data.
This function attempts to guess whether a table is a JSON array or
object. However, empty tables are assumed to be empty objects - use
[`textutils.empty_json_array`] to mark an empty array.
This is largely intended for interacting with various functions from the
[`commands`] API, though may also be used in making [`http`] requests.
Lua has a rather different data model to Javascript/JSON. As a result, some Lua
values do not serialise cleanly into JSON.
- Lua tables can contain arbitrary key-value pairs, but JSON only accepts arrays,
and objects (which require a string key). When serialising a table, if it only
has numeric keys, then it will be treated as an array. Otherwise, the table will
be serialised to an object using the string keys. Non-string keys (such as numbers
or tables) will be dropped.
A consequence of this is that an empty table will always be serialised to an object,
not an array. [`textutils.empty_json_array`] may be used to express an empty array.
- Lua strings are an a sequence of raw bytes, and do not have any specific encoding.
However, JSON strings must be valid unicode. By default, non-ASCII characters in a
string are serialised to their unicode code point (for instance, `"\xfe"` is
converted to `"\u00fe"`). The `unicode_strings` option may be set to treat all input
strings as UTF-8.
- Lua does not distinguish between missing keys (`undefined` in JS) and ones explicitly
set to `null`. As a result `{ x = nil }` is serialised to `{}`. [`textutils.json_null`]
may be used to get an explicit null value (`{ x = textutils.json_null }` will serialise
to `{"x": null}`).
@param[1] t The value to serialise. Like [`textutils.serialise`], this should not
contain recursive tables or functions.
@tparam[1,opt] {
@ -921,22 +942,21 @@ unserialiseJSON = unserialise_json
-- @since 1.31
function urlEncode(str)
expect(1, str, "string")
if str then
str = string.gsub(str, "\n", "\r\n")
str = string.gsub(str, "([^A-Za-z0-9 %-%_%.])", function(c)
local n = string.byte(c)
if n < 128 then
-- ASCII
return string.format("%%%02X", n)
else
-- Non-ASCII (encode as UTF-8)
return
string.format("%%%02X", 192 + bit32.band(bit32.arshift(n, 6), 31)) ..
string.format("%%%02X", 128 + bit32.band(n, 63))
end
end)
str = string.gsub(str, " ", "+")
end
local gsub, byte, format, band, arshift = string.gsub, string.byte, string.format, bit32.band, bit32.arshift
str = gsub(str, "\n", "\r\n")
str = gsub(str, "[^A-Za-z0-9%-%_%.]", function(c)
if c == " " then return "+" end
local n = byte(c)
if n < 128 then
-- ASCII
return format("%%%02X", n)
else
-- Non-ASCII (encode as UTF-8)
return format("%%%02X%%%02X", 192 + band(arshift(n, 6), 31), 128 + band(n, 63))
end
end)
return str
end

View File

@ -13,6 +13,11 @@
-- @module vector
-- @since 1.31
local getmetatable = getmetatable
local expect = dofile("rom/modules/main/cc/expect.lua").expect
local vmetatable
--- A 3-dimensional vector, with `x`, `y`, and `z` values.
--
-- This is suitable for representing both position and directional vectors.
@ -27,6 +32,9 @@ local vector = {
-- @usage v1:add(v2)
-- @usage v1 + v2
add = function(self, o)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
if getmetatable(o) ~= vmetatable then expect(2, o, "vector") end
return vector.new(
self.x + o.x,
self.y + o.y,
@ -42,6 +50,9 @@ local vector = {
-- @usage v1:sub(v2)
-- @usage v1 - v2
sub = function(self, o)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
if getmetatable(o) ~= vmetatable then expect(2, o, "vector") end
return vector.new(
self.x - o.x,
self.y - o.y,
@ -52,30 +63,36 @@ local vector = {
--- Multiplies a vector by a scalar value.
--
-- @tparam Vector self The vector to multiply.
-- @tparam number m The scalar value to multiply with.
-- @tparam number factor The scalar value to multiply with.
-- @treturn Vector A vector with value `(x * m, y * m, z * m)`.
-- @usage v:mul(3)
-- @usage v * 3
mul = function(self, m)
-- @usage vector.new(1, 2, 3):mul(3)
-- @usage vector.new(1, 2, 3) * 3
mul = function(self, factor)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
expect(2, factor, "number")
return vector.new(
self.x * m,
self.y * m,
self.z * m
self.x * factor,
self.y * factor,
self.z * factor
)
end,
--- Divides a vector by a scalar value.
--
-- @tparam Vector self The vector to divide.
-- @tparam number m The scalar value to divide by.
-- @tparam number factor The scalar value to divide by.
-- @treturn Vector A vector with value `(x / m, y / m, z / m)`.
-- @usage v:div(3)
-- @usage v / 3
div = function(self, m)
-- @usage vector.new(1, 2, 3):div(3)
-- @usage vector.new(1, 2, 3) / 3
div = function(self, factor)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
expect(2, factor, "number")
return vector.new(
self.x / m,
self.y / m,
self.z / m
self.x / factor,
self.y / factor,
self.z / factor
)
end,
@ -83,8 +100,9 @@ local vector = {
--
-- @tparam Vector self The vector to negate.
-- @treturn Vector The negated vector.
-- @usage -v
-- @usage -vector.new(1, 2, 3)
unm = function(self)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
return vector.new(
-self.x,
-self.y,
@ -96,9 +114,12 @@ local vector = {
--
-- @tparam Vector self The first vector to compute the dot product of.
-- @tparam Vector o The second vector to compute the dot product of.
-- @treturn Vector The dot product of `self` and `o`.
-- @treturn number The dot product of `self` and `o`.
-- @usage v1:dot(v2)
dot = function(self, o)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
if getmetatable(o) ~= vmetatable then expect(2, o, "vector") end
return self.x * o.x + self.y * o.y + self.z * o.z
end,
@ -109,6 +130,9 @@ local vector = {
-- @treturn Vector The cross product of `self` and `o`.
-- @usage v1:cross(v2)
cross = function(self, o)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
if getmetatable(o) ~= vmetatable then expect(2, o, "vector") end
return vector.new(
self.y * o.z - self.z * o.y,
self.z * o.x - self.x * o.z,
@ -120,6 +144,7 @@ local vector = {
-- @tparam Vector self This vector.
-- @treturn number The length of this vector.
length = function(self)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
end,
@ -141,6 +166,9 @@ local vector = {
-- nearest 0.5.
-- @treturn Vector The rounded vector.
round = function(self, tolerance)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
expect(2, tolerance, "number", "nil")
tolerance = tolerance or 1.0
return vector.new(
math.floor((self.x + tolerance * 0.5) / tolerance) * tolerance,
@ -156,6 +184,8 @@ local vector = {
-- @usage v:tostring()
-- @usage tostring(v)
tostring = function(self)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
return self.x .. "," .. self.y .. "," .. self.z
end,
@ -165,11 +195,15 @@ local vector = {
-- @tparam Vector other The second vector to compare to.
-- @treturn boolean Whether or not the vectors are equal.
equals = function(self, other)
if getmetatable(self) ~= vmetatable then expect(1, self, "vector") end
if getmetatable(other) ~= vmetatable then expect(2, other, "vector") end
return self.x == other.x and self.y == other.y and self.z == other.z
end,
}
local vmetatable = {
vmetatable = {
__name = "vector",
__index = vector,
__add = vector.add,
__sub = vector.sub,

View File

@ -1,3 +1,160 @@
# New features in CC: Tweaked 1.115.1
* Update various translations (cyb3r, kevk2156, teamer337, yakku).
* Support Fabric's item lookup API for registering media providers.
Several bug fixes:
* Fix crashes on Create 6.0 (ellellie).
* Fix `speaker.playAudio` not updating speaker volume.
* Resize pocket lectern textures to fix issues with generating mipmaps.
# New features in CC: Tweaked 1.115.0
* Support placing pocket computers on lecterns.
* Suggest alternative table keys on `nil` errors.
* Errors from inside `parallel` functions now have source information attached.
* Expose printout contents to the Java API.
Several bug fixes:
* Ignore unrepresentable characters in `char`/`paste` events.
# New features in CC: Tweaked 1.114.4
* Allow typing/pasting any character in the CC charset.
Several bug fixes:
* Fix command computers being exposed as peripherals (Forge only).
* Fix command computers having NBT set when placed in a Create contraption.
* Use correct bounding box when checking for entities in turtle movement.
# New features in CC: Tweaked 1.114.3
* `wget` now prints the error that occurred, rather than a generic "Failed" (tizu69).
* Update several translations.
Several bug fixes:
* Fix `fs.isDriveRoot` returning true for non-existent files.
* Fix possible memory leak when sending terminal contents.
# New features in CC: Tweaked 1.114.2
One bug fix:
* Fix OpenGL errors when rendering empty monitors.
# New features in CC: Tweaked 1.114.1
Several bug fixes:
* Fix monitor touch events only firing from one monitor.
* Fix crash when lectern has no item.
* Fix cursor not blinking on monitors.
# New features in CC: Tweaked 1.114.0
* Add redstone relay peripheral.
* Add support for `math.atan(y, x)`.
* Update several translations.
Several bug fixes:
* Fix pocket upgrades not appearing after crafting.
* Cancel `rednet.receive` and `Websocket.receive` timers after a message is received.
* Fix several issues with parsing and printing large doubles.
* Fix in-hand pocket computer being blank after changing dimension.
# New features in CC: Tweaked 1.113.1
* Update Japanese translation (konumatakaki).
* Improve performance of `textutils.urlEncode`.
Several bug fixes:
* Fix overflow when converting recursive objects from Java to Lua.
* Fix websocket compression not working under Forge.
# New features in CC: Tweaked 1.113.0
* Allow placing printed pages and books in lecterns.
Several bug fixes:
* Various documentation fixes (MCJack123)
* Fix computers and turtles not being dropped when exploded with TNT.
* Fix crash when turtles are broken while mining a block.
* Fix pocket computer terminals not updating when in the off-hand.
# New features in CC: Tweaked 1.112.0
* Report a custom error when using `!` instead of `not`.
* Update several translations (zyxkad, MineKID-LP).
* Add `cc.strings.split` function.
Several bug fixes:
* Fix `drive.getAudioTitle` returning `nil` when no disk is inserted.
* Preserve item data when upgrading pocket computers.
* Add missing bounds check to `cc.strings.wrap` (Lupus950).
* Fix modems not moving with Create contraptions.
# New features in CC: Tweaked 1.111.0
* Update several translations (Ale32bit).
* Split up turtle textures into individual textures.
* Add `r+`/`w+` support to the `io` library.
* Warn when capabilities are not registered and Optifine is installed.
Several bug fixes:
* Allow planks to be used for building in "adventure" (dan200).
* Fix `disk.getAudioTitle()` returning untranslated strings for some modded discs.
* Fix crash when right clicking turtles in spectator.
# New features in CC: Tweaked 1.110.3
* Update several translations (PatriikPlays).
Several bug fixes:
* Fix some errors missing source positions.
* Correctly handle multiple threads sending websocket messages at once.
# New features in CC: Tweaked 1.110.2
* Add `speaker sound` command (fatboychummy).
Several bug fixes:
* Improve error when calling `speaker play` with no path (fatboychummy).
* Prevent playing music discs with `speaker.playSound`.
* Various documentation fixes (cyberbit).
* Fix generic peripherals not being able to transfer to some inventories on Forge.
* Fix rare crash when holding a pocket computer.
* Fix modems breaking when moved by Create.
* Fix crash when rendering a turtle through an Immersive Portals portal.
# New features in CC: Tweaked 1.110.1
Several bug fixes:
* Fix computers not turning on after they're unloaded/not-ticked for a while.
* Fix networking cables sometimes not connecting on Forge.
# New features in CC: Tweaked 1.110.0
* Add a new `@c[...]` syntax for selecting computers in the `/computercraft` command.
* Remove custom breaking progress of modems on Forge.
Several bug fixes:
* Fix client and server DFPWM transcoders getting out of sync.
* Fix `turtle.suck` reporting incorrect error when failing to suck items.
* Fix pocket computers displaying state (blinking, modem light) for the wrong computer.
* Fix crash when wrapping an invalid BE as a generic peripheral.
* Chest peripherals now reattach when a chest is converted into a double chest.
* Fix `speaker` program not resolving files relative to the current directory.
* Skip main-thread tasks if the peripheral is detached.
* Fix internal Lua VM errors if yielding inside `__tostring`.
# New features in CC: Tweaked 1.109.7
* Improve performance of removing and unloading wired cables/modems.
Several bug fixes:
* Fix monitors sometimes not updating on the client when chunks are unloaded and reloaded.
* `colour.toBlit` correctly errors on out-of-bounds values.
* Round non-standard colours in `window`, like `term.native()` does.
* Fix the client monitor rendering both the current and outdated contents.
# New features in CC: Tweaked 1.109.6
* Improve several Lua parser error messages.
@ -724,7 +881,7 @@ And several bug fixes:
# New features in CC: Tweaked 1.86.2
* Fix peripheral.getMethods returning an empty table.
* Update to Minecraft 1.15.2. This is currently alpha-quality and so is missing missing features and may be unstable.
* Update to Minecraft 1.15.2. This is currently alpha-quality and so is missing features and may be unstable.
# New features in CC: Tweaked 1.86.1
@ -1340,7 +1497,7 @@ And several bug fixes:
* Turtles can now compare items in their inventories
* Turtles can place signs with text on them with `turtle.place( [signText] )`
* Turtles now optionally require fuel items to move, and can refuel themselves
* The size of the the turtle inventory has been increased to 16
* The size of the turtle inventory has been increased to 16
* The size of the turtle screen has been increased
* New turtle functions: `turtle.compareTo( [slotNum] )`, `turtle.craft()`, `turtle.attack()`, `turtle.attackUp()`, `turtle.attackDown()`, `turtle.dropUp()`, `turtle.dropDown()`, `turtle.getFuelLevel()`, `turtle.refuel()`
* New disk function: disk.getID()

View File

@ -1,9 +1,11 @@
New features in CC: Tweaked 1.109.6
New features in CC: Tweaked 1.115.1
* Improve several Lua parser error messages.
* Allow addon mods to register `require`able modules.
* Update various translations (cyb3r, kevk2156, teamer337, yakku).
* Support Fabric's item lookup API for registering media providers.
Several bug fixes:
* Fix weak tables becoming malformed when keys are GCed.
* Fix crashes on Create 6.0 (ellellie).
* Fix `speaker.playAudio` not updating speaker volume.
* Resize pocket lectern textures to fix issues with generating mipmaps.
Type "help changelog" to see the full version history.

View File

@ -6,14 +6,13 @@
Convert between streams of DFPWM audio data and a list of amplitudes.
DFPWM (Dynamic Filter Pulse Width Modulation) is an audio codec designed by GreaseMonkey. It's a relatively compact
format compared to raw PCM data, only using 1 bit per sample, but is simple enough to simple enough to encode and decode
in real time.
format compared to raw PCM data, only using 1 bit per sample, but is simple enough to encode and decode in real time.
Typically DFPWM audio is read from [the filesystem][`fs.BinaryReadHandle`] or a [a web request][`http.Response`] as a
string, and converted a format suitable for [`speaker.playAudio`].
Typically DFPWM audio is read from [the filesystem][`fs.ReadHandle`] or a [a web request][`http.Response`] as a string,
and converted a format suitable for [`speaker.playAudio`].
## Encoding and decoding files
This modules exposes two key functions, [`make_decoder`] and [`make_encoder`], which construct a new decoder or encoder.
This module exposes two key functions, [`make_decoder`] and [`make_encoder`], which construct a new decoder or encoder.
The returned encoder/decoder is itself a function, which converts between the two kinds of data.
These encoders and decoders have lots of hidden state, so you should be careful to use the same encoder or decoder for
@ -21,9 +20,9 @@ a specific audio stream. Typically you will want to create a decoder for each st
for each one you write.
## Converting audio to DFPWM
DFPWM is not a popular file format and so standard audio processing tools will not have an option to export to it.
DFPWM is not a popular file format and so standard audio processing tools may not have an option to export to it.
Instead, you can convert audio files online using [music.madefor.cc], the [LionRay Wav Converter][LionRay] Java
application or development builds of [FFmpeg].
application or [FFmpeg] 5.1 or later.
[music.madefor.cc]: https://music.madefor.cc/ "DFPWM audio converter for Computronics and CC: Tweaked"
[LionRay]: https://github.com/gamax92/LionRay/ "LionRay Wav Converter "
@ -211,7 +210,7 @@ end
--[[- A convenience function for encoding a complete file of audio at once.
This should only be used for complete pieces of audio. If you are writing writing multiple chunks to the same place,
This should only be used for complete pieces of audio. If you are writing multiple chunks to the same place,
you should use an encoder returned by [`make_encoder`] instead.
@tparam { number... } input The table of amplitude data.

View File

@ -118,8 +118,8 @@ end
--- Expect a number to be within a specific range.
--
-- @tparam number num The value to check.
-- @tparam number min The minimum value, if nil then `-math.huge` is used.
-- @tparam number max The maximum value, if nil then `math.huge` is used.
-- @tparam[opt=-math.huge] number min The minimum value.
-- @tparam[opt=math.huge] number max The maximum value.
-- @return The given `value`.
-- @throws If the value is outside of the allowed range.
-- @since 1.96.0

View File

@ -89,7 +89,7 @@ end
--
-- @tparam table image An image, as returned from [`load`] or [`parse`].
-- @tparam number xPos The x position to start drawing at.
-- @tparam number xPos The y position to start drawing at.
-- @tparam number yPos The y position to start drawing at.
-- @tparam[opt] term.Redirect target The terminal redirect to draw to. Defaults to the
-- current terminal.
local function draw(image, xPos, yPos, target)

View File

@ -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 }

View File

@ -12,7 +12,7 @@
]]
local expect = require "cc.expect".expect
local error_printer = require "cc.internal.error_printer"
local type, debug, coroutine = type, debug, coroutine
local function find_frame(thread, file, line)
-- Scan the first 16 frames for something interesting.
@ -21,18 +21,136 @@ local function find_frame(thread, file, line)
if not frame then break end
if frame.short_src == file and frame.what ~= "C" and frame.currentline == line then
return frame
return offset, frame
end
end
end
--[[- Check whether this error is an exception.
Currently we don't provide a stable API for throwing (and propagating) rich
errors, like those supported by this module. In lieu of that, we describe the
exception protocol, which may be used by user-written coroutine managers to
throw exceptions which are pretty-printed by the shell:
An exception is any table with:
- The `"exception"` type
- A string `message` field,
- And a coroutine `thread` fields.
To throw such an exception, the inner loop of your coroutine manager may look
something like this:
```lua
local ok, result = coroutine.resume(co, table.unpack(event, 1, event.n))
if not ok then
-- Rethrow non-string errors directly
if type(result) ~= "string" then error(result, 0) end
-- Otherwise, wrap it into an exception.
error(setmetatable({ message = result, thread = co }, {
__name = "exception",
__tostring = function(self) return self.message end,
}))
end
```
@param exn Some error object
@treturn boolean Whether this error is an exception.
]]
local function is_exception(exn)
if type(exn) ~= "table" then return false end
local mt = getmetatable(exn)
return mt and mt.__name == "exception" and type(rawget(exn, "message")) == "string" and type(rawget(exn, "thread")) == "thread"
end
local exn_mt = {
__name = "exception",
__tostring = function(self) return self.message end,
}
--[[- Create a new exception from a message and thread.
@tparam string message The exception message.
@tparam coroutine thread The coroutine the error occurred on.
@return The constructed exception.
]]
local function make_exception(message, thread)
return setmetatable({ message = message, thread = thread }, exn_mt)
end
--[[- A marker function for [`try`] and the wider exception machinery.
This function is typically the first function on the call stack. It acts as both
a signifier that this function is exception aware, and allows us to store
additional information for the exception machinery on the call stack.
@see can_wrap_errors
]]
local try_barrier = debug.getregistry().cc_try_barrier
if not try_barrier then
-- We define an extra "bounce" function to prevent f(...) being treated as a
-- tail call, and so ensure the barrier remains on the stack.
local function bounce(...) return ... end
--- @tparam { co = coroutine, can_wrap ?= boolean } parent The parent coroutine.
-- @tparam function f The function to call.
-- @param ... The arguments to this function.
try_barrier = function(parent, f, ...) return bounce(f(...)) end
debug.getregistry().cc_try_barrier = try_barrier
end
-- Functions that act as a barrier for exceptions.
local pcall_functions = { [pcall] = true, [xpcall] = true, [load] = true }
--[[- Check to see whether we can wrap errors into an exception.
This scans the current thread (up to a limit), and any parent threads, to
determine if there is a pcall anywhere on the callstack. If not, then we know
the error message is not observed by user code, and so may be wrapped into an
exception.
@tparam[opt] coroutine The thread to check. Defaults to the current thread.
@treturn boolean Whether we can wrap errors into exceptions.
]]
local function can_wrap_errors(thread)
if not thread then thread = coroutine.running() end
for offset = 0, 31 do
local frame = debug.getinfo(thread, offset, "f")
if not frame then return false end
local func = frame.func
if func == try_barrier then
-- If we've a try barrier, then extract the parent coroutine and
-- check if it can wrap errors.
local _, parent = debug.getlocal(thread, offset, 1)
if type(parent) ~= "table" or type(parent.co) ~= "thread" then return false end
local result = parent.can_wrap
if result == nil then
result = can_wrap_errors(parent.co)
parent.can_wrap = result
end
return result
elseif pcall_functions[func] then
-- If we're a pcall, then abort.
return false
end
end
return false
end
--[[- Attempt to call the provided function `func` with the provided arguments.
@tparam function func The function to call.
@param ... Arguments to this function.
@treturn[1] true If the function ran successfully.
@return[1] ... The return values of the function.
@return[1] ... The return values of the function.
@treturn[2] false If the function failed.
@return[2] The error message
@ -41,8 +159,8 @@ end
local function try(func, ...)
expect(1, func, "function")
local co = coroutine.create(func)
local result = table.pack(coroutine.resume(co, ...))
local co = coroutine.create(try_barrier)
local result = table.pack(coroutine.resume(co, { co = co, can_wrap = true }, func, ...))
while coroutine.status(co) ~= "dead" do
local event = table.pack(os.pullEventRaw(result[2]))
@ -51,8 +169,14 @@ local function try(func, ...)
end
end
if not result[1] then return false, result[2], co end
return table.unpack(result, 1, result.n)
if result[1] then
return table.unpack(result, 1, result.n)
elseif is_exception(result[2]) then
local exn = result[2]
return false, rawget(exn, "message"), rawget(exn, "thread")
else
return false, result[2], co
end
end
--[[- Report additional context about an error.
@ -67,11 +191,11 @@ local function report(err, thread, source_map)
if type(err) ~= "string" then return end
local file, line = err:match("^([^:]+):(%d+):")
local file, line, err = err:match("^([^:]+):(%d+): (.*)")
if not file then return end
line = tonumber(line)
local frame = find_frame(thread, file, line)
local frame_offset, frame = find_frame(thread, file, line)
if not frame or not frame.currentcolumn then return end
local column = frame.currentcolumn
@ -108,16 +232,22 @@ local function report(err, thread, source_map)
-- Could not determine the line. Bail.
if not line_contents or #line_contents == "" then return end
error_printer({
require("cc.internal.error_printer")({
get_pos = function() return line, column end,
get_line = function() return line_contents end,
}, {
{ tag = "annotate", start_pos = column, end_pos = column, msg = "" },
require "cc.internal.error_hints".get_tip(err, thread, frame_offset),
})
end
return {
make_exception = make_exception,
try_barrier = try_barrier,
can_wrap_errors = can_wrap_errors,
try = try,
report = report,
}

View File

@ -284,6 +284,23 @@ function errors.wrong_ne(start_pos, end_pos)
}
end
--[[- `!` was used instead of `not`.
@tparam number start_pos The start position of the token.
@tparam number end_pos The end position of the token.
@return The resulting parse error.
]]
function errors.wrong_not(start_pos, end_pos)
expect(1, start_pos, "number")
expect(2, end_pos, "number")
return {
"Unexpected character.",
annotate(start_pos, end_pos),
"Tip: Replace this with " .. code("not") .. " to negate a boolean.",
}
end
--[[- An unexpected character was used.
@tparam number pos The position of this character.

View File

@ -327,6 +327,9 @@ local function lex_token(context, str, pos)
elseif contents == "!=" or contents == "<>" then
context.report(errors.wrong_ne, pos, end_pos)
return tokens.NE, end_pos
elseif contents == "!" then
context.report(errors.wrong_not, pos, end_pos)
return tokens.NOT, end_pos
end
end

View File

@ -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

View File

@ -8,7 +8,8 @@
-- @since 1.95.0
-- @see textutils For additional string related utilities.
local expect = (require and require("cc.expect") or dofile("rom/modules/main/cc/expect.lua")).expect
local expect = require("cc.expect")
local expect, range = expect.expect, expect.range
--[[- Wraps a block of text, so that each line fits within the given width.
@ -32,7 +33,7 @@ local function wrap(text, width)
expect(1, text, "string")
expect(2, width, "number", "nil")
width = width or term.getSize()
range(width, 1)
local lines, lines_n, current_line = {}, 0, ""
local function push_line()
@ -109,7 +110,63 @@ local function ensure_width(line, width)
return line
end
--[[- Split a string into parts, each separated by a deliminator.
For instance, splitting the string `"a b c"` with the deliminator `" "`, would
return a table with three strings: `"a"`, `"b"`, and `"c"`.
By default, the deliminator is given as a [Lua pattern][pattern]. Passing `true`
to the `plain` argument will cause the deliminator to be treated as a litteral
string.
[pattern]: https://www.lua.org/manual/5.3/manual.html#6.4.1
@tparam string str The string to split.
@tparam string deliminator The pattern to split this string on.
@tparam[opt=false] boolean plain Treat the deliminator as a plain string, rather than a pattern.
@tparam[opt] number limit The maximum number of elements in the returned list.
@treturn { string... } The list of split strings.
@usage Split a string into words.
require "cc.strings".split("This is a sentence.", "%s+")
@usage Split a string by "-" into at most 3 elements.
require "cc.strings".split("a-separated-string-of-sorts", "-", true, 3)
@see table.concat To join strings together.
@since 1.112.0
]]
local function split(str, deliminator, plain, limit)
expect(1, str, "string")
expect(2, deliminator, "string")
expect(3, plain, "boolean", "nil")
expect(4, limit, "number", "nil")
local out, out_n, pos = {}, 0, 1
while not limit or out_n < limit - 1 do
local start, finish = str:find(deliminator, pos, plain)
if not start then break end
out_n = out_n + 1
out[out_n] = str:sub(pos, start - 1)
-- Require us to advance by at least one character.
if finish < start then error("separator is empty", 2) end
pos = finish + 1
end
if pos == 1 then return { str } end
out[out_n + 1] = str:sub(pos)
return out
end
return {
wrap = wrap,
ensure_width = ensure_width,
split = split,
}

View File

@ -108,6 +108,7 @@ local items = {
},
["some planks"] = {
aliases = { "planks", "wooden planks", "wood planks" },
material = true,
desc = "You could easily craft these planks into sticks.",
},
["some sticks"] = {

View File

@ -43,14 +43,18 @@ if cmd == "stop" then
for _, speaker in pairs(get_speakers(name)) do speaker.stop() end
elseif cmd == "play" then
local _, file, name = ...
if not file then
error("Usage: speaker play <file or url> [speaker]", 0)
end
local speaker = get_speakers(name)[1]
local handle, err
if http and file:match("^https?://") then
print("Downloading...")
handle, err = http.get{ url = file, binary = true }
handle, err = http.get(file)
else
handle, err = fs.open(file, "rb")
handle, err = fs.open(shell.resolve(file), "r")
end
if not handle then
@ -128,9 +132,47 @@ elseif cmd == "play" then
end
handle.close()
elseif cmd == "sound" then
local _, sound, volume, pitch, name = ...
if not sound then
error("Usage: speaker sound <sound> [volume] [pitch] [speaker]", 0)
return
end
if volume then
volume = tonumber(volume)
if not volume then
error("Volume must be a number", 0)
end
if volume < 0 or volume > 3 then
error("Volume must be between 0 and 3", 0)
end
end
if pitch then
pitch = tonumber(pitch)
if not pitch then
error("Pitch must be a number", 0)
end
if pitch < 0 or pitch > 2 then
error("Pitch must be between 0 and 2", 0)
end
end
local speaker = get_speakers(name)[1]
if speaker.playSound(sound, volume, pitch) then
print(("Played sound %q on speaker %q with volume %s and pitch %s."):format(
sound, peripheral.getName(speaker), volume or 1, pitch or 1
))
else
error(("Could not play sound %q"):format(sound), 0)
end
else
local programName = arg[0] or fs.getName(shell.getRunningProgram())
print("Usage:")
print(programName .. " play <file or url> [speaker]")
print(programName .. " sound <sound> [volume] [pitch] [speaker]")
print(programName .. " stop [speaker]")
end

View File

@ -56,6 +56,14 @@ local function get(url)
)
if response then
-- If spam protection is activated, we get redirected to /paste with Content-Type: text/html
local headers = response.getResponseHeaders()
if not headers["Content-Type"] or not headers["Content-Type"]:find("^text/plain") then
io.stderr:write("Failed.\n")
print("Pastebin blocked the download due to spam protection. Please complete the captcha in a web browser: https://pastebin.com/" .. textutils.urlEncode(paste))
return
end
print("Success.")
local sResponse = response.readAll()

View File

@ -35,13 +35,20 @@ local function getFilename(sUrl)
return sUrl:match("/([^/]+)$")
end
local function get(sUrl)
write("Connecting to " .. sUrl .. "... ")
local function get(url)
-- Check if the URL is valid
local ok, err = http.checkURL(url)
if not ok then
printError(err or "Invalid URL.")
return
end
local response = http.get(sUrl , nil , true)
write("Connecting to " .. url .. "... ")
local response, err = http.get(url)
if not response then
print("Failed.")
return nil
printError(err)
return
end
print("Success.")

View File

@ -31,7 +31,7 @@ setmetatable(tEnv, { __index = _ENV })
do
local make_package = require "cc.require".make
local dir = shell.dir()
_ENV.require, _ENV.package = make_package(_ENV, dir)
tEnv.require, tEnv.package = make_package(tEnv, dir)
end
if term.isColour() then
@ -104,7 +104,7 @@ while running do
end
else
printError(results[2])
require "cc.internal.exception".report(results[2], results[3], chunk_map)
exception.report(results[2], results[3], chunk_map)
end
else
local parser = require "cc.internal.syntax"

View File

@ -117,7 +117,7 @@ shell.setCompletionFunction("rom/programs/fun/dj.lua", completion.build(
completion.peripheral
))
shell.setCompletionFunction("rom/programs/fun/speaker.lua", completion.build(
{ completion.choice, { "play ", "stop " } },
{ completion.choice, { "play ", "sound ", "stop " } },
function(shell, text, previous)
if previous[2] == "play" then return completion.file(shell, text, previous, true)
elseif previous[2] == "stop" then return completion.peripheral(shell, text, previous, false)

2
vendor/Cobalt vendored

@ -1 +1 @@
Subproject commit 6536189750811a301cff560099dc2ce4ad34316e
Subproject commit 4842fa31a12072c630e236cc81496537bb3736ae