Editor/RelaySetup.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using Sandbox.Internal;

namespace TeamCreate;

/// <summary>Sends Cloudflare requests through the engine's HTTP client, which is the only network access an editor script has.</summary>
public sealed class SandboxCloudflareTransport : ICloudflareTransport
{
    private const int MaxBodyChars = 262144;

    public async Task<CloudflareResponse> SendAsync(CloudflareRequest request, CancellationToken cancellationToken)
    {
        HttpResponseMessage response = null;
        try
        {
            // Content-Type travels on the content itself; the engine refuses to set some headers by name, and everything the deploy needs is allowed.
            response = await Http.RequestAsync(request.Url, request.Method, request.Content, request.Headers.Count == 0 ? null : request.Headers, cancellationToken);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            return new CloudflareResponse(null, null, ex.GetType().Name + ": " + ex.Message);
        }
        using (response)
        {
            int status = (int)response.StatusCode;
            string body = null;
            try
            {
                string text = await response.Content.ReadAsStringAsync(cancellationToken);
                body = text != null && text.Length <= MaxBodyChars ? text : null;
            }
            catch (OperationCanceledException)
            {
                throw;
            }
            catch (Exception)
            {
                body = null;
            }
            return new CloudflareResponse(status, body);
        }
    }
}

/// <summary>
/// The Relay card's machinery. Two buttons drive it: one opens Cloudflare's token page, the other reads the token from the clipboard, deploys the relay to the
/// person's own free account, and remembers the address on this computer. The token is held only for the length of the deploy: it is never written to disk,
/// never logged, and the clipboard is cleared afterwards if it still holds it. All state is polled from the frame, never from a thread of its own.
/// </summary>
public static class RelaySetup
{
    private static Task<DeployResult> task;

    private static CancellationTokenSource cancel;

    private static volatile string progress;

    private static string tokenInUse;

    private static bool localLoaded;

    private static string localRelay;

    private static string localProblem;

    public static DeployResult LastResult { get; private set; }

    public static bool Working => task != null && !task.IsCompleted;

    public static string Progress => progress;

    /// <summary>The relay this computer set up, or null. Read once and kept, because the dock asks every frame.</summary>
    public static string LocalRelay
    {
        get
        {
            EnsureLocalLoaded();
            return localRelay;
        }
    }

    /// <summary>Why the local file could not be used, or null.</summary>
    public static string LocalProblem
    {
        get
        {
            EnsureLocalLoaded();
            return localProblem;
        }
    }

    private static string LocalFilePath => Path.Combine(SceneAdapter.Root, ".collaboration", LocalRelayFile.FileName);

    private static void EnsureLocalLoaded()
    {
        if (localLoaded)
        {
            return;
        }
        localLoaded = true;
        localRelay = null;
        localProblem = null;
        try
        {
            string path = LocalFilePath;
            if (File.Exists(path))
            {
                localRelay = LocalRelayFile.Parse(File.ReadAllText(path), out localProblem);
            }
        }
        catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
        {
            localProblem = "The relay address file could not be read: " + ex.Message;
        }
        if (localProblem != null)
        {
            GlobalGameNamespace.Log.Error("Collaboration relay: " + CollaborationDiagnostics.Bounded(localProblem));
        }
    }

    public static RelaySetupView Describe(string projectRelay)
    {
        RelaySetupView view = RelaySetupRules.Describe(new RelaySetupInput(projectRelay, LocalRelay, RelayDefaults.DefaultUrl, Working, progress, LastResult));
        if (localProblem != null && view.Mode == RelaySetupMode.Missing)
        {
            return view with { Text = localProblem + " " + view.Text, IsProblem = true };
        }
        return view;
    }

    /// <summary>Opens the Cloudflare page that creates the token, with the two permissions the relay needs already chosen.</summary>
    public static void OpenTokenPage()
    {
        // The engine's own tools open web addresses through this call (Project.EditUrl, Package.Url); MenuUtility.OpenUrl belongs to the menu assembly, which editor scripts cannot reference.
        EditorUtility.OpenFolder(CloudflareApi.TokenPageUrl());
    }

    /// <summary>Reads the token from the clipboard and deploys. The clipboard is checked before anything is sent anywhere.</summary>
    public static void Start()
    {
        if (Working)
        {
            return;
        }
        string clipboard = null;
        try
        {
            clipboard = EditorUtility.Clipboard.Paste();
        }
        catch (Exception)
        {
            clipboard = null;
        }
        if (!CloudflareApi.TryAcceptToken(clipboard, out string token, out string problem))
        {
            LastResult = new DeployResult(false, null, problem);
            progress = null;
            return;
        }
        LastResult = null;
        tokenInUse = token;
        cancel = new CancellationTokenSource();
        progress = "Starting…";
        var deployer = new RelayDeployer(new SandboxCloudflareTransport(), RelayWorkerSource.Text);
        task = deployer.RunAsync(token, text => progress = text, cancel.Token);
    }

    public static void Cancel()
    {
        cancel?.Cancel();
    }

    [EditorEvent.Frame]
    public static void Pump()
    {
        if (task == null || !task.IsCompleted)
        {
            return;
        }
        Task<DeployResult> finished = task;
        task = null;
        string token = tokenInUse;
        tokenInUse = null;
        cancel?.Dispose();
        cancel = null;
        progress = null;
        DeployResult result;
        try
        {
            result = finished.IsFaulted || finished.IsCanceled
                ? new DeployResult(false, null, "Setup stopped unexpectedly: " + CloudflareApi.Redact(finished.Exception?.GetBaseException().Message, token) + " Nothing was changed on this computer.")
                : finished.Result;
        }
        catch (Exception ex)
        {
            result = new DeployResult(false, null, "Setup stopped unexpectedly: " + CloudflareApi.Redact(ex.Message, token));
        }
        ClearClipboardIfStillHolds(token);
        if (result.Ok)
        {
            string saveProblem = Save(result.RelayUrl);
            if (saveProblem != null)
            {
                result = new DeployResult(false, null, saveProblem);
            }
        }
        LastResult = result;
        if (!result.Ok && !result.Cancelled)
        {
            GlobalGameNamespace.Log.Error("Collaboration relay setup: " + CollaborationDiagnostics.Bounded(result.Error));
        }
    }

    private static string Save(string relayUrl)
    {
        try
        {
            string path = LocalFilePath;
            Directory.CreateDirectory(Path.GetDirectoryName(path));
            string temp = path + ".tmp";
            File.WriteAllText(temp, LocalRelayFile.Format(relayUrl));
            File.Move(temp, path, true);
            localLoaded = false;
            return null;
        }
        catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
        {
            return "The relay is running at " + RelaySetupRules.HostOf(relayUrl) + ", but its address could not be saved on this computer (" + ex.Message + "). Put it in .collaboration/" + LocalRelayFile.FileName + " yourself, or set RelayUrl in ProjectSettings/collaboration.json.";
        }
    }

    /// <summary>The token has done its job; if it is still the thing on the clipboard, take it off so it is not pasted somewhere by accident.</summary>
    private static void ClearClipboardIfStillHolds(string token)
    {
        if (string.IsNullOrEmpty(token))
        {
            return;
        }
        try
        {
            string now = EditorUtility.Clipboard.Paste();
            if (now != null && string.Equals(now.Trim(), token, StringComparison.Ordinal))
            {
                EditorUtility.Clipboard.Copy(string.Empty);
            }
        }
        catch (Exception)
        {
            // Not being able to clear the clipboard changes nothing about the deploy.
        }
    }
}