using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;
namespace Sim.Runner;
///
/// 导出 EXE:把学生工程照教材工程的样子(.NET Framework 4.5 窗体应用,引用真机版设备类库)编译成一个 exe,
/// 在机房的 Windows 上双击就能运行,控制真的实训台。
///
/// - 编译的是学生写的原始代码:平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。
/// - 教材 dll(设备类库、设备控件库、媒体播放器的 Interop)嵌进 exe,启动时由模块初始化器从 exe 里加载,学生的 Program.cs 不用改。
/// - Properties.Resources 的图片、声音嵌进 exe;代码里按文件名用到的素材(如 Resources\ring.mp3)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样。
///
///
internal static class ExeExporter
{
public sealed record Result(bool Success, byte[] Exe, JsonArray Diagnostics);
private const string LibPrefix = "IotSimExport.lib.";
private const string FilePrefix = "IotSimExport.file.";
private const string MediaPrefix = "IotSimExport.media.";
///
/// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。
/// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。
///
internal const string MediaPlayerOcxState =
"AAEAAAD/////AQAAAAAAAAAMAgAAAFdTeXN0ZW0uV2luZG93cy5Gb3JtcywgVmVyc2lvbj00LjAuMC4w" +
"LCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAACFTeXN0" +
"ZW0uV2luZG93cy5Gb3Jtcy5BeEhvc3QrU3RhdGUBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAtwAAAAIB" +
"AAAAAQAAAAAAAAAAAAAAAKIAAAAAAwAACAAAAAAABQAAAAAAAADwPwMAAAAAAAUAAAAAAAAAAAAIAAIA" +
"AAAAAAMAAQAAAAsA//8DAAAAAAALAP//CAACAAAAAAADADIAAAALAAAACAAKAAAAZgB1AGwAbAAAAAsA" +
"AAALAAAACwD//wsA//8LAAAACAACAAAAAAAIAAIAAAAAAAgAAgAAAAAACAACAAAAAAALAAAANAYAAOcB" +
"AAAL";
private const string AxHostStateType =
"System.Windows.Forms.AxHost+State, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
/// 平台生成的 Properties/Resources.Designer.cs 从素材库取图片,导出时改成从 exe 里取
private const string PlatformResources = "global::Sim.Host.ProjectResources.";
private const string EmbeddedResources = "global::IotSimExport.EmbeddedFiles.";
private static readonly Regex ResourceFile = new(@"ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\)");
internal static readonly Regex OcxStateKey = new(@"GetObject\(""([^""]+\.OcxState)""\)");
internal static readonly CSharpCompilationOptions Options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
.WithPlatform(Platform.AnyCpu32BitPreferred)
.WithOptimizationLevel(OptimizationLevel.Release)
.WithNullableContextOptions(NullableContextOptions.Disable)
.WithAllowUnsafe(false)
.WithConcurrentBuild(true);
/// .NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj)
internal static readonly Lazy> Framework = new(() =>
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll")
.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))
.ToList());
/// libsDir:教材 dll 所在目录;galleryDir:平台素材库
public static Result Export(string projectDir, string libsDir, string galleryDir, string assemblyName)
{
var libs = TextbookLibs(libsDir, "导出 EXE");
var sources = StudentCompiler.SourceFiles(projectDir)
.Select(path => (Path: Path.GetRelativePath(projectDir, path), Text: File.ReadAllText(path)))
.ToList();
// 平台生成的资源类改成从 exe 里取,用到的素材文件嵌进去
var files = new SortedSet(StringComparer.Ordinal);
for (var i = 0; i < sources.Count; i++)
{
if (!sources[i].Text.Contains(PlatformResources)) continue;
foreach (Match m in ResourceFile.Matches(sources[i].Text)) files.Add(Path.GetFileName(m.Groups[1].Value));
sources[i] = (sources[i].Path, sources[i].Text.Replace(PlatformResources, EmbeddedResources));
}
// 代码里按文件名用到的素材(如 BaseDirectory + "//Resources//ring.mp3"),在 VS 里是复制到输出目录的 Resources 文件夹
var media = ReferencedMedia(galleryDir, sources.Select(s => s.Text));
var trees = sources
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), StudentCompiler.ParseOptions, path: s.Path))
.Append(CSharpSyntaxTree.ParseText(SourceText.From(Bootstrap(media), Encoding.UTF8), StudentCompiler.ParseOptions, path: "IotSimExport.cs"))
.ToList();
var references = Framework.Value.Concat(libs.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))).ToList();
var compilation = CSharpCompilation.Create(assemblyName, trees, references, Options);
var resources = new List();
foreach (var lib in libs)
{
var path = lib;
resources.Add(new ResourceDescription(LibPrefix + Path.GetFileName(path), () => File.OpenRead(path), isPublic: false));
}
foreach (var file in files)
{
var path = Path.Combine(galleryDir, file);
if (File.Exists(path)) resources.Add(new ResourceDescription(FilePrefix + file, () => File.OpenRead(path), isPublic: false));
}
foreach (var file in media)
{
var path = Path.Combine(galleryDir, file);
resources.Add(new ResourceDescription(MediaPrefix + file, () => File.OpenRead(path), isPublic: false));
}
resources.AddRange(ControlResources(compilation, sources.SelectMany(s => OcxStateKey.Matches(s.Text).Select(m => m.Groups[1].Value)).ToHashSet()));
using var pe = new MemoryStream();
using var win32 = compilation.CreateDefaultWin32Resources(versionResource: true, noManifest: false, manifestContents: null, iconInIcoFormat: null);
var emit = compilation.Emit(pe, win32Resources: win32, manifestResources: resources,
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.Embedded));
var diagnostics = new JsonArray();
// 和平台运行时一样按编译器给出的顺序:语法错误在前,真正的出错原因一般是第一条
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
{
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
}
return new Result(emit.Success, pe.ToArray(), diagnostics);
}
/// 代码里按文件名用到的素材库文件(如 BaseDirectory + "//Resources//ring.mp3"),按文件名排序
internal static List ReferencedMedia(string galleryDir, IEnumerable texts)
{
if (!Directory.Exists(galleryDir)) return new List();
var all = texts.ToList();
return Directory.GetFiles(galleryDir)
.Select(Path.GetFileName)
.OfType()
.Where(name => !name.Contains(".2x.") && name != "index.json")
.Where(name => all.Any(text => text.Contains(name + "\"")))
.OrderBy(name => name, StringComparer.Ordinal)
.ToList();
}
///
/// 教材 dll 的路径;没放时说明原因(what:"导出 EXE"或"导出工程")。目录或文件读不了时
/// (服务器上拷进去的文件权限是 600,容器里的平台用户读不到)也说明原因,不把英文的系统报错直接给学生看。
///
internal static string[] TextbookLibs(string libsDir, string what)
{
var libs = ReadableLibs(libsDir, what);
if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll"))
throw new InvalidOperationException($"服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能{what},请联系老师");
return libs;
}
private static string[] ReadableLibs(string libsDir, string what)
{
if (!Directory.Exists(libsDir)) return Array.Empty();
string? current = null;
try
{
var libs = Directory.GetFiles(libsDir, "*.dll").OrderBy(p => p, StringComparer.Ordinal).ToArray();
foreach (var lib in libs)
{
current = lib;
using var _ = File.OpenRead(lib);
}
return libs;
}
catch (UnauthorizedAccessException)
{
var item = current == null ? "目录" : Path.GetFileName(current);
throw new InvalidOperationException(
$"服务器上的教材设备类库读不了({item} 的权限不够),不能{what},请联系老师(服务器上执行 chmod 755 textbook-libs 和 chmod 644 textbook-libs/*.dll)");
}
}
///
/// 每个窗体、控件类的 .resources:VS 生成的 Designer.cs 用 ComponentResourceManager 读窗体的 .resx,
/// 平台没有 .resx。媒体播放器的 OcxState 放默认状态,其他取到 null(和运行时一样)。
///
private static List ControlResources(CSharpCompilation compilation, HashSet ocxKeys)
{
var stream = new MemoryStream();
using (var writer = new System.Resources.ResourceWriter(stream))
{
var state = Convert.FromBase64String(MediaPlayerOcxState);
foreach (var key in ocxKeys.OrderBy(k => k, StringComparer.Ordinal)) writer.AddResourceData(key, AxHostStateType, state);
writer.Generate();
}
var data = stream.ToArray();
var control = compilation.GetTypeByMetadataName("System.Windows.Forms.Control");
var resources = new List();
if (control == null) return resources;
var names = new HashSet();
foreach (var tree in compilation.SyntaxTrees)
{
var model = compilation.GetSemanticModel(tree);
foreach (var declaration in tree.GetRoot().DescendantNodes().OfType())
{
if (model.GetDeclaredSymbol(declaration) is not { } type) continue;
var derives = false;
for (var t = type.BaseType; t != null && !derives; t = t.BaseType) derives = SymbolEqualityComparer.Default.Equals(t, control);
var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") + ".resources";
if (derives && names.Add(name)) resources.Add(new ResourceDescription(name, () => new MemoryStream(data), isPublic: true));
}
}
return resources;
}
///
/// 加进 exe 的启动代码:模块初始化器在 Main 之前运行(.NET Framework 也支持),
/// 注册 AssemblyResolve,用到教材 dll 时从 exe 里加载;把代码里用到的素材文件放到 exe 旁边。
///
private static string Bootstrap(IReadOnlyList media) => $$"""
[assembly: global::System.Runtime.Versioning.TargetFramework(".NETFramework,Version=v4.5", FrameworkDisplayName = ".NET Framework 4.5")]
namespace System.Runtime.CompilerServices
{
[global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)]
internal sealed class ModuleInitializerAttribute : global::System.Attribute { }
}
///
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
/// 平台补上了它;导出时也补一个,书上的代码照样能编译。放在全局命名空间,不用 using 就能找到;类库自己有 Close 时用类库的。
///
internal static class IotSimExportLedCompat
{
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper) { }
}
namespace IotSimExport
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
internal static class Bootstrap
{
private static readonly Dictionary Loaded = new Dictionary(StringComparer.OrdinalIgnoreCase);
[global::System.Runtime.CompilerServices.ModuleInitializer]
internal static void Init()
{
AppDomain.CurrentDomain.AssemblyResolve += Resolve;
ExtractMedia();
}
private static Assembly Resolve(object sender, ResolveEventArgs args)
{
var name = new AssemblyName(args.Name).Name;
lock (Loaded)
{
Assembly assembly;
if (Loaded.TryGetValue(name, out assembly)) return assembly;
var bytes = EmbeddedFiles.Read("{{LibPrefix}}" + name + ".dll");
if (bytes == null) return null;
assembly = Assembly.Load(bytes);
Loaded[name] = assembly;
return assembly;
}
}
private static readonly string[] Media = { {{string.Join(", ", media.Select(m => SymbolDisplay.FormatLiteral(m, true)))}} };
private static void ExtractMedia()
{
foreach (var file in Media)
{
try
{
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources");
var path = Path.Combine(dir, file);
if (File.Exists(path)) continue;
Directory.CreateDirectory(dir);
File.WriteAllBytes(path, EmbeddedFiles.Read("{{MediaPrefix}}" + file));
}
catch (Exception)
{
// 文件夹不能写(如放在只读的 U 盘上)时不影响程序运行,只是用不到这个文件
}
}
}
}
internal static class EmbeddedFiles
{
internal static byte[] Read(string name)
{
using (var stream = typeof(EmbeddedFiles).Assembly.GetManifestResourceStream(name))
{
if (stream == null) return null;
var bytes = new byte[stream.Length];
var read = 0;
while (read < bytes.Length) read += stream.Read(bytes, read, bytes.Length - read);
return bytes;
}
}
internal static global::System.Drawing.Bitmap Image(string file)
{
return new global::System.Drawing.Bitmap(new MemoryStream(Bytes(file)));
}
internal static byte[] Bytes(string file)
{
return Read("{{FilePrefix}}" + Path.GetFileName(file));
}
}
}
""";
}