Introduce new package Catswords.Phantomizer

Introduce new package `Catswords.Phantomizer`

**Catswords.Phantomizer** is an HTTP-based dynamic-link library (DLL) loader designed for .NET applications.
It allows your application to fetch and load assemblies directly from your CDN (Azure Blob, S3, Cloudflare R2, etc.) at runtime, with optional GZip compression support.
This commit is contained in:
Namhyeon, Go 2025-12-08 00:49:10 +09:00
parent e8dbf69491
commit f30e43c2e3
11 changed files with 227 additions and 40 deletions

View File

@ -25,6 +25,7 @@ after_build:
#- cmd: xcopy /s /y WelsonJS.Toolkit\WelsonJS.Toolkit\bin\x86\%CONFIGURATION%\* artifacts\
- cmd: xcopy /s /y WelsonJS.Toolkit\WelsonJS.Service\bin\x86\%CONFIGURATION%\* artifacts\
- cmd: xcopy /s /y WelsonJS.Toolkit\WelsonJS.Launcher\bin\x86\%CONFIGURATION%\* artifacts\
- cmd: xcopy /s /y WelsonJS.Toolkit\Catswords.Phantomizer\bin\x86\%CONFIGURATION%\* artifacts\
- cmd: nuget pack WelsonJS.Toolkit\WelsonJS.Toolkit\ -properties Configuration=%CONFIGURATION% -properties Platform=x86 -OutputDirectory artifacts\
- ps: Start-BitsTransfer -Source "https://catswords.blob.core.windows.net/welsonjs/welsonjs_setup_unsigned.exe" -Destination "artifacts\welsonjs_setup.exe"
- ps: Start-BitsTransfer -Source "https://catswords.blob.core.windows.net/welsonjs/chakracore-build/x86_release/ChakraCore.dll" -Destination "artifacts\ChakraCore.dll"

View File

@ -1,10 +1,11 @@
// AssemblyLoader.cs
// SPDX-License-Identifier: GPL-3.0-or-later
// AssemblyLoader.cs (Catswords.Phantomizer)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Namhyeon Go <gnh1201@catswords.re.kr>, 2025 Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
//
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net;
@ -12,14 +13,14 @@ using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
namespace WelsonJS.Launcher
namespace Catswords.Phantomizer
{
/// <summary>
/// Network-aware loader for managed (.NET) and native (C/C++) binaries.
/// - Managed assemblies resolve via AssemblyResolve
/// - Native modules explicitly loaded via LoadNativeModules(...)
/// - All DLLs must have valid Authenticode signatures
/// - Cached at: %APPDATA%\WelsonJS\assembly\{Name}\{Version}\
/// - Cached at: %APPDATA%\Catswords\assembly\{Name}\{Version}\
/// - BaseUrl must be set by Main() before calling Register()
/// </summary>
public static class AssemblyLoader
@ -30,12 +31,12 @@ namespace WelsonJS.Launcher
/// Must be set before Register() or LoadNativeModules().
/// </summary>
public static string BaseUrl { get; set; } = null;
public static ICompatibleLogger Logger { get; set; } = null;
public static string LoaderNamespace { get; set; } = typeof(AssemblyLoader).Namespace;
public static string AppName { get; set; } = "Catswords";
private static readonly object SyncRoot = new object();
private static bool _registered;
private static readonly string LoaderNamespace = typeof(AssemblyLoader).Namespace ?? "WelsonJS.Launcher";
private static readonly HttpClientHandler LegacyHttpHandler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.None
@ -168,20 +169,20 @@ namespace WelsonJS.Launcher
if (string.IsNullOrWhiteSpace(BaseUrl))
{
Logger?.Error("AssemblyLoader.Register() called but BaseUrl is not set.");
Trace.TraceError("AssemblyLoader.Register() called but BaseUrl is not set.");
throw new InvalidOperationException("AssemblyLoader.BaseUrl must be configured before Register().");
}
if (!BaseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
Logger?.Error("AssemblyLoader.BaseUrl must use HTTPS for security.");
Trace.TraceError("AssemblyLoader.BaseUrl must use HTTPS for security.");
throw new InvalidOperationException("AssemblyLoader.BaseUrl must use HTTPS.");
}
AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve;
_registered = true;
Logger?.Info("AssemblyLoader: AssemblyResolve handler registered.");
Trace.TraceInformation("AssemblyLoader: AssemblyResolve handler registered.");
}
}
@ -203,17 +204,17 @@ namespace WelsonJS.Launcher
lock (SyncRoot)
{
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string cacheDir = Path.Combine(appData, "WelsonJS", "assembly", ownerAssemblyName, versionString);
string cacheDir = Path.Combine(appData, AppName, "assembly", ownerAssemblyName, versionString);
Directory.CreateDirectory(cacheDir);
try
{
if (!SetDllDirectory(cacheDir))
Logger?.Warn("SetDllDirectory failed for: {0}", cacheDir);
Trace.TraceWarning("SetDllDirectory failed for: {0}", cacheDir);
}
catch (Exception ex)
{
Logger?.Warn("SetDllDirectory threw exception: {0}", ex.Message);
Trace.TraceWarning("SetDllDirectory threw exception: {0}", ex.Message);
}
foreach (string raw in fileNames)
@ -228,11 +229,11 @@ namespace WelsonJS.Launcher
{
string url = $"{BaseUrl.TrimEnd('/')}/native/{ownerAssemblyName}/{versionString}/{fileName}";
DownloadFile(url, localPath);
Logger?.Info("Downloaded native module: {0}", fileName);
Trace.TraceInformation("Downloaded native module: {0}", fileName);
}
else
{
Logger?.Info("Using cached native module: {0}", localPath);
Trace.TraceInformation("Using cached native module: {0}", localPath);
}
EnsureSignedFileOrThrow(localPath, fileName);
@ -241,12 +242,12 @@ namespace WelsonJS.Launcher
if (h == IntPtr.Zero)
{
int errorCode = Marshal.GetLastWin32Error();
Logger?.Error("LoadLibrary failed for {0} with error code {1}", localPath, errorCode);
Trace.TraceError("LoadLibrary failed for {0} with error code {1}", localPath, errorCode);
throw new InvalidOperationException($"Failed to load native module: {fileName} (error: {errorCode})");
}
else
{
Logger?.Info("Loaded native module: {0}", fileName);
Trace.TraceInformation("Loaded native module: {0}", fileName);
}
}
}
@ -277,7 +278,7 @@ namespace WelsonJS.Launcher
private static Assembly OnAssemblyResolve(object sender, ResolveEventArgs args)
{
Logger?.Info("AssemblyResolve: {0}", args.Name);
Trace.TraceInformation("AssemblyResolve: {0}", args.Name);
AssemblyName req = new AssemblyName(args.Name);
string simpleName = req.Name;
@ -290,7 +291,7 @@ namespace WelsonJS.Launcher
var entryName = entry.GetName().Name;
if (string.Equals(simpleName, entryName, StringComparison.OrdinalIgnoreCase))
{
Logger?.Info("AssemblyResolve: skipping entry assembly {0}", simpleName);
Trace.TraceInformation("AssemblyResolve: skipping entry assembly {0}", simpleName);
return null;
}
}
@ -301,7 +302,7 @@ namespace WelsonJS.Launcher
lock (SyncRoot)
{
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string cacheDir = Path.Combine(appData, "WelsonJS", "assembly", simpleName, versionStr);
string cacheDir = Path.Combine(appData, AppName, "assembly", simpleName, versionStr);
string dllPath = Path.Combine(cacheDir, simpleName + ".dll");
Directory.CreateDirectory(cacheDir);
@ -310,16 +311,16 @@ namespace WelsonJS.Launcher
{
string url = $"{BaseUrl.TrimEnd('/')}/managed/{simpleName}/{versionStr}/{simpleName}.dll";
DownloadFile(url, dllPath);
Logger?.Info("Downloaded managed assembly: {0}", simpleName);
Trace.TraceInformation("Downloaded managed assembly: {0}", simpleName);
}
else
{
Logger?.Info("Using cached managed assembly: {0}", dllPath);
Trace.TraceInformation("Using cached managed assembly: {0}", dllPath);
}
if (!File.Exists(dllPath))
{
Logger?.Warn("AssemblyResolve: managed assembly not found after download attempt: {0}", simpleName);
Trace.TraceWarning("AssemblyResolve: managed assembly not found after download attempt: {0}", simpleName);
return null;
}
@ -345,13 +346,13 @@ namespace WelsonJS.Launcher
if (isDll && TryDownloadCompressedFile(gzUrl, dest))
{
Logger?.Info("Downloaded and decompressed file to: {0}", dest);
Trace.TraceInformation("Downloaded and decompressed file to: {0}", dest);
downloaded = true;
}
if (!downloaded)
{
Logger?.Info("Downloading file from: {0}", url);
Trace.TraceInformation("Downloading file from: {0}", url);
res = Http.GetAsync(url).GetAwaiter().GetResult();
res.EnsureSuccessStatusCode();
@ -361,7 +362,7 @@ namespace WelsonJS.Launcher
s.CopyTo(fs);
}
Logger?.Info("Downloaded file to: {0}", dest);
Trace.TraceInformation("Downloaded file to: {0}", dest);
}
if (!File.Exists(dest))
@ -371,12 +372,12 @@ namespace WelsonJS.Launcher
}
catch (HttpRequestException ex)
{
Logger?.Error("Network or I/O error downloading {0}: {1}", url, ex.Message);
Trace.TraceError("Network or I/O error downloading {0}: {1}", url, ex.Message);
throw;
}
catch (Exception ex)
{
Logger?.Error("Unexpected error downloading {0}: {1}", url, ex.Message);
Trace.TraceError("Unexpected error downloading {0}: {1}", url, ex.Message);
throw;
}
finally
@ -396,7 +397,7 @@ namespace WelsonJS.Launcher
{
if (res.StatusCode == HttpStatusCode.NotFound)
{
Logger?.Info("No gzipped variant at {0}; falling back to uncompressed URL.", gzUrl);
Trace.TraceInformation("No gzipped variant at {0}; falling back to uncompressed URL.", gzUrl);
return false;
}
@ -419,12 +420,12 @@ namespace WelsonJS.Launcher
}
catch (HttpRequestException ex)
{
Logger?.Warn("Network or I/O error downloading compressed file from {0}: {1}", gzUrl, ex.Message);
Trace.TraceWarning("Network or I/O error downloading compressed file from {0}: {1}", gzUrl, ex.Message);
throw;
}
catch (Exception ex)
{
Logger?.Error("Unexpected error downloading compressed file from {0}: {1}", gzUrl, ex.Message);
Trace.TraceError("Unexpected error downloading compressed file from {0}: {1}", gzUrl, ex.Message);
throw;
}
finally
@ -437,7 +438,7 @@ namespace WelsonJS.Launcher
}
catch (Exception ex)
{
Logger?.Info("Failed to delete temporary file {0}: {1}", tempFile, ex.Message);
Trace.TraceInformation("Failed to delete temporary file {0}: {1}", tempFile, ex.Message);
}
}
}
@ -461,7 +462,7 @@ namespace WelsonJS.Launcher
{
if (!File.Exists(path))
{
Logger?.Error("File does not exist for signature verification: {0}", logicalName);
Trace.TraceError("File does not exist for signature verification: {0}", logicalName);
throw new FileNotFoundException("File not found for signature verification: " + logicalName, path);
}
@ -469,17 +470,17 @@ namespace WelsonJS.Launcher
if (status == FileSignatureStatus.Valid)
{
Logger?.Info("Signature OK: {0}", logicalName);
Trace.TraceInformation("Signature OK: {0}", logicalName);
return;
}
if (status == FileSignatureStatus.NoSignature)
{
Logger?.Error("BLOCKED unsigned binary: {0}", logicalName);
Trace.TraceError("BLOCKED unsigned binary: {0}", logicalName);
throw new InvalidOperationException("Unsigned binary blocked: " + logicalName);
}
Logger?.Error("BLOCKED invalid signature: {0}", logicalName);
Trace.TraceError("BLOCKED invalid signature: {0}", logicalName);
throw new InvalidOperationException("Invalid signature: " + logicalName);
}

View File

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) Namhyeon Go <gnh1201@catswords.re.kr>, 2025 Catswords OSS and WelsonJS Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,93 @@
# Catswords.Phantomizer
**Catswords.Phantomizer** is an HTTP-based dynamic-link library (DLL) loader designed for .NET applications.
It allows your application to fetch and load assemblies directly from your CDN (Azure Blob, S3, Cloudflare R2, etc.) at runtime, with optional GZip compression support.
---
## 🚀 Features
* Load managed (`*.dll`) and native (`*.dll`) assemblies over HTTP
* Optional `.dll.gz` decompression for faster network delivery
* CDN-friendly URL structure
* Easy bootstrap through a small embedded loader
---
## 📦 How to Use
### 1. Embed Phantomizer into your project
Add `Catswords.Phantomizer.dll.gz` to your `Resources.resx` file.
---
### 2. Initialize Phantomizer at application startup
Place the following code inside your `Main` method, static constructor, or any early entry point:
```csharp
static Program() {
InitializeAssemblyLoader();
}
private static void InitializeAssemblyLoader()
{
byte[] gzBytes = Properties.Resources.Phantomizer;
byte[] dllBytes;
using (var input = new MemoryStream(gzBytes))
using (var gz = new GZipStream(input, CompressionMode.Decompress))
using (var output = new MemoryStream())
{
gz.CopyTo(output);
dllBytes = output.ToArray();
}
Assembly phantomAsm = Assembly.Load(dllBytes);
Type loaderType = phantomAsm.GetType("Catswords.Phantomizer.AssemblyLoader", true);
loaderType.GetProperty("BaseUrl")?.SetValue(null, GetAppConfig("AssemblyBaseUrl")); // Set your CDN base URL
loaderType.GetProperty("AppName")?.SetValue(null, "WelsonJS"); // Set your application name
loaderType.GetMethod("Register")?.Invoke(null, null);
var loadNativeModulesMethod = loaderType.GetMethod(
"LoadNativeModules",
BindingFlags.Public | BindingFlags.Static,
binder: null,
types: new[] { typeof(string), typeof(Version), typeof(string[]) },
modifiers: null
);
if (loadNativeModulesMethod == null)
throw new InvalidOperationException("LoadNativeModules(string, Version, string[]) method not found.");
loadNativeModulesMethod.Invoke(null, new object[]
{
"ChakraCore",
new Version(1, 13, 0, 0),
new[] { "ChakraCore.dll" }
});
}
```
---
### 3. Upload your DLL files to a CDN
Upload your managed and native assemblies to your CDN following the URL pattern below.
#### 📁 URL Rules
| Type | Example URL | Description |
| ------------------ | ----------------------------------------------------------------------------------- | ------------------------------- |
| Managed DLL | `https://example.cdn.tld/packages/managed/MyManagedLib/1.0.0.0/MyManagedLib.dll` | Normal .NET assembly |
| Managed DLL (GZip) | `https://example.cdn.tld/packages/managed/MyManagedLib/1.0.0.0/MyManagedLib.dll.gz` | GZip-compressed .NET assembly |
| Native DLL | `https://example.cdn.tld/packages/native/MyNativeLib/1.0.0.0/MyNativeLib.dll` | Native assembly |
| Native DLL (GZip) | `https://example.cdn.tld/packages/native/MyNativeLib/1.0.0.0/MyNativeLib.dll.gz` | GZip-compressed native assembly |
---
### 4. 🎉 Start loading assemblies over HTTP
Once Phantomizer is initialized, your application will automatically fetch missing assemblies from your CDN.

View File

@ -8,7 +8,9 @@ using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Windows.Forms;
using WelsonJS.Launcher.Telemetry;
@ -33,10 +35,7 @@ namespace WelsonJS.Launcher
_logger = new TraceLogger();
// load external assemblies
AssemblyLoader.BaseUrl = GetAppConfig("AssemblyBaseUrl");
AssemblyLoader.Logger = _logger;
AssemblyLoader.Register();
AssemblyLoader.LoadNativeModules("ChakraCore", new Version(1, 13, 0, 0), new[] { "ChakraCore.dll" });
InitializeAssemblyLoader();
// telemetry
try
@ -114,6 +113,48 @@ namespace WelsonJS.Launcher
_mutex.Dispose();
}
private static void InitializeAssemblyLoader()
{
byte[] gzBytes = Properties.Resources.Phantomizer;
byte[] dllBytes;
using (var input = new MemoryStream(gzBytes))
using (var gz = new GZipStream(input, CompressionMode.Decompress))
using (var output = new MemoryStream())
{
gz.CopyTo(output);
dllBytes = output.ToArray();
}
Assembly phantomAsm = Assembly.Load(dllBytes);
Type loaderType = phantomAsm.GetType("Catswords.Phantomizer.AssemblyLoader", true);
loaderType.GetProperty("BaseUrl")?.SetValue(null, GetAppConfig("AssemblyBaseUrl"));
loaderType.GetProperty("AppName")?.SetValue(null, "WelsonJS");
loaderType.GetMethod("Register")?.Invoke(null, null);
var loadNativeModulesMethod = loaderType.GetMethod(
"LoadNativeModules",
BindingFlags.Public | BindingFlags.Static,
binder: null,
types: new[] { typeof(string), typeof(Version), typeof(string[]) },
modifiers: null
);
if (loadNativeModulesMethod == null)
{
throw new InvalidOperationException("LoadNativeModules(string, Version, string[]) method not found.");
}
loadNativeModulesMethod.Invoke(null, new object[]
{
"ChakraCore",
new Version(1, 13, 0, 0),
new[] { "ChakraCore.dll" }
});
}
public static void RecordFirstDeployTime(string directory, string instanceId)
{
// get current time

View File

@ -342,6 +342,16 @@ namespace WelsonJS.Launcher.Properties {
}
}
/// <summary>
/// System.Byte[] 형식의 지역화된 리소스를 찾습니다.
/// </summary>
internal static byte[] Phantomizer {
get {
object obj = ResourceManager.GetObject("Phantomizer", resourceCulture);
return ((byte[])(obj));
}
}
/// <summary>
/// https://github.com/gnh1201/welsonjs과(와) 유사한 지역화된 문자열을 찾습니다.
/// </summary>

View File

@ -241,4 +241,7 @@
<data name="AssemblyBaseUrl" xml:space="preserve">
<value>https://catswords.blob.core.windows.net/welsonjs/packages</value>
</data>
<data name="Phantomizer" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\Catswords.Phantomizer.dll.gz;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</data>
</root>

View File

@ -88,7 +88,6 @@
<Reference Include="System.Xml.Linq" />
</ItemGroup>
<ItemGroup>
<Compile Include="AssemblyLoader.cs" />
<Compile Include="ICompatibleLogger.cs" />
<Compile Include="IResourceTool.cs" />
<Compile Include="JsCore.cs" />
@ -171,6 +170,7 @@
</Compile>
</ItemGroup>
<ItemGroup>
<None Include="Resources\Catswords.Phantomizer.dll.gz" />
<None Include="Resources\icon_link_128.png" />
</ItemGroup>
<ItemGroup>

View File

@ -17,6 +17,8 @@ Project("{778DAE3C-4631-46EA-AA77-85C1314464D9}") = "WelsonJS.Cryptography", "We
EndProject
Project("{778DAE3C-4631-46EA-AA77-85C1314464D9}") = "WelsonJS.Cryptography.Test", "WelsonJS.Cryptography.Test\WelsonJS.Cryptography.Test.vbproj", "{C65EC34B-71C7-47CF-912E-D304283EB412}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Catswords.Phantomizer", "Catswords.Phantomizer\Catswords.Phantomizer.csproj", "{59C67003-C14E-4703-927F-318BFF15F903}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -81,6 +83,14 @@ Global
{C65EC34B-71C7-47CF-912E-D304283EB412}.Release|Any CPU.Build.0 = Release|Any CPU
{C65EC34B-71C7-47CF-912E-D304283EB412}.Release|x86.ActiveCfg = Release|x86
{C65EC34B-71C7-47CF-912E-D304283EB412}.Release|x86.Build.0 = Release|x86
{59C67003-C14E-4703-927F-318BFF15F903}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59C67003-C14E-4703-927F-318BFF15F903}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59C67003-C14E-4703-927F-318BFF15F903}.Debug|x86.ActiveCfg = Debug|Any CPU
{59C67003-C14E-4703-927F-318BFF15F903}.Debug|x86.Build.0 = Debug|Any CPU
{59C67003-C14E-4703-927F-318BFF15F903}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59C67003-C14E-4703-927F-318BFF15F903}.Release|Any CPU.Build.0 = Release|Any CPU
{59C67003-C14E-4703-927F-318BFF15F903}.Release|x86.ActiveCfg = Release|Any CPU
{59C67003-C14E-4703-927F-318BFF15F903}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE