using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
namespace DorkNet.Launcher.Backend;
/// Runs a localtunnel client. Prefer angelobreuer/localtunnel.net
/// because it is a native .NET CLI and does not require Node.js; fall
/// back to npx localtunnel when users already have Node on PATH.
public sealed class LocaltunnelTunnel : IAsyncDisposable
{
private Process? _process;
private TaskCompletionSource? _urlReady;
private readonly object _outputLock = new();
private readonly Queue _recentOutput = new();
public string? PublicUrl { get; private set; }
public async Task StartAsync(int localPort, CancellationToken ct = default)
{
var executable = await LocaltunnelInstaller.TryEnsureAsync(ct: ct);
LocaltunnelKind kind;
if (executable is not null)
{
kind = LocaltunnelKind.DotNet;
}
else
{
executable = ResolveExecutable(out kind);
}
_urlReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var psi = new ProcessStartInfo
{
FileName = executable,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
if (kind == LocaltunnelKind.DotNet)
{
psi.ArgumentList.Add("--no-dashboard");
psi.ArgumentList.Add("--host");
psi.ArgumentList.Add("127.0.0.1");
psi.ArgumentList.Add("--port");
psi.ArgumentList.Add(localPort.ToString());
psi.ArgumentList.Add("http");
}
else
{
psi.ArgumentList.Add("--yes");
psi.ArgumentList.Add("localtunnel");
psi.ArgumentList.Add("--port");
psi.ArgumentList.Add(localPort.ToString());
psi.ArgumentList.Add("--local-host");
psi.ArgumentList.Add("127.0.0.1");
}
_process = Process.Start(psi)
?? throw new InvalidOperationException("Failed to start localtunnel through npx.");
_process.EnableRaisingEvents = true;
_process.Exited += (_, _) =>
{
if (_urlReady is null || _urlReady.Task.IsCompleted) return;
_urlReady.TrySetException(new InvalidOperationException(
"localtunnel exited before reporting a public URL." + FormatRecentOutput()));
};
_process.OutputDataReceived += (_, e) => ParseLine(e.Data);
_process.ErrorDataReceived += (_, e) => ParseLine(e.Data);
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
var completed = await Task.WhenAny(_urlReady.Task, Task.Delay(TimeSpan.FromSeconds(60), ct));
if (completed != _urlReady.Task)
{
ct.ThrowIfCancellationRequested();
throw new TimeoutException("localtunnel did not report a public URL within 60 seconds." + FormatRecentOutput());
}
PublicUrl = await _urlReady.Task;
return PublicUrl;
}
private static string ResolveExecutable(out LocaltunnelKind kind)
{
var ltName = OperatingSystem.IsWindows() ? "localtunnel.exe" : "localtunnel";
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, ltName),
Path.Combine(AppPaths.LocalRoot, ltName),
};
foreach (var candidate in candidates)
{
if (!File.Exists(candidate)) continue;
kind = LocaltunnelKind.DotNet;
return candidate;
}
var ltOnPath = Environment.GetEnvironmentVariable("PATH")?
.Split(Path.PathSeparator)
.Select(p => Path.Combine(p.Trim(), ltName))
.FirstOrDefault(File.Exists);
if (ltOnPath is not null)
{
kind = LocaltunnelKind.DotNet;
return ltOnPath;
}
var name = OperatingSystem.IsWindows() ? "npx.cmd" : "npx";
var onPath = Environment.GetEnvironmentVariable("PATH")?
.Split(Path.PathSeparator)
.Select(p => Path.Combine(p.Trim(), name))
.FirstOrDefault(File.Exists);
if (onPath is not null)
{
kind = LocaltunnelKind.Npx;
return onPath;
}
throw new FileNotFoundException(
"localtunnel was not found. Put angelobreuer/localtunnel.net's localtunnel.exe next to dorknet.exe, or install Node.js LTS for the npx fallback.");
}
private static readonly Regex UrlRegex = new(
@"https://[a-z0-9][a-z0-9\-]*\.(?:loca\.lt|localtunnel\.me)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private void ParseLine(string? line)
{
if (line is null) return;
lock (_outputLock)
{
_recentOutput.Enqueue(line);
while (_recentOutput.Count > 8) _recentOutput.Dequeue();
}
if (_urlReady is null || _urlReady.Task.IsCompleted) return;
var match = UrlRegex.Match(line);
if (match.Success) _urlReady.TrySetResult(match.Value);
}
private string FormatRecentOutput()
{
lock (_outputLock)
{
return _recentOutput.Count == 0
? ""
: " Last localtunnel output: " + string.Join(" | ", _recentOutput);
}
}
public async ValueTask DisposeAsync()
{
if (_process is null) return;
try
{
if (!_process.HasExited) _process.Kill(entireProcessTree: true);
await _process.WaitForExitAsync();
}
catch { }
_process.Dispose();
}
private enum LocaltunnelKind { DotNet, Npx }
}