diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 00cd191..a52add6 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -45,6 +45,16 @@ body:
description: Provide a comprehensive description of the problem you're facing. Don't forget to attach any additional resources you may have, such as log files and screenshots.
validations:
required: true
+ - type: textarea
+ id: repro-steps
+ attributes:
+ label: How do you reproduce the problem?
+ description: Include the steps to reproduce the problem from start to finish. Include details such as FastFlags you added and settings you changed.
+ placeholder: |
+ 1. Go to '...'
+ 2. Click on '...'
+ 3. Scroll down to '...'
+ 4. See error
- type: textarea
id: log
attributes:
diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml
index 7e9e09d..e76a433 100644
--- a/.github/workflows/winget.yml
+++ b/.github/workflows/winget.yml
@@ -8,7 +8,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- - uses: vedantmgoyal2009/winget-releaser@v2
+ - uses: vedantmgoyal9/winget-releaser@main
with:
identifier: pizzaboxer.Bloxstrap
token: ${{ secrets.WINGET_TOKEN }}
diff --git a/Bloxstrap/App.xaml b/Bloxstrap/App.xaml
index ae4bfac..2029b34 100644
--- a/Bloxstrap/App.xaml
+++ b/Bloxstrap/App.xaml
@@ -11,6 +11,8 @@
+
+
pack://application:,,,/Resources/Fonts/#Rubik Light
diff --git a/Bloxstrap/App.xaml.cs b/Bloxstrap/App.xaml.cs
index 5083121..dfc11ca 100644
--- a/Bloxstrap/App.xaml.cs
+++ b/Bloxstrap/App.xaml.cs
@@ -42,7 +42,7 @@ namespace Bloxstrap
public static bool IsProductionBuild => IsActionBuild && BuildMetadata.CommitRef.StartsWith("tag", StringComparison.Ordinal);
- public static bool IsStudioVisible => !String.IsNullOrEmpty(App.State.Prop.Studio.VersionGuid);
+ public static bool IsStudioVisible => !String.IsNullOrEmpty(App.RobloxState.Prop.Studio.VersionGuid);
public static readonly MD5 MD5Provider = MD5.Create();
@@ -54,6 +54,8 @@ namespace Bloxstrap
public static readonly JsonManager State = new();
+ public static readonly JsonManager RobloxState = new();
+
public static readonly FastFlagManager FastFlags = new();
public static readonly HttpClient HttpClient = new(
@@ -181,6 +183,22 @@ namespace Bloxstrap
}
}
+ public static void AssertWindowsOSVersion()
+ {
+ const string LOG_IDENT = "App::AssertWindowsOSVersion";
+
+ int major = Environment.OSVersion.Version.Major;
+ if (major < 10) // Windows 10 and newer only
+ {
+ Logger.WriteLine(LOG_IDENT, $"Detected unsupported Windows version ({Environment.OSVersion.Version}).");
+
+ if (!LaunchSettings.QuietFlag.Active)
+ Frontend.ShowMessageBox(Strings.App_OSDeprecation_Win7_81, MessageBoxImage.Error);
+
+ Terminate(ErrorCode.ERROR_INVALID_FUNCTION);
+ }
+ }
+
protected override void OnStartup(StartupEventArgs e)
{
const string LOG_IDENT = "App::OnStartup";
@@ -213,6 +231,8 @@ namespace Bloxstrap
#endif
}
+ Logger.WriteLine(LOG_IDENT, $"OSVersion: {Environment.OSVersion}");
+
Logger.WriteLine(LOG_IDENT, $"Loaded from {Paths.Process}");
Logger.WriteLine(LOG_IDENT, $"Temp path is {Paths.Temp}");
Logger.WriteLine(LOG_IDENT, $"WindowsStartMenu path is {Paths.WindowsStartMenu}");
@@ -292,6 +312,7 @@ namespace Bloxstrap
{
Logger.Initialize(true);
Logger.WriteLine(LOG_IDENT, "Not installed, launching the installer");
+ AssertWindowsOSVersion(); // prevent new installs from unsupported operating systems
LaunchHandler.LaunchInstaller();
}
else
@@ -317,6 +338,7 @@ namespace Bloxstrap
Settings.Load();
State.Load();
+ RobloxState.Load();
FastFlags.Load();
if (!Locale.SupportedLocales.ContainsKey(Settings.Prop.Locale))
diff --git a/Bloxstrap/AppData/RobloxPlayerData.cs b/Bloxstrap/AppData/RobloxPlayerData.cs
index 3c4f728..bba6cc8 100644
--- a/Bloxstrap/AppData/RobloxPlayerData.cs
+++ b/Bloxstrap/AppData/RobloxPlayerData.cs
@@ -16,7 +16,7 @@ namespace Bloxstrap.AppData
public override string ExecutableName => "RobloxPlayerBeta.exe";
- public override AppState State => App.State.Prop.Player;
+ public override AppState State => App.RobloxState.Prop.Player;
public override IReadOnlyDictionary PackageDirectoryMap { get; set; } = new Dictionary()
{
diff --git a/Bloxstrap/AppData/RobloxStudioData.cs b/Bloxstrap/AppData/RobloxStudioData.cs
index 2ada1c2..18c8e36 100644
--- a/Bloxstrap/AppData/RobloxStudioData.cs
+++ b/Bloxstrap/AppData/RobloxStudioData.cs
@@ -10,7 +10,7 @@
public override string ExecutableName => "RobloxStudioBeta.exe";
- public override AppState State => App.State.Prop.Studio;
+ public override AppState State => App.RobloxState.Prop.Studio;
public override IReadOnlyDictionary PackageDirectoryMap { get; set; } = new Dictionary()
{
diff --git a/Bloxstrap/Bloxstrap.csproj b/Bloxstrap/Bloxstrap.csproj
index 29525fb..bb30ff6 100644
--- a/Bloxstrap/Bloxstrap.csproj
+++ b/Bloxstrap/Bloxstrap.csproj
@@ -7,8 +7,8 @@
true
True
Bloxstrap.ico
- 2.8.5
- 2.8.5
+ 2.9.0
+ 2.9.0
app.manifest
true
false
@@ -25,9 +25,14 @@
+
+
+
+
+
@@ -49,16 +54,18 @@
-
+
+
-
-
+
+
all
+
diff --git a/Bloxstrap/Bootstrapper.cs b/Bloxstrap/Bootstrapper.cs
index ea0cd74..f680e00 100644
--- a/Bloxstrap/Bootstrapper.cs
+++ b/Bloxstrap/Bootstrapper.cs
@@ -45,10 +45,11 @@ namespace Bloxstrap
private readonly FastZipEvents _fastZipEvents = new();
private readonly CancellationTokenSource _cancelTokenSource = new();
- private readonly IAppData AppData;
- private readonly LaunchMode _launchMode;
+ private IAppData AppData = default!;
+ private LaunchMode _launchMode;
private string _launchCommandLine = App.LaunchSettings.RobloxLaunchArgs;
+ private Version? _latestVersion = null;
private string _latestVersionGuid = null!;
private string _latestVersionDirectory = null!;
private PackageManifest _versionPackageManifest = null!;
@@ -58,8 +59,9 @@ namespace Bloxstrap
private double _taskbarProgressIncrement;
private double _taskbarProgressMaximum;
private long _totalDownloadedBytes = 0;
+ private bool _packageExtractionSuccess = true;
- private bool _mustUpgrade => String.IsNullOrEmpty(AppData.State.VersionGuid) || !File.Exists(AppData.ExecutablePath);
+ private bool _mustUpgrade => App.LaunchSettings.ForceFlag.Active || App.State.Prop.ForceReinstall || String.IsNullOrEmpty(AppData.State.VersionGuid) || !File.Exists(AppData.ExecutablePath);
private bool _noConnection = false;
private AsyncMutex? _mutex;
@@ -69,6 +71,9 @@ namespace Bloxstrap
public IBootstrapperDialog? Dialog = null;
public bool IsStudioLaunch => _launchMode != LaunchMode.Player;
+
+ public string MutexName { get; set; } = "Bloxstrap-Bootstrapper";
+ public bool QuitIfMutexExists { get; set; } = false;
#endregion
#region Core
@@ -78,10 +83,23 @@ namespace Bloxstrap
// https://github.com/icsharpcode/SharpZipLib/blob/master/src/ICSharpCode.SharpZipLib/Zip/FastZip.cs/#L669-L680
// exceptions don't get thrown if we define events without actually binding to the failure events. probably a bug. ¯\_(ツ)_/¯
- _fastZipEvents.FileFailure += (_, e) => throw e.Exception;
+ _fastZipEvents.FileFailure += (_, e) =>
+ {
+ // only give a pass to font files (no idea whats wrong with them)
+ if (!e.Name.EndsWith(".ttf"))
+ throw e.Exception;
+
+ App.Logger.WriteLine("FastZipEvents::OnFileFailure", $"Failed to extract {e.Name}");
+ _packageExtractionSuccess = false;
+ };
_fastZipEvents.DirectoryFailure += (_, e) => throw e.Exception;
_fastZipEvents.ProcessFile += (_, e) => e.ContinueRunning = !_cancelTokenSource.IsCancellationRequested;
+ SetupAppData();
+ }
+
+ private void SetupAppData()
+ {
AppData = IsStudioLaunch ? new RobloxStudioData() : new RobloxPlayerData();
Deployment.BinaryType = AppData.BinaryType;
}
@@ -179,25 +197,29 @@ namespace Bloxstrap
}
#endif
+ App.AssertWindowsOSVersion();
+
// ensure only one instance of the bootstrapper is running at the time
// so that we don't have stuff like two updates happening simultaneously
- bool mutexExists = false;
+ bool mutexExists = Utilities.DoesMutexExist(MutexName);
- try
+ if (mutexExists)
{
- Mutex.OpenExisting("Bloxstrap-Bootstrapper").Close();
- App.Logger.WriteLine(LOG_IDENT, "Bloxstrap-Bootstrapper mutex exists, waiting...");
- SetStatus(Strings.Bootstrapper_Status_WaitingOtherInstances);
- mutexExists = true;
- }
- catch (Exception)
- {
- // no mutex exists
+ if (!QuitIfMutexExists)
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"{MutexName} mutex exists, waiting...");
+ SetStatus(Strings.Bootstrapper_Status_WaitingOtherInstances);
+ }
+ else
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"{MutexName} mutex exists, exiting!");
+ return;
+ }
}
// wait for mutex to be released if it's not yet
- await using var mutex = new AsyncMutex(false, "Bloxstrap-Bootstrapper");
+ await using var mutex = new AsyncMutex(false, MutexName);
await mutex.AcquireAsync(_cancelTokenSource.Token);
_mutex = mutex;
@@ -207,6 +229,7 @@ namespace Bloxstrap
{
App.Settings.Load();
App.State.Load();
+ App.RobloxState.Load();
}
if (!_noConnection)
@@ -221,17 +244,42 @@ namespace Bloxstrap
}
}
+ CleanupVersionsFolder(); // cleanup after background updater
+
+ bool allModificationsApplied = true;
+
if (!_noConnection)
{
if (AppData.State.VersionGuid != _latestVersionGuid || _mustUpgrade)
- await UpgradeRoblox();
+ {
+ bool backgroundUpdaterMutexOpen = Utilities.DoesMutexExist("Bloxstrap-BackgroundUpdater");
+ if (App.LaunchSettings.BackgroundUpdaterFlag.Active)
+ backgroundUpdaterMutexOpen = false; // we want to actually update lol
+
+ App.Logger.WriteLine(LOG_IDENT, $"Background updater running: {backgroundUpdaterMutexOpen}");
+
+ if (backgroundUpdaterMutexOpen && _mustUpgrade)
+ {
+ // I am Forced Upgrade, killer of Background Updates
+ Utilities.KillBackgroundUpdater();
+ backgroundUpdaterMutexOpen = false;
+ }
+
+ if (!backgroundUpdaterMutexOpen)
+ {
+ if (IsEligibleForBackgroundUpdate())
+ StartBackgroundUpdater();
+ else
+ await UpgradeRoblox();
+ }
+ }
if (_cancelTokenSource.IsCancellationRequested)
return;
// we require deployment details for applying modifications for a worst case scenario,
// where we'd need to restore files from a package that isn't present on disk and needs to be redownloaded
- await ApplyModifications();
+ allModificationsApplied = await ApplyModifications();
}
// check registry entries for every launch, just in case the stock bootstrapper changes it back
@@ -245,7 +293,15 @@ namespace Bloxstrap
await mutex.ReleaseAsync();
if (!App.LaunchSettings.NoLaunchFlag.Active && !_cancelTokenSource.IsCancellationRequested)
+ {
+ // show some balloon tips
+ if (!_packageExtractionSuccess)
+ Frontend.ShowBalloonTip(Strings.Bootstrapper_ExtractionFailed_Title, Strings.Bootstrapper_ExtractionFailed_Message, ToolTipIcon.Warning);
+ else if (!allModificationsApplied)
+ Frontend.ShowBalloonTip(Strings.Bootstrapper_ModificationsFailed_Title, Strings.Bootstrapper_ModificationsFailed_Message, ToolTipIcon.Warning);
+
StartRoblox();
+ }
await mutex.ReleaseAsync();
@@ -267,12 +323,17 @@ namespace Bloxstrap
using var key = Registry.CurrentUser.CreateSubKey($"SOFTWARE\\ROBLOX Corporation\\Environments\\{AppData.RegistryName}\\Channel");
var match = Regex.Match(
- App.LaunchSettings.RobloxLaunchArgs,
- "channel:([a-zA-Z0-9-_]+)",
+ App.LaunchSettings.RobloxLaunchArgs,
+ "channel:([a-zA-Z0-9-_]+)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
);
- if (match.Groups.Count == 2)
+ if (App.LaunchSettings.ChannelFlag.Active && !string.IsNullOrEmpty(App.LaunchSettings.ChannelFlag.Data))
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"Channel set to {App.LaunchSettings.ChannelFlag.Data} from arguments");
+ Deployment.Channel = App.LaunchSettings.ChannelFlag.Data.ToLowerInvariant();
+ }
+ else if (match.Groups.Count == 2)
{
Deployment.Channel = match.Groups[1].Value.ToLowerInvariant();
}
@@ -289,37 +350,125 @@ namespace Bloxstrap
if (!Deployment.IsDefaultChannel)
App.SendStat("robloxChannel", Deployment.Channel);
- ClientVersion clientVersion;
-
- try
+ if (!App.LaunchSettings.VersionFlag.Active || string.IsNullOrEmpty(App.LaunchSettings.VersionFlag.Data))
{
- clientVersion = await Deployment.GetInfo();
+ ClientVersion clientVersion;
+
+ try
+ {
+ clientVersion = await Deployment.GetInfo();
+ }
+ catch (InvalidChannelException ex)
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"Resetting channel from {Deployment.Channel} because {ex.StatusCode}");
+
+ Deployment.Channel = Deployment.DefaultChannel;
+ clientVersion = await Deployment.GetInfo();
+ }
+
+ key.SetValueSafe("www.roblox.com", Deployment.IsDefaultChannel ? "" : Deployment.Channel);
+
+ _latestVersionGuid = clientVersion.VersionGuid;
+ _latestVersion = Utilities.ParseVersionSafe(clientVersion.Version);
}
- catch (InvalidChannelException ex)
+ else
{
- App.Logger.WriteLine(LOG_IDENT, $"Resetting channel from {Deployment.Channel} because {ex.StatusCode}");
-
- Deployment.Channel = Deployment.DefaultChannel;
- clientVersion = await Deployment.GetInfo();
+ App.Logger.WriteLine(LOG_IDENT, $"Version set to {App.LaunchSettings.VersionFlag.Data} from arguments");
+ _latestVersionGuid = App.LaunchSettings.VersionFlag.Data;
+ // we can't determine the version
}
- if (clientVersion.IsBehindDefaultChannel)
- {
- App.Logger.WriteLine(LOG_IDENT, $"Resetting channel from {Deployment.Channel} because it's behind production");
-
- Deployment.Channel = Deployment.DefaultChannel;
- clientVersion = await Deployment.GetInfo();
- }
-
- key.SetValueSafe("www.roblox.com", Deployment.IsDefaultChannel ? "" : Deployment.Channel);
-
- _latestVersionGuid = clientVersion.VersionGuid;
_latestVersionDirectory = Path.Combine(Paths.Versions, _latestVersionGuid);
string pkgManifestUrl = Deployment.GetLocation($"/{_latestVersionGuid}-rbxPkgManifest.txt");
var pkgManifestData = await App.HttpClient.GetStringAsync(pkgManifestUrl);
_versionPackageManifest = new(pkgManifestData);
+
+ // this can happen if version is set through arguments
+ if (_launchMode == LaunchMode.Unknown)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Identifying launch mode from package manifest");
+
+ bool isPlayer = _versionPackageManifest.Exists(x => x.Name == "RobloxApp.zip");
+ App.Logger.WriteLine(LOG_IDENT, $"isPlayer: {isPlayer}");
+
+ _launchMode = isPlayer ? LaunchMode.Player : LaunchMode.Studio;
+ SetupAppData(); // we need to set it up again
+ }
+ }
+
+ private bool IsEligibleForBackgroundUpdate()
+ {
+ const string LOG_IDENT = "Bootstrapper::IsEligibleForBackgroundUpdate";
+
+ if (App.LaunchSettings.BackgroundUpdaterFlag.Active)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Not eligible: Is the background updater process");
+ return false;
+ }
+
+ if (!App.Settings.Prop.BackgroundUpdatesEnabled)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Not eligible: Background updates disabled");
+ return false;
+ }
+
+ if (IsStudioLaunch)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Not eligible: Studio launch");
+ return false;
+ }
+
+ if (_mustUpgrade)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Not eligible: Must upgrade is true");
+ return false;
+ }
+
+ // at least 3GB of free space
+ const long minimumFreeSpace = 3_000_000_000;
+ long space = Filesystem.GetFreeDiskSpace(Paths.Base);
+ if (space < minimumFreeSpace)
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"Not eligible: User has {space} free space, at least {minimumFreeSpace} is required");
+ return false;
+ }
+
+ if (_latestVersion == default)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Not eligible: Latest version is undefined");
+ return false;
+ }
+
+ Version? currentVersion = Utilities.GetRobloxVersion(AppData);
+ if (currentVersion == default)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Not eligible: Current version is undefined");
+ return false;
+ }
+
+ // always normally upgrade for downgrades
+ if (currentVersion.Minor > _latestVersion.Minor)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Not eligible: Downgrade");
+ return false;
+ }
+
+ // only background update if we're:
+ // - one major update behind
+ // - the same major update
+ int diff = _latestVersion.Minor - currentVersion.Minor;
+ if (diff == 0 || diff == 1)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Eligible");
+ return true;
+ }
+ else
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"Not eligible: Major version diff is {diff}");
+ return false;
+ }
}
private void StartRoblox()
@@ -649,16 +798,51 @@ namespace Bloxstrap
#endregion
#region Roblox Install
- private void CleanupVersionsFolder()
+ private static bool TryDeleteRobloxInDirectory(string dir)
+ {
+ string clientPath = Path.Combine(dir, "RobloxPlayerBeta.exe");
+ if (!File.Exists(dir))
+ {
+ clientPath = Path.Combine(dir, "RobloxStudioBeta.exe");
+ if (!File.Exists(dir))
+ return true; // ok???
+ }
+
+ try
+ {
+ File.Delete(clientPath);
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ public static void CleanupVersionsFolder()
{
const string LOG_IDENT = "Bootstrapper::CleanupVersionsFolder";
+ if (App.LaunchSettings.BackgroundUpdaterFlag.Active)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Background updater tried to cleanup, stopping!");
+ return;
+ }
+
foreach (string dir in Directory.GetDirectories(Paths.Versions))
{
string dirName = Path.GetFileName(dir);
- if (dirName != App.State.Prop.Player.VersionGuid && dirName != App.State.Prop.Studio.VersionGuid)
+ if (dirName != App.RobloxState.Prop.Player.VersionGuid && dirName != App.RobloxState.Prop.Studio.VersionGuid)
{
+ // TODO: this is too expensive
+ //Filesystem.AssertReadOnlyDirectory(dir);
+
+ // check if it's still being used first
+ // we dont want to accidentally delete the files of a running roblox instance
+ if (!TryDeleteRobloxInDirectory(dir))
+ continue;
+
try
{
Directory.Delete(dir, true);
@@ -729,11 +913,11 @@ namespace Bloxstrap
_isInstalling = true;
// make sure nothing is running before continuing upgrade
- if (!IsStudioLaunch) // TODO: wait for studio processes to close before updating to prevent data loss
+ if (!App.LaunchSettings.BackgroundUpdaterFlag.Active && !IsStudioLaunch) // TODO: wait for studio processes to close before updating to prevent data loss
KillRobloxPlayers();
// get a fully clean install
- if (Directory.Exists(_latestVersionDirectory))
+ if (!App.LaunchSettings.BackgroundUpdaterFlag.Active && Directory.Exists(_latestVersionDirectory))
{
try
{
@@ -886,23 +1070,26 @@ namespace Bloxstrap
var allPackageHashes = new List();
- allPackageHashes.AddRange(App.State.Prop.Player.PackageHashes.Values);
- allPackageHashes.AddRange(App.State.Prop.Studio.PackageHashes.Values);
+ allPackageHashes.AddRange(App.RobloxState.Prop.Player.PackageHashes.Values);
+ allPackageHashes.AddRange(App.RobloxState.Prop.Studio.PackageHashes.Values);
- foreach (string hash in cachedPackageHashes)
+ if (!App.Settings.Prop.DebugDisableVersionPackageCleanup)
{
- if (!allPackageHashes.Contains(hash))
+ foreach (string hash in cachedPackageHashes)
{
- App.Logger.WriteLine(LOG_IDENT, $"Deleting unused package {hash}");
-
- try
+ if (!allPackageHashes.Contains(hash))
{
- File.Delete(Path.Combine(Paths.Downloads, hash));
- }
- catch (Exception ex)
- {
- App.Logger.WriteLine(LOG_IDENT, $"Failed to delete {hash}!");
- App.Logger.WriteException(LOG_IDENT, ex);
+ App.Logger.WriteLine(LOG_IDENT, $"Deleting unused package {hash}");
+
+ try
+ {
+ File.Delete(Path.Combine(Paths.Downloads, hash));
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"Failed to delete {hash}!");
+ App.Logger.WriteException(LOG_IDENT, ex);
+ }
}
}
}
@@ -913,7 +1100,7 @@ namespace Bloxstrap
AppData.State.Size = distributionSize;
- int totalSize = App.State.Prop.Player.Size + App.State.Prop.Studio.Size;
+ int totalSize = App.RobloxState.Prop.Player.Size + App.RobloxState.Prop.Studio.Size;
using (var uninstallKey = Registry.CurrentUser.CreateSubKey(App.UninstallKey))
{
@@ -923,14 +1110,32 @@ namespace Bloxstrap
App.Logger.WriteLine(LOG_IDENT, $"Registered as {totalSize} KB");
App.State.Save();
+ App.RobloxState.Save();
_isInstalling = false;
}
- private async Task ApplyModifications()
+ private static void StartBackgroundUpdater()
+ {
+ const string LOG_IDENT = "Bootstrapper::StartBackgroundUpdater";
+
+ if (Utilities.DoesMutexExist("Bloxstrap-BackgroundUpdater"))
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Background updater already running");
+ return;
+ }
+
+ App.Logger.WriteLine(LOG_IDENT, "Starting background updater");
+
+ Process.Start(Paths.Process, "-backgroundupdater");
+ }
+
+ private async Task ApplyModifications()
{
const string LOG_IDENT = "Bootstrapper::ApplyModifications";
+ bool success = true;
+
SetStatus(Strings.Bootstrapper_Status_ApplyingModifications);
// handle file mods
@@ -1006,7 +1211,7 @@ namespace Bloxstrap
foreach (string file in Directory.GetFiles(Paths.Modifications, "*.*", SearchOption.AllDirectories))
{
if (_cancelTokenSource.IsCancellationRequested)
- return;
+ return true;
// get relative directory path
string relativeFile = file.Substring(Paths.Modifications.Length + 1);
@@ -1038,10 +1243,18 @@ namespace Bloxstrap
Directory.CreateDirectory(Path.GetDirectoryName(fileVersionFolder)!);
Filesystem.AssertReadOnly(fileVersionFolder);
- File.Copy(fileModFolder, fileVersionFolder, true);
- Filesystem.AssertReadOnly(fileVersionFolder);
-
- App.Logger.WriteLine(LOG_IDENT, $"{relativeFile} has been copied to the version folder");
+ try
+ {
+ File.Copy(fileModFolder, fileVersionFolder, true);
+ Filesystem.AssertReadOnly(fileVersionFolder);
+ App.Logger.WriteLine(LOG_IDENT, $"{relativeFile} has been copied to the version folder");
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"Failed to apply modification ({relativeFile})");
+ App.Logger.WriteException(LOG_IDENT, ex);
+ success = false;
+ }
}
// the manifest is primarily here to keep track of what files have been
@@ -1050,7 +1263,7 @@ namespace Bloxstrap
var fileRestoreMap = new Dictionary>();
- foreach (string fileLocation in App.State.Prop.ModManifest)
+ foreach (string fileLocation in App.RobloxState.Prop.ModManifest)
{
if (modFolderFiles.Contains(fileLocation))
continue;
@@ -1088,17 +1301,31 @@ namespace Bloxstrap
if (package is not null)
{
if (_cancelTokenSource.IsCancellationRequested)
- return;
+ return true;
await DownloadPackage(package);
ExtractPackage(package, entry.Value);
}
}
- App.State.Prop.ModManifest = modFolderFiles;
- App.State.Save();
+ // make sure we're not overwriting a new update
+ // if we're the background update process, always overwrite
+ if (App.LaunchSettings.BackgroundUpdaterFlag.Active || !App.RobloxState.HasFileOnDiskChanged())
+ {
+ App.RobloxState.Prop.ModManifest = modFolderFiles;
+ App.RobloxState.Save();
+ }
+ else
+ {
+ App.Logger.WriteLine(LOG_IDENT, "RobloxState disk mismatch, not saving ModManifest");
+ }
App.Logger.WriteLine(LOG_IDENT, $"Finished checking file mods");
+
+ if (!success)
+ App.Logger.WriteLine(LOG_IDENT, "Failed to apply all modifications");
+
+ return success;
}
private async Task DownloadPackage(Package package)
diff --git a/Bloxstrap/Enums/BootstrapperStyle.cs b/Bloxstrap/Enums/BootstrapperStyle.cs
index 5c5f6fd..c638858 100644
--- a/Bloxstrap/Enums/BootstrapperStyle.cs
+++ b/Bloxstrap/Enums/BootstrapperStyle.cs
@@ -10,6 +10,7 @@
ByfronDialog,
[EnumName(StaticName = "Bloxstrap")]
FluentDialog,
- FluentAeroDialog
+ FluentAeroDialog,
+ CustomDialog
}
}
diff --git a/Bloxstrap/Enums/CustomThemeTemplate.cs b/Bloxstrap/Enums/CustomThemeTemplate.cs
new file mode 100644
index 0000000..6497907
--- /dev/null
+++ b/Bloxstrap/Enums/CustomThemeTemplate.cs
@@ -0,0 +1,8 @@
+namespace Bloxstrap.Enums
+{
+ public enum CustomThemeTemplate
+ {
+ Blank,
+ Simple
+ }
+}
diff --git a/Bloxstrap/Enums/LaunchMode.cs b/Bloxstrap/Enums/LaunchMode.cs
index c045d4f..4778442 100644
--- a/Bloxstrap/Enums/LaunchMode.cs
+++ b/Bloxstrap/Enums/LaunchMode.cs
@@ -3,6 +3,10 @@
public enum LaunchMode
{
None,
+ ///
+ /// Launch mode will be determined inside the bootstrapper. Only works if the VersionFlag is set.
+ ///
+ Unknown,
Player,
Studio,
StudioAuth
diff --git a/Bloxstrap/Exceptions/CustomThemeException.cs b/Bloxstrap/Exceptions/CustomThemeException.cs
new file mode 100644
index 0000000..68ad0a1
--- /dev/null
+++ b/Bloxstrap/Exceptions/CustomThemeException.cs
@@ -0,0 +1,60 @@
+using Bloxstrap.Extensions;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Bloxstrap.Exceptions
+{
+ internal class CustomThemeException : Exception
+ {
+ ///
+ /// The exception message in English (for logging)
+ ///
+ public string EnglishMessage { get; } = null!;
+
+ public CustomThemeException(string translationString)
+ : base(Strings.ResourceManager.GetStringSafe(translationString))
+ {
+ EnglishMessage = Strings.ResourceManager.GetStringSafe(translationString, new CultureInfo("en-GB"));
+ }
+
+ public CustomThemeException(Exception innerException, string translationString)
+ : base(Strings.ResourceManager.GetStringSafe(translationString), innerException)
+ {
+ EnglishMessage = Strings.ResourceManager.GetStringSafe(translationString, new CultureInfo("en-GB"));
+ }
+
+ public CustomThemeException(string translationString, params object?[] args)
+ : base(string.Format(Strings.ResourceManager.GetStringSafe(translationString), args))
+ {
+ EnglishMessage = string.Format(Strings.ResourceManager.GetStringSafe(translationString, new CultureInfo("en-GB")), args);
+ }
+
+ public CustomThemeException(Exception innerException, string translationString, params object?[] args)
+ : base(string.Format(Strings.ResourceManager.GetStringSafe(translationString), args), innerException)
+ {
+ EnglishMessage = string.Format(Strings.ResourceManager.GetStringSafe(translationString, new CultureInfo("en-GB")), args);
+ }
+
+ public override string ToString()
+ {
+ StringBuilder sb = new StringBuilder(GetType().ToString());
+
+ if (!string.IsNullOrEmpty(Message))
+ sb.Append($": {Message}");
+
+ if (!string.IsNullOrEmpty(EnglishMessage) && Message != EnglishMessage)
+ sb.Append($" ({EnglishMessage})");
+
+ if (InnerException != null)
+ sb.Append($"\r\n ---> {InnerException}\r\n ");
+
+ if (StackTrace != null)
+ sb.Append($"\r\n{StackTrace}");
+
+ return sb.ToString();
+ }
+ }
+}
diff --git a/Bloxstrap/Extensions/BootstrapperStyleEx.cs b/Bloxstrap/Extensions/BootstrapperStyleEx.cs
index 3802264..693c78e 100644
--- a/Bloxstrap/Extensions/BootstrapperStyleEx.cs
+++ b/Bloxstrap/Extensions/BootstrapperStyleEx.cs
@@ -13,7 +13,8 @@
BootstrapperStyle.ProgressDialog,
BootstrapperStyle.LegacyDialog2011,
BootstrapperStyle.LegacyDialog2008,
- BootstrapperStyle.VistaDialog
+ BootstrapperStyle.VistaDialog,
+ BootstrapperStyle.CustomDialog
};
}
}
diff --git a/Bloxstrap/Extensions/CustomThemeTemplateEx.cs b/Bloxstrap/Extensions/CustomThemeTemplateEx.cs
new file mode 100644
index 0000000..ed9dcea
--- /dev/null
+++ b/Bloxstrap/Extensions/CustomThemeTemplateEx.cs
@@ -0,0 +1,36 @@
+using System.Text;
+
+namespace Bloxstrap.Extensions
+{
+ static class CustomThemeTemplateEx
+ {
+ const string EXAMPLES_URL = "https://github.com/bloxstraplabs/custom-bootstrapper-examples";
+
+ public static string GetFileName(this CustomThemeTemplate template)
+ {
+ return $"CustomBootstrapperTemplate_{template}.xml";
+ }
+
+ public static string GetFileContents(this CustomThemeTemplate template)
+ {
+ string contents = Encoding.UTF8.GetString(Resource.Get(template.GetFileName()).Result);
+
+ switch (template)
+ {
+ case CustomThemeTemplate.Blank:
+ {
+ string moreText = string.Format(Strings.CustomTheme_Templates_Blank_MoreExamples, EXAMPLES_URL);
+ return string.Format(contents, Strings.CustomTheme_Templates_Blank_UIElements, moreText);
+ }
+ case CustomThemeTemplate.Simple:
+ {
+ string moreText = string.Format(Strings.CustomTheme_Templates_Simple_MoreExamples, EXAMPLES_URL);
+ return string.Format(contents, moreText);
+ }
+ default:
+ Debug.Assert(false);
+ return contents;
+ }
+ }
+ }
+}
diff --git a/Bloxstrap/Installer.cs b/Bloxstrap/Installer.cs
index 93f40c1..d57e985 100644
--- a/Bloxstrap/Installer.cs
+++ b/Bloxstrap/Installer.cs
@@ -197,7 +197,7 @@ namespace Bloxstrap
var processes = new List();
- if (!String.IsNullOrEmpty(App.State.Prop.Player.VersionGuid))
+ if (!String.IsNullOrEmpty(App.RobloxState.Prop.Player.VersionGuid))
processes.AddRange(Process.GetProcessesByName(App.RobloxPlayerAppName));
if (App.IsStudioVisible)
@@ -587,16 +587,23 @@ namespace Bloxstrap
}
}
- if (Utilities.CompareVersions(existingVer, "2.8.3") == VersionComparison.LessThan)
+ if (Utilities.CompareVersions(existingVer, "2.9.0") == VersionComparison.LessThan)
{
- // force reinstallation
- App.State.Prop.Player.VersionGuid = "";
- App.State.Prop.Studio.VersionGuid = "";
+ // move from App.State to App.RobloxState
+ if (App.State.Prop.GetDeprecatedPlayer() != null)
+ App.RobloxState.Prop.Player = App.State.Prop.GetDeprecatedPlayer()!;
+
+ if (App.State.Prop.GetDeprecatedStudio() != null)
+ App.RobloxState.Prop.Studio = App.State.Prop.GetDeprecatedStudio()!;
+
+ if (App.State.Prop.GetDeprecatedModManifest() != null)
+ App.RobloxState.Prop.ModManifest = App.State.Prop.GetDeprecatedModManifest()!;
}
App.Settings.Save();
App.FastFlags.Save();
App.State.Save();
+ App.RobloxState.Save();
}
if (currentVer is null)
diff --git a/Bloxstrap/JsonManager.cs b/Bloxstrap/JsonManager.cs
index bb1eb77..6c04a2a 100644
--- a/Bloxstrap/JsonManager.cs
+++ b/Bloxstrap/JsonManager.cs
@@ -8,6 +8,11 @@ namespace Bloxstrap
public T Prop { get; set; } = new();
+ ///
+ /// The file hash when last retrieved from disk
+ ///
+ public string? LastFileHash { get; private set; }
+
public virtual string ClassName => typeof(T).Name;
public virtual string FileLocation => Path.Combine(Paths.Base, $"{ClassName}.json");
@@ -22,12 +27,15 @@ namespace Bloxstrap
try
{
- T? settings = JsonSerializer.Deserialize(File.ReadAllText(FileLocation));
+ string contents = File.ReadAllText(FileLocation);
+
+ T? settings = JsonSerializer.Deserialize(contents);
if (settings is null)
throw new ArgumentNullException("Deserialization returned null");
Prop = settings;
+ LastFileHash = MD5Hash.FromString(contents);
App.Logger.WriteLine(LOG_IDENT, "Loaded successfully!");
}
@@ -74,7 +82,11 @@ namespace Bloxstrap
try
{
- File.WriteAllText(FileLocation, JsonSerializer.Serialize(Prop, new JsonSerializerOptions { WriteIndented = true }));
+ string contents = JsonSerializer.Serialize(Prop, new JsonSerializerOptions { WriteIndented = true });
+
+ File.WriteAllText(FileLocation, contents);
+
+ LastFileHash = MD5Hash.FromString(contents);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
@@ -89,5 +101,13 @@ namespace Bloxstrap
App.Logger.WriteLine(LOG_IDENT, "Save complete!");
}
+
+ ///
+ /// Is the file on disk different to the one deserialised during this session?
+ ///
+ public bool HasFileOnDiskChanged()
+ {
+ return LastFileHash != MD5Hash.FromFile(FileLocation);
+ }
}
}
diff --git a/Bloxstrap/LaunchHandler.cs b/Bloxstrap/LaunchHandler.cs
index 2dda0c9..1eee8b2 100644
--- a/Bloxstrap/LaunchHandler.cs
+++ b/Bloxstrap/LaunchHandler.cs
@@ -4,6 +4,7 @@ using Windows.Win32;
using Windows.Win32.Foundation;
using Bloxstrap.UI.Elements.Dialogs;
+using Bloxstrap.Enums;
namespace Bloxstrap
{
@@ -58,6 +59,11 @@ namespace Bloxstrap
App.Logger.WriteLine(LOG_IDENT, "Opening watcher");
LaunchWatcher();
}
+ else if (App.LaunchSettings.BackgroundUpdaterFlag.Active)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Opening background updater");
+ LaunchBackgroundUpdater();
+ }
else if (App.LaunchSettings.RobloxLaunchMode != LaunchMode.None)
{
App.Logger.WriteLine(LOG_IDENT, $"Opening bootstrapper ({App.LaunchSettings.RobloxLaunchMode})");
@@ -295,5 +301,51 @@ namespace Bloxstrap
App.Terminate();
});
}
+
+ public static void LaunchBackgroundUpdater()
+ {
+ const string LOG_IDENT = "LaunchHandler::LaunchBackgroundUpdater";
+
+ // Activate some LaunchFlags we need
+ App.LaunchSettings.QuietFlag.Active = true;
+ App.LaunchSettings.NoLaunchFlag.Active = true;
+
+ App.Logger.WriteLine(LOG_IDENT, "Initializing bootstrapper");
+ App.Bootstrapper = new Bootstrapper(LaunchMode.Player)
+ {
+ MutexName = "Bloxstrap-BackgroundUpdater",
+ QuitIfMutexExists = true
+ };
+
+ CancellationTokenSource cts = new CancellationTokenSource();
+
+ Task.Run(() =>
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Started event waiter");
+ using (EventWaitHandle handle = new EventWaitHandle(false, EventResetMode.AutoReset, "Bloxstrap-BackgroundUpdaterKillEvent"))
+ handle.WaitOne();
+
+ App.Logger.WriteLine(LOG_IDENT, "Received close event, killing it all!");
+ App.Bootstrapper.Cancel();
+ }, cts.Token);
+
+ Task.Run(App.Bootstrapper.Run).ContinueWith(t =>
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Bootstrapper task has finished");
+ cts.Cancel(); // stop event waiter
+
+ if (t.IsFaulted)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "An exception occurred when running the bootstrapper");
+
+ if (t.Exception is not null)
+ App.FinalizeExceptionHandling(t.Exception);
+ }
+
+ App.Terminate();
+ });
+
+ App.Logger.WriteLine(LOG_IDENT, "Exiting");
+ }
}
}
diff --git a/Bloxstrap/LaunchSettings.cs b/Bloxstrap/LaunchSettings.cs
index f2ae908..e0a5051 100644
--- a/Bloxstrap/LaunchSettings.cs
+++ b/Bloxstrap/LaunchSettings.cs
@@ -12,25 +12,33 @@ namespace Bloxstrap
{
public class LaunchSettings
{
- public LaunchFlag MenuFlag { get; } = new("preferences,menu,settings");
+ public LaunchFlag MenuFlag { get; } = new("preferences,menu,settings");
- public LaunchFlag WatcherFlag { get; } = new("watcher");
+ public LaunchFlag WatcherFlag { get; } = new("watcher");
- public LaunchFlag QuietFlag { get; } = new("quiet");
+ public LaunchFlag BackgroundUpdaterFlag { get; } = new("backgroundupdater");
- public LaunchFlag UninstallFlag { get; } = new("uninstall");
+ public LaunchFlag QuietFlag { get; } = new("quiet");
- public LaunchFlag NoLaunchFlag { get; } = new("nolaunch");
+ public LaunchFlag UninstallFlag { get; } = new("uninstall");
+
+ public LaunchFlag NoLaunchFlag { get; } = new("nolaunch");
- public LaunchFlag TestModeFlag { get; } = new("testmode");
+ public LaunchFlag TestModeFlag { get; } = new("testmode");
- public LaunchFlag NoGPUFlag { get; } = new("nogpu");
+ public LaunchFlag NoGPUFlag { get; } = new("nogpu");
- public LaunchFlag UpgradeFlag { get; } = new("upgrade");
+ public LaunchFlag UpgradeFlag { get; } = new("upgrade");
- public LaunchFlag PlayerFlag { get; } = new("player");
+ public LaunchFlag PlayerFlag { get; } = new("player");
- public LaunchFlag StudioFlag { get; } = new("studio");
+ public LaunchFlag StudioFlag { get; } = new("studio");
+
+ public LaunchFlag VersionFlag { get; } = new("version");
+
+ public LaunchFlag ChannelFlag { get; } = new("channel");
+
+ public LaunchFlag ForceFlag { get; } = new("force");
#if DEBUG
public bool BypassUpdateCheck => true;
@@ -87,6 +95,13 @@ namespace Bloxstrap
RobloxLaunchArgs = arg;
startIdx = 1;
}
+ else if (arg.StartsWith("version-"))
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Got version argument");
+ VersionFlag.Active = true;
+ VersionFlag.Data = arg;
+ startIdx = 1;
+ }
}
// parse
@@ -108,11 +123,18 @@ namespace Bloxstrap
continue;
}
+ if (flag.Active)
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"Tried to set {identifier} flag twice");
+ continue;
+ }
+
flag.Active = true;
if (i < Args.Length - 1 && Args[i+1] is string nextArg && !nextArg.StartsWith('-'))
{
flag.Data = nextArg;
+ i++;
App.Logger.WriteLine(LOG_IDENT, $"Identifier '{identifier}' is active with data");
}
else
@@ -121,6 +143,9 @@ namespace Bloxstrap
}
}
+ if (VersionFlag.Active)
+ RobloxLaunchMode = LaunchMode.Unknown; // determine in bootstrapper
+
if (PlayerFlag.Active)
ParsePlayer(PlayerFlag.Data);
else if (StudioFlag.Active)
diff --git a/Bloxstrap/Models/APIs/Roblox/ClientVersion.cs b/Bloxstrap/Models/APIs/Roblox/ClientVersion.cs
index 9fa405e..5e584f4 100644
--- a/Bloxstrap/Models/APIs/Roblox/ClientVersion.cs
+++ b/Bloxstrap/Models/APIs/Roblox/ClientVersion.cs
@@ -12,7 +12,5 @@
public string BootstrapperVersion { get; set; } = null!;
public DateTime? Timestamp { get; set; }
-
- public bool IsBehindDefaultChannel { get; set; }
}
}
diff --git a/Bloxstrap/Models/Persistable/RobloxState.cs b/Bloxstrap/Models/Persistable/RobloxState.cs
new file mode 100644
index 0000000..f4b3098
--- /dev/null
+++ b/Bloxstrap/Models/Persistable/RobloxState.cs
@@ -0,0 +1,11 @@
+namespace Bloxstrap.Models.Persistable
+{
+ public class RobloxState
+ {
+ public AppState Player { get; set; } = new();
+
+ public AppState Studio { get; set; } = new();
+
+ public List ModManifest { get; set; } = new();
+ }
+}
diff --git a/Bloxstrap/Models/Persistable/Settings.cs b/Bloxstrap/Models/Persistable/Settings.cs
index d640fc3..0f6bb86 100644
--- a/Bloxstrap/Models/Persistable/Settings.cs
+++ b/Bloxstrap/Models/Persistable/Settings.cs
@@ -17,6 +17,9 @@ namespace Bloxstrap.Models.Persistable
public bool UseFastFlagManager { get; set; } = true;
public bool WPFSoftwareRender { get; set; } = false;
public bool EnableAnalytics { get; set; } = true;
+ public bool BackgroundUpdatesEnabled { get; set; } = true;
+ public bool DebugDisableVersionPackageCleanup { get; set; } = false;
+ public string? SelectedCustomTheme { get; set; } = null;
// integration configuration
public bool EnableActivityTracking { get; set; } = true;
diff --git a/Bloxstrap/Models/Persistable/State.cs b/Bloxstrap/Models/Persistable/State.cs
index de05265..070f8e0 100644
--- a/Bloxstrap/Models/Persistable/State.cs
+++ b/Bloxstrap/Models/Persistable/State.cs
@@ -6,12 +6,28 @@
public bool PromptWebView2Install { get; set; } = true;
- public AppState Player { get; set; } = new();
-
- public AppState Studio { get; set; } = new();
+ public bool ForceReinstall { get; set; } = false;
public WindowState SettingsWindow { get; set; } = new();
- public List ModManifest { get; set; } = new();
+ #region Deprecated properties
+ ///
+ /// Deprecated, use App.RobloxState.Player
+ ///
+ public AppState? Player { private get; set; }
+ public AppState? GetDeprecatedPlayer() => Player;
+
+ ///
+ /// Deprecated, use App.RobloxState.Studio
+ ///
+ public AppState? Studio { private get; set; }
+ public AppState? GetDeprecatedStudio() => Studio;
+
+ ///
+ /// Deprecated, use App.RobloxState.ModManifest
+ ///
+ public List? ModManifest { private get; set; }
+ public List? GetDeprecatedModManifest() => ModManifest;
+ #endregion
}
}
diff --git a/Bloxstrap/Paths.cs b/Bloxstrap/Paths.cs
index 43d4a1c..1205d67 100644
--- a/Bloxstrap/Paths.cs
+++ b/Bloxstrap/Paths.cs
@@ -22,6 +22,7 @@
public static string Integrations { get; private set; } = "";
public static string Versions { get; private set; } = "";
public static string Modifications { get; private set; } = "";
+ public static string CustomThemes { get; private set; } = "";
public static string Application { get; private set; } = "";
@@ -37,6 +38,7 @@
Integrations = Path.Combine(Base, "Integrations");
Versions = Path.Combine(Base, "Versions");
Modifications = Path.Combine(Base, "Modifications");
+ CustomThemes = Path.Combine(Base, "CustomThemes");
Application = Path.Combine(Base, $"{App.ProjectName}.exe");
}
diff --git a/Bloxstrap/Resource.cs b/Bloxstrap/Resource.cs
index 46c8e82..612d846 100644
--- a/Bloxstrap/Resource.cs
+++ b/Bloxstrap/Resource.cs
@@ -21,5 +21,10 @@ namespace Bloxstrap
await stream.CopyToAsync(memoryStream);
return memoryStream.ToArray();
}
+
+ public static async Task GetString(string name)
+ {
+ return Encoding.UTF8.GetString(await Get(name));
+ }
}
}
diff --git a/Bloxstrap/Resources/CustomBootstrapperSchema.json b/Bloxstrap/Resources/CustomBootstrapperSchema.json
new file mode 100644
index 0000000..44b6631
--- /dev/null
+++ b/Bloxstrap/Resources/CustomBootstrapperSchema.json
@@ -0,0 +1,521 @@
+{
+ "Elements": {
+ "FrameworkElement": {
+ "IsCreatable": false,
+ "Attributes": {
+ "Name": "string",
+ "Visibility": "Visibility",
+ "IsEnabled": "bool",
+ "Margin": "Thickness",
+ "Height": "double",
+ "Width": "double",
+ "HorizontalAlignment": "HorizontalAlignment",
+ "VerticalAlignment": "VerticalAlignment",
+ "RenderTransform": "Transform",
+ "LayoutTransform": "Transform",
+ "Opacity": "double",
+ "OpacityMask": "Brush",
+ "RenderTransformOrigin": "Point",
+ "Panel.ZIndex": "int",
+ "Grid.Row": "int",
+ "Grid.RowSpan": "int",
+ "Grid.Column": "int",
+ "Grid.ColumnSpan": "int"
+ }
+ },
+ "Control": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": false,
+ "Attributes": {
+ "Padding": "Thickness",
+ "BorderThickness": "Thickness",
+ "Foreground": "Brush",
+ "Background": "Brush",
+ "BorderBrush": "Brush",
+ "FontSize": "double",
+ "FontWeight": "FontWeight",
+ "FontStyle": "FontStyle",
+ "FontFamily": "FontFamily"
+ }
+ },
+ "BloxstrapCustomBootstrapper": {
+ "SuperClass": "Control",
+ "IsCreatable": true,
+ "Attributes": {
+ "Version": "int",
+ "Theme": "Theme",
+ "Title": "string",
+ "IgnoreTitleBarInset": "bool",
+ "WindowCornerPreference": "WindowCornerPreference"
+ }
+ },
+ "TitleBar": {
+ "SuperClass": "Control",
+ "IsCreatable": true,
+ "Attributes": {
+ "ShowMinimize": "bool",
+ "ShowClose": "bool",
+ "Title": "string"
+ }
+ },
+ "Button": {
+ "SuperClass": "Control",
+ "IsCreatable": true,
+ "Attributes": {
+ "Content": "object"
+ }
+ },
+ "RangeBase": {
+ "SuperClass": "Control",
+ "IsCreatable": false,
+ "Attributes": {
+ "Value": "double",
+ "Maximum": "double"
+ }
+ },
+ "ProgressBar": {
+ "SuperClass": "RangeBase",
+ "IsCreatable": true,
+ "Attributes": {
+ "IsIndeterminate": "bool",
+ "CornerRadius": "CornerRadius",
+ "IndicatorCornerRadius": "CornerRadius"
+ }
+ },
+ "ProgressRing": {
+ "SuperClass": "RangeBase",
+ "IsCreatable": true,
+ "Attributes": {
+ "IsIndeterminate": "bool"
+ }
+ },
+ "TextBlock": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "Text": "string",
+ "Foreground": "Brush",
+ "Background": "Brush",
+ "FontSize": "double",
+ "FontWeight": "FontWeight",
+ "FontStyle": "FontStyle",
+ "FontFamily": "FontFamily",
+ "LineHeight": "double",
+ "LineStackingStrategy": "LineStackingStrategy",
+ "TextAlignment": "TextAlignment",
+ "TextTrimming": "TextTrimming",
+ "TextWrapping": "TextWrapping",
+ "TextDecorations": "TextDecorations",
+ "IsHyphenationEnabled": "bool",
+ "BaselineOffset": "double",
+ "Padding": "Thickness"
+ }
+ },
+ "MarkdownTextBlock": {
+ "SuperClass": "TextBlock",
+ "IsCreatable": true,
+ "Attributes": {}
+ },
+ "Image": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "Stretch": "Stretch",
+ "StretchDirection": "StretchDirection",
+ "Source": "ImageSource",
+ "IsAnimated": "bool"
+ }
+ },
+ "Grid": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "RowDefinitions": "object",
+ "ColumnDefinitions": "object"
+ }
+ },
+ "StackPanel": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "Orientation": "Orientation"
+ }
+ },
+ "Border": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": true,
+ "Attributes": {
+ "Background": "Brush",
+ "BorderBrush": "Brush",
+ "BorderThickness": "Thickness",
+ "Padding": "Thickness",
+ "CornerRadius": "CornerRadius"
+ }
+ },
+ "RowDefinition": {
+ "IsCreatable": true,
+ "Attributes": {
+ "Height": "GridLength",
+ "MinHeight": "double",
+ "MaxHeight": "double"
+ }
+ },
+ "ColumnDefinition": {
+ "IsCreatable": true,
+ "Attributes": {
+ "Width": "GridLength",
+ "MinWidth": "double",
+ "MaxWidth": "double"
+ }
+ },
+ "ScaleTransform": {
+ "IsCreatable": true,
+ "Attributes": {
+ "ScaleX": "double",
+ "ScaleY": "double",
+ "CenterX": "double",
+ "CenterY": "double"
+ }
+ },
+ "SkewTransform": {
+ "IsCreatable": true,
+ "Attributes": {
+ "AngleX": "double",
+ "AngleY": "double",
+ "CenterX": "double",
+ "CenterY": "double"
+ }
+ },
+ "RotateTransform": {
+ "IsCreatable": true,
+ "Attributes": {
+ "Angle": "double",
+ "CenterX": "double",
+ "CenterY": "double"
+ }
+ },
+ "TranslateTransform": {
+ "IsCreatable": true,
+ "Attributes": {
+ "X": "double",
+ "Y": "double"
+ }
+ },
+ "Brush": {
+ "IsCreatable": false,
+ "Attributes": {
+ "Opacity": "double"
+ }
+ },
+ "SolidColorBrush": {
+ "SuperClass": "Brush",
+ "IsCreatable": true,
+ "Attributes": {
+ "Color": "Color"
+ }
+ },
+ "ImageBrush": {
+ "SuperClass": "Brush",
+ "IsCreatable": true,
+ "Attributes": {
+ "AlignmentX": "AlignmentX",
+ "AlignmentY": "AlignmentY",
+ "Stretch": "Stretch",
+ "TileMode": "TileMode",
+ "ViewboxUnits": "BrushMappingMode",
+ "ViewportUnits": "BrushMappingMode",
+ "Viewbox": "Rect",
+ "Viewport": "Rect",
+ "ImageSource": "ImageSource"
+ }
+ },
+ "LinearGradientBrush": {
+ "SuperClass": "Brush",
+ "IsCreatable": true,
+ "Attributes": {
+ "StartPoint": "Point",
+ "EndPoint": "Point",
+ "ColorInterpolationMode": "ColorInterpolationMode",
+ "MappingMode": "BrushMappingMode",
+ "SpreadMethod": "GradientSpreadMethod"
+ }
+ },
+ "GradientStop": {
+ "IsCreatable": true,
+ "Attributes": {
+ "Color": "Color",
+ "Offset": "double"
+ }
+ },
+ "Shape": {
+ "SuperClass": "FrameworkElement",
+ "IsCreatable": false,
+ "Attributes": {
+ "Fill": "Brush",
+ "Stroke": "Brush",
+ "Stretch": "Stretch",
+ "StrokeDashCap": "PenLineCap",
+ "StrokeDashOffset": "double",
+ "StrokeEndLineCap": "PenLineCap",
+ "StrokeLineJoin": "PenLineJoin",
+ "StrokeMiterLimit": "double",
+ "StrokeStartLineCap": "PenLineCap",
+ "StrokeThickness": "double"
+ }
+ },
+ "Ellipse": {
+ "SuperClass": "Shape",
+ "IsCreatable": true,
+ "Attributes": {}
+ },
+ "Line": {
+ "SuperClass": "Shape",
+ "IsCreatable": true,
+ "Attributes": {
+ "X1": "double",
+ "X2": "double",
+ "Y1": "double",
+ "Y2": "double"
+ }
+ },
+ "Rectangle": {
+ "SuperClass": "Shape",
+ "IsCreatable": true,
+ "Attributes": {
+ "RadiusX": "double",
+ "RadiusY": "double"
+ }
+ },
+ "BlurEffect": {
+ "IsCreatable": true,
+ "Attributes": {
+ "KernelType": "KernelType",
+ "Radius": "double",
+ "RenderingBias": "RenderingBias"
+ }
+ },
+ "DropShadowEffect": {
+ "IsCreatable": true,
+ "Attributes": {
+ "BlurRadius": "double",
+ "Direction": "double",
+ "Opacity": "double",
+ "ShadowDepth": "double",
+ "RenderingBias": "RenderingBias",
+ "Color": "Color"
+ }
+ }
+ },
+ "Types": {
+ "string": {},
+ "bool": {
+ "Values": [
+ "True",
+ "False"
+ ]
+ },
+ "int": {},
+ "double": {},
+ "object": { "CanHaveElement": true },
+ "Thickness": {},
+ "Rect": {},
+ "Point": {},
+ "CornerRadius": {},
+ "Brush": { "CanHaveElement": true },
+ "Color": {},
+ "ImageSource": {},
+ "Transform": { "CanHaveElement": true },
+ "FontFamily": {},
+ "GridLength": {},
+ "Visibility": {
+ "Values": [
+ "Visible",
+ "Hidden",
+ "Collapsed"
+ ]
+ },
+ "HorizontalAlignment": {
+ "Values": [
+ "Left",
+ "Center",
+ "Right",
+ "Stretch"
+ ]
+ },
+ "VerticalAlignment": {
+ "Values": [
+ "Top",
+ "Center",
+ "Bottom",
+ "Stretch"
+ ]
+ },
+ "Theme": {
+ "Values": [
+ "Default",
+ "Dark",
+ "Light"
+ ]
+ },
+ "FontWeight": {
+ "Values": [
+ "Thin",
+ "ExtraLight",
+ "UltraLight",
+ "Medium",
+ "Normal",
+ "Regular",
+ "DemiBold",
+ "SemiBold",
+ "Bold",
+ "ExtraBold",
+ "UltraBold",
+ "Black",
+ "Heavy",
+ "ExtraBlack",
+ "ExtraHeavy"
+ ]
+ },
+ "FontStyle": {
+ "Values": [
+ "Normal",
+ "Italic",
+ "Oblique"
+ ]
+ },
+ "LineStackingStrategy": {
+ "Values": [
+ "BlockLineHeight",
+ "MaxHeight"
+ ]
+ },
+ "TextAlignment": {
+ "Values": [
+ "Left",
+ "Right",
+ "Center",
+ "Justify"
+ ]
+ },
+ "TextTrimming": {
+ "Values": [
+ "None",
+ "CharacterEllipsis",
+ "WordEllipsis"
+ ]
+ },
+ "TextWrapping": {
+ "Values": [
+ "WrapWithOverflow",
+ "NoWrap",
+ "Wrap"
+ ]
+ },
+ "TextDecorations": {
+ "Values": [
+ "Baseline",
+ "OverLine",
+ "Strikethrough",
+ "Underline"
+ ]
+ },
+ "Stretch": {
+ "Values": [
+ "None",
+ "Fill",
+ "Uniform",
+ "UniformToFill"
+ ]
+ },
+ "StretchDirection": {
+ "Values": [
+ "UpOnly",
+ "DownOnly",
+ "Both"
+ ]
+ },
+ "AlignmentX": {
+ "Values": [
+ "Left",
+ "Center",
+ "Right"
+ ]
+ },
+ "AlignmentY": {
+ "Values": [
+ "Top",
+ "Center",
+ "Bottom"
+ ]
+ },
+ "TileMode": {
+ "Values": [
+ "None",
+ "FlipX",
+ "FlipY",
+ "FlipXY",
+ "Tile"
+ ]
+ },
+ "BrushMappingMode": {
+ "Values": [
+ "Absolute",
+ "RelativeToBoundingBox"
+ ]
+ },
+ "ColorInterpolationMode": {
+ "Values": [
+ "ScRgbLinearInterpolation",
+ "SRgbLinearInterpolation"
+ ]
+ },
+ "GradientSpreadMethod": {
+ "Values": [
+ "Pad",
+ "Reflect",
+ "Repeat"
+ ]
+ },
+ "PenLineCap": {
+ "Values": [
+ "Flat",
+ "Square",
+ "Round",
+ "Triangle"
+ ]
+ },
+ "PenLineJoin": {
+ "Values": [
+ "Miter",
+ "Bevel",
+ "Round"
+ ]
+ },
+ "KernelType": {
+ "Values": [
+ "Gaussian",
+ "Box"
+ ]
+ },
+ "RenderingBias": {
+ "Values": [
+ "Performance",
+ "Quality"
+ ]
+ },
+ "Orientation": {
+ "Values": [
+ "Horizontal",
+ "Vertical"
+ ]
+ },
+ "WindowCornerPreference": {
+ "Values": [
+ "Default",
+ "DoNotRound",
+ "Round",
+ "RoundSmall"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml b/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml
new file mode 100644
index 0000000..3945086
--- /dev/null
+++ b/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml b/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml
new file mode 100644
index 0000000..abe586d
--- /dev/null
+++ b/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/Resources/Strings.Designer.cs b/Bloxstrap/Resources/Strings.Designer.cs
index fd14b8f..848b724 100644
--- a/Bloxstrap/Resources/Strings.Designer.cs
+++ b/Bloxstrap/Resources/Strings.Designer.cs
@@ -151,6 +151,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Roblox no longer supports Windows 7 or 8.1. To continue playing Roblox, please upgrade to Windows 10 or newer..
+ ///
+ public static string App_OSDeprecation_Win7_81 {
+ get {
+ return ResourceManager.GetString("App.OSDeprecation.Win7_81", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Bloxstrap was unable to automatically update to version {0}. Please update it manually by downloading and running it from the website..
///
@@ -169,6 +178,24 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Some content may be missing. Force a Roblox reinstallation in settings to fix this..
+ ///
+ public static string Bootstrapper_ExtractionFailed_Message {
+ get {
+ return ResourceManager.GetString("Bootstrapper.ExtractionFailed.Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to extract all files.
+ ///
+ public static string Bootstrapper_ExtractionFailed_Title {
+ get {
+ return ResourceManager.GetString("Bootstrapper.ExtractionFailed.Title", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Bloxstrap tried to upgrade Roblox but can't because Roblox's files are still in use.
///
@@ -198,6 +225,24 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Not all modifications will be present in the current launch..
+ ///
+ public static string Bootstrapper_ModificationsFailed_Message {
+ get {
+ return ResourceManager.GetString("Bootstrapper.ModificationsFailed.Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to apply all modifications.
+ ///
+ public static string Bootstrapper_ModificationsFailed_Title {
+ get {
+ return ResourceManager.GetString("Bootstrapper.ModificationsFailed.Title", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Bloxstrap does not have enough disk space to download and install Roblox. Please free up some disk space and try again..
///
@@ -396,6 +441,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Create New.
+ ///
+ public static string Common_CreateNew {
+ get {
+ return ResourceManager.GetString("Common.CreateNew", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Custom.
///
@@ -432,6 +486,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Edit.
+ ///
+ public static string Common_Edit {
+ get {
+ return ResourceManager.GetString("Common.Edit", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Export.
///
@@ -450,6 +513,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Import.
+ ///
+ public static string Common_Import {
+ get {
+ return ResourceManager.GetString("Common.Import", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Import from file.
///
@@ -585,6 +657,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Rename.
+ ///
+ public static string Common_Rename {
+ get {
+ return ResourceManager.GetString("Common.Rename", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Reset.
///
@@ -639,6 +720,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Template.
+ ///
+ public static string Common_Template {
+ get {
+ return ResourceManager.GetString("Common.Template", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Value.
///
@@ -802,6 +892,421 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to File must be a ZIP.
+ ///
+ public static string CustomTheme_Add_Errors_FileNotZip {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.FileNotZip", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Name cannot be empty.
+ ///
+ public static string CustomTheme_Add_Errors_NameEmpty {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.NameEmpty", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Name contains illegal characters.
+ ///
+ public static string CustomTheme_Add_Errors_NameIllegalCharacters {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.NameIllegalCharacters", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Name cannot be used.
+ ///
+ public static string CustomTheme_Add_Errors_NameReserved {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.NameReserved", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Name is already in use.
+ ///
+ public static string CustomTheme_Add_Errors_NameTaken {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.NameTaken", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Unknown error.
+ ///
+ public static string CustomTheme_Add_Errors_Unknown {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.Unknown", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Invalid or corrupted ZIP file.
+ ///
+ public static string CustomTheme_Add_Errors_ZipInvalidData {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.ZipInvalidData", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Theme file could not be found in the ZIP file.
+ ///
+ public static string CustomTheme_Add_Errors_ZipMissingThemeFile {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Errors.ZipMissingThemeFile", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Add Custom Theme.
+ ///
+ public static string CustomTheme_Add_Title {
+ get {
+ return ResourceManager.GetString("CustomTheme.Add.Title", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Custom Theme {0}.
+ ///
+ public static string CustomTheme_DefaultName {
+ get {
+ return ResourceManager.GetString("CustomTheme.DefaultName", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Save changes to {0}?.
+ ///
+ public static string CustomTheme_Editor_ConfirmSave {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.ConfirmSave", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Open Theme Directory.
+ ///
+ public static string CustomTheme_Editor_OpenThemeDirectory {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.OpenThemeDirectory", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Preview.
+ ///
+ public static string CustomTheme_Editor_Preview {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Preview", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Save.
+ ///
+ public static string CustomTheme_Editor_Save {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Save", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to An error occurred while saving your theme..
+ ///
+ public static string CustomTheme_Editor_Save_Error {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Save.Error", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Theme successfully saved!.
+ ///
+ public static string CustomTheme_Editor_Save_Success {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Save.Success", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Editing "{0}".
+ ///
+ public static string CustomTheme_Editor_Title {
+ get {
+ return ResourceManager.GetString("CustomTheme.Editor.Title", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Custom dialog has already been initialised.
+ ///
+ public static string CustomTheme_Errors_DialogAlreadyInitialised {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.DialogAlreadyInitialised", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} uses blacklisted scheme {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeBlacklistedUriScheme {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeBlacklistedUriScheme", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} has invalid {1}: {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeConversionError {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeConversionError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} is not a valid {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeInvalidType {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeInvalidType", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Element {0} is missing the {1} attribute.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMissing {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMissing", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} is missing its child.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMissingChild {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMissingChild", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} can only have one child.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMultipleChildren {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMultipleChildren", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} can only have one {1} defined.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMultipleDefinitions {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMultipleDefinitions", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} must be larger than {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMustBeLargerThanMin {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMustBeLargerThanMin", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} must be smaller than {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeMustBeSmallerThanMax {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMustBeSmallerThanMax", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} could not be parsed into a {2}.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeParseError {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeParseError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}.{1} {2} is null.
+ ///
+ public static string CustomTheme_Errors_ElementAttributeParseErrorNull {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeParseErrorNull", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} cannot have a child of {1}.
+ ///
+ public static string CustomTheme_Errors_ElementInvalidChild {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementInvalidChild", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} can only have one child.
+ ///
+ public static string CustomTheme_Errors_ElementMultipleChildren {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementMultipleChildren", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} failed to create {1}: {2}.
+ ///
+ public static string CustomTheme_Errors_ElementTypeCreationFailed {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.ElementTypeCreationFailed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Theme XML root is not {0}.
+ ///
+ public static string CustomTheme_Errors_InvalidRoot {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.InvalidRoot", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No custom theme selected.
+ ///
+ public static string CustomTheme_Errors_NoThemeSelected {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.NoThemeSelected", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to setup custom bootstrapper: {0}.
+ ///Defaulting to {1}..
+ ///
+ public static string CustomTheme_Errors_SetupFailed {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.SetupFailed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Custom bootstrappers can only have a maximum of {0} elements, got {1}..
+ ///
+ public static string CustomTheme_Errors_TooManyElements {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.TooManyElements", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Unknown element {0}.
+ ///
+ public static string CustomTheme_Errors_UnknownElement {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.UnknownElement", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} Unknown {1} '{2}'.
+ ///
+ public static string CustomTheme_Errors_UnknownEnumValue {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.UnknownEnumValue", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} version is not a number.
+ ///
+ public static string CustomTheme_Errors_VersionNotNumber {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.VersionNotNumber", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} version {1} is not recognised.
+ ///
+ public static string CustomTheme_Errors_VersionNotRecognised {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.VersionNotRecognised", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} version is not set.
+ ///
+ public static string CustomTheme_Errors_VersionNotSet {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.VersionNotSet", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} version {1} is no longer supported.
+ ///
+ public static string CustomTheme_Errors_VersionNotSupported {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.VersionNotSupported", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to parse the theme file: {0}.
+ ///
+ public static string CustomTheme_Errors_XMLParseFailed {
+ get {
+ return ResourceManager.GetString("CustomTheme.Errors.XMLParseFailed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Examples of custom bootstrappers can be found at {0}.
+ ///
+ public static string CustomTheme_Templates_Blank_MoreExamples {
+ get {
+ return ResourceManager.GetString("CustomTheme.Templates.Blank.MoreExamples", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Put UI elements here.
+ ///
+ public static string CustomTheme_Templates_Blank_UIElements {
+ get {
+ return ResourceManager.GetString("CustomTheme.Templates.Blank.UIElements", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Find more custom bootstrapper examples at {0}.
+ ///
+ public static string CustomTheme_Templates_Simple_MoreExamples {
+ get {
+ return ResourceManager.GetString("CustomTheme.Templates.Simple.MoreExamples", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Add Fast Flag.
///
@@ -1098,6 +1603,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Custom.
+ ///
+ public static string Enums_BootstrapperStyle_CustomDialog {
+ get {
+ return ResourceManager.GetString("Enums.BootstrapperStyle.CustomDialog", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Bloxstrap (Glass).
///
@@ -1161,6 +1675,24 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Blank.
+ ///
+ public static string Enums_CustomThemeTemplate_Blank {
+ get {
+ return ResourceManager.GetString("Enums.CustomThemeTemplate.Blank", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Simple.
+ ///
+ public static string Enums_CustomThemeTemplate_Simple {
+ get {
+ return ResourceManager.GetString("Enums.CustomThemeTemplate.Simple", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Catmoji.
///
@@ -1761,6 +2293,15 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Apache License 2.0.
+ ///
+ public static string Menu_About_Licenses_Apache {
+ get {
+ return ResourceManager.GetString("Menu.About.Licenses.Apache", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to BSD 2-Clause License.
///
@@ -1905,6 +2446,33 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Failed to delete custom theme {0}: {1}.
+ ///
+ public static string Menu_Appearance_CustomThemes_DeleteFailed {
+ get {
+ return ResourceManager.GetString("Menu.Appearance.CustomThemes.DeleteFailed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No custom theme selected..
+ ///
+ public static string Menu_Appearance_CustomThemes_NoneSelected {
+ get {
+ return ResourceManager.GetString("Menu.Appearance.CustomThemes.NoneSelected", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to rename custom theme {0}: {1}.
+ ///
+ public static string Menu_Appearance_CustomThemes_RenameFailed {
+ get {
+ return ResourceManager.GetString("Menu.Appearance.CustomThemes.RenameFailed", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Configure how Bloxstrap should look..
///
@@ -2013,6 +2581,24 @@ namespace Bloxstrap.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Update Roblox in the background instead of waiting. Not recommended for slow networks. At least 3GB of free storage space is required for this feature to work..
+ ///
+ public static string Menu_Behaviour_BackgroundUpdates_Description {
+ get {
+ return ResourceManager.GetString("Menu.Behaviour.BackgroundUpdates.Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Background updates.
+ ///
+ public static string Menu_Behaviour_BackgroundUpdates_Title {
+ get {
+ return ResourceManager.GetString("Menu.Behaviour.BackgroundUpdates.Title", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Prevent against closures of your existing game from accidentally launching another one..
///
diff --git a/Bloxstrap/Resources/Strings.resx b/Bloxstrap/Resources/Strings.resx
index c8a5479..f0355ae 100644
--- a/Bloxstrap/Resources/Strings.resx
+++ b/Bloxstrap/Resources/Strings.resx
@@ -1239,6 +1239,9 @@ Would you like to enable test mode?
Version {0}
+
+ Custom
+
Bloxstrap tried to upgrade Roblox but can't because Roblox's files are still in use.
@@ -1267,4 +1270,221 @@ Please close any applications that may be using Roblox's files, and relaunch.All Bloxstrap logs
Label that appears next to a checkbox
+
+ Roblox no longer supports Windows 7 or 8.1. To continue playing Roblox, please upgrade to Windows 10 or newer.
+
+
+ Failed to extract all files
+
+
+ Some content may be missing. Force a Roblox reinstallation in settings to fix this.
+
+
+ Failed to apply all modifications
+
+
+ Not all modifications will be present in the current launch.
+
+
+ Apache License 2.0
+
+
+ Blank
+
+
+ Simple
+
+
+ Theme XML root is not {0}
+ {0} is the element name (e.g. Button)
+
+
+ Custom dialog has already been initialised
+
+
+ Custom bootstrappers can only have a maximum of {0} elements, got {1}.
+ {0} and {1} are numbers
+
+
+ {0} version is not set
+ {0} is the element name (e.g. Button)
+
+
+ {0} version is not a number
+ {0} is the element name (e.g. Button)
+
+
+ {0} version {1} is no longer supported
+ {0} is the element name (e.g. Button), {1} is the version number
+
+
+ {0} version {1} is not recognised
+ {0} is the element name (e.g. Button), {1} is the version number
+
+
+ {0} cannot have a child of {1}
+ {0} and {1} are element names (e.g. Button)
+
+
+ Unknown element {0}
+ {0} is the element name (e.g. Button)
+
+
+ Failed to parse the theme file: {0}
+
+
+ {0} has invalid {1}: {2}
+ {0} is the element name (e.g. Button), {1} is the attribute name (e.g. Text), {2} is the error reason
+
+
+ Element {0} is missing the {1} attribute
+ {0} is the element name (e.g. Button), {1} is the attribute name (e.g. Text)
+
+
+ {0}.{1} is not a valid {2}
+ {0}.{1} is the element & attribute name (e.g. Button.Text), {2} is the type name (e.g. string)
+
+
+ {0}.{1} must be larger than {2}
+ {0}.{1} is the element & attribute name (e.g. Button.Text), {2} is a number
+
+
+ {0}.{1} must be smaller than {2}
+ {0}.{1} is the element & attribute name (e.g. Button.Text), {2} is a number
+
+
+ {0} Unknown {1} '{2}'
+ {0} is the element name (e.g. Button), {1} is the enum name (e.g. WindowCornerType), {2} is the value
+
+
+ {0} can only have one {1} defined
+ {0} is the element name (e.g. Button), {1} is the attribute name (e.g. Text)
+
+
+ {0}.{1} can only have one child
+ {0}.{1} is the element & attribute name (e.g. Button.Text)
+
+
+ {0} can only have one child
+ {0} is the element name (e.g. Button)
+
+
+ {0}.{1} is missing its child
+ {0}.{1} is the element & attribute name (e.g. Button.Text)
+
+
+ {0}.{1} could not be parsed into a {2}
+ {0}.{1} is the element & attribute name (e.g. Button.Text), {2} is the type name (e.g. string)
+
+
+ {0}.{1} {2} is null
+ {0}.{1} is the element & attribute name (e.g. Button.Text), {2} is the type name (e.g. string)
+
+
+ {0}.{1} uses blacklisted scheme {2}
+ {0}.{1} is the element & attribute name (e.g. Button.Text), {2} is the URI scheme (e.g. http)
+
+
+ {0} failed to create {1}: {2}
+ {0} is the element name (e.g. Button), {1} is the attribute name (e.g. Text), {2} is the error reason
+
+
+ Editing "{0}"
+
+
+ Theme successfully saved!
+
+
+ An error occurred while saving your theme.
+
+
+ Save changes to {0}?
+
+
+ Save
+
+
+ Preview
+
+
+ Open Theme Directory
+
+
+ Create New
+
+
+ Import
+
+
+ Add Custom Theme
+
+
+ Template
+
+
+ Name cannot be empty
+
+
+ Name contains illegal characters
+
+
+ Name cannot be used
+
+
+ Unknown error
+
+
+ Name is already in use
+
+
+ File must be a ZIP
+
+
+ Theme file could not be found in the ZIP file
+
+
+ Invalid or corrupted ZIP file
+
+
+ No custom theme selected
+
+
+ Failed to setup custom bootstrapper: {0}.
+Defaulting to {1}.
+ {0} is the error reason, {1} is the theme name (e.g. Bloxstrap (Classic))
+
+
+ No custom theme selected.
+
+
+ Rename
+
+
+ Edit
+
+
+ Failed to delete custom theme {0}: {1}
+
+
+ Failed to rename custom theme {0}: {1}
+
+
+ Background updates
+
+
+ Update Roblox in the background instead of waiting. Not recommended for slow networks. At least 3GB of free storage space is required for this feature to work.
+
+
+ Put UI elements here
+
+
+ Examples of custom bootstrappers can be found at {0}
+
+
+ Find more custom bootstrapper examples at {0}
+
+
+ Custom Theme {0}
+ {0} is a string (e.g. '1', '1-1234')
+
\ No newline at end of file
diff --git a/Bloxstrap/RobloxInterfaces/Deployment.cs b/Bloxstrap/RobloxInterfaces/Deployment.cs
index da88c14..10fae6c 100644
--- a/Bloxstrap/RobloxInterfaces/Deployment.cs
+++ b/Bloxstrap/RobloxInterfaces/Deployment.cs
@@ -176,16 +176,15 @@
App.Logger.WriteLine(LOG_IDENT, "Failed to contact clientsettingscdn! Falling back to clientsettings...");
App.Logger.WriteException(LOG_IDENT, ex);
- clientVersion = await Http.GetJson("https://clientsettings.roblox.com" + path);
- }
-
- // check if channel is behind LIVE
- if (!isDefaultChannel)
- {
- var defaultClientVersion = await GetInfo(DefaultChannel);
-
- if (Utilities.CompareVersions(clientVersion.Version, defaultClientVersion.Version) == VersionComparison.LessThan)
- clientVersion.IsBehindDefaultChannel = true;
+ try
+ {
+ clientVersion = await Http.GetJson("https://clientsettings.roblox.com" + path);
+ }
+ catch (HttpRequestException httpEx)
+ when (!isDefaultChannel && BadChannelCodes.Contains(httpEx.StatusCode))
+ {
+ throw new InvalidChannelException(httpEx.StatusCode);
+ }
}
ClientVersionCache[cacheKey] = clientVersion;
diff --git a/Bloxstrap/UI/Elements/About/MainWindow.xaml b/Bloxstrap/UI/Elements/About/MainWindow.xaml
index cf9daf9..4b134a3 100644
--- a/Bloxstrap/UI/Elements/About/MainWindow.xaml
+++ b/Bloxstrap/UI/Elements/About/MainWindow.xaml
@@ -10,8 +10,8 @@
mc:Ignorable="d"
Title="{x:Static resources:Strings.About_Title}"
Background="{ui:ThemeResource ApplicationBackgroundBrush}"
- MinWidth="740"
- Width="740"
+ MinWidth="800"
+ Width="800"
Height="440"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
diff --git a/Bloxstrap/UI/Elements/About/Pages/LicensesPage.xaml b/Bloxstrap/UI/Elements/About/Pages/LicensesPage.xaml
index dbcad7b..f3b44cd 100644
--- a/Bloxstrap/UI/Elements/About/Pages/LicensesPage.xaml
+++ b/Bloxstrap/UI/Elements/About/Pages/LicensesPage.xaml
@@ -43,30 +43,44 @@
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Base/WpfUiWindow.cs b/Bloxstrap/UI/Elements/Base/WpfUiWindow.cs
index db06e7f..9802ab1 100644
--- a/Bloxstrap/UI/Elements/Base/WpfUiWindow.cs
+++ b/Bloxstrap/UI/Elements/Base/WpfUiWindow.cs
@@ -18,9 +18,15 @@ namespace Bloxstrap.UI.Elements.Base
public void ApplyTheme()
{
+ const int customThemeIndex = 2; // index for CustomTheme merged dictionary
+
_themeService.SetTheme(App.Settings.Prop.Theme.GetFinal() == Enums.Theme.Dark ? ThemeType.Dark : ThemeType.Light);
_themeService.SetSystemAccent();
+ // there doesn't seem to be a way to query the name for merged dictionaries
+ var dict = new ResourceDictionary { Source = new Uri($"pack://application:,,,/UI/Style/{Enum.GetName(App.Settings.Prop.Theme.GetFinal())}.xaml") };
+ Application.Current.Resources.MergedDictionaries[customThemeIndex] = dict;
+
#if QA_BUILD
this.BorderBrush = System.Windows.Media.Brushes.Red;
this.BorderThickness = new Thickness(4);
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/ByfronDialog.xaml.cs b/Bloxstrap/UI/Elements/Bootstrapper/ByfronDialog.xaml.cs
index 5afcc43..7a89d97 100644
--- a/Bloxstrap/UI/Elements/Bootstrapper/ByfronDialog.xaml.cs
+++ b/Bloxstrap/UI/Elements/Bootstrapper/ByfronDialog.xaml.cs
@@ -104,7 +104,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
public ByfronDialog()
{
- string version = Utilities.GetRobloxVersion(Bootstrapper?.IsStudioLaunch ?? false);
+ string version = Utilities.GetRobloxVersionStr(Bootstrapper?.IsStudioLaunch ?? false);
_viewModel = new ByfronDialogViewModel(this, version);
DataContext = _viewModel;
Title = App.Settings.Prop.BootstrapperTitle;
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Converters.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Converters.cs
new file mode 100644
index 0000000..3490e4a
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Converters.cs
@@ -0,0 +1,90 @@
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Media;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ public partial class CustomDialog
+ {
+ // https://stackoverflow.com/a/2961702
+ private static T? ConvertValue(string input) where T : struct
+ {
+ try
+ {
+ var converter = TypeDescriptor.GetConverter(typeof(T));
+ if (converter != null)
+ {
+ return (T?)converter.ConvertFromInvariantString(input);
+ }
+ return default;
+ }
+ catch (NotSupportedException)
+ {
+ return default;
+ }
+ }
+
+ private static object? GetTypeFromXElement(TypeConverter converter, XElement xmlElement, string attributeName)
+ {
+ string? attributeValue = xmlElement.Attribute(attributeName)?.Value?.ToString();
+ if (attributeValue == null)
+ return null;
+
+ try
+ {
+ return converter.ConvertFromInvariantString(attributeValue);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.ElementAttributeConversionError", xmlElement.Name, attributeName, ex.Message);
+ }
+ }
+
+ private static ThicknessConverter ThicknessConverter { get; } = new ThicknessConverter();
+ private static object? GetThicknessFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(ThicknessConverter, xmlElement, attributeName);
+
+ private static RectConverter RectConverter { get; } = new RectConverter();
+ private static object? GetRectFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(RectConverter, xmlElement, attributeName);
+
+ private static ColorConverter ColorConverter { get; } = new ColorConverter();
+ private static object? GetColorFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(ColorConverter, xmlElement, attributeName);
+
+ private static PointConverter PointConverter { get; } = new PointConverter();
+ private static object? GetPointFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(PointConverter, xmlElement, attributeName);
+
+ private static CornerRadiusConverter CornerRadiusConverter { get; } = new CornerRadiusConverter();
+ private static object? GetCornerRadiusFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(CornerRadiusConverter, xmlElement, attributeName);
+
+ private static GridLengthConverter GridLengthConverter { get; } = new GridLengthConverter();
+ private static object? GetGridLengthFromXElement(XElement xmlElement, string attributeName) => GetTypeFromXElement(GridLengthConverter, xmlElement, attributeName);
+
+
+ private static BrushConverter BrushConverter { get; } = new BrushConverter();
+
+ ///
+ /// Return type of string = Name of DynamicResource
+ /// Return type of brush = ... The Brush!!!
+ ///
+ private static object? GetBrushFromXElement(XElement element, string attributeName)
+ {
+ string? value = element.Attribute(attributeName)?.Value?.ToString();
+ if (value == null)
+ return null;
+
+ // dynamic resource name
+ if (value.StartsWith('{') && value.EndsWith('}'))
+ return value[1..^1];
+
+ try
+ {
+ return BrushConverter.ConvertFromInvariantString(value);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.ElementAttributeConversionError", element.Name, attributeName, ex.Message);
+ }
+ }
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs
new file mode 100644
index 0000000..e5f0c29
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Creator.cs
@@ -0,0 +1,151 @@
+using System.Windows;
+using System.Xml.Linq;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ public partial class CustomDialog
+ {
+ const int Version = 1;
+
+ private class DummyFrameworkElement : FrameworkElement { }
+
+ private const int MaxElements = 100;
+
+ private bool _initialised = false;
+
+ // prevent users from creating elements with the same name multiple times
+ private List UsedNames { get; } = new List();
+
+ private string ThemeDir { get; set; } = "";
+
+ delegate object HandleXmlElementDelegate(CustomDialog dialog, XElement xmlElement);
+
+ private static Dictionary _elementHandlerMap = new Dictionary()
+ {
+ ["BloxstrapCustomBootstrapper"] = HandleXmlElement_BloxstrapCustomBootstrapper_Fake,
+ ["TitleBar"] = HandleXmlElement_TitleBar,
+ ["Button"] = HandleXmlElement_Button,
+ ["ProgressBar"] = HandleXmlElement_ProgressBar,
+ ["ProgressRing"] = HandleXmlElement_ProgressRing,
+ ["TextBlock"] = HandleXmlElement_TextBlock,
+ ["MarkdownTextBlock"] = HandleXmlElement_MarkdownTextBlock,
+ ["Image"] = HandleXmlElement_Image,
+ ["Grid"] = HandleXmlElement_Grid,
+ ["StackPanel"] = HandleXmlElement_StackPanel,
+ ["Border"] = HandleXmlElement_Border,
+
+ ["SolidColorBrush"] = HandleXmlElement_SolidColorBrush,
+ ["ImageBrush"] = HandleXmlElement_ImageBrush,
+ ["LinearGradientBrush"] = HandleXmlElement_LinearGradientBrush,
+
+ ["GradientStop"] = HandleXmlElement_GradientStop,
+
+ ["ScaleTransform"] = HandleXmlElement_ScaleTransform,
+ ["SkewTransform"] = HandleXmlElement_SkewTransform,
+ ["RotateTransform"] = HandleXmlElement_RotateTransform,
+ ["TranslateTransform"] = HandleXmlElement_TranslateTransform,
+
+ ["BlurEffect"] = HandleXmlElement_BlurEffect,
+ ["DropShadowEffect"] = HandleXmlElement_DropShadowEffect,
+
+ ["Ellipse"] = HandleXmlElement_Ellipse,
+ ["Line"] = HandleXmlElement_Line,
+ ["Rectangle"] = HandleXmlElement_Rectangle,
+
+ ["RowDefinition"] = HandleXmlElement_RowDefinition,
+ ["ColumnDefinition"] = HandleXmlElement_ColumnDefinition
+ };
+
+ private static T HandleXml(CustomDialog dialog, XElement xmlElement) where T : class
+ {
+ if (!_elementHandlerMap.ContainsKey(xmlElement.Name.ToString()))
+ throw new CustomThemeException("CustomTheme.Errors.UnknownElement", xmlElement.Name);
+
+ var element = _elementHandlerMap[xmlElement.Name.ToString()](dialog, xmlElement);
+ if (element is not T)
+ throw new CustomThemeException("CustomTheme.Errors.ElementInvalidChild", xmlElement.Parent!.Name, xmlElement.Name);
+
+ return (T)element;
+ }
+
+ private static void AddXml(CustomDialog dialog, XElement xmlElement)
+ {
+ if (xmlElement.Name.ToString().StartsWith($"{xmlElement.Parent!.Name}."))
+ return; // not an xml element
+
+ var uiElement = HandleXml(dialog, xmlElement);
+ if (uiElement is not DummyFrameworkElement)
+ dialog.ElementGrid.Children.Add(uiElement);
+ }
+
+ private static void AssertThemeVersion(string? versionStr)
+ {
+ if (string.IsNullOrEmpty(versionStr))
+ throw new CustomThemeException("CustomTheme.Errors.VersionNotSet", "BloxstrapCustomBootstrapper");
+
+ if (!uint.TryParse(versionStr, out uint version))
+ throw new CustomThemeException("CustomTheme.Errors.VersionNotNumber", "BloxstrapCustomBootstrapper");
+
+ switch (version)
+ {
+ case Version:
+ break;
+ case 0: // Themes made between Oct 19, 2024 to Mar 11, 2025 (on the feature/custom-bootstrappers branch)
+ throw new CustomThemeException("CustomTheme.Errors.VersionNotSupported", "BloxstrapCustomBootstrapper", version);
+ default:
+ throw new CustomThemeException("CustomTheme.Errors.VersionNotRecognised", "BloxstrapCustomBootstrapper", version);
+ }
+ }
+
+ private void HandleXmlBase(XElement xml)
+ {
+ if (_initialised)
+ throw new CustomThemeException("CustomTheme.Errors.DialogAlreadyInitialised");
+
+ if (xml.Name != "BloxstrapCustomBootstrapper")
+ throw new CustomThemeException("CustomTheme.Errors.InvalidRoot", "BloxstrapCustomBootstrapper");
+
+ AssertThemeVersion(xml.Attribute("Version")?.Value);
+
+ if (xml.Descendants().Count() > MaxElements)
+ throw new CustomThemeException("CustomTheme.Errors.TooManyElements", MaxElements, xml.Descendants().Count());
+
+ _initialised = true;
+
+ // handle root
+ HandleXmlElement_BloxstrapCustomBootstrapper(this, xml);
+
+ // handle everything else
+ foreach (var child in xml.Elements())
+ AddXml(this, child);
+ }
+
+ #region Public APIs
+ public void ApplyCustomTheme(string name, string contents)
+ {
+ ThemeDir = System.IO.Path.Combine(Paths.CustomThemes, name);
+
+ XElement xml;
+
+ try
+ {
+ using (MemoryStream ms = new MemoryStream(Encoding.UTF8.GetBytes(contents)))
+ xml = XElement.Load(ms);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.XMLParseFailed", ex.Message);
+ }
+
+ HandleXmlBase(xml);
+ }
+
+ public void ApplyCustomTheme(string name)
+ {
+ string path = System.IO.Path.Combine(Paths.CustomThemes, name, "Theme.xml");
+
+ ApplyCustomTheme(name, File.ReadAllText(path));
+ }
+ #endregion
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Elements.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Elements.cs
new file mode 100644
index 0000000..68a61d0
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Elements.cs
@@ -0,0 +1,772 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Data;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Media.Effects;
+using System.Windows.Shapes;
+using System.Xml.Linq;
+
+using Wpf.Ui.Markup;
+
+using Bloxstrap.UI.Elements.Controls;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ public partial class CustomDialog
+ {
+ #region Transformation
+ private static Transform HandleXmlElement_ScaleTransform(CustomDialog dialog, XElement xmlElement)
+ {
+ var st = new ScaleTransform();
+
+ st.ScaleX = ParseXmlAttribute(xmlElement, "ScaleX", 1);
+ st.ScaleY = ParseXmlAttribute(xmlElement, "ScaleY", 1);
+ st.CenterX = ParseXmlAttribute(xmlElement, "CenterX", 0);
+ st.CenterY = ParseXmlAttribute(xmlElement, "CenterY", 0);
+
+ return st;
+ }
+
+ private static Transform HandleXmlElement_SkewTransform(CustomDialog dialog, XElement xmlElement)
+ {
+ var st = new SkewTransform();
+
+ st.AngleX = ParseXmlAttribute(xmlElement, "AngleX", 0);
+ st.AngleY = ParseXmlAttribute(xmlElement, "AngleY", 0);
+ st.CenterX = ParseXmlAttribute(xmlElement, "CenterX", 0);
+ st.CenterY = ParseXmlAttribute(xmlElement, "CenterY", 0);
+
+ return st;
+ }
+
+ private static Transform HandleXmlElement_RotateTransform(CustomDialog dialog, XElement xmlElement)
+ {
+ var rt = new RotateTransform();
+
+ rt.Angle = ParseXmlAttribute(xmlElement, "Angle", 0);
+ rt.CenterX = ParseXmlAttribute(xmlElement, "CenterX", 0);
+ rt.CenterY = ParseXmlAttribute(xmlElement, "CenterY", 0);
+
+ return rt;
+ }
+
+ private static Transform HandleXmlElement_TranslateTransform(CustomDialog dialog, XElement xmlElement)
+ {
+ var tt = new TranslateTransform();
+
+ tt.X = ParseXmlAttribute(xmlElement, "X", 0);
+ tt.Y = ParseXmlAttribute(xmlElement, "Y", 0);
+
+ return tt;
+ }
+ #endregion
+
+ #region Effects
+ private static BlurEffect HandleXmlElement_BlurEffect(CustomDialog dialog, XElement xmlElement)
+ {
+ var effect = new BlurEffect();
+
+ effect.KernelType = ParseXmlAttribute(xmlElement, "KernelType", KernelType.Gaussian);
+ effect.Radius = ParseXmlAttribute(xmlElement, "Radius", 5);
+ effect.RenderingBias = ParseXmlAttribute(xmlElement, "RenderingBias", RenderingBias.Performance);
+
+ return effect;
+ }
+
+ private static DropShadowEffect HandleXmlElement_DropShadowEffect(CustomDialog dialog, XElement xmlElement)
+ {
+ var effect = new DropShadowEffect();
+
+ effect.BlurRadius = ParseXmlAttribute(xmlElement, "BlurRadius", 5);
+ effect.Direction = ParseXmlAttribute(xmlElement, "Direction", 315);
+ effect.Opacity = ParseXmlAttribute(xmlElement, "Opacity", 1);
+ effect.ShadowDepth = ParseXmlAttribute(xmlElement, "ShadowDepth", 5);
+ effect.RenderingBias = ParseXmlAttribute(xmlElement, "RenderingBias", RenderingBias.Performance);
+
+ var color = GetColorFromXElement(xmlElement, "Color");
+ if (color is Color)
+ effect.Color = (Color)color;
+
+ return effect;
+ }
+ #endregion
+
+ #region Brushes
+ private static void HandleXml_Brush(Brush brush, XElement xmlElement)
+ {
+ brush.Opacity = ParseXmlAttribute(xmlElement, "Opacity", 1.0);
+ }
+
+ private static Brush HandleXmlElement_SolidColorBrush(CustomDialog dialog, XElement xmlElement)
+ {
+ var brush = new SolidColorBrush();
+ HandleXml_Brush(brush, xmlElement);
+
+ object? color = GetColorFromXElement(xmlElement, "Color");
+ if (color is Color)
+ brush.Color = (Color)color;
+
+ return brush;
+ }
+
+ private static Brush HandleXmlElement_ImageBrush(CustomDialog dialog, XElement xmlElement)
+ {
+ var imageBrush = new ImageBrush();
+ HandleXml_Brush(imageBrush, xmlElement);
+
+ imageBrush.AlignmentX = ParseXmlAttribute(xmlElement, "AlignmentX", AlignmentX.Center);
+ imageBrush.AlignmentY = ParseXmlAttribute(xmlElement, "AlignmentY", AlignmentY.Center);
+
+ imageBrush.Stretch = ParseXmlAttribute(xmlElement, "Stretch", Stretch.Fill);
+ imageBrush.TileMode = ParseXmlAttribute(xmlElement, "TileMode", TileMode.None);
+
+ imageBrush.ViewboxUnits = ParseXmlAttribute(xmlElement, "ViewboxUnits", BrushMappingMode.RelativeToBoundingBox);
+ imageBrush.ViewportUnits = ParseXmlAttribute(xmlElement, "ViewportUnits", BrushMappingMode.RelativeToBoundingBox);
+
+ var viewbox = GetRectFromXElement(xmlElement, "Viewbox");
+ if (viewbox is Rect)
+ imageBrush.Viewbox = (Rect)viewbox;
+
+ var viewport = GetRectFromXElement(xmlElement, "Viewport");
+ if (viewport is Rect)
+ imageBrush.Viewport = (Rect)viewport;
+
+ var sourceData = GetImageSourceData(dialog, "ImageSource", xmlElement);
+
+ if (sourceData.IsIcon)
+ {
+ // bind the icon property
+ Binding binding = new Binding("Icon") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(imageBrush, ImageBrush.ImageSourceProperty, binding);
+ }
+ else
+ {
+ BitmapImage bitmapImage;
+ try
+ {
+ bitmapImage = new BitmapImage(sourceData.Uri!);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.ElementTypeCreationFailed", "Image", "BitmapImage", ex.Message);
+ }
+
+ imageBrush.ImageSource = bitmapImage;
+ }
+
+ return imageBrush;
+ }
+
+ private static GradientStop HandleXmlElement_GradientStop(CustomDialog dialog, XElement xmlElement)
+ {
+ var gs = new GradientStop();
+
+ object? color = GetColorFromXElement(xmlElement, "Color");
+ if (color is Color)
+ gs.Color = (Color)color;
+
+ gs.Offset = ParseXmlAttribute(xmlElement, "Offset", 0.0);
+
+ return gs;
+ }
+
+ private static Brush HandleXmlElement_LinearGradientBrush(CustomDialog dialog, XElement xmlElement)
+ {
+ var brush = new LinearGradientBrush();
+ HandleXml_Brush(brush, xmlElement);
+
+ object? startPoint = GetPointFromXElement(xmlElement, "StartPoint");
+ if (startPoint is Point)
+ brush.StartPoint = (Point)startPoint;
+
+ object? endPoint = GetPointFromXElement(xmlElement, "EndPoint");
+ if (endPoint is Point)
+ brush.EndPoint = (Point)endPoint;
+
+ brush.ColorInterpolationMode = ParseXmlAttribute(xmlElement, "ColorInterpolationMode", ColorInterpolationMode.SRgbLinearInterpolation);
+ brush.MappingMode = ParseXmlAttribute(xmlElement, "MappingMode", BrushMappingMode.RelativeToBoundingBox);
+ brush.SpreadMethod = ParseXmlAttribute(xmlElement, "SpreadMethod", GradientSpreadMethod.Pad);
+
+ foreach (var child in xmlElement.Elements())
+ brush.GradientStops.Add(HandleXml(dialog, child));
+
+ return brush;
+ }
+
+ private static void ApplyBrush_UIElement(CustomDialog dialog, FrameworkElement uiElement, string name, DependencyProperty dependencyProperty, XElement xmlElement)
+ {
+ // check if attribute exists
+ object? brushAttr = GetBrushFromXElement(xmlElement, name);
+ if (brushAttr is Brush)
+ {
+ uiElement.SetValue(dependencyProperty, brushAttr);
+ return;
+ }
+ else if (brushAttr is string)
+ {
+ uiElement.SetResourceReference(dependencyProperty, brushAttr);
+ return;
+ }
+
+ // check if element exists
+ var brushElement = xmlElement.Element($"{xmlElement.Name}.{name}");
+ if (brushElement == null)
+ return;
+
+ var first = brushElement.FirstNode as XElement;
+ if (first == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissingChild", xmlElement.Name, name);
+
+ var brush = HandleXml(dialog, first);
+ uiElement.SetValue(dependencyProperty, brush);
+ }
+ #endregion
+
+ #region Shapes
+ private static void HandleXmlElement_Shape(CustomDialog dialog, Shape shape, XElement xmlElement)
+ {
+ HandleXmlElement_FrameworkElement(dialog, shape, xmlElement);
+
+ ApplyBrush_UIElement(dialog, shape, "Fill", Shape.FillProperty, xmlElement);
+ ApplyBrush_UIElement(dialog, shape, "Stroke", Shape.StrokeProperty, xmlElement);
+
+ shape.Stretch = ParseXmlAttribute(xmlElement, "Stretch", Stretch.Fill);
+
+ shape.StrokeDashCap = ParseXmlAttribute(xmlElement, "StrokeDashCap", PenLineCap.Flat);
+ shape.StrokeDashOffset = ParseXmlAttribute(xmlElement, "StrokeDashOffset", 0);
+ shape.StrokeEndLineCap = ParseXmlAttribute(xmlElement, "StrokeEndLineCap", PenLineCap.Flat);
+ shape.StrokeLineJoin = ParseXmlAttribute(xmlElement, "StrokeLineJoin", PenLineJoin.Miter);
+ shape.StrokeMiterLimit = ParseXmlAttribute(xmlElement, "StrokeMiterLimit", 10);
+ shape.StrokeStartLineCap = ParseXmlAttribute(xmlElement, "StrokeStartLineCap", PenLineCap.Flat);
+ shape.StrokeThickness = ParseXmlAttribute(xmlElement, "StrokeThickness", 1);
+ }
+
+ private static Ellipse HandleXmlElement_Ellipse(CustomDialog dialog, XElement xmlElement)
+ {
+ var ellipse = new Ellipse();
+ HandleXmlElement_Shape(dialog, ellipse, xmlElement);
+
+ return ellipse;
+ }
+
+ private static Line HandleXmlElement_Line(CustomDialog dialog, XElement xmlElement)
+ {
+ var line = new Line();
+ HandleXmlElement_Shape(dialog, line, xmlElement);
+
+ line.X1 = ParseXmlAttribute(xmlElement, "X1", 0);
+ line.X2 = ParseXmlAttribute(xmlElement, "X2", 0);
+ line.Y1 = ParseXmlAttribute(xmlElement, "Y1", 0);
+ line.Y2 = ParseXmlAttribute(xmlElement, "Y2", 0);
+
+ return line;
+ }
+
+ private static Rectangle HandleXmlElement_Rectangle(CustomDialog dialog, XElement xmlElement)
+ {
+ var rectangle = new Rectangle();
+ HandleXmlElement_Shape(dialog, rectangle, xmlElement);
+
+ rectangle.RadiusX = ParseXmlAttribute(xmlElement, "RadiusX", 0);
+ rectangle.RadiusY = ParseXmlAttribute(xmlElement, "RadiusY", 0);
+
+ return rectangle;
+ }
+
+ #endregion
+
+ #region Elements
+ private static void HandleXmlElement_FrameworkElement(CustomDialog dialog, FrameworkElement uiElement, XElement xmlElement)
+ {
+ // prevent two elements from having the same name
+ string? name = xmlElement.Attribute("Name")?.Value?.ToString();
+ if (name != null)
+ {
+ if (dialog.UsedNames.Contains(name))
+ throw new Exception($"{xmlElement.Name} has duplicate name {name}");
+
+ dialog.UsedNames.Add(name);
+ }
+
+ uiElement.Name = name;
+
+ uiElement.Visibility = ParseXmlAttribute(xmlElement, "Visibility", Visibility.Visible);
+ uiElement.IsEnabled = ParseXmlAttribute(xmlElement, "IsEnabled", true);
+
+ object? margin = GetThicknessFromXElement(xmlElement, "Margin");
+ if (margin != null)
+ uiElement.Margin = (Thickness)margin;
+
+ uiElement.Height = ParseXmlAttribute(xmlElement, "Height", double.NaN);
+ uiElement.Width = ParseXmlAttribute(xmlElement, "Width", double.NaN);
+
+ // default values of these were originally Stretch but that was no good
+ uiElement.HorizontalAlignment = ParseXmlAttribute(xmlElement, "HorizontalAlignment", HorizontalAlignment.Left);
+ uiElement.VerticalAlignment = ParseXmlAttribute(xmlElement, "VerticalAlignment", VerticalAlignment.Top);
+
+ uiElement.Opacity = ParseXmlAttribute(xmlElement, "Opacity", 1);
+ ApplyBrush_UIElement(dialog, uiElement, "OpacityMask", FrameworkElement.OpacityMaskProperty, xmlElement);
+
+ object? renderTransformOrigin = GetPointFromXElement(xmlElement, "RenderTransformOrigin");
+ if (renderTransformOrigin is Point)
+ uiElement.RenderTransformOrigin = (Point)renderTransformOrigin;
+
+ int zIndex = ParseXmlAttributeClamped(xmlElement, "Panel.ZIndex", defaultValue: 0, min: 0, max: 1000);
+ Panel.SetZIndex(uiElement, zIndex);
+
+ int gridRow = ParseXmlAttribute(xmlElement, "Grid.Row", 0);
+ Grid.SetRow(uiElement, gridRow);
+ int gridRowSpan = ParseXmlAttribute(xmlElement, "Grid.RowSpan", 1);
+ Grid.SetRowSpan(uiElement, gridRowSpan);
+
+ int gridColumn = ParseXmlAttribute(xmlElement, "Grid.Column", 0);
+ Grid.SetColumn(uiElement, gridColumn);
+ int gridColumnSpan = ParseXmlAttribute(xmlElement, "Grid.ColumnSpan", 1);
+ Grid.SetColumnSpan(uiElement, gridColumnSpan);
+
+ ApplyTransformations_UIElement(dialog, uiElement, xmlElement);
+ ApplyEffects_UIElement(dialog, uiElement, xmlElement);
+ }
+
+ private static void HandleXmlElement_Control(CustomDialog dialog, Control uiElement, XElement xmlElement)
+ {
+ HandleXmlElement_FrameworkElement(dialog, uiElement, xmlElement);
+
+ object? padding = GetThicknessFromXElement(xmlElement, "Padding");
+ if (padding != null)
+ uiElement.Padding = (Thickness)padding;
+
+ object? borderThickness = GetThicknessFromXElement(xmlElement, "BorderThickness");
+ if (borderThickness != null)
+ uiElement.BorderThickness = (Thickness)borderThickness;
+
+ ApplyBrush_UIElement(dialog, uiElement, "Foreground", Control.ForegroundProperty, xmlElement);
+
+ ApplyBrush_UIElement(dialog, uiElement, "Background", Control.BackgroundProperty, xmlElement);
+
+ ApplyBrush_UIElement(dialog, uiElement, "BorderBrush", Control.BorderBrushProperty, xmlElement);
+
+ var fontSize = ParseXmlAttributeNullable(xmlElement, "FontSize");
+ if (fontSize is double)
+ uiElement.FontSize = (double)fontSize;
+ uiElement.FontWeight = GetFontWeightFromXElement(xmlElement);
+ uiElement.FontStyle = GetFontStyleFromXElement(xmlElement);
+
+ // NOTE: font family can both be the name of the font or a uri
+ string? fontFamily = GetFullPath(dialog, xmlElement.Attribute("FontFamily")?.Value);
+ if (fontFamily != null)
+ uiElement.FontFamily = new System.Windows.Media.FontFamily(fontFamily);
+ }
+
+ private static UIElement HandleXmlElement_BloxstrapCustomBootstrapper(CustomDialog dialog, XElement xmlElement)
+ {
+ xmlElement.SetAttributeValue("Visibility", "Collapsed"); // don't show the bootstrapper yet!!!
+ xmlElement.SetAttributeValue("IsEnabled", "True");
+ HandleXmlElement_Control(dialog, dialog, xmlElement);
+
+ dialog.Opacity = 1;
+
+ // transfer effect to element grid
+ dialog.ElementGrid.RenderTransform = dialog.RenderTransform;
+ dialog.RenderTransform = null;
+ dialog.ElementGrid.LayoutTransform = dialog.LayoutTransform;
+ dialog.LayoutTransform = null;
+
+ dialog.ElementGrid.Effect = dialog.Effect;
+ dialog.Effect = null;
+
+ var theme = ParseXmlAttribute(xmlElement, "Theme", Theme.Default);
+ if (theme == Theme.Default)
+ theme = App.Settings.Prop.Theme;
+
+ var wpfUiTheme = theme.GetFinal() == Theme.Dark ? Wpf.Ui.Appearance.ThemeType.Dark : Wpf.Ui.Appearance.ThemeType.Light;
+
+ dialog.Resources.MergedDictionaries.Clear();
+ dialog.Resources.MergedDictionaries.Add(new ThemesDictionary() { Theme = wpfUiTheme });
+ dialog.DefaultBorderThemeOverwrite = wpfUiTheme;
+
+ dialog.WindowCornerPreference = ParseXmlAttribute(xmlElement, "WindowCornerPreference", Wpf.Ui.Appearance.WindowCornerPreference.Round);
+
+ // disable default window border if border is modified
+ if (xmlElement.Attribute("BorderBrush") != null || xmlElement.Attribute("BorderThickness") != null)
+ dialog.DefaultBorderEnabled = false;
+
+ // set the margin & padding on the element grid
+ dialog.ElementGrid.Margin = dialog.Margin;
+ // TODO: put elementgrid inside a border?
+
+ dialog.Margin = new Thickness(0, 0, 0, 0);
+ dialog.Padding = new Thickness(0, 0, 0, 0);
+
+ string? title = xmlElement.Attribute("Title")?.Value?.ToString() ?? "Bloxstrap";
+ dialog.Title = title;
+
+ bool ignoreTitleBarInset = ParseXmlAttribute(xmlElement, "IgnoreTitleBarInset", false);
+ if (ignoreTitleBarInset)
+ {
+ Grid.SetRow(dialog.ElementGrid, 0);
+ Grid.SetRowSpan(dialog.ElementGrid, 2);
+ }
+
+ return new DummyFrameworkElement();
+ }
+
+ private static UIElement HandleXmlElement_BloxstrapCustomBootstrapper_Fake(CustomDialog dialog, XElement xmlElement)
+ {
+ // this only exists to error out the theme if someone tries to use two BloxstrapCustomBootstrappers
+ throw new Exception($"{xmlElement.Parent!.Name} cannot have a child of {xmlElement.Name}");
+ }
+
+ private static DummyFrameworkElement HandleXmlElement_TitleBar(CustomDialog dialog, XElement xmlElement)
+ {
+ xmlElement.SetAttributeValue("Name", "TitleBar"); // prevent two titlebars from existing
+ xmlElement.SetAttributeValue("IsEnabled", "True");
+ HandleXmlElement_Control(dialog, dialog.RootTitleBar, xmlElement);
+
+ // get rid of all effects
+ dialog.RootTitleBar.RenderTransform = null;
+ dialog.RootTitleBar.LayoutTransform = null;
+
+ dialog.RootTitleBar.Effect = null;
+
+ Panel.SetZIndex(dialog.RootTitleBar, 1001); // always show above others
+
+ // properties we dont want modifiable
+ dialog.RootTitleBar.Height = double.NaN;
+ dialog.RootTitleBar.Width = double.NaN;
+ dialog.RootTitleBar.HorizontalAlignment = HorizontalAlignment.Stretch;
+ dialog.RootTitleBar.Margin = new Thickness(0, 0, 0, 0);
+
+ dialog.RootTitleBar.ShowMinimize = ParseXmlAttribute(xmlElement, "ShowMinimize", true);
+ dialog.RootTitleBar.ShowClose = ParseXmlAttribute(xmlElement, "ShowClose", true);
+
+ string? title = xmlElement.Attribute("Title")?.Value?.ToString() ?? "Bloxstrap";
+ dialog.RootTitleBar.Title = title;
+
+ return new DummyFrameworkElement(); // dont add anything
+ }
+
+ private static UIElement HandleXmlElement_Button(CustomDialog dialog, XElement xmlElement)
+ {
+ var button = new Button();
+ HandleXmlElement_Control(dialog, button, xmlElement);
+
+ button.Content = GetContentFromXElement(dialog, xmlElement);
+
+ if (xmlElement.Attribute("Name")?.Value == "CancelButton")
+ {
+ Binding cancelEnabledBinding = new Binding("CancelEnabled") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(button, Button.IsEnabledProperty, cancelEnabledBinding);
+
+ Binding cancelCommandBinding = new Binding("CancelInstallCommand");
+ BindingOperations.SetBinding(button, Button.CommandProperty, cancelCommandBinding);
+ }
+
+ return button;
+ }
+
+ private static void HandleXmlElement_RangeBase(CustomDialog dialog, RangeBase rangeBase, XElement xmlElement)
+ {
+ HandleXmlElement_Control(dialog, rangeBase, xmlElement);
+
+ rangeBase.Value = ParseXmlAttribute(xmlElement, "Value", 0);
+ rangeBase.Maximum = ParseXmlAttribute(xmlElement, "Maximum", 100);
+ }
+
+ private static UIElement HandleXmlElement_ProgressBar(CustomDialog dialog, XElement xmlElement)
+ {
+ var progressBar = new Wpf.Ui.Controls.ProgressBar();
+ HandleXmlElement_RangeBase(dialog, progressBar, xmlElement);
+
+ progressBar.IsIndeterminate = ParseXmlAttribute(xmlElement, "IsIndeterminate", false);
+
+ object? cornerRadius = GetCornerRadiusFromXElement(xmlElement, "CornerRadius");
+ if (cornerRadius != null)
+ progressBar.CornerRadius = (CornerRadius)cornerRadius;
+
+ object? indicatorCornerRadius = GetCornerRadiusFromXElement(xmlElement, "IndicatorCornerRadius");
+ if (indicatorCornerRadius != null)
+ progressBar.IndicatorCornerRadius = (CornerRadius)indicatorCornerRadius;
+
+ if (xmlElement.Attribute("Name")?.Value == "PrimaryProgressBar")
+ {
+ Binding isIndeterminateBinding = new Binding("ProgressIndeterminate") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, ProgressBar.IsIndeterminateProperty, isIndeterminateBinding);
+
+ Binding maximumBinding = new Binding("ProgressMaximum") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, ProgressBar.MaximumProperty, maximumBinding);
+
+ Binding valueBinding = new Binding("ProgressValue") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, ProgressBar.ValueProperty, valueBinding);
+ }
+
+ return progressBar;
+ }
+
+ private static UIElement HandleXmlElement_ProgressRing(CustomDialog dialog, XElement xmlElement)
+ {
+ var progressBar = new Wpf.Ui.Controls.ProgressRing();
+ HandleXmlElement_RangeBase(dialog, progressBar, xmlElement);
+
+ progressBar.IsIndeterminate = ParseXmlAttribute(xmlElement, "IsIndeterminate", false);
+
+ if (xmlElement.Attribute("Name")?.Value == "PrimaryProgressRing")
+ {
+ Binding isIndeterminateBinding = new Binding("ProgressIndeterminate") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, Wpf.Ui.Controls.ProgressRing.IsIndeterminateProperty, isIndeterminateBinding);
+
+ Binding maximumBinding = new Binding("ProgressMaximum") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, Wpf.Ui.Controls.ProgressRing.MaximumProperty, maximumBinding);
+
+ Binding valueBinding = new Binding("ProgressValue") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(progressBar, Wpf.Ui.Controls.ProgressRing.ValueProperty, valueBinding);
+ }
+
+ return progressBar;
+ }
+
+ private static void HandleXmlElement_TextBlock_Base(CustomDialog dialog, TextBlock textBlock, XElement xmlElement)
+ {
+ HandleXmlElement_FrameworkElement(dialog, textBlock, xmlElement);
+
+ ApplyBrush_UIElement(dialog, textBlock, "Foreground", TextBlock.ForegroundProperty, xmlElement);
+
+ ApplyBrush_UIElement(dialog, textBlock, "Background", TextBlock.BackgroundProperty, xmlElement);
+
+ var fontSize = ParseXmlAttributeNullable(xmlElement, "FontSize");
+ if (fontSize is double)
+ textBlock.FontSize = (double)fontSize;
+ textBlock.FontWeight = GetFontWeightFromXElement(xmlElement);
+ textBlock.FontStyle = GetFontStyleFromXElement(xmlElement);
+
+ textBlock.LineHeight = ParseXmlAttribute(xmlElement, "LineHeight", double.NaN);
+ textBlock.LineStackingStrategy = ParseXmlAttribute(xmlElement, "LineStackingStrategy", LineStackingStrategy.MaxHeight);
+
+ textBlock.TextAlignment = ParseXmlAttribute(xmlElement, "TextAlignment", TextAlignment.Center);
+ textBlock.TextTrimming = ParseXmlAttribute(xmlElement, "TextTrimming", TextTrimming.None);
+ textBlock.TextWrapping = ParseXmlAttribute(xmlElement, "TextWrapping", TextWrapping.NoWrap);
+ textBlock.TextDecorations = GetTextDecorationsFromXElement(xmlElement);
+
+ textBlock.IsHyphenationEnabled = ParseXmlAttribute(xmlElement, "IsHyphenationEnabled", false);
+ textBlock.BaselineOffset = ParseXmlAttribute(xmlElement, "BaselineOffset", double.NaN);
+
+ // NOTE: font family can both be the name of the font or a uri
+ string? fontFamily = GetFullPath(dialog, xmlElement.Attribute("FontFamily")?.Value);
+ if (fontFamily != null)
+ textBlock.FontFamily = new System.Windows.Media.FontFamily(fontFamily);
+
+ object? padding = GetThicknessFromXElement(xmlElement, "Padding");
+ if (padding != null)
+ textBlock.Padding = (Thickness)padding;
+ }
+
+ private static UIElement HandleXmlElement_TextBlock(CustomDialog dialog, XElement xmlElement)
+ {
+ var textBlock = new TextBlock();
+ HandleXmlElement_TextBlock_Base(dialog, textBlock, xmlElement);
+
+ textBlock.Text = GetTranslatedText(xmlElement.Attribute("Text")?.Value);
+
+ if (xmlElement.Attribute("Name")?.Value == "StatusText")
+ {
+ Binding textBinding = new Binding("Message") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(textBlock, TextBlock.TextProperty, textBinding);
+ }
+
+ return textBlock;
+ }
+
+ private static UIElement HandleXmlElement_MarkdownTextBlock(CustomDialog dialog, XElement xmlElement)
+ {
+ var textBlock = new MarkdownTextBlock();
+ HandleXmlElement_TextBlock_Base(dialog, textBlock, xmlElement);
+
+ string? text = GetTranslatedText(xmlElement.Attribute("Text")?.Value);
+ if (text != null)
+ textBlock.MarkdownText = text;
+
+ return textBlock;
+ }
+
+ private static UIElement HandleXmlElement_Image(CustomDialog dialog, XElement xmlElement)
+ {
+ var image = new Image();
+ HandleXmlElement_FrameworkElement(dialog, image, xmlElement);
+
+ image.Stretch = ParseXmlAttribute(xmlElement, "Stretch", Stretch.Uniform);
+ image.StretchDirection = ParseXmlAttribute(xmlElement, "StretchDirection", StretchDirection.Both);
+
+ RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.HighQuality); // should this be modifiable by the user?
+
+ var sourceData = GetImageSourceData(dialog, "Source", xmlElement);
+
+ if (sourceData.IsIcon)
+ {
+ // bind the icon property
+ Binding binding = new Binding("Icon") { Mode = BindingMode.OneWay };
+ BindingOperations.SetBinding(image, Image.SourceProperty, binding);
+ }
+ else
+ {
+ bool isAnimated = ParseXmlAttribute(xmlElement, "IsAnimated", false);
+ if (!isAnimated)
+ {
+ BitmapImage bitmapImage;
+ try
+ {
+ bitmapImage = new BitmapImage(sourceData.Uri!);
+ }
+ catch (Exception ex)
+ {
+ throw new CustomThemeException(ex, "CustomTheme.Errors.ElementTypeCreationFailed", "Image", "BitmapImage", ex.Message);
+ }
+
+ image.Source = bitmapImage;
+ }
+ else
+ {
+ XamlAnimatedGif.AnimationBehavior.SetSourceUri(image, sourceData.Uri!);
+ }
+ }
+
+ return image;
+ }
+
+ private static RowDefinition HandleXmlElement_RowDefinition(CustomDialog dialog, XElement xmlElement)
+ {
+ var rowDefinition = new RowDefinition();
+
+ var height = GetGridLengthFromXElement(xmlElement, "Height");
+ if (height != null)
+ rowDefinition.Height = (GridLength)height;
+
+ rowDefinition.MinHeight = ParseXmlAttribute(xmlElement, "MinHeight", 0);
+ rowDefinition.MaxHeight = ParseXmlAttribute(xmlElement, "MaxHeight", double.PositiveInfinity);
+
+ return rowDefinition;
+ }
+
+ private static ColumnDefinition HandleXmlElement_ColumnDefinition(CustomDialog dialog, XElement xmlElement)
+ {
+ var columnDefinition = new ColumnDefinition();
+
+ var width = GetGridLengthFromXElement(xmlElement, "Width");
+ if (width != null)
+ columnDefinition.Width = (GridLength)width;
+
+ columnDefinition.MinWidth = ParseXmlAttribute(xmlElement, "MinWidth", 0);
+ columnDefinition.MaxWidth = ParseXmlAttribute(xmlElement, "MaxWidth", double.PositiveInfinity);
+
+ return columnDefinition;
+ }
+
+ private static void HandleXmlElement_Grid_RowDefinitions(Grid grid, CustomDialog dialog, XElement xmlElement)
+ {
+ foreach (var element in xmlElement.Elements())
+ {
+ var rowDefinition = HandleXml(dialog, element);
+ grid.RowDefinitions.Add(rowDefinition);
+ }
+ }
+
+ private static void HandleXmlElement_Grid_ColumnDefinitions(Grid grid, CustomDialog dialog, XElement xmlElement)
+ {
+ foreach (var element in xmlElement.Elements())
+ {
+ var columnDefinition = HandleXml(dialog, element);
+ grid.ColumnDefinitions.Add(columnDefinition);
+ }
+ }
+
+ private static Grid HandleXmlElement_Grid(CustomDialog dialog, XElement xmlElement)
+ {
+ var grid = new Grid();
+ HandleXmlElement_FrameworkElement(dialog, grid, xmlElement);
+
+ bool rowsSet = false;
+ bool columnsSet = false;
+
+ foreach (var element in xmlElement.Elements())
+ {
+ if (element.Name == "Grid.RowDefinitions")
+ {
+ if (rowsSet)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleDefinitions", "Grid", "RowDefinitions");
+ rowsSet = true;
+
+ HandleXmlElement_Grid_RowDefinitions(grid, dialog, element);
+ }
+ else if (element.Name == "Grid.ColumnDefinitions")
+ {
+ if (columnsSet)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleDefinitions", "Grid", "ColumnDefinitions");
+ columnsSet = true;
+
+ HandleXmlElement_Grid_ColumnDefinitions(grid, dialog, element);
+ }
+ else if (element.Name.ToString().StartsWith("Grid."))
+ {
+ continue; // ignore others
+ }
+ else
+ {
+ var uiElement = HandleXml(dialog, element);
+ grid.Children.Add(uiElement);
+ }
+ }
+
+ return grid;
+ }
+
+ private static StackPanel HandleXmlElement_StackPanel(CustomDialog dialog, XElement xmlElement)
+ {
+ var stackPanel = new StackPanel();
+ HandleXmlElement_FrameworkElement(dialog, stackPanel, xmlElement);
+
+ stackPanel.Orientation = ParseXmlAttribute(xmlElement, "Orientation", Orientation.Vertical);
+
+ foreach (var element in xmlElement.Elements())
+ {
+ var uiElement = HandleXml(dialog, element);
+ stackPanel.Children.Add(uiElement);
+ }
+
+ return stackPanel;
+ }
+
+ private static Border HandleXmlElement_Border(CustomDialog dialog, XElement xmlElement)
+ {
+ var border = new Border();
+ HandleXmlElement_FrameworkElement(dialog, border, xmlElement);
+
+ ApplyBrush_UIElement(dialog, border, "Background", Border.BackgroundProperty, xmlElement);
+ ApplyBrush_UIElement(dialog, border, "BorderBrush", Border.BorderBrushProperty, xmlElement);
+
+ object? borderThickness = GetThicknessFromXElement(xmlElement, "BorderThickness");
+ if (borderThickness != null)
+ border.BorderThickness = (Thickness)borderThickness;
+
+ object? padding = GetThicknessFromXElement(xmlElement, "Padding");
+ if (padding != null)
+ border.Padding = (Thickness)padding;
+
+ object? cornerRadius = GetCornerRadiusFromXElement(xmlElement, "CornerRadius");
+ if (cornerRadius != null)
+ border.CornerRadius = (CornerRadius)cornerRadius;
+
+ var children = xmlElement.Elements().Where(x => !x.Name.ToString().StartsWith("Border."));
+ if (children.Any())
+ {
+ if (children.Count() > 1)
+ throw new CustomThemeException("CustomTheme.Errors.ElementMultipleChildren", "Border");
+
+ border.Child = HandleXml(dialog, children.First());
+ }
+
+ return border;
+ }
+ #endregion
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Utilities.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Utilities.cs
new file mode 100644
index 0000000..0deafb3
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.Utilities.cs
@@ -0,0 +1,300 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Effects;
+using System.Xml.Linq;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ public partial class CustomDialog
+ {
+ struct GetImageSourceDataResult
+ {
+ public bool IsIcon = false;
+ public Uri? Uri = null;
+
+ public GetImageSourceDataResult()
+ {
+ }
+ }
+
+ private static string GetXmlAttribute(XElement element, string attributeName, string? defaultValue = null)
+ {
+ var attribute = element.Attribute(attributeName);
+
+ if (attribute == null)
+ {
+ if (defaultValue != null)
+ return defaultValue;
+
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissing", element.Name, attributeName);
+ }
+
+ return attribute.Value.ToString();
+ }
+
+ private static T ParseXmlAttribute(XElement element, string attributeName, T? defaultValue = null) where T : struct
+ {
+ var attribute = element.Attribute(attributeName);
+
+ if (attribute == null)
+ {
+ if (defaultValue != null)
+ return (T)defaultValue;
+
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissing", element.Name, attributeName);
+ }
+
+ T? parsed = ConvertValue(attribute.Value);
+ if (parsed == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeInvalidType", element.Name, attributeName, typeof(T).Name);
+
+ return (T)parsed;
+ }
+
+ ///
+ /// ParseXmlAttribute but the default value is always null
+ ///
+ private static T? ParseXmlAttributeNullable(XElement element, string attributeName) where T : struct
+ {
+ var attribute = element.Attribute(attributeName);
+
+ if (attribute == null)
+ return null;
+
+ T? parsed = ConvertValue(attribute.Value);
+ if (parsed == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeInvalidType", element.Name, attributeName, typeof(T).Name);
+
+ return (T)parsed;
+ }
+
+ private static void ValidateXmlElement(string elementName, string attributeName, int value, int? min = null, int? max = null)
+ {
+ if (min != null && value < min)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeLargerThanMin", elementName, attributeName, min);
+ if (max != null && value > max)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeSmallerThanMax", elementName, attributeName, max);
+ }
+
+ private static void ValidateXmlElement(string elementName, string attributeName, double value, double? min = null, double? max = null)
+ {
+ if (min != null && value < min)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeLargerThanMin", elementName, attributeName, min);
+ if (max != null && value > max)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeSmallerThanMax", elementName, attributeName, max);
+ }
+
+ // You can't do numeric only generics in .NET 6. The feature is exclusive to .NET 7+.
+ private static int ParseXmlAttributeClamped(XElement element, string attributeName, int? defaultValue = null, int? min = null, int? max = null)
+ {
+ int value = ParseXmlAttribute(element, attributeName, defaultValue);
+ ValidateXmlElement(element.Name.ToString(), attributeName, value, min, max);
+ return value;
+ }
+
+ private static FontWeight GetFontWeightFromXElement(XElement element)
+ {
+ string? value = element.Attribute("FontWeight")?.Value?.ToString();
+ if (string.IsNullOrEmpty(value))
+ value = "Normal";
+
+ // bruh
+ // https://learn.microsoft.com/en-us/dotnet/api/system.windows.fontweights?view=windowsdesktop-6.0
+ switch (value)
+ {
+ case "Thin":
+ return FontWeights.Thin;
+
+ case "ExtraLight":
+ case "UltraLight":
+ return FontWeights.ExtraLight;
+
+ case "Medium":
+ return FontWeights.Medium;
+
+ case "Normal":
+ case "Regular":
+ return FontWeights.Normal;
+
+ case "DemiBold":
+ case "SemiBold":
+ return FontWeights.DemiBold;
+
+ case "Bold":
+ return FontWeights.Bold;
+
+ case "ExtraBold":
+ case "UltraBold":
+ return FontWeights.ExtraBold;
+
+ case "Black":
+ case "Heavy":
+ return FontWeights.Black;
+
+ case "ExtraBlack":
+ case "UltraBlack":
+ return FontWeights.UltraBlack;
+
+ default:
+ throw new CustomThemeException("CustomTheme.Errors.UnknownEnumValue", element.Name, "FontWeight", value);
+ }
+ }
+
+ private static FontStyle GetFontStyleFromXElement(XElement element)
+ {
+ string? value = element.Attribute("FontStyle")?.Value?.ToString();
+ if (string.IsNullOrEmpty(value))
+ value = "Normal";
+
+ switch (value)
+ {
+ case "Normal":
+ return FontStyles.Normal;
+
+ case "Italic":
+ return FontStyles.Italic;
+
+ case "Oblique":
+ return FontStyles.Oblique;
+
+ default:
+ throw new CustomThemeException("CustomTheme.Errors.UnknownEnumValue", element.Name, "FontStyle", value);
+ }
+ }
+
+ private static TextDecorationCollection? GetTextDecorationsFromXElement(XElement element)
+ {
+ string? value = element.Attribute("TextDecorations")?.Value?.ToString();
+ if (string.IsNullOrEmpty(value))
+ return null;
+
+ switch (value)
+ {
+ case "Baseline":
+ return TextDecorations.Baseline;
+
+ case "OverLine":
+ return TextDecorations.OverLine;
+
+ case "Strikethrough":
+ return TextDecorations.Strikethrough;
+
+ case "Underline":
+ return TextDecorations.Underline;
+
+ default:
+ throw new CustomThemeException("CustomTheme.Errors.UnknownEnumValue", element.Name, "TextDecorations", value);
+ }
+ }
+
+ private static string? GetTranslatedText(string? text)
+ {
+ if (text == null || !text.StartsWith('{') || !text.EndsWith('}'))
+ return text; // can't be translated (not in the correct format)
+
+ string resourceName = text[1..^1];
+
+ if (resourceName == "Version")
+ return App.Version;
+
+ return Strings.ResourceManager.GetStringSafe(resourceName);
+ }
+
+ private static string? GetFullPath(CustomDialog dialog, string? sourcePath)
+ {
+ if (sourcePath == null)
+ return null;
+
+ // TODO: this is bad :(
+ return sourcePath.Replace("theme://", $"{dialog.ThemeDir}\\");
+ }
+
+ private static GetImageSourceDataResult GetImageSourceData(CustomDialog dialog, string name, XElement xmlElement)
+ {
+ string path = GetXmlAttribute(xmlElement, name);
+
+ if (path == "{Icon}")
+ return new GetImageSourceDataResult { IsIcon = true };
+
+ path = GetFullPath(dialog, path)!;
+
+ if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out Uri? result))
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeParseError", xmlElement.Name, name, "Uri");
+
+ if (result == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeParseErrorNull", xmlElement.Name, name, "Uri");
+
+ if (result.Scheme != "file")
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeBlacklistedUriScheme", xmlElement.Name, name, result.Scheme);
+
+ return new GetImageSourceDataResult { Uri = result };
+ }
+
+ private static object? GetContentFromXElement(CustomDialog dialog, XElement xmlElement)
+ {
+ var contentAttr = xmlElement.Attribute("Content");
+ var contentElement = xmlElement.Element($"{xmlElement.Name}.Content");
+ if (contentAttr != null && contentElement != null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleDefinitions", xmlElement.Name, "Content");
+
+ if (contentAttr != null)
+ return GetTranslatedText(contentAttr.Value);
+
+ if (contentElement == null)
+ return null;
+
+ var children = contentElement.Elements();
+ if (children.Count() > 1)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleChildren", xmlElement.Name, "Content");
+
+ var first = contentElement.FirstNode as XElement;
+ if (first == null)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissingChild", xmlElement.Name, "Content");
+
+ var uiElement = HandleXml(dialog, first);
+ return uiElement;
+ }
+
+ private static void ApplyEffects_UIElement(CustomDialog dialog, UIElement uiElement, XElement xmlElement)
+ {
+ var effectElement = xmlElement.Element($"{xmlElement.Name}.Effect");
+ if (effectElement == null)
+ return;
+
+ var children = effectElement.Elements();
+ if (children.Count() > 1)
+ throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleChildren", xmlElement.Name, "Effect");
+
+ var child = children.FirstOrDefault();
+ if (child == null)
+ return;
+
+ Effect effect = HandleXml(dialog, child);
+ uiElement.Effect = effect;
+ }
+
+ private static void ApplyTransformation_UIElement(CustomDialog dialog, string name, DependencyProperty property, UIElement uiElement, XElement xmlElement)
+ {
+ var transformElement = xmlElement.Element($"{xmlElement.Name}.{name}");
+
+ if (transformElement == null)
+ return;
+
+ var tg = new TransformGroup();
+
+ foreach (var child in transformElement.Elements())
+ {
+ Transform element = HandleXml(dialog, child);
+ tg.Children.Add(element);
+ }
+
+ uiElement.SetValue(property, tg);
+ }
+
+ private static void ApplyTransformations_UIElement(CustomDialog dialog, UIElement uiElement, XElement xmlElement)
+ {
+ ApplyTransformation_UIElement(dialog, "RenderTransform", FrameworkElement.RenderTransformProperty, uiElement, xmlElement);
+ ApplyTransformation_UIElement(dialog, "LayoutTransform", FrameworkElement.LayoutTransformProperty, uiElement, xmlElement);
+ }
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml
new file mode 100644
index 0000000..44e7065
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml.cs b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml.cs
new file mode 100644
index 0000000..35684f4
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Bootstrapper/CustomDialog.xaml.cs
@@ -0,0 +1,122 @@
+using Bloxstrap.UI.Elements.Bootstrapper.Base;
+using Bloxstrap.UI.ViewModels.Bootstrapper;
+using System.ComponentModel;
+using System.Windows.Forms;
+using System.Windows.Shell;
+
+namespace Bloxstrap.UI.Elements.Bootstrapper
+{
+ ///
+ /// Interaction logic for CustomDialog.xaml
+ ///
+ public partial class CustomDialog : IBootstrapperDialog
+ {
+ private readonly BootstrapperDialogViewModel _viewModel;
+
+ public Bloxstrap.Bootstrapper? Bootstrapper { get; set; }
+
+ private bool _isClosing;
+
+ #region UI Elements
+ public string Message
+ {
+ get => _viewModel.Message;
+ set
+ {
+ _viewModel.Message = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.Message));
+ }
+ }
+
+ public ProgressBarStyle ProgressStyle
+ {
+ get => _viewModel.ProgressIndeterminate ? ProgressBarStyle.Marquee : ProgressBarStyle.Continuous;
+ set
+ {
+ _viewModel.ProgressIndeterminate = (value == ProgressBarStyle.Marquee);
+ _viewModel.OnPropertyChanged(nameof(_viewModel.ProgressIndeterminate));
+ }
+ }
+
+ public int ProgressMaximum
+ {
+ get => _viewModel.ProgressMaximum;
+ set
+ {
+ _viewModel.ProgressMaximum = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.ProgressMaximum));
+ }
+ }
+
+ public int ProgressValue
+ {
+ get => _viewModel.ProgressValue;
+ set
+ {
+ _viewModel.ProgressValue = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.ProgressValue));
+ }
+ }
+
+ public TaskbarItemProgressState TaskbarProgressState
+ {
+ get => _viewModel.TaskbarProgressState;
+ set
+ {
+ _viewModel.TaskbarProgressState = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.TaskbarProgressState));
+ }
+ }
+
+ public double TaskbarProgressValue
+ {
+ get => _viewModel.TaskbarProgressValue;
+ set
+ {
+ _viewModel.TaskbarProgressValue = value;
+ _viewModel.OnPropertyChanged(nameof(_viewModel.TaskbarProgressValue));
+ }
+ }
+
+ public bool CancelEnabled
+ {
+ get => _viewModel.CancelEnabled;
+ set
+ {
+ _viewModel.CancelEnabled = value;
+
+ _viewModel.OnPropertyChanged(nameof(_viewModel.CancelButtonVisibility));
+ _viewModel.OnPropertyChanged(nameof(_viewModel.CancelEnabled));
+ }
+ }
+ #endregion
+
+ public CustomDialog()
+ {
+ InitializeComponent();
+
+ _viewModel = new BootstrapperDialogViewModel(this);
+ DataContext = _viewModel;
+ Title = App.Settings.Prop.BootstrapperTitle;
+ Icon = App.Settings.Prop.BootstrapperIcon.GetIcon().GetImageSource();
+ }
+
+ private void UiWindow_Closing(object sender, CancelEventArgs e)
+ {
+ if (!_isClosing)
+ Bootstrapper?.Cancel();
+ }
+
+ #region IBootstrapperDialog Methods
+ public void ShowBootstrapper() => this.ShowDialog();
+
+ public void CloseBootstrapper()
+ {
+ _isClosing = true;
+ Dispatcher.BeginInvoke(this.Close);
+ }
+
+ public void ShowSuccess(string message, Action? callback) => BaseFunctions.ShowSuccess(message, callback);
+ #endregion
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml b/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml
new file mode 100644
index 0000000..b49fcb0
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml.cs b/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml.cs
new file mode 100644
index 0000000..9d88c8c
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Dialogs/AddCustomThemeDialog.xaml.cs
@@ -0,0 +1,231 @@
+using Bloxstrap.UI.Elements.Base;
+using Bloxstrap.UI.ViewModels.Dialogs;
+using Microsoft.Win32;
+using System.IO.Compression;
+using System.Windows;
+
+namespace Bloxstrap.UI.Elements.Dialogs
+{
+ ///
+ /// Interaction logic for AddCustomThemeDialog.xaml
+ ///
+ public partial class AddCustomThemeDialog : WpfUiWindow
+ {
+ private const int CreateNewTabId = 0;
+ private const int ImportTabId = 1;
+
+ private readonly AddCustomThemeViewModel _viewModel;
+
+ public bool Created { get; private set; } = false;
+ public string ThemeName { get; private set; } = "";
+ public bool OpenEditor { get; private set; } = false;
+
+ public AddCustomThemeDialog()
+ {
+ _viewModel = new AddCustomThemeViewModel();
+ _viewModel.Name = GenerateRandomName();
+
+ DataContext = _viewModel;
+
+ InitializeComponent();
+ }
+
+ private static string GetThemePath(string name)
+ {
+ return Path.Combine(Paths.CustomThemes, name, "Theme.xml");
+ }
+
+ private static string GenerateRandomName()
+ {
+ int count = Directory.GetDirectories(Paths.CustomThemes).Count();
+
+ int i = count + 1;
+ string name = string.Format(Strings.CustomTheme_DefaultName, i);
+
+ // TODO: this sucks
+ if (File.Exists(GetThemePath(name)))
+ name = string.Format(Strings.CustomTheme_DefaultName, $"{i}-{Random.Shared.Next(1, 100000)}"); // easy
+
+ return name;
+ }
+
+ private static string GetUniqueName(string name)
+ {
+ const int maxTries = 100;
+
+ if (!File.Exists(GetThemePath(name)))
+ return name;
+
+ for (int i = 1; i <= maxTries; i++)
+ {
+ string newName = $"{name}_{i}";
+ if (!File.Exists(GetThemePath(newName)))
+ return newName;
+ }
+
+ // last resort
+ return $"{name}_{Random.Shared.Next(maxTries+1, 1_000_000)}";
+ }
+
+ private static void CreateCustomTheme(string name, CustomThemeTemplate template)
+ {
+ string dir = Path.Combine(Paths.CustomThemes, name);
+
+ if (Directory.Exists(dir))
+ Directory.Delete(dir, true);
+ Directory.CreateDirectory(dir);
+
+ string themeFilePath = Path.Combine(dir, "Theme.xml");
+
+ string templateContent = template.GetFileContents();
+
+ File.WriteAllText(themeFilePath, templateContent);
+ }
+
+ private bool ValidateCreateNew()
+ {
+ const string LOG_IDENT = "AddCustomThemeDialog::ValidateCreateNew";
+
+ if (string.IsNullOrEmpty(_viewModel.Name))
+ {
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_NameEmpty;
+ return false;
+ }
+
+ var validationResult = PathValidator.IsFileNameValid(_viewModel.Name);
+
+ if (validationResult != PathValidator.ValidationResult.Ok)
+ {
+ switch (validationResult)
+ {
+ case PathValidator.ValidationResult.IllegalCharacter:
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_NameIllegalCharacters;
+ break;
+ case PathValidator.ValidationResult.ReservedFileName:
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_NameReserved;
+ break;
+ default:
+ App.Logger.WriteLine(LOG_IDENT, $"Got unhandled PathValidator::ValidationResult {validationResult}");
+ Debug.Assert(false);
+
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_Unknown;
+ break;
+ }
+
+ return false;
+ }
+
+ // better to check for the file instead of the directory so broken themes can be overwritten
+ string path = Path.Combine(Paths.CustomThemes, _viewModel.Name, "Theme.xml");
+ if (File.Exists(path))
+ {
+ _viewModel.NameError = Strings.CustomTheme_Add_Errors_NameTaken;
+ return false;
+ }
+
+ return true;
+ }
+
+ private bool ValidateImport()
+ {
+ const string LOG_IDENT = "AddCustomThemeDialog::ValidateImport";
+
+ if (!_viewModel.FilePath.EndsWith(".zip"))
+ {
+ _viewModel.FileError = Strings.CustomTheme_Add_Errors_FileNotZip;
+ return false;
+ }
+
+ try
+ {
+ using var zipFile = ZipFile.OpenRead(_viewModel.FilePath);
+ var entries = zipFile.Entries;
+
+ bool foundThemeFile = false;
+
+ foreach (var entry in entries)
+ {
+ if (entry.FullName == "Theme.xml")
+ {
+ foundThemeFile = true;
+ break;
+ }
+ }
+
+ if (!foundThemeFile)
+ {
+ _viewModel.FileError = Strings.CustomTheme_Add_Errors_ZipMissingThemeFile;
+ return false;
+ }
+
+ return true;
+ }
+ catch (InvalidDataException ex)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Got invalid data");
+ App.Logger.WriteException(LOG_IDENT, ex);
+
+ _viewModel.FileError = Strings.CustomTheme_Add_Errors_ZipInvalidData;
+ return false;
+ }
+ }
+
+ private void CreateNew()
+ {
+ if (!ValidateCreateNew())
+ return;
+
+ CreateCustomTheme(_viewModel.Name, _viewModel.Template);
+
+ Created = true;
+ ThemeName = _viewModel.Name;
+ OpenEditor = true;
+
+ Close();
+ }
+
+ private void Import()
+ {
+ if (!ValidateImport())
+ return;
+
+ string fileName = Path.GetFileNameWithoutExtension(_viewModel.FilePath);
+ string name = GetUniqueName(fileName);
+
+ string directory = Path.Combine(Paths.CustomThemes, name);
+ if (Directory.Exists(directory))
+ Directory.Delete(directory, true);
+ Directory.CreateDirectory(directory);
+
+ var fastZip = new ICSharpCode.SharpZipLib.Zip.FastZip();
+ fastZip.ExtractZip(_viewModel.FilePath, directory, null);
+
+ Created = true;
+ ThemeName = name;
+ OpenEditor = false;
+
+ Close();
+ }
+
+ private void OnOkButtonClicked(object sender, RoutedEventArgs e)
+ {
+ if (_viewModel.SelectedTab == CreateNewTabId)
+ CreateNew();
+ else
+ Import();
+ }
+
+ private void OnImportButtonClicked(object sender, RoutedEventArgs e)
+ {
+ var dialog = new OpenFileDialog
+ {
+ Filter = $"{Strings.FileTypes_ZipArchive}|*.zip"
+ };
+
+ if (dialog.ShowDialog() != true)
+ return;
+
+ _viewModel.FilePath = dialog.FileName;
+ }
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml
new file mode 100644
index 0000000..31b9449
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs
new file mode 100644
index 0000000..9cfc678
--- /dev/null
+++ b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs
@@ -0,0 +1,580 @@
+using System.Windows.Input;
+using System.Xml;
+
+using ICSharpCode.AvalonEdit.CodeCompletion;
+using ICSharpCode.AvalonEdit.Document;
+using ICSharpCode.AvalonEdit.Editing;
+using ICSharpCode.AvalonEdit.Highlighting.Xshd;
+using ICSharpCode.AvalonEdit.Highlighting;
+
+using Bloxstrap.UI.Elements.Base;
+using Bloxstrap.UI.ViewModels.Editor;
+using System.Windows;
+
+namespace Bloxstrap.UI.Elements.Editor
+{
+ ///
+ /// Interaction logic for BootstrapperEditorWindow.xaml
+ ///
+ public partial class BootstrapperEditorWindow : WpfUiWindow
+ {
+ private static class CustomBootstrapperSchema
+ {
+ private class Schema
+ {
+ public Dictionary Elements { get; set; } = new Dictionary();
+ public Dictionary Types { get; set; } = new Dictionary();
+ }
+
+ private class Element
+ {
+ public string? SuperClass { get; set; } = null;
+ public bool IsCreatable { get; set; } = false;
+
+ // [AttributeName] = [TypeName]
+ public Dictionary Attributes { get; set; } = new Dictionary();
+ }
+
+ public class Type
+ {
+ public bool CanHaveElement { get; set; } = false;
+ public List? Values { get; set; } = null;
+ }
+
+ private static Schema? _schema;
+
+ ///
+ /// Elements and their attributes
+ ///
+ public static SortedDictionary> ElementInfo { get; set; } = new();
+
+ ///
+ /// Attributes of elements that can have property elements
+ ///
+ public static Dictionary> PropertyElements { get; set; } = new();
+
+ ///
+ /// All type info
+ ///
+ public static SortedDictionary Types { get; set; } = new();
+
+ public static void ParseSchema()
+ {
+ if (_schema != null)
+ return;
+
+ _schema = JsonSerializer.Deserialize(Resource.GetString("CustomBootstrapperSchema.json").Result);
+ if (_schema == null)
+ throw new Exception("Deserialised CustomBootstrapperSchema is null");
+
+ foreach (var type in _schema.Types)
+ Types.Add(type.Key, type.Value);
+
+ PopulateElementInfo();
+ }
+
+ private static (SortedDictionary, List) GetElementAttributes(string name, Element element)
+ {
+ if (ElementInfo.ContainsKey(name))
+ return (ElementInfo[name], PropertyElements[name]);
+
+ List properties = new List();
+ SortedDictionary attributes = new();
+
+ foreach (var attribute in element.Attributes)
+ {
+ attributes.Add(attribute.Key, attribute.Value);
+
+ if (!Types.ContainsKey(attribute.Value))
+ throw new Exception($"Schema for type {attribute.Value} is missing. Blame Matt!");
+
+ Type type = Types[attribute.Value];
+ if (type.CanHaveElement)
+ properties.Add(attribute.Key);
+ }
+
+ if (element.SuperClass != null)
+ {
+ (SortedDictionary superAttributes, List superProperties) = GetElementAttributes(element.SuperClass, _schema!.Elements[element.SuperClass]);
+ foreach (var attribute in superAttributes)
+ attributes.Add(attribute.Key, attribute.Value);
+
+ foreach (var property in superProperties)
+ properties.Add(property);
+ }
+
+ properties.Sort();
+
+ ElementInfo[name] = attributes;
+ PropertyElements[name] = properties;
+
+ return (attributes, properties);
+ }
+
+ private static void PopulateElementInfo()
+ {
+ List toRemove = new List();
+
+ foreach (var element in _schema!.Elements)
+ {
+ GetElementAttributes(element.Key, element.Value);
+
+ if (!element.Value.IsCreatable)
+ toRemove.Add(element.Key);
+ }
+
+ // remove non-creatable from list now that everything is done
+ foreach (var name in toRemove)
+ {
+ ElementInfo.Remove(name);
+ }
+ }
+ }
+
+ private BootstrapperEditorWindowViewModel _viewModel;
+ private CompletionWindow? _completionWindow = null;
+
+ public BootstrapperEditorWindow(string name)
+ {
+ CustomBootstrapperSchema.ParseSchema();
+
+ string directory = Path.Combine(Paths.CustomThemes, name);
+
+ string themeContents = File.ReadAllText(Path.Combine(directory, "Theme.xml"));
+ themeContents = ToCRLF(themeContents); // make sure the theme is in CRLF. a function expects CRLF.
+
+ _viewModel = new BootstrapperEditorWindowViewModel();
+ _viewModel.ThemeSavedCallback = ThemeSavedCallback;
+ _viewModel.Directory = directory;
+ _viewModel.Name = name;
+ _viewModel.Title = string.Format(Strings.CustomTheme_Editor_Title, name);
+ _viewModel.Code = themeContents;
+
+ DataContext = _viewModel;
+ InitializeComponent();
+
+ UIXML.Text = _viewModel.Code;
+ UIXML.TextChanged += OnCodeChanged;
+ UIXML.TextArea.TextEntered += OnTextAreaTextEntered;
+
+ LoadHighlightingTheme();
+ }
+
+ private void LoadHighlightingTheme()
+ {
+ string name = $"Editor-Theme-{App.Settings.Prop.Theme.GetFinal()}.xshd";
+ using Stream xmlStream = Resource.GetStream(name);
+ using XmlReader reader = XmlReader.Create(xmlStream);
+ UIXML.SyntaxHighlighting = HighlightingLoader.Load(reader, HighlightingManager.Instance);
+
+ UIXML.TextArea.TextView.SetResourceReference(ICSharpCode.AvalonEdit.Rendering.TextView.LinkTextForegroundBrushProperty, "NewTextEditorLink");
+ }
+
+ private void ThemeSavedCallback(bool success, string message)
+ {
+ if (success)
+ Snackbar.Show(Strings.CustomTheme_Editor_Save_Success, message, Wpf.Ui.Common.SymbolRegular.CheckmarkCircle32, Wpf.Ui.Common.ControlAppearance.Success);
+ else
+ Snackbar.Show(Strings.CustomTheme_Editor_Save_Error, message, Wpf.Ui.Common.SymbolRegular.ErrorCircle24, Wpf.Ui.Common.ControlAppearance.Danger);
+ }
+
+ private static string ToCRLF(string text)
+ {
+ return text.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n");
+ }
+
+ private void OnCodeChanged(object? sender, EventArgs e)
+ {
+ _viewModel.Code = UIXML.Text;
+ _viewModel.CodeChanged = true;
+ }
+
+ private void OnClosing(object sender, System.ComponentModel.CancelEventArgs e)
+ {
+ if (!_viewModel.CodeChanged)
+ return;
+
+ var result = Frontend.ShowMessageBox(string.Format(Strings.CustomTheme_Editor_ConfirmSave, _viewModel.Name), MessageBoxImage.Information, MessageBoxButton.YesNoCancel);
+ if (result == MessageBoxResult.Cancel)
+ {
+ e.Cancel = true;
+ }
+ else if (result == MessageBoxResult.Yes)
+ {
+ _viewModel.SaveCommand.Execute(null);
+ }
+ }
+
+ private void OnTextAreaTextEntered(object sender, TextCompositionEventArgs e)
+ {
+ switch (e.Text)
+ {
+ case "<":
+ OpenElementAutoComplete();
+ break;
+ case " ":
+ OpenAttributeAutoComplete();
+ break;
+ case ".":
+ OpenPropertyElementAutoComplete();
+ break;
+ case "/":
+ AddEndTag();
+ break;
+ case ">":
+ CloseCompletionWindow();
+ break;
+ case "!":
+ CloseCompletionWindow();
+ break;
+ }
+ }
+
+ private (string, int) GetLineAndPosAtCaretPosition()
+ {
+ // this assumes the file was saved as CSLF (\r\n newlines)
+ int offset = UIXML.CaretOffset - 1;
+ int lineStartIdx = UIXML.Text.LastIndexOf('\n', offset);
+ int lineEndIdx = UIXML.Text.IndexOf('\n', offset);
+
+ string line;
+ int pos;
+ if (lineStartIdx == -1 && lineEndIdx == -1)
+ {
+ line = UIXML.Text;
+ pos = offset;
+ }
+ else if (lineStartIdx == -1)
+ {
+ line = UIXML.Text[..(lineEndIdx - 1)];
+ pos = offset;
+ }
+ else if (lineEndIdx == -1)
+ {
+ line = UIXML.Text[(lineStartIdx + 1)..];
+ pos = offset - lineStartIdx - 2;
+ }
+ else
+ {
+ line = UIXML.Text[(lineStartIdx + 1)..(lineEndIdx - 1)];
+ pos = offset - lineStartIdx - 2;
+ }
+
+ return (line, pos);
+ }
+
+ ///
+ /// Source: https://xsemmel.codeplex.com
+ ///
+ ///
+ ///
+ ///
+ public static string? GetElementAtCursor(string xml, int offset, bool onlyAllowInside = false)
+ {
+ if (offset == xml.Length)
+ {
+ offset--;
+ }
+ int startIdx = xml.LastIndexOf('<', offset);
+ if (startIdx < 0) return null;
+
+ if (startIdx < xml.Length && xml[startIdx + 1] == '/')
+ {
+ startIdx = startIdx + 1;
+ }
+
+ int endIdx1 = xml.IndexOf(' ', startIdx);
+ if (endIdx1 == -1 /*|| endIdx1 > offset*/) endIdx1 = int.MaxValue;
+
+ int endIdx2 = xml.IndexOf('>', startIdx);
+ if (endIdx2 == -1 /*|| endIdx2 > offset*/)
+ {
+ endIdx2 = int.MaxValue;
+ }
+ else
+ {
+ if (onlyAllowInside && endIdx2 < offset)
+ return null; // we dont want attribute auto complete to show outside of elements
+
+ if (endIdx2 < xml.Length && xml[endIdx2 - 1] == '/')
+ {
+ endIdx2 = endIdx2 - 1;
+ }
+ }
+
+ int endIdx = Math.Min(endIdx1, endIdx2);
+ if (endIdx2 > 0 && endIdx2 < int.MaxValue && endIdx > startIdx)
+ {
+ string element = xml.Substring(startIdx + 1, endIdx - startIdx - 1);
+ return element == "!--" ? null : element; // dont treat comments as elements
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// A space between the cursor and the element will completely cancel this function
+ ///
+ private string? GetElementAtCursorNoSpaces(string xml, int offset)
+ {
+ (string line, int pos) = GetLineAndPosAtCaretPosition();
+
+ string curr = "";
+ while (pos != -1)
+ {
+ char c = line[pos];
+ if (c == ' ' || c == '\t')
+ return null;
+ if (c == '<')
+ return curr;
+ curr = c + curr;
+ pos--;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Returns null if not eligible to auto complete there.
+ /// Returns the name of the element to show the attributes for
+ ///
+ ///
+ private string? ShowAttributesForElementName()
+ {
+ (string line, int pos) = GetLineAndPosAtCaretPosition();
+
+ // check if theres an even number of speech marks on the line
+ int numSpeech = line.Count(x => x == '"');
+ if (numSpeech % 2 == 0)
+ {
+ // we have an equal number, let's check if pos is in between the speech marks
+ int count = -1;
+ int idx = pos;
+ int size = line.Length - 1;
+ while (idx != -1)
+ {
+ count++;
+
+ if (size > idx + 1)
+ idx = line.IndexOf('"', idx + 1);
+ else
+ idx = -1;
+ }
+
+ if (count % 2 != 0)
+ {
+ // odd number of speech marks means we're inside a string right now
+ // we dont want to display attribute auto complete while we're inside a string
+ return null;
+ }
+ }
+
+ return GetElementAtCursor(UIXML.Text, UIXML.CaretOffset, true);
+ }
+
+ private void AddEndTag()
+ {
+ CloseCompletionWindow();
+
+ if (UIXML.Text.Length > 2 && UIXML.Text[UIXML.CaretOffset - 2] == '<')
+ {
+ var elementName = GetElementAtCursor(UIXML.Text, UIXML.CaretOffset - 3);
+ if (elementName == null)
+ return;
+
+ UIXML.TextArea.Document.Insert(UIXML.CaretOffset, $"{elementName}>");
+ }
+ else
+ {
+ if (UIXML.Text.Length > UIXML.CaretOffset && UIXML.Text[UIXML.CaretOffset] == '>')
+ return;
+
+ var elementName = ShowAttributesForElementName(); // re-using functions :)
+ if (elementName != null)
+ UIXML.TextArea.Document.Insert(UIXML.CaretOffset, ">");
+ }
+ }
+
+ private void OpenElementAutoComplete()
+ {
+ var data = new List();
+
+ foreach (var element in CustomBootstrapperSchema.ElementInfo.Keys)
+ data.Add(new ElementCompletionData(element));
+
+ ShowCompletionWindow(data);
+ }
+
+ private void OpenAttributeAutoComplete()
+ {
+ string? element = ShowAttributesForElementName();
+ if (element == null)
+ {
+ CloseCompletionWindow();
+ return;
+ }
+
+ if (!CustomBootstrapperSchema.ElementInfo.ContainsKey(element))
+ {
+ CloseCompletionWindow();
+ return;
+ }
+
+ var attributes = CustomBootstrapperSchema.ElementInfo[element];
+
+ var data = new List();
+
+ foreach (var attribute in attributes)
+ data.Add(new AttributeCompletionData(attribute.Key, () => OpenTypeValueAutoComplete(attribute.Value)));
+
+ ShowCompletionWindow(data);
+ }
+
+ private void OpenTypeValueAutoComplete(string typeName)
+ {
+ var typeValues = CustomBootstrapperSchema.Types[typeName].Values;
+ if (typeValues == null)
+ return;
+
+ var data = new List();
+
+ foreach (var value in typeValues)
+ data.Add(new TypeValueCompletionData(value));
+
+ ShowCompletionWindow(data);
+ }
+
+ private void OpenPropertyElementAutoComplete()
+ {
+ string? element = GetElementAtCursorNoSpaces(UIXML.Text, UIXML.CaretOffset);
+ if (element == null)
+ {
+ CloseCompletionWindow();
+ return;
+ }
+
+ if (!CustomBootstrapperSchema.PropertyElements.ContainsKey(element))
+ {
+ CloseCompletionWindow();
+ return;
+ }
+
+ var properties = CustomBootstrapperSchema.PropertyElements[element];
+
+ var data = new List();
+
+ foreach (var property in properties)
+ data.Add(new TypeValueCompletionData(property));
+
+ ShowCompletionWindow(data);
+ }
+
+ private void CloseCompletionWindow()
+ {
+ if (_completionWindow != null)
+ {
+ _completionWindow.Close();
+ _completionWindow = null;
+ }
+ }
+
+ private void ShowCompletionWindow(List completionData)
+ {
+ CloseCompletionWindow();
+
+ if (!completionData.Any())
+ return;
+
+ _completionWindow = new CompletionWindow(UIXML.TextArea);
+
+ IList data = _completionWindow.CompletionList.CompletionData;
+ foreach (var c in completionData)
+ data.Add(c);
+
+ _completionWindow.Show();
+ _completionWindow.Closed += (_, _) => _completionWindow = null;
+ }
+ }
+
+ public class ElementCompletionData : ICompletionData
+ {
+ public ElementCompletionData(string text)
+ {
+ this.Text = text;
+ }
+
+ public System.Windows.Media.ImageSource? Image => null;
+
+ public string Text { get; private set; }
+
+ // Use this property if you want to show a fancy UIElement in the list.
+ public object Content => Text;
+
+ public object? Description => null;
+
+ public double Priority { get; }
+
+ public void Complete(TextArea textArea, ISegment completionSegment,
+ EventArgs insertionRequestEventArgs)
+ {
+ textArea.Document.Replace(completionSegment, this.Text);
+ }
+ }
+
+ public class AttributeCompletionData : ICompletionData
+ {
+ private Action _openValueAutoCompleteAction;
+
+ public AttributeCompletionData(string text, Action openValueAutoCompleteAction)
+ {
+ _openValueAutoCompleteAction = openValueAutoCompleteAction;
+ this.Text = text;
+ }
+
+ public System.Windows.Media.ImageSource? Image => null;
+
+ public string Text { get; private set; }
+
+ // Use this property if you want to show a fancy UIElement in the list.
+ public object Content => Text;
+
+ public object? Description => null;
+
+ public double Priority { get; }
+
+ public void Complete(TextArea textArea, ISegment completionSegment,
+ EventArgs insertionRequestEventArgs)
+ {
+ textArea.Document.Replace(completionSegment, this.Text + "=\"\"");
+ textArea.Caret.Offset = textArea.Caret.Offset - 1;
+ _openValueAutoCompleteAction();
+ }
+ }
+
+ public class TypeValueCompletionData : ICompletionData
+ {
+ public TypeValueCompletionData(string text)
+ {
+ this.Text = text;
+ }
+
+ public System.Windows.Media.ImageSource? Image => null;
+
+ public string Text { get; private set; }
+
+ // Use this property if you want to show a fancy UIElement in the list.
+ public object Content => Text;
+
+ public object? Description => null;
+
+ public double Priority { get; }
+
+ public void Complete(TextArea textArea, ISegment completionSegment,
+ EventArgs insertionRequestEventArgs)
+ {
+ textArea.Document.Replace(completionSegment, this.Text);
+ }
+ }
+}
diff --git a/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml b/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml
index 2ad5ead..63a3bca 100644
--- a/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml
+++ b/Bloxstrap/UI/Elements/Settings/Pages/AppearancePage.xaml
@@ -8,7 +8,7 @@
xmlns:controls="clr-namespace:Bloxstrap.UI.Elements.Controls"
xmlns:resources="clr-namespace:Bloxstrap.Resources"
mc:Ignorable="d"
- d:DesignHeight="640" d:DesignWidth="800"
+ d:DesignHeight="900" d:DesignWidth="800"
Title="AppearancePage"
Scrollable="True">
@@ -43,17 +43,83 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -12,5 +14,16 @@ namespace Bloxstrap.UI.Elements.Settings.Pages
DataContext = new AppearanceViewModel(this);
InitializeComponent();
}
+
+ public void CustomThemeSelection(object sender, SelectionChangedEventArgs e)
+ {
+ AppearanceViewModel viewModel = (AppearanceViewModel)DataContext;
+
+ viewModel.SelectedCustomTheme = (string)((ListBox)sender).SelectedItem;
+ viewModel.SelectedCustomThemeName = viewModel.SelectedCustomTheme;
+
+ viewModel.OnPropertyChanged(nameof(viewModel.SelectedCustomTheme));
+ viewModel.OnPropertyChanged(nameof(viewModel.SelectedCustomThemeName));
+ }
}
}
diff --git a/Bloxstrap/UI/Elements/Settings/Pages/BootstrapperPage.xaml b/Bloxstrap/UI/Elements/Settings/Pages/BootstrapperPage.xaml
index 370b84f..569e526 100644
--- a/Bloxstrap/UI/Elements/Settings/Pages/BootstrapperPage.xaml
+++ b/Bloxstrap/UI/Elements/Settings/Pages/BootstrapperPage.xaml
@@ -30,13 +30,19 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/Style/Editor-Theme-Dark.xshd b/Bloxstrap/UI/Style/Editor-Theme-Dark.xshd
new file mode 100644
index 0000000..5f5da3e
--- /dev/null
+++ b/Bloxstrap/UI/Style/Editor-Theme-Dark.xshd
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <!--
+ -->
+
+
+ <!\[CDATA\[
+ ]]>
+
+
+ <!DOCTYPE
+ >
+
+
+ <\?
+ \?>
+
+
+ <
+ >
+
+
+
+ "
+ "|(?=<)
+
+
+ '
+ '|(?=<)
+
+ [\d\w_\-\.]+(?=(\s*=))
+ =
+
+
+
+
+
+
+
+ &
+ [\w\d\#]+
+ ;
+
+
+
+ &
+ [\w\d\#]*
+ #missing ;
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/Style/Editor-Theme-Light.xshd b/Bloxstrap/UI/Style/Editor-Theme-Light.xshd
new file mode 100644
index 0000000..8f0bdef
--- /dev/null
+++ b/Bloxstrap/UI/Style/Editor-Theme-Light.xshd
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <!--
+ -->
+
+
+ <!\[CDATA\[
+ ]]>
+
+
+ <!DOCTYPE
+ >
+
+
+ <\?
+ \?>
+
+
+ <
+ >
+
+
+
+ "
+ "|(?=<)
+
+
+ '
+ '|(?=<)
+
+ [\d\w_\-\.]+(?=(\s*=))
+ =
+
+
+
+
+
+
+
+ &
+ [\w\d\#]+
+ ;
+
+
+
+ &
+ [\w\d\#]*
+ #missing ;
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/Style/Light.xaml b/Bloxstrap/UI/Style/Light.xaml
new file mode 100644
index 0000000..fcc308d
--- /dev/null
+++ b/Bloxstrap/UI/Style/Light.xaml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Bloxstrap/UI/ViewModels/Dialogs/AddCustomThemeViewModel.cs b/Bloxstrap/UI/ViewModels/Dialogs/AddCustomThemeViewModel.cs
new file mode 100644
index 0000000..ec4282d
--- /dev/null
+++ b/Bloxstrap/UI/ViewModels/Dialogs/AddCustomThemeViewModel.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace Bloxstrap.UI.ViewModels.Dialogs
+{
+ internal class AddCustomThemeViewModel : NotifyPropertyChangedViewModel
+ {
+ public static CustomThemeTemplate[] Templates => Enum.GetValues();
+
+ public CustomThemeTemplate Template { get; set; } = CustomThemeTemplate.Simple;
+
+ public string Name { get; set; } = "";
+
+ private string _filePath = "";
+ public string FilePath
+ {
+ get => _filePath;
+ set
+ {
+ if (_filePath != value)
+ {
+ _filePath = value;
+ OnPropertyChanged(nameof(FilePath));
+ OnPropertyChanged(nameof(FilePathVisibility));
+ }
+ }
+ }
+ public Visibility FilePathVisibility => string.IsNullOrEmpty(FilePath) ? Visibility.Collapsed : Visibility.Visible;
+
+ public int SelectedTab { get; set; } = 0;
+
+ private string _nameError = "";
+ public string NameError
+ {
+ get => _nameError;
+ set
+ {
+ if (_nameError != value)
+ {
+ _nameError = value;
+ OnPropertyChanged(nameof(NameError));
+ OnPropertyChanged(nameof(NameErrorVisibility));
+ }
+ }
+ }
+ public Visibility NameErrorVisibility => string.IsNullOrEmpty(NameError) ? Visibility.Collapsed : Visibility.Visible;
+
+ private string _fileError = "";
+ public string FileError
+ {
+ get => _fileError;
+ set
+ {
+ if (_fileError != value)
+ {
+ _fileError = value;
+ OnPropertyChanged(nameof(FileError));
+ OnPropertyChanged(nameof(FileErrorVisibility));
+ }
+ }
+ }
+ public Visibility FileErrorVisibility => string.IsNullOrEmpty(FileError) ? Visibility.Collapsed : Visibility.Visible;
+ }
+}
diff --git a/Bloxstrap/UI/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs b/Bloxstrap/UI/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs
new file mode 100644
index 0000000..2e0bcb4
--- /dev/null
+++ b/Bloxstrap/UI/ViewModels/Editor/BootstrapperEditorWindowViewModel.cs
@@ -0,0 +1,84 @@
+using Bloxstrap.UI.Elements.Bootstrapper;
+using CommunityToolkit.Mvvm.Input;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Input;
+
+namespace Bloxstrap.UI.ViewModels.Editor
+{
+ public class BootstrapperEditorWindowViewModel : NotifyPropertyChangedViewModel
+ {
+ private CustomDialog? _dialog = null;
+
+ public ICommand PreviewCommand => new RelayCommand(Preview);
+ public ICommand SaveCommand => new RelayCommand(Save);
+ public ICommand OpenThemeFolderCommand => new RelayCommand(OpenThemeFolder);
+
+ public Action ThemeSavedCallback { get; set; } = null!;
+
+ public string Directory { get; set; } = "";
+
+ public string Name { get; set; } = "";
+ public string Title { get; set; } = "Editing \"Custom Theme\"";
+ public string Code { get; set; } = "";
+
+ public bool CodeChanged { get; set; } = false;
+
+ private void Preview()
+ {
+ const string LOG_IDENT = "BootstrapperEditorWindowViewModel::Preview";
+
+ try
+ {
+ CustomDialog dialog = new CustomDialog();
+
+ dialog.ApplyCustomTheme(Name, Code);
+
+ _dialog?.CloseBootstrapper();
+ _dialog = dialog;
+
+ dialog.Message = Strings.Bootstrapper_StylePreview_TextCancel;
+ dialog.CancelEnabled = true;
+ dialog.ShowBootstrapper();
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Failed to preview custom theme");
+ App.Logger.WriteException(LOG_IDENT, ex);
+
+ Frontend.ShowMessageBox($"Failed to preview theme: {ex.Message}", MessageBoxImage.Error, MessageBoxButton.OK);
+ }
+ }
+
+ private void Save()
+ {
+ const string LOG_IDENT = "BootstrapperEditorWindowViewModel::Save";
+
+ string path = Path.Combine(Directory, "Theme.xml");
+
+ try
+ {
+ File.WriteAllText(path, Code);
+ CodeChanged = false;
+ ThemeSavedCallback.Invoke(true, "Your theme has been saved!");
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteLine(LOG_IDENT, "Failed to save custom theme");
+ App.Logger.WriteException(LOG_IDENT, ex);
+
+ //Frontend.ShowMessageBox($"Failed to save theme: {ex.Message}", MessageBoxImage.Error, MessageBoxButton.OK);
+ ThemeSavedCallback.Invoke(false, ex.Message);
+ }
+ }
+
+ private void OpenThemeFolder()
+ {
+ Process.Start("explorer.exe", Directory);
+ }
+ }
+}
diff --git a/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs b/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs
index 21ae1d3..d09f23c 100644
--- a/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs
+++ b/Bloxstrap/UI/ViewModels/Settings/AppearanceViewModel.cs
@@ -4,10 +4,13 @@ using System.Windows.Controls;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
+using ICSharpCode.SharpZipLib.Zip;
using Microsoft.Win32;
using Bloxstrap.UI.Elements.Settings;
+using Bloxstrap.UI.Elements.Editor;
+using Bloxstrap.UI.Elements.Dialogs;
namespace Bloxstrap.UI.ViewModels.Settings
{
@@ -18,6 +21,12 @@ namespace Bloxstrap.UI.ViewModels.Settings
public ICommand PreviewBootstrapperCommand => new RelayCommand(PreviewBootstrapper);
public ICommand BrowseCustomIconLocationCommand => new RelayCommand(BrowseCustomIconLocation);
+ public ICommand AddCustomThemeCommand => new RelayCommand(AddCustomTheme);
+ public ICommand DeleteCustomThemeCommand => new RelayCommand(DeleteCustomTheme);
+ public ICommand RenameCustomThemeCommand => new RelayCommand(RenameCustomTheme);
+ public ICommand EditCustomThemeCommand => new RelayCommand(EditCustomTheme);
+ public ICommand ExportCustomThemeCommand => new RelayCommand(ExportCustomTheme);
+
private void PreviewBootstrapper()
{
IBootstrapperDialog dialog = App.Settings.Prop.BootstrapperStyle.GetNew();
@@ -51,6 +60,8 @@ namespace Bloxstrap.UI.ViewModels.Settings
foreach (var entry in BootstrapperIconEx.Selections)
Icons.Add(new BootstrapperIconEntry { IconType = entry });
+
+ PopulateCustomThemes();
}
public IEnumerable Themes { get; } = Enum.GetValues(typeof(Theme)).Cast();
@@ -78,9 +89,15 @@ namespace Bloxstrap.UI.ViewModels.Settings
public BootstrapperStyle Dialog
{
get => App.Settings.Prop.BootstrapperStyle;
- set => App.Settings.Prop.BootstrapperStyle = value;
+ set
+ {
+ App.Settings.Prop.BootstrapperStyle = value;
+ OnPropertyChanged(nameof(CustomThemesExpanded)); // TODO: only fire when needed
+ }
}
+ public bool CustomThemesExpanded => App.Settings.Prop.BootstrapperStyle == BootstrapperStyle.CustomDialog;
+
public ObservableCollection Icons { get; set; } = new();
public BootstrapperIcon Icon
@@ -116,5 +133,183 @@ namespace Bloxstrap.UI.ViewModels.Settings
OnPropertyChanged(nameof(Icons));
}
}
+
+ private void DeleteCustomThemeStructure(string name)
+ {
+ string dir = Path.Combine(Paths.CustomThemes, name);
+ Directory.Delete(dir, true);
+ }
+
+ private void RenameCustomThemeStructure(string oldName, string newName)
+ {
+ string oldDir = Path.Combine(Paths.CustomThemes, oldName);
+ string newDir = Path.Combine(Paths.CustomThemes, newName);
+ Directory.Move(oldDir, newDir);
+ }
+
+ private void AddCustomTheme()
+ {
+ var dialog = new AddCustomThemeDialog();
+ dialog.ShowDialog();
+
+ if (dialog.Created)
+ {
+ CustomThemes.Add(dialog.ThemeName);
+ SelectedCustomThemeIndex = CustomThemes.Count - 1;
+
+ OnPropertyChanged(nameof(SelectedCustomThemeIndex));
+ OnPropertyChanged(nameof(IsCustomThemeSelected));
+
+ if (dialog.OpenEditor)
+ EditCustomTheme();
+ }
+ }
+
+ private void DeleteCustomTheme()
+ {
+ if (SelectedCustomTheme is null)
+ return;
+
+ try
+ {
+ DeleteCustomThemeStructure(SelectedCustomTheme);
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteException("AppearanceViewModel::DeleteCustomTheme", ex);
+ Frontend.ShowMessageBox(string.Format(Strings.Menu_Appearance_CustomThemes_DeleteFailed, SelectedCustomTheme, ex.Message), MessageBoxImage.Error);
+ return;
+ }
+
+ CustomThemes.Remove(SelectedCustomTheme);
+
+ if (CustomThemes.Any())
+ {
+ SelectedCustomThemeIndex = CustomThemes.Count - 1;
+ OnPropertyChanged(nameof(SelectedCustomThemeIndex));
+ }
+
+ OnPropertyChanged(nameof(IsCustomThemeSelected));
+ }
+
+ private void RenameCustomTheme()
+ {
+ if (SelectedCustomTheme is null)
+ return;
+
+ if (SelectedCustomTheme == SelectedCustomThemeName)
+ return;
+
+ try
+ {
+ RenameCustomThemeStructure(SelectedCustomTheme, SelectedCustomThemeName);
+ }
+ catch (Exception ex)
+ {
+ App.Logger.WriteException("AppearanceViewModel::RenameCustomTheme", ex);
+ Frontend.ShowMessageBox(string.Format(Strings.Menu_Appearance_CustomThemes_RenameFailed, SelectedCustomTheme, ex.Message), MessageBoxImage.Error);
+ return;
+ }
+
+ int idx = CustomThemes.IndexOf(SelectedCustomTheme);
+ CustomThemes[idx] = SelectedCustomThemeName;
+
+ SelectedCustomThemeIndex = idx;
+ OnPropertyChanged(nameof(SelectedCustomThemeIndex));
+ }
+
+ private void EditCustomTheme()
+ {
+ if (SelectedCustomTheme is null)
+ return;
+
+ new BootstrapperEditorWindow(SelectedCustomTheme).ShowDialog();
+ }
+
+ private void ExportCustomTheme()
+ {
+ if (SelectedCustomTheme is null)
+ return;
+
+ var dialog = new SaveFileDialog
+ {
+ FileName = $"{SelectedCustomTheme}.zip",
+ Filter = $"{Strings.FileTypes_ZipArchive}|*.zip"
+ };
+
+ if (dialog.ShowDialog() != true)
+ return;
+
+ string themeDir = Path.Combine(Paths.CustomThemes, SelectedCustomTheme);
+
+ using var memStream = new MemoryStream();
+ using var zipStream = new ZipOutputStream(memStream);
+
+ foreach (var filePath in Directory.EnumerateFiles(themeDir, "*.*", SearchOption.AllDirectories))
+ {
+ string relativePath = filePath[(themeDir.Length + 1)..];
+
+ var entry = new ZipEntry(relativePath);
+ entry.DateTime = DateTime.Now;
+
+ zipStream.PutNextEntry(entry);
+
+ using var fileStream = File.OpenRead(filePath);
+ fileStream.CopyTo(zipStream);
+ }
+
+ zipStream.CloseEntry();
+ zipStream.Finish();
+ memStream.Position = 0;
+
+ using var outputStream = File.OpenWrite(dialog.FileName);
+ memStream.CopyTo(outputStream);
+
+ Process.Start("explorer.exe", $"/select,\"{dialog.FileName}\"");
+ }
+
+ private void PopulateCustomThemes()
+ {
+ string? selected = App.Settings.Prop.SelectedCustomTheme;
+
+ Directory.CreateDirectory(Paths.CustomThemes);
+
+ foreach (string directory in Directory.GetDirectories(Paths.CustomThemes))
+ {
+ if (!File.Exists(Path.Combine(directory, "Theme.xml")))
+ continue; // missing the main theme file, ignore
+
+ string name = Path.GetFileName(directory);
+ CustomThemes.Add(name);
+ }
+
+ if (selected != null)
+ {
+ int idx = CustomThemes.IndexOf(selected);
+
+ if (idx != -1)
+ {
+ SelectedCustomThemeIndex = idx;
+ OnPropertyChanged(nameof(SelectedCustomThemeIndex));
+ }
+ else
+ {
+ SelectedCustomTheme = null;
+ }
+ }
+ }
+
+ public string? SelectedCustomTheme
+ {
+ get => App.Settings.Prop.SelectedCustomTheme;
+ set => App.Settings.Prop.SelectedCustomTheme = value;
+ }
+
+ public string SelectedCustomThemeName { get; set; } = "";
+
+ public int SelectedCustomThemeIndex { get; set; }
+
+ public ObservableCollection CustomThemes { get; set; } = new();
+ public bool IsCustomThemeSelected => SelectedCustomTheme is not null;
}
}
diff --git a/Bloxstrap/UI/ViewModels/Settings/BehaviourViewModel.cs b/Bloxstrap/UI/ViewModels/Settings/BehaviourViewModel.cs
index fdbc6db..2dea65c 100644
--- a/Bloxstrap/UI/ViewModels/Settings/BehaviourViewModel.cs
+++ b/Bloxstrap/UI/ViewModels/Settings/BehaviourViewModel.cs
@@ -2,9 +2,6 @@
{
public class BehaviourViewModel : NotifyPropertyChangedViewModel
{
- private string _oldPlayerVersionGuid = "";
- private string _oldStudioVersionGuid = "";
-
public bool ConfirmLaunches
{
get => App.Settings.Prop.ConfirmLaunches;
@@ -17,26 +14,18 @@
set => App.Settings.Prop.ForceRobloxLanguage = value;
}
+ public bool BackgroundUpdates
+ {
+ get => App.Settings.Prop.BackgroundUpdatesEnabled;
+ set => App.Settings.Prop.BackgroundUpdatesEnabled = value;
+ }
+
+ public bool IsRobloxInstallationMissing => String.IsNullOrEmpty(App.RobloxState.Prop.Player.VersionGuid) && String.IsNullOrEmpty(App.RobloxState.Prop.Studio.VersionGuid);
+
public bool ForceRobloxReinstallation
{
- // wouldnt it be better to check old version guids?
- // what about fresh installs?
- get => String.IsNullOrEmpty(App.State.Prop.Player.VersionGuid) && String.IsNullOrEmpty(App.State.Prop.Studio.VersionGuid);
- set
- {
- if (value)
- {
- _oldPlayerVersionGuid = App.State.Prop.Player.VersionGuid;
- _oldStudioVersionGuid = App.State.Prop.Studio.VersionGuid;
- App.State.Prop.Player.VersionGuid = "";
- App.State.Prop.Studio.VersionGuid = "";
- }
- else
- {
- App.State.Prop.Player.VersionGuid = _oldPlayerVersionGuid;
- App.State.Prop.Studio.VersionGuid = _oldStudioVersionGuid;
- }
- }
+ get => App.State.Prop.ForceReinstall || IsRobloxInstallationMissing;
+ set => App.State.Prop.ForceReinstall = value;
}
}
}
diff --git a/Bloxstrap/Utilities.cs b/Bloxstrap/Utilities.cs
index eeea1d6..0a27315 100644
--- a/Bloxstrap/Utilities.cs
+++ b/Bloxstrap/Utilities.cs
@@ -75,10 +75,24 @@ namespace Bloxstrap
}
}
- public static string GetRobloxVersion(bool studio)
+ ///
+ /// Parses the input version string and prints if fails
+ ///
+ public static Version? ParseVersionSafe(string versionStr)
{
- IAppData data = studio ? new RobloxStudioData() : new RobloxPlayerData();
+ const string LOG_IDENT = "Utilities::ParseVersionSafe";
+ if (!Version.TryParse(versionStr, out Version? version))
+ {
+ App.Logger.WriteLine(LOG_IDENT, $"Failed to convert {versionStr} to a valid Version type.");
+ return version;
+ }
+
+ return version;
+ }
+
+ public static string GetRobloxVersionStr(IAppData data)
+ {
string playerLocation = data.ExecutablePath;
if (!File.Exists(playerLocation))
@@ -92,6 +106,19 @@ namespace Bloxstrap
return versionInfo.ProductVersion.Replace(", ", ".");
}
+ public static string GetRobloxVersionStr(bool studio)
+ {
+ IAppData data = studio ? new RobloxStudioData() : new RobloxPlayerData();
+
+ return GetRobloxVersionStr(data);
+ }
+
+ public static Version? GetRobloxVersion(IAppData data)
+ {
+ string str = GetRobloxVersionStr(data);
+ return ParseVersionSafe(str);
+ }
+
public static Process[] GetProcessesSafe()
{
const string LOG_IDENT = "Utilities::GetProcessesSafe";
@@ -107,5 +134,24 @@ namespace Bloxstrap
return Array.Empty(); // can we retry?
}
}
+
+ public static bool DoesMutexExist(string name)
+ {
+ try
+ {
+ Mutex.OpenExisting(name).Close();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public static void KillBackgroundUpdater()
+ {
+ using EventWaitHandle handle = new EventWaitHandle(false, EventResetMode.AutoReset, "Bloxstrap-BackgroundUpdaterKillEvent");
+ handle.Set();
+ }
}
}
diff --git a/Bloxstrap/Utility/Filesystem.cs b/Bloxstrap/Utility/Filesystem.cs
index 77bd284..13a9d7f 100644
--- a/Bloxstrap/Utility/Filesystem.cs
+++ b/Bloxstrap/Utility/Filesystem.cs
@@ -31,5 +31,15 @@ namespace Bloxstrap.Utility
fileInfo.IsReadOnly = false;
App.Logger.WriteLine("Filesystem::AssertReadOnly", $"The following file was set as read-only: {filePath}");
}
+
+ internal static void AssertReadOnlyDirectory(string directoryPath)
+ {
+ var directory = new DirectoryInfo(directoryPath) { Attributes = FileAttributes.Normal };
+
+ foreach (var info in directory.GetFileSystemInfos("*", SearchOption.AllDirectories))
+ info.Attributes = FileAttributes.Normal;
+
+ App.Logger.WriteLine("Filesystem::AssertReadOnlyDirectory", $"The following directory was set as read-only: {directoryPath}");
+ }
}
}
diff --git a/Bloxstrap/Utility/MD5Hash.cs b/Bloxstrap/Utility/MD5Hash.cs
index c282c0a..df49d44 100644
--- a/Bloxstrap/Utility/MD5Hash.cs
+++ b/Bloxstrap/Utility/MD5Hash.cs
@@ -25,6 +25,11 @@ namespace Bloxstrap.Utility
return FromStream(stream);
}
+ public static string FromString(string str)
+ {
+ return FromBytes(Encoding.UTF8.GetBytes(str));
+ }
+
public static string Stringify(byte[] hash)
{
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
diff --git a/Bloxstrap/Utility/PathValidator.cs b/Bloxstrap/Utility/PathValidator.cs
new file mode 100644
index 0000000..024ed09
--- /dev/null
+++ b/Bloxstrap/Utility/PathValidator.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Bloxstrap.Utility
+{
+ internal static class PathValidator
+ {
+ public enum ValidationResult
+ {
+ Ok,
+ IllegalCharacter,
+ ReservedFileName,
+ ReservedDirectoryName
+ }
+
+ private static readonly string[] _reservedNames = new string[]
+ {
+ "CON",
+ "PRN",
+ "AUX",
+ "NUL",
+ "COM1",
+ "COM2",
+ "COM3",
+ "COM4",
+ "COM5",
+ "COM6",
+ "COM7",
+ "COM8",
+ "COM9",
+ "LPT1",
+ "LPT2",
+ "LPT3",
+ "LPT4",
+ "LPT5",
+ "LPT6",
+ "LPT7",
+ "LPT8",
+ "LPT9"
+ };
+
+ private static readonly char[] _directorySeperatorDelimiters = new char[]
+ {
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ };
+
+ private static readonly char[] _invalidPathChars = GetInvalidPathChars();
+
+ public static char[] GetInvalidPathChars()
+ {
+ char[] invalids = new char[] { '/', '\\', ':', '*', '?', '"', '<', '>', '|' };
+ char[] otherInvalids = Path.GetInvalidPathChars();
+
+ char[] result = new char[invalids.Length + otherInvalids.Length];
+ invalids.CopyTo(result, 0);
+ otherInvalids.CopyTo(result, invalids.Length);
+
+ return result;
+ }
+
+ public static ValidationResult IsFileNameValid(string fileName)
+ {
+ if (fileName.IndexOfAny(_invalidPathChars) != -1)
+ return ValidationResult.IllegalCharacter;
+
+ string fileNameNoExt = Path.GetFileNameWithoutExtension(fileName).ToUpperInvariant();
+ if (_reservedNames.Contains(fileNameNoExt))
+ return ValidationResult.ReservedFileName;
+
+ return ValidationResult.Ok;
+ }
+
+ public static ValidationResult IsPathValid(string path)
+ {
+ string? pathRoot = Path.GetPathRoot(path);
+ string pathNoRoot = pathRoot != null ? path[pathRoot.Length..] : path;
+
+ string[] pathParts = pathNoRoot.Split(_directorySeperatorDelimiters);
+
+ foreach (var part in pathParts)
+ {
+ if (part.IndexOfAny(_invalidPathChars) != -1)
+ return ValidationResult.IllegalCharacter;
+
+ if (_reservedNames.Contains(part))
+ return ValidationResult.ReservedDirectoryName;
+ }
+
+ string fileName = Path.GetFileName(path);
+ if (fileName.IndexOfAny(_invalidPathChars) != -1)
+ return ValidationResult.IllegalCharacter;
+
+ string fileNameNoExt = Path.GetFileNameWithoutExtension(path).ToUpperInvariant();
+ if (_reservedNames.Contains(fileNameNoExt))
+ return ValidationResult.ReservedFileName;
+
+ return ValidationResult.Ok;
+ }
+ }
+}
diff --git a/wpfui b/wpfui
index 9080158..dca423b 160000
--- a/wpfui
+++ b/wpfui
@@ -1 +1 @@
-Subproject commit 9080158ba8d496501146d1167aae910898eff9af
+Subproject commit dca423b724ec24bd3377da3a27f4055ae317b50a