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