Editor/Core/RelayDefaults.cs
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace TeamCreate;

/// <summary>
/// Which relay a project uses when the person set nothing up. The library carries one address of its own so that hosting works the first time, with
/// no server to run and no setting to edit; a project can name a different relay in ProjectSettings/collaboration.json, or turn relaying off.
/// </summary>
public static class RelayDefaults
{
    /// <summary>
    /// The relay every project uses unless it says otherwise. Null until the library's own relay has been deployed; Tools/RelayWorker/set-default-relay.js
    /// writes the address here after `wrangler deploy`.
    /// </summary>
    public static readonly string DefaultUrl = null;

    /// <summary>The value of RelayUrl that opts a project out of relaying altogether.</summary>
    public const string Off = "off";

    /// <summary>What to tell a host whose session has no relay a friend on another network could use. Null when nothing needs saying.</summary>
    public static string NoRelayNotice(string effectiveRelay, string configured)
    {
        if (!string.IsNullOrWhiteSpace(effectiveRelay))
        {
            return RelayProtocol.IsPublicRelay(effectiveRelay)
                ? null
                : "The relay is on this computer, so only this computer can use it. A friend on another network cannot join through it; use a public relay address.";
        }
        if (string.Equals(configured?.Trim(), Off, StringComparison.OrdinalIgnoreCase))
        {
            return "Relaying is turned off for this project (RelayUrl is \"off\"), so friends can join only from your network or through a port you forward.";
        }
        return "No relay is set up, so friends can join only from the same network or through a port you forward. To let friends anywhere join, open the Setup tab and press Set up relay.";
    }

    /// <summary>The relay to use: the project's own if it names one, nothing if it says "off", otherwise the built-in one.</summary>
    public static string Resolve(string configured, string builtIn)
    {
        if (string.Equals(configured?.Trim(), Off, StringComparison.OrdinalIgnoreCase))
        {
            return null;
        }
        if (!string.IsNullOrWhiteSpace(configured))
        {
            return configured.Trim();
        }
        return string.IsNullOrWhiteSpace(builtIn) ? null : builtIn.Trim();
    }
}

public sealed record SetupResult(bool Created, string Error);

/// <summary>
/// Makes the shared settings file on first use, so a new project needs no hand-written JSON. It only ever adds what is missing: an existing file keeps
/// every choice in it, including a relaxed admission gate, and a file it cannot read is reported and left exactly as it was.
/// </summary>
public static class CollaborationSetup
{
    public static SetupResult EnsureConfig(string path, string org, string libraryIdent, string libraryVersion)
    {
        try
        {
            JsonObject document;
            if (File.Exists(path))
            {
                JsonNode parsed;
                try
                {
                    parsed = JsonNode.Parse(File.ReadAllText(path));
                }
                catch (JsonException ex)
                {
                    return new SetupResult(false, path + " is not valid JSON (" + ex.Message + "). It was left as it is; fix it or delete it.");
                }
                if (!(parsed is JsonObject existing))
                {
                    return new SetupResult(false, path + " must contain a JSON object. It was left as it is.");
                }
                document = existing;
                if (document.TryGetPropertyValue("ProjectId", out JsonNode idNode) && idNode != null)
                {
                    string text = (idNode is JsonValue value && value.TryGetValue<string>(out string s)) ? s : null;
                    return text != null && Guid.TryParse(text, out _)
                        ? new SetupResult(false, null)
                        : new SetupResult(false, "ProjectId in " + path + " is not a GUID. It was left as it is; fix it or delete the file.");
                }
                document["ProjectId"] = Guid.NewGuid().ToString("D");
            }
            else
            {
                document = new JsonObject { ["ProjectId"] = Guid.NewGuid().ToString("D") };
                if (!string.IsNullOrWhiteSpace(org))
                {
                    document["Org"] = org.Trim();
                }
                if (!string.IsNullOrWhiteSpace(libraryIdent))
                {
                    document["LibraryIdent"] = libraryIdent.Trim();
                }
                if (!string.IsNullOrWhiteSpace(libraryVersion))
                {
                    document["LibraryVersion"] = libraryVersion.Trim();
                }
                // Every admission gate starts ON. A person can relax one by editing the file; nothing here ever does.
                document["RequireSteamFriend"] = true;
                document["RequireOrgMember"] = true;
                document["RequirePublished"] = true;
            }
            Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
            string temp = path + ".tmp";
            File.WriteAllText(temp, document.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
            File.Move(temp, path, true);
            return new SetupResult(true, null);
        }
        catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
        {
            return new SetupResult(false, path + " could not be written: " + ex.Message);
        }
    }
}