Add native TLS for Android love.system and desktop gen1tls.

Expose a non-blocking TLS socket API to mods (WSS clients) without bundling
any game-specific multiworld content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Solidus Snake
2026-08-10 17:43:53 -04:00
parent 4d03a38067
commit a9767e5df1
15 changed files with 1267 additions and 0 deletions
+7
View File
@@ -68,3 +68,10 @@ mobile/ios/bundle_id.local
/ports/uwp/build/
/ports/uwp/third_party/*/source/
/ports/uwp/third_party/angle/depot_tools/
# Native TLS dialer build output (dotnet publish)
/native/tls_dial/bin/
/native/tls_dial/obj/
/dist/native/
/dist/win/
/.bazinga/
@@ -23,6 +23,7 @@
#ifdef LOVE_ANDROID
#include <cerrno>
#include <cstring>
#include <unordered_map>
#include <SDL.h>
@@ -324,6 +325,182 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
return result;
}
/*
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a
* missing method answers like a platform without TLS instead of aborting.
*/
static jclass tlsActivityClass(JNIEnv *env)
{
jobject activityObj = (jobject) SDL_AndroidGetActivity();
if (activityObj == nullptr)
return nullptr;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
return activity;
}
int tlsOpen(const char *host, int port)
{
if (host == nullptr)
return -1;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return -1;
jmethodID method = env->GetStaticMethodID(activity, "tlsOpen", "(Ljava/lang/String;I)I");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
jstring jhost = env->NewStringUTF(host);
jint result = env->CallStaticIntMethod(activity, method, jhost, (jint) port);
env->DeleteLocalRef(jhost);
env->DeleteLocalRef(activity);
return (int) result;
}
int tlsStatus(int handle)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return -1;
jmethodID method = env->GetStaticMethodID(activity, "tlsStatus", "(I)I");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
jint result = env->CallStaticIntMethod(activity, method, (jint) handle);
env->DeleteLocalRef(activity);
return (int) result;
}
int tlsSend(int handle, const char *data, int length)
{
if (data == nullptr || length <= 0)
return 0;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return -1;
jmethodID method = env->GetStaticMethodID(activity, "tlsSend", "(I[B)I");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
jbyteArray payload = env->NewByteArray((jsize) length);
if (payload == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
env->SetByteArrayRegion(payload, 0, (jsize) length, (const jbyte*) data);
jint result = env->CallStaticIntMethod(activity, method, (jint) handle, payload);
env->DeleteLocalRef(payload);
env->DeleteLocalRef(activity);
return (int) result;
}
int tlsReceive(int handle, char *buf, int max)
{
if (buf == nullptr || max <= 0)
return 0;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return -1;
jmethodID method = env->GetStaticMethodID(activity, "tlsReceive", "(II)[B");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return -1;
}
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle, (jint) max);
env->DeleteLocalRef(activity);
if (result == nullptr)
return 0;
jbyteArray bytes = (jbyteArray) result;
jsize length = env->GetArrayLength(bytes);
if (length > max)
length = max;
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) buf);
env->DeleteLocalRef(result);
return (int) length;
}
bool tlsError(int handle, char *buf, int max)
{
if (buf == nullptr || max <= 0)
return false;
buf[0] = '\0';
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return false;
jmethodID method = env->GetStaticMethodID(activity, "tlsError", "(I)Ljava/lang/String;");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle);
env->DeleteLocalRef(activity);
if (result == nullptr)
return false;
jstring text = (jstring) result;
const char *utf = env->GetStringUTFChars(text, nullptr);
if (utf != nullptr)
{
strncpy(buf, utf, (size_t) max - 1);
buf[max - 1] = '\0';
env->ReleaseStringUTFChars(text, utf);
}
env->DeleteLocalRef(result);
return buf[0] != '\0';
}
void tlsClose(int handle)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = tlsActivityClass(env);
if (activity == nullptr)
return;
jmethodID method = env->GetStaticMethodID(activity, "tlsClose", "(I)V");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return;
}
env->CallStaticVoidMethod(activity, method, (jint) handle);
env->DeleteLocalRef(activity);
}
/*
* Helper functions for the filesystem module
*/
@@ -98,6 +98,27 @@ bool restartApp();
**/
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
/**
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
* unreachable -- and an Archipelago room hosted on archipelago.gg accepts a
* plain connection only to drop it. The platform has both a TLS stack and the
* system trust store, so this borrows them rather than vendoring mbedTLS.
*
* tlsOpen returns a handle immediately and connects on its own thread: poll
* tlsStatus for 0 connecting / 1 open / 2 closed, and -1 for a handle that
* does not exist. Bytes given to tlsSend before the handshake finishes are
* queued rather than refused. tlsReceive fills buf and returns how much it
* took, 0 when nothing is waiting. A closed connection keeps both its reason
* (tlsError) and whatever arrived before it closed until tlsClose.
**/
int tlsOpen(const char *host, int port);
int tlsStatus(int handle);
int tlsSend(int handle, const char *data, int length);
int tlsReceive(int handle, char *buf, int max);
bool tlsError(int handle, char *buf, int max);
void tlsClose(int handle);
/*
* Helper functions for the filesystem module
*/
@@ -244,6 +244,72 @@ bool System::httpDownload(const char *url, const char *destPath,
#endif
}
int System::tlsOpen(const char *host, int port) const
{
#ifdef LOVE_ANDROID
return love::android::tlsOpen(host, port);
#else
LOVE_UNUSED(host);
LOVE_UNUSED(port);
return -1;
#endif
}
int System::tlsStatus(int handle) const
{
#ifdef LOVE_ANDROID
return love::android::tlsStatus(handle);
#else
LOVE_UNUSED(handle);
return -1;
#endif
}
int System::tlsSend(int handle, const char *data, int length) const
{
#ifdef LOVE_ANDROID
return love::android::tlsSend(handle, data, length);
#else
LOVE_UNUSED(handle);
LOVE_UNUSED(data);
LOVE_UNUSED(length);
return -1;
#endif
}
int System::tlsReceive(int handle, char *buf, int max) const
{
#ifdef LOVE_ANDROID
return love::android::tlsReceive(handle, buf, max);
#else
LOVE_UNUSED(handle);
LOVE_UNUSED(buf);
LOVE_UNUSED(max);
return -1;
#endif
}
bool System::tlsError(int handle, char *buf, int max) const
{
#ifdef LOVE_ANDROID
return love::android::tlsError(handle, buf, max);
#else
LOVE_UNUSED(handle);
LOVE_UNUSED(buf);
LOVE_UNUSED(max);
return false;
#endif
}
void System::tlsClose(int handle) const
{
#ifdef LOVE_ANDROID
love::android::tlsClose(handle);
#else
LOVE_UNUSED(handle);
#endif
}
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
@@ -149,6 +149,20 @@ public:
virtual bool httpDownload(const char *url, const char *destPath,
const char *userAgent = nullptr, const char *accept = nullptr) const;
/**
* TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract:
* tlsOpen returns a handle and connects on its own thread, tlsStatus
* reports 0 connecting / 1 open / 2 closed / -1 unknown, and bytes sent
* before the handshake completes are queued rather than refused.
**/
virtual int tlsOpen(const char *host, int port) const;
virtual int tlsStatus(int handle) const;
virtual int tlsSend(int handle, const char *data, int length) const;
virtual int tlsReceive(int handle, char *buf, int max) const;
virtual bool tlsError(int handle, char *buf, int max) const;
virtual void tlsClose(int handle) const;
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
@@ -139,6 +139,79 @@ int w_hasBackgroundMusic(lua_State *L)
return 1;
}
/*
* TLS sockets. Deliberately a handle-and-poll API rather than an object:
* the caller is a per-frame pump that must never block, and everything with
* a thread behind it lives on the Java side.
*/
int w_tlsOpen(lua_State *L)
{
const char *host = luaL_checkstring(L, 1);
int port = (int) luaL_checknumber(L, 2);
lua_pushnumber(L, instance()->tlsOpen(host, port));
return 1;
}
int w_tlsStatus(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
lua_pushnumber(L, instance()->tlsStatus(handle));
return 1;
}
int w_tlsSend(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
size_t length = 0;
const char *data = luaL_checklstring(L, 2, &length);
lua_pushnumber(L, instance()->tlsSend(handle, data, (int) length));
return 1;
}
int w_tlsReceive(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
int max = (int) luaL_optnumber(L, 2, 8192);
if (max <= 0)
{
lua_pushliteral(L, "");
return 1;
}
// A frame's worth of a busy room, on the C stack rather than the heap:
// this runs every frame and an allocation per poll is not worth it.
if (max > 65536)
max = 65536;
char buf[65536];
int got = instance()->tlsReceive(handle, buf, max);
if (got < 0)
{
lua_pushnil(L);
return 1;
}
lua_pushlstring(L, buf, (size_t) got);
return 1;
}
int w_tlsError(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
char buf[512];
if (!instance()->tlsError(handle, buf, (int) sizeof(buf)))
{
lua_pushnil(L);
return 1;
}
lua_pushstring(L, buf);
return 1;
}
int w_tlsClose(lua_State *L)
{
int handle = (int) luaL_checknumber(L, 1);
instance()->tlsClose(handle);
return 0;
}
static const luaL_Reg functions[] =
{
{ "getOS", w_getOS },
@@ -153,6 +226,12 @@ static const luaL_Reg functions[] =
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "httpDownload", w_httpDownload },
{ "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend },
{ "tlsReceive", w_tlsReceive },
{ "tlsError", w_tlsError },
{ "tlsClose", w_tlsClose },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -606,6 +606,45 @@ public class GameActivity extends SDLActivity {
* The body lands in a .part file and is renamed only once complete, so a
* dropped connection can never leave a half file the caller trusts.
*/
/**
* TLS client sockets, exposed as love.system.tls* and used by the
* Archipelago mod for wss:// rooms. LuaSocket speaks TCP only, so without
* these a hosted room -- every one of which is TLS-only -- is unreachable
* from the game. The work is in TlsSocket; these are the static entry
* points, because the JNI side resolves methods on the activity's own
* class (see love/src/common/android.cpp) and cannot see other classes
* from a worker thread.
*/
@Keep
public static int tlsOpen(String host, int port) {
return TlsSocket.open(host, port);
}
@Keep
public static int tlsStatus(int handle) {
return TlsSocket.status(handle);
}
@Keep
public static int tlsSend(int handle, byte[] data) {
return TlsSocket.send(handle, data);
}
@Keep
public static byte[] tlsReceive(int handle, int max) {
return TlsSocket.receive(handle, max);
}
@Keep
public static String tlsError(int handle) {
return TlsSocket.error(handle);
}
@Keep
public static void tlsClose(int handle) {
TlsSocket.close(handle);
}
@Keep
public static boolean httpDownload(String url, String destPath, String userAgent, String accept) {
if (url == null || destPath == null) return false;
@@ -0,0 +1,310 @@
package org.love2d.android;
import android.util.Log;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SNIServerName;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
/**
* A TLS client socket that Lua can drive without ever blocking a frame.
*
* WHY THIS EXISTS. LuaSocket, which is what LOVE ships, speaks TCP and nothing
* else, so wss:// was simply unreachable from the game -- and every room hosted
* on archipelago.gg is TLS-only, accepting a plain connection just long enough
* to drop it. The alternative was vendoring mbedTLS into the NDK build and
* carrying a CA bundle in the APK; the platform already has both a TLS stack
* and the system trust store, so this asks Android instead.
*
* THE CONTRACT. Callers get an int handle and poll it. open() returns
* immediately and the connect and handshake happen on their own thread, so a
* slow or unreachable host costs nothing on the game thread -- which matters
* more here than it did for httpDownload, since that runs on a worker and this
* is serviced from the frame loop. Bytes handed to send() before the handshake
* finishes are queued, not refused, so a caller can write its request the
* moment it has a handle and never think about readiness again.
*
* Reads are drained by a thread into a chunk queue and handed over a copy at a
* time; a caller that stops polling stops the connection rather than growing
* the heap without limit.
*/
final class TlsSocket {
static final int STATUS_CONNECTING = 0;
static final int STATUS_OPEN = 1;
static final int STATUS_CLOSED = 2;
private static final int CONNECT_TIMEOUT_MS = 15000;
private static final int READ_CHUNK = 16384;
/** Roughly a second of a very chatty room; past this the reader is gone. */
private static final int MAX_BUFFERED = 4 * 1024 * 1024;
private static final ConcurrentHashMap<Integer, TlsSocket> LIVE =
new ConcurrentHashMap<Integer, TlsSocket>();
private static final AtomicInteger NEXT_HANDLE = new AtomicInteger(1);
private final String host;
private final int port;
private final int handle;
private volatile int status = STATUS_CONNECTING;
private volatile String error = null;
private volatile boolean closing = false;
private volatile SSLSocket socket = null;
private final Object inLock = new Object();
private final ArrayDeque<byte[]> inChunks = new ArrayDeque<byte[]>();
private int inHeadOffset = 0;
private int inAvailable = 0;
private final Object outLock = new Object();
private final ArrayDeque<byte[]> outChunks = new ArrayDeque<byte[]>();
private TlsSocket(String host, int port, int handle) {
this.host = host;
this.port = port;
this.handle = handle;
}
// ---------------------------------------------------------------- API
static int open(String host, int port) {
if (host == null || host.length() == 0 || port <= 0 || port > 65535) return -1;
final int handle = NEXT_HANDLE.getAndIncrement();
final TlsSocket self = new TlsSocket(host, port, handle);
LIVE.put(Integer.valueOf(handle), self);
Thread dialer = new Thread(new Runnable() {
@Override public void run() { self.dial(); }
}, "tls-dial-" + handle);
dialer.setDaemon(true);
dialer.start();
return handle;
}
static int status(int handle) {
TlsSocket self = LIVE.get(Integer.valueOf(handle));
return self == null ? -1 : self.status;
}
static String error(int handle) {
TlsSocket self = LIVE.get(Integer.valueOf(handle));
return self == null ? null : self.error;
}
static int send(int handle, byte[] data) {
TlsSocket self = LIVE.get(Integer.valueOf(handle));
if (self == null || data == null) return -1;
if (self.status == STATUS_CLOSED) return -1;
if (data.length == 0) return 0;
synchronized (self.outLock) {
self.outChunks.add(data);
self.outLock.notifyAll();
}
return data.length;
}
static byte[] receive(int handle, int max) {
TlsSocket self = LIVE.get(Integer.valueOf(handle));
if (self == null || max <= 0) return null;
return self.take(max);
}
static void close(int handle) {
TlsSocket self = LIVE.remove(Integer.valueOf(handle));
if (self != null) self.shutdown(null);
}
// ------------------------------------------------------------ internals
private void dial() {
Socket plain = null;
try {
plain = new Socket();
plain.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS);
plain.setTcpNoDelay(true);
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
SSLSocket ssl = (SSLSocket) factory.createSocket(plain, host, port, true);
// Wrapping an already-connected socket skips the SNI and hostname
// checking that createSocket(host, port) would have done for us, and
// a shared address like archipelago.gg answers with the wrong
// certificate without the name in the hello. Both are set through
// SSLParameters where the platform has it, with the verifier below
// as the floor for anything older.
boolean verifiedByPlatform = false;
try {
SSLParameters params = ssl.getSSLParameters();
params.setEndpointIdentificationAlgorithm("HTTPS");
List<SNIServerName> names = new ArrayList<SNIServerName>(1);
names.add(new SNIHostName(host));
params.setServerNames(names);
ssl.setSSLParameters(params);
verifiedByPlatform = true;
} catch (Throwable ignored) {
// Older platform: handled after the handshake instead.
}
enableModernProtocols(ssl);
ssl.startHandshake();
if (!verifiedByPlatform
&& !HttpsURLConnection.getDefaultHostnameVerifier()
.verify(host, ssl.getSession())) {
throw new java.io.IOException(
"certificate does not match " + host);
}
socket = ssl;
if (closing) { shutdown(null); return; }
status = STATUS_OPEN;
Thread writer = new Thread(new Runnable() {
@Override public void run() { pumpOut(); }
}, "tls-write-" + handle);
writer.setDaemon(true);
writer.start();
pumpIn();
} catch (Throwable t) {
shutdown(describe(t));
if (plain != null) {
try { plain.close(); } catch (Throwable ignored) {}
}
}
}
/**
* minSdk is 16, where TLS 1.1/1.2 exist but are off by default. Every
* modern server refuses everything older, so switch on whatever the
* platform has rather than leaving an old device negotiating TLS 1.0.
*/
private static void enableModernProtocols(SSLSocket ssl) {
try {
List<String> wanted = new ArrayList<String>(3);
for (String supported : ssl.getSupportedProtocols()) {
if (supported.startsWith("TLSv1.1")
|| supported.startsWith("TLSv1.2")
|| supported.startsWith("TLSv1.3")) {
wanted.add(supported);
}
}
if (!wanted.isEmpty()) {
ssl.setEnabledProtocols(wanted.toArray(new String[wanted.size()]));
}
} catch (Throwable ignored) {
}
}
private void pumpIn() {
try {
InputStream in = socket.getInputStream();
byte[] buf = new byte[READ_CHUNK];
while (!closing) {
int n = in.read(buf);
if (n < 0) break;
if (n == 0) continue;
byte[] chunk = new byte[n];
System.arraycopy(buf, 0, chunk, 0, n);
synchronized (inLock) {
if (inAvailable + n > MAX_BUFFERED) {
throw new java.io.IOException("read buffer overflow");
}
inChunks.add(chunk);
inAvailable += n;
}
}
shutdown(null);
} catch (Throwable t) {
shutdown(describe(t));
}
}
private void pumpOut() {
try {
OutputStream out = socket.getOutputStream();
while (true) {
byte[] chunk;
synchronized (outLock) {
while (outChunks.isEmpty() && !closing && status != STATUS_CLOSED) {
outLock.wait();
}
if (closing || status == STATUS_CLOSED) return;
chunk = outChunks.poll();
}
if (chunk != null) {
out.write(chunk);
out.flush();
}
}
} catch (Throwable t) {
shutdown(describe(t));
}
}
private byte[] take(int max) {
synchronized (inLock) {
if (inAvailable <= 0) return null;
int want = Math.min(max, inAvailable);
byte[] out = new byte[want];
int filled = 0;
while (filled < want) {
byte[] head = inChunks.peek();
if (head == null) break;
int have = head.length - inHeadOffset;
int take = Math.min(have, want - filled);
System.arraycopy(head, inHeadOffset, out, filled, take);
filled += take;
inHeadOffset += take;
if (inHeadOffset >= head.length) {
inChunks.poll();
inHeadOffset = 0;
}
}
inAvailable -= filled;
if (filled == want) return out;
byte[] short_ = new byte[filled];
System.arraycopy(out, 0, short_, 0, filled);
return short_;
}
}
/**
* The handle stays registered until the caller closes it, so why the
* connection ended and whatever arrived before it did are both still
* readable. Dropping it here instead would turn a server that states its
* refusal and hangs up into an unknown handle, which is the one failure a
* player most needs the reason for.
*/
private void shutdown(String why) {
if (why != null && error == null) error = why;
closing = true;
status = STATUS_CLOSED;
synchronized (outLock) { outLock.notifyAll(); }
SSLSocket s = socket;
socket = null;
if (s != null) {
try { s.close(); } catch (Throwable ignored) {}
}
if (why != null) Log.d("TlsSocket", host + ":" + port + " -- " + why);
}
private static String describe(Throwable t) {
String msg = t.getMessage();
String name = t.getClass().getSimpleName();
if (msg == null || msg.length() == 0) return name;
return name + ": " + msg;
}
}
+24
View File
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Desktop TLS dialer for mods that need outbound WSS/TLS (e.g. multiworld
clients). Native AOT so the game gets a plain C ABI DLL with no .NET
runtime to install. Windows uses Schannel via SslStream; Linux and macOS
use their platform backends the same way.
Publish examples:
dotnet publish -c Release -r win-x64 -o ../../dist/native/win-x64
dotnet publish -c Release -r linux-x64 -o ../../dist/native/linux-x64
dotnet publish -c Release -r osx-x64 -o ../../dist/native/osx-x64
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<PublishAot>true</PublishAot>
<NativeLib>Shared</NativeLib>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
<AssemblyName>gen1tls</AssemblyName>
<RootNamespace>Gen1Tls</RootNamespace>
</PropertyGroup>
</Project>
+30
View File
@@ -0,0 +1,30 @@
# gen1tls — desktop TLS dialer
Native AOT library that gives mods a non-blocking TLS client with the same
handle/poll contract as the Android `TlsSocket` / `love.system.tls*` bridge.
- **Windows:** Schannel via `SslStream` (system trust store, SNI)
- **Linux / macOS:** same project, publish with `-r linux-x64` / `osx-x64` /
`osx-arm64` when those builds are wired up
## Build
```powershell
dotnet publish native/tls_dial/Gen1Tls.csproj -c Release -r win-x64 -o dist/native/win-x64
```
The Windows game zip script already does this:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/build_windows.ps1 -Version 0.1.77
```
Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
required on the player's machine.
## Android
On Android, the matching API is exposed as `love.system.tlsOpen` /
`tlsStatus` / `tlsSend` / `tlsReceive` / `tlsError` / `tlsClose`, backed by
`org.love2d.android.TlsSocket`.
+282
View File
@@ -0,0 +1,282 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Security;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Authentication;
using System.Text;
using System.Threading;
namespace Gen1Tls;
/// <summary>
/// Non-blocking TLS client with the same handle/poll contract as the Android
/// TlsSocket bridge. Connect and handshake run on a background thread; send
/// queues until the stream is ready; receive drains a chunk queue. Certificate
/// validation and SNI are SslStream's defaults (platform trust store).
/// </summary>
public static class TlsDialer
{
public const int StatusConnecting = 0;
public const int StatusOpen = 1;
public const int StatusClosed = 2;
const int ConnectTimeoutMs = 15000;
const int ReadChunk = 16384;
const int MaxBuffered = 4 * 1024 * 1024;
static readonly ConcurrentDictionary<int, Conn> Live = new();
static int NextHandle = 1;
sealed class Conn
{
public required string Host;
public required int Port;
public int Status = StatusConnecting;
public string? Error;
public bool Closing;
public SslStream? Stream;
public TcpClient? Client;
public readonly object InLock = new();
public readonly Queue<byte[]> InChunks = new();
public int InHeadOffset;
public int InAvailable;
public readonly object OutLock = new();
public readonly Queue<byte[]> OutChunks = new();
public readonly ManualResetEventSlim OutPulse = new(false);
}
// ---------------------------------------------------------------- C ABI
[UnmanagedCallersOnly(EntryPoint = "gen1tls_open")]
public static int Open(nint hostPtr, int port)
{
if (hostPtr == 0 || port <= 0 || port > 65535) return -1;
string? host = Marshal.PtrToStringUTF8(hostPtr);
if (string.IsNullOrEmpty(host)) return -1;
int handle = Interlocked.Increment(ref NextHandle);
var conn = new Conn { Host = host, Port = port };
if (!Live.TryAdd(handle, conn)) return -1;
var dialer = new Thread(() => Dial(conn))
{
IsBackground = true,
Name = "gen1tls-dial-" + handle,
};
dialer.Start();
return handle;
}
[UnmanagedCallersOnly(EntryPoint = "gen1tls_status")]
public static int Status(int handle)
=> Live.TryGetValue(handle, out var conn) ? conn.Status : -1;
[UnmanagedCallersOnly(EntryPoint = "gen1tls_send")]
public static int Send(int handle, nint dataPtr, int length)
{
if (!Live.TryGetValue(handle, out var conn) || dataPtr == 0) return -1;
if (conn.Status == StatusClosed) return -1;
if (length <= 0) return 0;
var copy = new byte[length];
Marshal.Copy(dataPtr, copy, 0, length);
lock (conn.OutLock)
{
conn.OutChunks.Enqueue(copy);
conn.OutPulse.Set();
}
return length;
}
[UnmanagedCallersOnly(EntryPoint = "gen1tls_receive")]
public static int Receive(int handle, nint bufPtr, int max)
{
if (!Live.TryGetValue(handle, out var conn) || bufPtr == 0 || max <= 0)
return 0;
return Take(conn, bufPtr, max);
}
[UnmanagedCallersOnly(EntryPoint = "gen1tls_error")]
public static int Error(int handle, nint bufPtr, int max)
{
if (!Live.TryGetValue(handle, out var conn)) return 0;
string? err = conn.Error;
if (string.IsNullOrEmpty(err) || bufPtr == 0 || max <= 1) return 0;
byte[] utf8 = Encoding.UTF8.GetBytes(err);
int n = Math.Min(utf8.Length, max - 1);
Marshal.Copy(utf8, 0, bufPtr, n);
Marshal.WriteByte(bufPtr, n, 0);
return 1;
}
[UnmanagedCallersOnly(EntryPoint = "gen1tls_close")]
public static void Close(int handle)
{
if (Live.TryRemove(handle, out var conn))
Shutdown(conn, null);
}
// ------------------------------------------------------------ internals
static void Dial(Conn conn)
{
try
{
var client = new TcpClient();
var connect = client.ConnectAsync(conn.Host, conn.Port);
if (!connect.Wait(ConnectTimeoutMs))
throw new TimeoutException($"connect to {conn.Host}:{conn.Port} timed out");
connect.GetAwaiter().GetResult();
client.NoDelay = true;
conn.Client = client;
var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
// AuthenticateAsClient sets SNI from targetHost and validates against
// the platform trust store -- the whole reason this dialer exists.
var auth = ssl.AuthenticateAsClientAsync(conn.Host);
if (!auth.Wait(ConnectTimeoutMs))
throw new TimeoutException($"TLS handshake with {conn.Host} timed out");
auth.GetAwaiter().GetResult();
conn.Stream = ssl;
if (conn.Closing) { Shutdown(conn, null); return; }
conn.Status = StatusOpen;
var writer = new Thread(() => PumpOut(conn))
{
IsBackground = true,
Name = "gen1tls-write",
};
writer.Start();
PumpIn(conn);
}
catch (Exception ex)
{
Shutdown(conn, Describe(ex));
}
}
static void PumpIn(Conn conn)
{
var stream = conn.Stream;
if (stream == null) return;
var buf = new byte[ReadChunk];
try
{
while (!conn.Closing)
{
int n = stream.Read(buf, 0, buf.Length);
if (n <= 0) break;
var chunk = new byte[n];
Buffer.BlockCopy(buf, 0, chunk, 0, n);
lock (conn.InLock)
{
// A stalled Lua pump must not grow forever; drop the
// connection rather than the room's backlog.
if (conn.InAvailable + n > MaxBuffered)
throw new InvalidOperationException("TLS receive buffer overflow");
conn.InChunks.Enqueue(chunk);
conn.InAvailable += n;
}
}
}
catch (Exception ex)
{
if (!conn.Closing) Shutdown(conn, Describe(ex));
return;
}
Shutdown(conn, null);
}
static void PumpOut(Conn conn)
{
var stream = conn.Stream;
if (stream == null) return;
try
{
while (!conn.Closing)
{
byte[]? chunk = null;
lock (conn.OutLock)
{
if (conn.OutChunks.Count == 0)
{
conn.OutPulse.Reset();
// fall through to wait outside the lock
}
else
{
chunk = conn.OutChunks.Dequeue();
}
}
if (chunk == null)
{
conn.OutPulse.Wait(250);
continue;
}
stream.Write(chunk, 0, chunk.Length);
stream.Flush();
}
}
catch (Exception ex)
{
if (!conn.Closing) Shutdown(conn, Describe(ex));
}
}
static int Take(Conn conn, nint bufPtr, int max)
{
lock (conn.InLock)
{
if (conn.InAvailable == 0 || conn.InChunks.Count == 0) return 0;
int copied = 0;
while (copied < max && conn.InChunks.Count > 0)
{
byte[] head = conn.InChunks.Peek();
int avail = head.Length - conn.InHeadOffset;
int n = Math.Min(avail, max - copied);
Marshal.Copy(head, conn.InHeadOffset, bufPtr + copied, n);
copied += n;
conn.InHeadOffset += n;
conn.InAvailable -= n;
if (conn.InHeadOffset >= head.Length)
{
conn.InChunks.Dequeue();
conn.InHeadOffset = 0;
}
}
return copied;
}
}
static void Shutdown(Conn conn, string? why)
{
if (conn.Closing && why == null && conn.Status == StatusClosed) return;
conn.Closing = true;
if (why != null) conn.Error = why;
conn.Status = StatusClosed;
conn.OutPulse.Set();
try { conn.Stream?.Dispose(); } catch { /* ignore */ }
try { conn.Client?.Dispose(); } catch { /* ignore */ }
conn.Stream = null;
conn.Client = null;
}
static string Describe(Exception ex)
{
for (Exception? e = ex; e != null; e = e.InnerException)
{
if (e is AuthenticationException) return e.Message;
if (e is SocketException se) return se.Message;
if (e is TimeoutException) return e.Message;
if (e is IOException) return e.Message;
}
return ex.GetBaseException().Message;
}
}
+94
View File
@@ -0,0 +1,94 @@
# Build a Windows win64 Gen1Recomp zip with native TLS (gen1tls.dll).
#
# Usage:
# powershell -ExecutionPolicy Bypass -File scripts\build_windows.ps1 [-Version 0.1.77]
#
# Produces:
# dist\win\gen1recomp-<version>-windows.zip
# gen1recomp.exe fused LÖVE + game.love
# gen1tls.dll Native AOT TLS dialer (Schannel via SslStream)
#
# Requires: .NET 8 SDK (for Native AOT), Git Bash, and tools/winbuild on PATH
# for pack_love.sh under Git Bash on Windows.
param(
[string]$Version = "0.1.77"
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
if (-not (Test-Path (Join-Path $Root "main.lua"))) {
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
}
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
throw "Version must be X.Y.Z (got '$Version')"
}
$Cache = Join-Path $Root ".bazinga\cache"
$Work = Join-Path $Root ".bazinga\work"
$OutDir = Join-Path $Work "gen1recomp-win64"
$DistDir = Join-Path $Root "dist\win"
$NativeOut = Join-Path $Root "dist\native\win-x64"
$LoveZip = Join-Path $Cache "love-11.5-win64.zip"
$LoveUrl = "https://github.com/love2d/love/releases/download/11.5/love-11.5-win64.zip"
$Bash = "C:\Program Files\Git\bin\bash.exe"
New-Item -ItemType Directory -Force -Path $Cache, $Work, $DistDir, $NativeOut | Out-Null
# Put zip/python shims first so pack_love.sh works in Git Bash on Windows.
$env:PATH = "$(Join-Path $Root 'tools\winbuild');$env:PATH"
Write-Host "==> building gen1tls.dll (Native AOT)"
dotnet publish (Join-Path $Root "native\tls_dial\Gen1Tls.csproj") `
-c Release -r win-x64 -o $NativeOut
if ($LASTEXITCODE -ne 0) { throw "gen1tls publish failed" }
$TlsDll = Join-Path $NativeOut "gen1tls.dll"
if (-not (Test-Path $TlsDll)) { throw "gen1tls.dll missing after publish" }
if (-not (Test-Path $LoveZip) -or (Get-Item $LoveZip).Length -lt 1MB) {
Write-Host "==> downloading love-11.5-win64.zip"
Invoke-WebRequest -Uri $LoveUrl -OutFile $LoveZip -UseBasicParsing
}
Write-Host "==> packing game.love (engine $Version)"
if (-not (Test-Path $Bash)) { throw "Git Bash not found at $Bash" }
$RootPosix = ($Root -replace '\\', '/') -replace '^([A-Za-z]):', '/$1'
& $Bash -lc "cd '$RootPosix' && PATH=`"`$PWD/tools/winbuild:`$PATH`" scripts/pack_love.sh --output .bazinga/work/game.love --listing .bazinga/work/love-listing.txt --version $Version"
if ($LASTEXITCODE -ne 0) { throw "pack_love failed" }
Write-Host "==> fusing gen1recomp.exe"
$Extract = Join-Path $Work "love-win64"
Remove-Item -Recurse -Force $Extract, $OutDir -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Extract, $OutDir | Out-Null
Expand-Archive -Path $LoveZip -DestinationPath $Extract -Force
$LoveDir = Get-ChildItem $Extract -Directory | Select-Object -First 1
Copy-Item (Join-Path $LoveDir.FullName "*.dll") $OutDir
Copy-Item (Join-Path $LoveDir.FullName "license.txt") $OutDir -ErrorAction SilentlyContinue
Copy-Item $TlsDll $OutDir
$out = [IO.File]::Create((Join-Path $OutDir "gen1recomp.exe"))
try {
$a = [IO.File]::OpenRead((Join-Path $LoveDir.FullName "love.exe"))
try { $a.CopyTo($out) } finally { $a.Dispose() }
$b = [IO.File]::OpenRead((Join-Path $Work "game.love"))
try { $b.CopyTo($out) } finally { $b.Dispose() }
} finally { $out.Dispose() }
@"
Gen1Recomp Windows (engine $Version) native TLS
gen1tls.dll sits next to gen1recomp.exe and exposes a non-blocking TLS
client (Windows Schannel via .NET SslStream, Native AOT). Mods can load it
through LuaJIT FFI, or call love.system.tls* on Android.
Keep gen1tls.dll beside the executable when you redistribute the zip.
"@ | Set-Content (Join-Path $OutDir "README-TLS.txt") -Encoding UTF8
$ZipOut = Join-Path $DistDir "gen1recomp-$Version-windows.zip"
Remove-Item $ZipOut -ErrorAction SilentlyContinue
Compress-Archive -Path $OutDir -DestinationPath $ZipOut -Force
Write-Host "==> built $ZipOut"
Get-Item $ZipOut | ForEach-Object { " {0:N1} MB" -f ($_.Length / 1MB) }
Get-ChildItem $OutDir | ForEach-Object { " $($_.Name)" }
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Windows ships a python3.exe under WindowsApps that exists purely to advertise
# the Microsoft Store, so `command -v python3` succeeds and running it fails.
# build_android.sh calls python3 three times (the Yellow manifest check, the
# gradle.properties rewrite and the manifest permission trim), and this puts a
# real interpreter behind that name for the length of the build.
exec py "$@"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Stands in for Info-ZIP's `zip`, which Git for Windows does not ship. Put
# this directory first on PATH before running scripts/build_android.sh; see
# zip_impl.py for what is and is not supported.
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec py "$here/zip_impl.py" "$@"
+111
View File
@@ -0,0 +1,111 @@
"""Enough of Info-ZIP's `zip` for scripts/build_android.sh, on Windows.
Git for Windows ships unzip but not zip, which is the one tool standing between
a Windows checkout and a local Android build. Rather than fetching a binary
from somewhere unaudited, this covers exactly the two forms the build script
uses and refuses anything it does not understand, so a future flag fails loudly
instead of silently producing a wrong archive:
zip -q -9 -r out.love main.lua src data -x '*.DS_Store' -x 'data/generated/*'
zip -q out.love src/core/Version.lua # add or replace one entry
Paths are stored relative to the working directory with forward slashes, and
directories are walked in sorted order so the archive is reproducible.
"""
import fnmatch
import os
import sys
import zipfile
def excluded(name, patterns):
# zip's wildcards cross directory separators, which is also what fnmatch
# does, so the build script's '*/.git/*' behaves the same either way.
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
def collect(paths, patterns):
"""-> archive-relative names, in a stable order."""
names = []
for path in paths:
if os.path.isdir(path):
for root, dirs, files in os.walk(path):
dirs.sort()
for f in sorted(files):
full = os.path.join(root, f)
name = os.path.relpath(full, ".").replace(os.sep, "/")
if not excluded(name, patterns):
names.append(name)
elif os.path.isfile(path):
name = os.path.relpath(path, ".").replace(os.sep, "/")
if not excluded(name, patterns):
names.append(name)
else:
sys.stderr.write("zip: %s not found\n" % path)
return None
return names
def main(argv):
patterns, paths, archive = [], [], None
i = 0
while i < len(argv):
arg = argv[i]
if arg == "-x":
# Info-ZIP accepts one -x followed by several patterns, which is how
# pack_love.sh and build.sh write their excludes. Consume every
# following non-flag argument as a pattern; paths always come before
# -x in those scripts, so this does not steal archive members.
i += 1
if i >= len(argv) or argv[i].startswith("-"):
sys.stderr.write("zip: -x needs a pattern\n")
return 2
while i < len(argv) and not argv[i].startswith("-"):
patterns.append(argv[i])
i += 1
continue
elif arg in ("-q", "-9", "-r", "-X", "-o"):
pass # quiet, compression level, recurse, no-extra, ordering
elif arg.startswith("-"):
sys.stderr.write("zip shim: unsupported flag %s\n" % arg)
return 2
elif archive is None:
archive = arg
else:
paths.append(arg)
i += 1
if archive is None or not paths:
sys.stderr.write("usage: zip [-q9r] [-x pat] archive path...\n")
return 2
names = collect(paths, patterns)
if names is None:
return 1
# Adding to an existing archive means rewriting it: zipfile can append, but
# appending a name that is already in there leaves both copies and readers
# disagree about which one wins. The version stamp does exactly that.
keep = []
if os.path.exists(archive):
replacing = set(names)
with zipfile.ZipFile(archive, "r") as old:
for info in old.infolist():
if info.filename not in replacing:
keep.append((info, old.read(info.filename)))
tmp = archive + ".shimtmp"
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as out:
for info, data in keep:
out.writestr(info, data)
for name in names:
out.write(name, name)
if os.path.exists(archive):
os.remove(archive)
os.rename(tmp, archive)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))