Editor/Launcher/HotCodeInstaller.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
/// <summary>
/// Adds HotCode to other projects by copying it into their Libraries folder, which is where
/// s&box's Library Manager puts libraries too. Next time that project is opened in s&box,
/// HotCode loads with it.
///
/// Until HotCode is published, this is the way to get it into another project; afterwards the
/// Library Manager can install (and update) it as well.
/// </summary>
public static class HotCodeInstaller
{
public const string Ident = "hotcode";
/// <summary>
/// A file only HotCode has, so it can be recognised whatever org it's published under.
/// </summary>
static readonly string MarkerFile = Path.Combine( "Editor", "Launcher", "HotCodeInstaller.cs" );
/// <summary>
/// "org.hotcode", using the org from HotCode's own .sbproj (the same naming the Library Manager uses).
/// </summary>
public static string FolderName( string sourceRoot )
{
try
{
using var doc = JsonDocument.Parse( File.ReadAllText( Path.Combine( sourceRoot, Ident + ".sbproj" ) ) );
if ( doc.RootElement.TryGetProperty( "Org", out var org ) && org.GetString() is { Length: > 0 } name )
return $"{name.ToLowerInvariant()}.{Ident}";
}
catch ( Exception e ) when ( e is IOException or JsonException or UnauthorizedAccessException )
{
}
return Ident;
}
/// <summary>
/// Folders never copied: build output, editor state, and the logo source script.
/// </summary>
static readonly HashSet<string> SkipFolders = new( StringComparer.OrdinalIgnoreCase ) { "obj", "bin", ".vs", ".git", ".idea", "logo", "Properties" };
/// <summary>
/// Files never copied: s&box generates these per project, with paths that only make sense where they were made.
/// </summary>
static readonly HashSet<string> SkipExtensions = new( StringComparer.OrdinalIgnoreCase ) { ".csproj", ".sln", ".slnx", ".user" };
/// <summary>
/// Where HotCode is installed in a project (any folder under Libraries whose .sbproj is HotCode's), or null.
/// </summary>
public static string FindInstalled( string projectRoot )
{
var libraries = Path.Combine( projectRoot, "Libraries" );
if ( !Directory.Exists( libraries ) ) return null;
foreach ( var folder in Directory.EnumerateDirectories( libraries ) )
{
foreach ( var sbproj in Directory.EnumerateFiles( folder, "*.sbproj" ) )
if ( IsHotCode( sbproj ) ) return folder;
}
return null;
}
/// <summary>
/// True when an .sbproj is HotCode's own: ident "hotcode" and HotCode's files next to it.
/// Doesn't depend on the org, so renaming or changing the org doesn't break anything.
/// </summary>
public static bool IsHotCode( string sbproj )
{
try
{
if ( !File.Exists( Path.Combine( Path.GetDirectoryName( sbproj ), MarkerFile ) ) ) return false;
using var doc = JsonDocument.Parse( File.ReadAllText( sbproj ) );
return doc.RootElement.TryGetProperty( "Ident", out var ident ) && string.Equals( ident.GetString(), Ident, StringComparison.OrdinalIgnoreCase );
}
catch ( Exception e ) when ( e is IOException or JsonException or UnauthorizedAccessException )
{
return false;
}
}
/// <summary>
/// Copies HotCode from <paramref name="sourceRoot"/> into the project. An existing copy is moved
/// to the Recycle Bin first (via <paramref name="recycle"/>), so updating never loses anything for good.
/// Returns the folder HotCode now lives in. Throws <see cref="InvalidOperationException"/> with a readable message.
/// </summary>
public static string Install( string sourceRoot, string projectRoot, Action<string> recycle )
{
sourceRoot = Path.GetFullPath( sourceRoot ).TrimEnd( '\\', '/' );
projectRoot = Path.GetFullPath( projectRoot ).TrimEnd( '\\', '/' );
if ( !File.Exists( Path.Combine( sourceRoot, Ident + ".sbproj" ) ) )
throw new InvalidOperationException( "Couldn't find HotCode's own files to copy." );
if ( ProjectFiles.IsUnder( projectRoot, sourceRoot ) || ProjectFiles.IsUnder( sourceRoot, projectRoot ) )
throw new InvalidOperationException( "That's the project HotCode is being developed in - it already has it." );
if ( !Directory.Exists( projectRoot ) || !Directory.EnumerateFiles( projectRoot, "*.sbproj" ).Any() )
throw new InvalidOperationException( "That folder isn't an s&box project (no .sbproj file)." );
// Replace an existing copy wherever it is, otherwise use the standard folder name
var target = FindInstalled( projectRoot ) ?? Path.Combine( projectRoot, "Libraries", FolderName( sourceRoot ) );
if ( ProjectFiles.IsUnder( target, sourceRoot ) )
throw new InvalidOperationException( "Refusing to overwrite HotCode's own source folder." );
// Copy into a staging folder first, so a failed copy never leaves a half-installed library
var staging = target + ".installing";
if ( Directory.Exists( staging ) ) Directory.Delete( staging, recursive: true );
CopyFolder( sourceRoot, staging );
if ( Directory.Exists( target ) ) recycle( target );
Directory.Move( staging, target );
return target;
}
/// <summary>
/// Removes HotCode from a project (to the Recycle Bin). Returns false if it wasn't installed.
/// </summary>
public static bool Uninstall( string sourceRoot, string projectRoot, Action<string> recycle )
{
var installed = FindInstalled( projectRoot );
if ( installed is null ) return false;
if ( ProjectFiles.IsUnder( installed, sourceRoot ) )
throw new InvalidOperationException( "That's HotCode's own source folder - not removing it." );
recycle( installed );
return true;
}
private static void CopyFolder( string from, string to )
{
Directory.CreateDirectory( to );
foreach ( var file in Directory.EnumerateFiles( from ) )
{
if ( SkipExtensions.Contains( Path.GetExtension( file ) ) ) continue;
File.Copy( file, Path.Combine( to, Path.GetFileName( file ) ), overwrite: true );
}
foreach ( var folder in Directory.EnumerateDirectories( from ) )
{
if ( SkipFolders.Contains( Path.GetFileName( folder ) ) ) continue;
CopyFolder( folder, Path.Combine( to, Path.GetFileName( folder ) ) );
}
}
}