diff --git a/PepperDash.Essentials.4Series.sln b/PepperDash.Essentials.4Series.sln
index fc8c20f1..a39555e8 100644
--- a/PepperDash.Essentials.4Series.sln
+++ b/PepperDash.Essentials.4Series.sln
@@ -42,6 +42,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Core.Tests", "sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Essentials.Core.Tests", "src\PepperDash.Essentials.Core.Tests\PepperDash.Essentials.Core.Tests.csproj", "{F508E0BA-E885-424F-9D4C-359CF0011DEF}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Essentials.Tests", "src\PepperDash.Essentials.Tests\PepperDash.Essentials.Tests.csproj", "{841EE676-7784-4456-8F76-3697C6D432A6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug 4.7.2|Any CPU = Debug 4.7.2|Any CPU
@@ -181,6 +183,24 @@ Global
{F508E0BA-E885-424F-9D4C-359CF0011DEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F508E0BA-E885-424F-9D4C-359CF0011DEF}.Release|x64.ActiveCfg = Release|Any CPU
{F508E0BA-E885-424F-9D4C-359CF0011DEF}.Release|x86.ActiveCfg = Release|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|Any CPU.ActiveCfg = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|Any CPU.Build.0 = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x64.ActiveCfg = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x64.Build.0 = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x86.ActiveCfg = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x86.Build.0 = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x64.Build.0 = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x86.Build.0 = Debug|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Release|x64.ActiveCfg = Release|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Release|x64.Build.0 = Release|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Release|x86.ActiveCfg = Release|Any CPU
+ {841EE676-7784-4456-8F76-3697C6D432A6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -194,6 +214,7 @@ Global
{E5336563-1194-501E-BC4A-79AD9283EF90} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{680BA287-E61F-4B8D-BD7A-84C2504F5F9C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F508E0BA-E885-424F-9D4C-359CF0011DEF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {841EE676-7784-4456-8F76-3697C6D432A6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {6907A4BF-7201-47CF-AAB1-3597F3B8E1C3}
diff --git a/src/PepperDash.Essentials.Tests/ControlSystem/LoadAssetsTests.cs b/src/PepperDash.Essentials.Tests/ControlSystem/LoadAssetsTests.cs
new file mode 100644
index 00000000..0d06bcb8
--- /dev/null
+++ b/src/PepperDash.Essentials.Tests/ControlSystem/LoadAssetsTests.cs
@@ -0,0 +1,334 @@
+using System.IO.Compression;
+using FluentAssertions;
+using Xunit;
+
+namespace PepperDash.Essentials.Tests.ControlSystem;
+
+///
+/// Tests for .
+/// is the System.IO-based implementation that backs
+/// . Tests run against a
+/// temporary directory tree so no Crestron runtime is required.
+/// Debug is initialised with fakes via TestInitializer.
+///
+public sealed class LoadAssetsTests : IDisposable
+{
+ // ---------------------------------------------------------------------------
+ // Fixture: each test gets an isolated temp directory tree
+ //
+ // _rootDir/
+ // appdir/ ← applicationDirectoryPath (where the loader scans)
+ // user/
+ // program1/ ← filePathPrefix (where assets land)
+ // ---------------------------------------------------------------------------
+
+ private readonly string _rootDir;
+ private readonly string _appDir;
+ private readonly string _filePathPrefix;
+
+ public LoadAssetsTests()
+ {
+ _rootDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ _appDir = Path.Combine(_rootDir, "appdir");
+ _filePathPrefix = Path.Combine(_rootDir, "user", "program1") + Path.DirectorySeparatorChar;
+
+ Directory.CreateDirectory(_appDir);
+ Directory.CreateDirectory(_filePathPrefix);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_rootDir))
+ Directory.Delete(_rootDir, recursive: true);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static byte[] EmptyZip()
+ {
+ using var ms = new MemoryStream();
+ using (var _ = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { }
+ return ms.ToArray();
+ }
+
+ private static byte[] ZipWithFile(string entryName, string content = "test")
+ {
+ using var ms = new MemoryStream();
+ using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ var entry = archive.CreateEntry(entryName);
+ using var sw = new StreamWriter(entry.Open());
+ sw.Write(content);
+ }
+ return ms.ToArray();
+ }
+
+ private static byte[] ZipWithDirectory(string directoryEntryName)
+ {
+ // Directory entries have a trailing slash and an empty Name
+ var normalised = directoryEntryName.TrimEnd('/') + '/';
+ using var ms = new MemoryStream();
+ using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ archive.CreateEntry(normalised);
+ }
+ return ms.ToArray();
+ }
+
+ private static byte[] ZipWithTraversalEntry()
+ {
+ using var ms = new MemoryStream();
+ using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ var entry = archive.CreateEntry("../traversal.txt");
+ using var sw = new StreamWriter(entry.Open());
+ sw.Write("should not appear");
+ }
+ return ms.ToArray();
+ }
+
+ private void WriteToAppDir(string fileName, byte[] contents) =>
+ File.WriteAllBytes(Path.Combine(_appDir, fileName), contents);
+
+ private void WriteTextToAppDir(string fileName, string text) =>
+ File.WriteAllText(Path.Combine(_appDir, fileName), text);
+
+ // ---------------------------------------------------------------------------
+ // No-op cases — nothing in the application directory
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void LoadAssets_EmptyApplicationDirectory_DoesNotThrow()
+ {
+ var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
+ act.Should().NotThrow();
+ }
+
+ // ---------------------------------------------------------------------------
+ // assets*.zip
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void LoadAssets_MultipleAssetsZips_ThrowsException()
+ {
+ WriteToAppDir("assets1.zip", EmptyZip());
+ WriteToAppDir("assets2.zip", EmptyZip());
+
+ var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
+ act.Should().Throw().WithMessage("*Multiple assets zip files*");
+ }
+
+ [Fact]
+ public void LoadAssets_SingleAssetsZip_ExtractsFileToFilePathPrefix()
+ {
+ WriteToAppDir("assets.zip", ZipWithFile("config.json", "{}"));
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ File.Exists(Path.Combine(_filePathPrefix, "config.json")).Should().BeTrue();
+ }
+
+ [Fact]
+ public void LoadAssets_SingleAssetsZip_FileContentsArePreserved()
+ {
+ const string expected = "{\"key\":\"value\"}";
+ WriteToAppDir("assets.zip", ZipWithFile("data.json", expected));
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ File.ReadAllText(Path.Combine(_filePathPrefix, "data.json")).Should().Be(expected);
+ }
+
+ [Fact]
+ public void LoadAssets_SingleAssetsZip_ZipIsDeletedAfterExtraction()
+ {
+ var zipPath = Path.Combine(_appDir, "assets.zip");
+ WriteToAppDir("assets.zip", ZipWithFile("file.txt"));
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ File.Exists(zipPath).Should().BeFalse("assets zip should be cleaned up after extraction");
+ }
+
+ [Fact]
+ public void LoadAssets_AssetsZipWithDirectoryEntry_CreatesDirectory()
+ {
+ WriteToAppDir("assets.zip", ZipWithDirectory("subdir/"));
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ Directory.Exists(Path.Combine(_filePathPrefix, "subdir/")).Should().BeTrue();
+ }
+
+ [Fact]
+ public void LoadAssets_AssetsZipWithPathTraversal_ThrowsInvalidOperationException()
+ {
+ WriteToAppDir("assets.zip", ZipWithTraversalEntry());
+
+ var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
+ act.Should().Throw()
+ .WithMessage("*trying to extract outside of the target directory*");
+ }
+
+ // ---------------------------------------------------------------------------
+ // htmlassets*.zip
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void LoadAssets_MultipleHtmlAssetsZips_ThrowsException()
+ {
+ WriteToAppDir("htmlassets1.zip", EmptyZip());
+ WriteToAppDir("htmlassets2.zip", EmptyZip());
+
+ var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
+ act.Should().Throw().WithMessage("*Multiple htmlassets zip files*");
+ }
+
+ [Fact]
+ public void LoadAssets_SingleHtmlAssetsZip_ExtractsToHtmlDirectory()
+ {
+ // htmlDir = rootDir/html
+ WriteToAppDir("htmlassets.zip", ZipWithFile("index.html", ""));
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ var expectedHtmlDir = Path.Combine(_rootDir, "html");
+ File.Exists(Path.Combine(expectedHtmlDir, "index.html")).Should().BeTrue();
+ }
+
+ [Fact]
+ public void LoadAssets_SingleHtmlAssetsZip_ZipIsDeletedAfterExtraction()
+ {
+ var zipPath = Path.Combine(_appDir, "htmlassets.zip");
+ WriteToAppDir("htmlassets.zip", ZipWithFile("page.html"));
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ File.Exists(zipPath).Should().BeFalse();
+ }
+
+ [Fact]
+ public void LoadAssets_HtmlAssetsZipWithPathTraversal_ThrowsInvalidOperationException()
+ {
+ WriteToAppDir("htmlassets.zip", ZipWithTraversalEntry());
+
+ var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
+ act.Should().Throw()
+ .WithMessage("*trying to extract outside of the target directory*");
+ }
+
+ [Fact]
+ public void LoadAssets_HtmlAssetsZip_ShallowFilePathPrefixThrowsWhenRootCannotBeDetermined()
+ {
+ // A path that is exactly ONE level below the filesystem root has no grandparent,
+ // so rootDir (programDir.Parent.Parent) is null and the guard should throw.
+ // DirectoryInfo works with non-existent paths, so we don't create the directory.
+ var filesystemRoot = Path.GetPathRoot(Path.GetTempPath())!;
+ var shallowPrefix = Path.Combine(filesystemRoot, "pepperDashTestShallow") + Path.DirectorySeparatorChar;
+ WriteToAppDir("htmlassets.zip", ZipWithFile("page.html"));
+
+ var act = () => AssetLoader.Load(_appDir, shallowPrefix);
+ act.Should().Throw().WithMessage("*Unable to determine root directory for html extraction*");
+ }
+
+ // ---------------------------------------------------------------------------
+ // essentials-devtools*.zip
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void LoadAssets_MultipleDevToolsZips_ThrowsException()
+ {
+ WriteToAppDir("essentials-devtools1.zip", EmptyZip());
+ WriteToAppDir("essentials-devtools2.zip", EmptyZip());
+
+ var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
+ act.Should().Throw().WithMessage("*Multiple essentials-devtools zip files*");
+ }
+
+ [Fact]
+ public void LoadAssets_SingleDevToolsZip_ExtractsToHtmlDebugDirectory()
+ {
+ // debugDir = rootDir/html/debug
+ WriteToAppDir("essentials-devtools.zip", ZipWithFile("app.js", "console.log('hi');"));
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ var expectedDebugDir = Path.Combine(_rootDir, "html", "debug");
+ File.Exists(Path.Combine(expectedDebugDir, "app.js")).Should().BeTrue();
+ }
+
+ [Fact]
+ public void LoadAssets_SingleDevToolsZip_ZipIsDeletedAfterExtraction()
+ {
+ var zipPath = Path.Combine(_appDir, "essentials-devtools.zip");
+ WriteToAppDir("essentials-devtools.zip", ZipWithFile("tool.js"));
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ File.Exists(zipPath).Should().BeFalse();
+ }
+
+ [Fact]
+ public void LoadAssets_DevToolsZipWithPathTraversal_ThrowsInvalidOperationException()
+ {
+ WriteToAppDir("essentials-devtools.zip", ZipWithTraversalEntry());
+
+ var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
+ act.Should().Throw()
+ .WithMessage("*trying to extract outside of the target directory*");
+ }
+
+ // ---------------------------------------------------------------------------
+ // *configurationFile*.json
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void LoadAssets_MultipleJsonConfigFiles_ThrowsException()
+ {
+ WriteTextToAppDir("abcconfigurationFile1.json", "{}");
+ WriteTextToAppDir("abcconfigurationFile2.json", "{}");
+
+ var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
+ act.Should().Throw().WithMessage("*Multiple configuration files found*");
+ }
+
+ [Fact]
+ public void LoadAssets_SingleJsonConfigFile_IsMovedToFilePathPrefix()
+ {
+ const string fileName = "myconfigurationFile.json";
+ WriteTextToAppDir(fileName, "{}");
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ File.Exists(Path.Combine(_appDir, fileName)).Should().BeFalse("source file should be moved, not copied");
+ File.Exists(Path.Combine(_filePathPrefix, fileName)).Should().BeTrue("file should exist at the file path prefix");
+ }
+
+ [Fact]
+ public void LoadAssets_SingleJsonConfigFile_ExistingDestinationIsReplaced()
+ {
+ const string fileName = "myconfigurationFile.json";
+ WriteTextToAppDir(fileName, "new content");
+
+ // Pre-populate the destination with stale content
+ File.WriteAllText(Path.Combine(_filePathPrefix, fileName), "old content");
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ File.ReadAllText(Path.Combine(_filePathPrefix, fileName)).Should().Be("new content");
+ }
+
+ [Fact]
+ public void LoadAssets_SingleJsonConfigFile_ContentIsPreserved()
+ {
+ const string content = "{\"devices\":[]}";
+ const string fileName = "myconfigurationFile.json";
+ WriteTextToAppDir(fileName, content);
+
+ AssetLoader.Load(_appDir, _filePathPrefix);
+
+ File.ReadAllText(Path.Combine(_filePathPrefix, fileName)).Should().Be(content);
+ }
+}
diff --git a/src/PepperDash.Essentials.Tests/Fakes/Fakes.cs b/src/PepperDash.Essentials.Tests/Fakes/Fakes.cs
new file mode 100644
index 00000000..ba195788
--- /dev/null
+++ b/src/PepperDash.Essentials.Tests/Fakes/Fakes.cs
@@ -0,0 +1,61 @@
+using PepperDash.Core.Abstractions;
+
+namespace PepperDash.Essentials.Tests.Fakes;
+
+internal class FakeCrestronEnvironment : ICrestronEnvironment
+{
+ public DevicePlatform DevicePlatform { get; set; } = DevicePlatform.Appliance;
+ public RuntimeEnvironment RuntimeEnvironment { get; set; } = RuntimeEnvironment.SimplSharpPro;
+ public string NewLine { get; set; } = "\r\n";
+ public uint ApplicationNumber { get; set; } = 1;
+ public uint RoomId { get; set; } = 0;
+
+ public event EventHandler? ProgramStatusChanged
+ {
+ add { }
+ remove { }
+ }
+
+ public event EventHandler? EthernetEventReceived
+ {
+ add { }
+ remove { }
+ }
+
+ public string GetApplicationRootDirectory() => System.IO.Path.GetTempPath();
+ public bool IsHardwareRuntime => false;
+}
+
+internal class NoOpCrestronConsole : ICrestronConsole
+{
+ public void PrintLine(string message) { }
+ public void Print(string message) { }
+ public void ConsoleCommandResponse(string message) { }
+ public void AddNewConsoleCommand(Action _, string __, string ___, ConsoleAccessLevel ____) { }
+}
+
+internal class InMemoryCrestronDataStore : ICrestronDataStore
+{
+ private readonly Dictionary _store = new();
+
+ public void InitStore() { }
+
+ public bool TryGetLocalInt(string key, out int value)
+ {
+ if (_store.TryGetValue(key, out var raw) && raw is int i) { value = i; return true; }
+ value = 0;
+ return false;
+ }
+
+ public bool SetLocalInt(string key, int value) { _store[key] = value; return true; }
+ public bool SetLocalUint(string key, uint value) { _store[key] = (int)value; return true; }
+
+ public bool TryGetLocalBool(string key, out bool value)
+ {
+ if (_store.TryGetValue(key, out var raw) && raw is bool b) { value = b; return true; }
+ value = false;
+ return false;
+ }
+
+ public bool SetLocalBool(string key, bool value) { _store[key] = value; return true; }
+}
diff --git a/src/PepperDash.Essentials.Tests/PepperDash.Essentials.Tests.csproj b/src/PepperDash.Essentials.Tests/PepperDash.Essentials.Tests.csproj
new file mode 100644
index 00000000..b32ff955
--- /dev/null
+++ b/src/PepperDash.Essentials.Tests/PepperDash.Essentials.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+ net9.0
+ enable
+ enable
+ false
+ false
+ false
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/src/PepperDash.Essentials.Tests/TestInitializer.cs b/src/PepperDash.Essentials.Tests/TestInitializer.cs
new file mode 100644
index 00000000..acf5e271
--- /dev/null
+++ b/src/PepperDash.Essentials.Tests/TestInitializer.cs
@@ -0,0 +1,28 @@
+using System.Runtime.CompilerServices;
+using PepperDash.Core.Abstractions;
+using PepperDash.Essentials.Tests.Fakes;
+
+namespace PepperDash.Essentials.Tests;
+
+///
+/// Runs once before any type in this assembly is accessed.
+/// Registers fake Crestron service implementations so that the Debug static
+/// constructor never tries to reach the real Crestron SDK.
+///
+internal static class TestInitializer
+{
+ [ModuleInitializer]
+ internal static void Initialize()
+ {
+ DebugServiceRegistration.Register(
+ new FakeCrestronEnvironment
+ {
+ DevicePlatform = DevicePlatform.Server,
+ RuntimeEnvironment = RuntimeEnvironment.Other,
+ },
+ new NoOpCrestronConsole(),
+ new InMemoryCrestronDataStore());
+ }
+}
+
+
diff --git a/src/PepperDash.Essentials/AssetLoader.cs b/src/PepperDash.Essentials/AssetLoader.cs
new file mode 100644
index 00000000..9db98c7e
--- /dev/null
+++ b/src/PepperDash.Essentials/AssetLoader.cs
@@ -0,0 +1,256 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using PepperDash.Core;
+using Serilog.Events;
+
+namespace PepperDash.Essentials;
+
+///
+/// Handles extracting embedded asset bundles and moving configuration files from the
+/// application directory to the program file-path prefix at startup.
+/// Implemented using System.IO types so it can run (and be tested) outside
+/// of a Crestron runtime environment.
+///
+internal static class AssetLoader
+{
+ ///
+ /// Scans for well-known zip bundles and
+ /// JSON configuration files and deploys them to .
+ ///
+ ///
+ /// The directory to scan (typically the Crestron application root).
+ ///
+ ///
+ /// The program's runtime working directory (e.g. /nvram/program1/).
+ ///
+ internal static void Load(string applicationDirectoryPath, string filePathPrefix)
+ {
+ var applicationDirectory = new DirectoryInfo(applicationDirectoryPath);
+ Debug.LogMessage(LogEventLevel.Information,
+ "Searching: {applicationDirectory:l} for embedded assets - {Destination}",
+ applicationDirectory.FullName, filePathPrefix);
+
+ ExtractAssetsZip(applicationDirectory, filePathPrefix);
+ ExtractHtmlAssetsZip(applicationDirectory, filePathPrefix);
+ ExtractDevToolsZip(applicationDirectory, filePathPrefix);
+ MoveConfigurationFile(applicationDirectory, filePathPrefix);
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private static void ExtractAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix)
+ {
+ var zipFiles = applicationDirectory.GetFiles("assets*.zip");
+
+ if (zipFiles.Length > 1)
+ throw new Exception("Multiple assets zip files found. Cannot continue.");
+
+ if (zipFiles.Length == 1)
+ {
+ var zipFile = zipFiles[0];
+ var assetsRoot = Path.GetFullPath(filePathPrefix);
+ if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
+ !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
+ {
+ assetsRoot += Path.DirectorySeparatorChar;
+ }
+
+ Debug.LogMessage(LogEventLevel.Information,
+ "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName);
+
+ using (var archive = ZipFile.OpenRead(zipFile.FullName))
+ {
+ foreach (var entry in archive.Entries)
+ {
+ var destinationPath = Path.Combine(filePathPrefix, entry.FullName);
+ var fullDest = Path.GetFullPath(destinationPath);
+ if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException(
+ $"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
+
+ if (string.IsNullOrEmpty(entry.Name))
+ {
+ Directory.CreateDirectory(destinationPath);
+ continue;
+ }
+
+ if (Directory.Exists(destinationPath))
+ Directory.Delete(destinationPath, recursive: true);
+
+ Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
+ entry.ExtractToFile(destinationPath, overwrite: true);
+ Debug.LogMessage(LogEventLevel.Information,
+ "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
+ }
+ }
+ }
+
+ foreach (var file in zipFiles)
+ File.Delete(file.FullName);
+ }
+
+ private static void ExtractHtmlAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix)
+ {
+ var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip");
+
+ if (htmlZipFiles.Length > 1)
+ throw new Exception(
+ "Multiple htmlassets zip files found in application directory. " +
+ "Please ensure only one htmlassets*.zip file is present and retry.");
+
+ if (htmlZipFiles.Length == 1)
+ {
+ var htmlZipFile = htmlZipFiles[0];
+ var programDir = new DirectoryInfo(
+ filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
+ var userOrNvramDir = programDir.Parent;
+ var rootDir = userOrNvramDir?.Parent;
+ if (rootDir == null)
+ throw new Exception(
+ $"Unable to determine root directory for html extraction. Current path: {filePathPrefix}");
+
+ var htmlDir = Path.Combine(rootDir.FullName, "html");
+ var htmlRoot = Path.GetFullPath(htmlDir);
+ if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
+ !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
+ {
+ htmlRoot += Path.DirectorySeparatorChar;
+ }
+
+ Debug.LogMessage(LogEventLevel.Information,
+ "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName);
+
+ using (var archive = ZipFile.OpenRead(htmlZipFile.FullName))
+ {
+ foreach (var entry in archive.Entries)
+ {
+ var destinationPath = Path.Combine(htmlDir, entry.FullName);
+ var fullDest = Path.GetFullPath(destinationPath);
+ if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException(
+ $"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
+
+ if (string.IsNullOrEmpty(entry.Name))
+ {
+ Directory.CreateDirectory(destinationPath);
+ continue;
+ }
+
+ if (File.Exists(destinationPath))
+ File.Delete(destinationPath);
+
+ var parentDir = Path.GetDirectoryName(destinationPath);
+ if (!string.IsNullOrEmpty(parentDir))
+ Directory.CreateDirectory(parentDir);
+
+ entry.ExtractToFile(destinationPath, overwrite: true);
+ Debug.LogMessage(LogEventLevel.Information,
+ "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
+ }
+ }
+ }
+
+ foreach (var file in htmlZipFiles)
+ File.Delete(file.FullName);
+ }
+
+ private static void ExtractDevToolsZip(DirectoryInfo applicationDirectory, string filePathPrefix)
+ {
+ var devToolsZipFiles = applicationDirectory.GetFiles("essentials-devtools*.zip");
+
+ if (devToolsZipFiles.Length > 1)
+ throw new Exception(
+ "Multiple essentials-devtools zip files found in application directory. " +
+ "Please ensure only one essentials-devtools*.zip file is present and retry.");
+
+ if (devToolsZipFiles.Length == 1)
+ {
+ var devToolsZipFile = devToolsZipFiles[0];
+ var programDir = new DirectoryInfo(
+ filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
+ var userOrNvramDir = programDir.Parent;
+ var rootDir = userOrNvramDir?.Parent;
+ if (rootDir == null)
+ throw new Exception(
+ $"Unable to determine root directory for debug html extraction. Current path: {filePathPrefix}");
+
+ var debugDir = Path.Combine(rootDir.FullName, "html", "debug");
+ var debugRoot = Path.GetFullPath(debugDir);
+ if (!debugRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
+ !debugRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
+ {
+ debugRoot += Path.DirectorySeparatorChar;
+ }
+
+ Debug.LogMessage(LogEventLevel.Information,
+ "Found essentials-devtools zip file: {zipFile:l}... Unzipping to {Destination}...",
+ devToolsZipFile.FullName, debugDir);
+
+ using (var archive = ZipFile.OpenRead(devToolsZipFile.FullName))
+ {
+ foreach (var entry in archive.Entries)
+ {
+ var destinationPath = Path.Combine(debugDir, entry.FullName);
+ var fullDest = Path.GetFullPath(destinationPath);
+ if (!fullDest.StartsWith(debugRoot, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException(
+ $"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
+
+ if (string.IsNullOrEmpty(entry.Name))
+ {
+ Directory.CreateDirectory(destinationPath);
+ continue;
+ }
+
+ if (File.Exists(destinationPath))
+ File.Delete(destinationPath);
+
+ var parentDir = Path.GetDirectoryName(destinationPath);
+ if (!string.IsNullOrEmpty(parentDir))
+ Directory.CreateDirectory(parentDir);
+
+ entry.ExtractToFile(destinationPath, overwrite: true);
+ Debug.LogMessage(LogEventLevel.Information,
+ "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
+ }
+ }
+ }
+
+ foreach (var file in devToolsZipFiles)
+ File.Delete(file.FullName);
+ }
+
+ private static void MoveConfigurationFile(DirectoryInfo applicationDirectory, string filePathPrefix)
+ {
+ var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json");
+
+ if (jsonFiles.Length > 1)
+ {
+ Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}",
+ jsonFiles.Select(f => f.FullName).ToArray());
+ throw new Exception("Multiple configuration files found. Cannot continue.");
+ }
+
+ if (jsonFiles.Length == 1)
+ {
+ var jsonFile = jsonFiles[0];
+ var finalPath = Path.Combine(filePathPrefix, jsonFile.Name);
+ Debug.LogMessage(LogEventLevel.Information,
+ "Found configuration file: {jsonFile:l}... Moving to: {Destination}",
+ jsonFile.FullName, finalPath);
+
+ if (File.Exists(finalPath))
+ {
+ Debug.LogMessage(LogEventLevel.Information,
+ "Removing existing configuration file: {Destination}", finalPath);
+ File.Delete(finalPath);
+ }
+
+ jsonFile.MoveTo(finalPath);
+ }
+ }
+}
diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs
index c91f8927..52a47273 100644
--- a/src/PepperDash.Essentials/ControlSystem.cs
+++ b/src/PepperDash.Essentials/ControlSystem.cs
@@ -271,7 +271,7 @@ public class ControlSystem : CrestronControlSystem, ILoadConfig
_ = new Core.DeviceFactory();
- LoadAssets();
+ LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix);
Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration");
@@ -520,201 +520,7 @@ public class ControlSystem : CrestronControlSystem, ILoadConfig
}
- private static void LoadAssets()
- {
- var applicationDirectory = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix);
- Debug.LogMessage(LogEventLevel.Information, "Searching: {applicationDirectory:l} for embedded assets - {Destination}", applicationDirectory.FullName, Global.FilePathPrefix);
-
- var zipFiles = applicationDirectory.GetFiles("assets*.zip");
-
- if (zipFiles.Length > 1)
- {
- throw new Exception("Multiple assets zip files found. Cannot continue.");
- }
-
- if (zipFiles.Length == 1)
- {
- var zipFile = zipFiles[0];
- var assetsRoot = System.IO.Path.GetFullPath(Global.FilePathPrefix);
- if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
- {
- assetsRoot += Path.DirectorySeparatorChar;
- }
- Debug.LogMessage(LogEventLevel.Information, "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName);
- using (var archive = ZipFile.OpenRead(zipFile.FullName))
- {
- foreach (var entry in archive.Entries)
- {
- var destinationPath = Path.Combine(Global.FilePathPrefix, entry.FullName);
- var fullDest = System.IO.Path.GetFullPath(destinationPath);
- if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase))
- throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
-
- if (string.IsNullOrEmpty(entry.Name))
- {
- Directory.CreateDirectory(destinationPath);
- continue;
- }
-
- // If a directory exists where a file should go, delete it
- if (Directory.Exists(destinationPath))
- Directory.Delete(destinationPath, true);
-
- Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));
- entry.ExtractToFile(destinationPath, true);
- Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
- }
- }
- }
-
- // cleaning up zip files
- foreach (var file in zipFiles)
- {
- File.Delete(file.FullName);
- }
-
- var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip");
-
- if (htmlZipFiles.Length > 1)
- {
- throw new Exception("Multiple htmlassets zip files found in application directory. Please ensure only one htmlassets*.zip file is present and retry.");
- }
-
-
- if (htmlZipFiles.Length == 1)
- {
- var htmlZipFile = htmlZipFiles[0];
- var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
- var userOrNvramDir = programDir.Parent;
- var rootDir = userOrNvramDir?.Parent;
- if (rootDir == null)
- {
- throw new Exception($"Unable to determine root directory for html extraction. Current path: {Global.FilePathPrefix}");
- }
- var htmlDir = Path.Combine(rootDir.FullName, "html");
- var htmlRoot = System.IO.Path.GetFullPath(htmlDir);
- if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
- !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
- {
- htmlRoot += Path.DirectorySeparatorChar;
- }
- Debug.LogMessage(LogEventLevel.Information, "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName);
- using (var archive = ZipFile.OpenRead(htmlZipFile.FullName))
- {
- foreach (var entry in archive.Entries)
- {
- var destinationPath = Path.Combine(htmlDir, entry.FullName);
- var fullDest = System.IO.Path.GetFullPath(destinationPath);
- if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase))
- throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
-
- if (string.IsNullOrEmpty(entry.Name))
- {
- Directory.CreateDirectory(destinationPath);
- continue;
- }
-
- // Only delete the file if it exists and is a file, not a directory
- if (File.Exists(destinationPath))
- File.Delete(destinationPath);
-
- var parentDir = Path.GetDirectoryName(destinationPath);
- if (!string.IsNullOrEmpty(parentDir))
- Directory.CreateDirectory(parentDir);
-
- entry.ExtractToFile(destinationPath, true);
- Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
- }
- }
- }
-
- // cleaning up html zip files
- foreach (var file in htmlZipFiles)
- {
- File.Delete(file.FullName);
- }
-
- var devToolsZipFiles = applicationDirectory.GetFiles("essentials-devtools*.zip");
-
- if (devToolsZipFiles.Length > 1)
- {
- throw new Exception("Multiple essentials-devtools zip files found in application directory. Please ensure only one essentials-devtools*.zip file is present and retry.");
- }
-
- if (devToolsZipFiles.Length == 1)
- {
- var devToolsZipFile = devToolsZipFiles[0];
- var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
- var userOrNvramDir = programDir.Parent;
- var rootDir = userOrNvramDir?.Parent;
- if (rootDir == null)
- {
- throw new Exception($"Unable to determine root directory for debug html extraction. Current path: {Global.FilePathPrefix}");
- }
- var debugDir = Path.Combine(Path.Combine(rootDir.FullName, "html"), "debug");
- var debugRoot = System.IO.Path.GetFullPath(debugDir);
- if (!debugRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
- !debugRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
- {
- debugRoot += Path.DirectorySeparatorChar;
- }
- Debug.LogMessage(LogEventLevel.Information, "Found essentials-devtools zip file: {zipFile:l}... Unzipping to {Destination}...", devToolsZipFile.FullName, debugDir);
- using (var archive = ZipFile.OpenRead(devToolsZipFile.FullName))
- {
- foreach (var entry in archive.Entries)
- {
- var destinationPath = Path.Combine(debugDir, entry.FullName);
- var fullDest = System.IO.Path.GetFullPath(destinationPath);
- if (!fullDest.StartsWith(debugRoot, StringComparison.OrdinalIgnoreCase))
- throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
-
- if (string.IsNullOrEmpty(entry.Name))
- {
- Directory.CreateDirectory(destinationPath);
- continue;
- }
-
- if (File.Exists(destinationPath))
- File.Delete(destinationPath);
-
- var parentDir = Path.GetDirectoryName(destinationPath);
- if (!string.IsNullOrEmpty(parentDir))
- Directory.CreateDirectory(parentDir);
-
- entry.ExtractToFile(destinationPath, true);
- Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
- }
- }
- }
-
- // cleaning up devtools zip files
- foreach (var file in devToolsZipFiles)
- {
- File.Delete(file.FullName);
- }
-
- var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json");
-
- if (jsonFiles.Length > 1)
- {
- Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", jsonFiles.Select(f => f.FullName).ToArray());
- throw new Exception("Multiple configuration files found. Cannot continue.");
- }
-
- if (jsonFiles.Length == 1)
- {
- var jsonFile = jsonFiles[0];
- var finalPath = Path.Combine(Global.FilePathPrefix, jsonFile.Name);
- Debug.LogMessage(LogEventLevel.Information, "Found configuration file: {jsonFile:l}... Moving to: {Destination}", jsonFile.FullName, finalPath);
-
- if (File.Exists(finalPath))
- {
- Debug.LogMessage(LogEventLevel.Information, "Removing existing configuration file: {Destination}", finalPath);
- File.Delete(finalPath);
- }
-
- jsonFile.MoveTo(finalPath);
- }
- }
+ internal static void LoadAssets(string applicationDirectoryPath, string filePathPrefix) =>
+ AssetLoader.Load(applicationDirectoryPath, filePathPrefix);
}
diff --git a/src/PepperDash.Essentials/PepperDash.Essentials.csproj b/src/PepperDash.Essentials/PepperDash.Essentials.csproj
index d122c7b0..4bfd1f62 100644
--- a/src/PepperDash.Essentials/PepperDash.Essentials.csproj
+++ b/src/PepperDash.Essentials/PepperDash.Essentials.csproj
@@ -27,6 +27,11 @@
pdbonly
bin\$(Configuration)\PepperDashEssentials.xml
+
+
+ <_Parameter1>PepperDash.Essentials.Tests
+
+