Compare commits

..

20 Commits

Author SHA1 Message Date
bluepilledgreat
4a0aa7e265 update button margin on appearance page
Some checks failed
CI (Debug) / build (push) Has been cancelled
CI (Release) / build (push) Has been cancelled
CI (Release) / release (push) Has been cancelled
CI (Release) / release-test (push) Has been cancelled
2025-03-11 19:12:26 +00:00
bluepilledgreat
0d04a36590 change icons on appearance page 2025-03-11 19:11:25 +00:00
bluepilledgreat
800f49b0c8 change customtheme error strings namespace 2025-03-11 19:07:28 +00:00
bluepilledgreat
19c82c66d0 localise appearance menu page 2025-03-11 19:01:29 +00:00
bluepilledgreat
667fa5c1db localise frontend 2025-03-11 18:53:57 +00:00
bluepilledgreat
f873b7785e localise custom theme add dialog 2025-03-11 18:50:09 +00:00
bluepilledgreat
3cfa9a0aad localise custom theme editor 2025-03-11 18:41:03 +00:00
bluepilledgreat
94ed521d31 localise CustomDialog exceptions 2025-03-11 18:29:20 +00:00
bluepilledgreat
a4a82e1057 Merge branch 'main' into feature/custom-bootstrappers 2025-03-11 11:57:28 +00:00
bluepilledgreat
71667c6c11 update version number 2025-03-11 11:44:41 +00:00
bluepilledgreat
1b7c8b2b3a add an export theme button 2025-03-11 11:37:28 +00:00
bluepilledgreat
239a0e3e1d add the import dialog 2025-03-11 11:21:07 +00:00
Matt
33243bfd0a
Improve version cleanup (#4810)
Some checks are pending
CI (Debug) / build (push) Waiting to run
CI (Release) / build (push) Waiting to run
CI (Release) / release (push) Blocked by required conditions
CI (Release) / release-test (push) Blocked by required conditions
* more safely delete the roblox instance

* assertreadonly the whole directory
2025-03-11 11:00:23 +00:00
Matt
2acd0162fb
Fix font related crashes (#4729)
* don't crash if mod or package extraction fails

* add to strings
2025-03-11 10:14:44 +00:00
bluepilledgreat
552f2a52a6 bump version 2025-03-02 13:07:52 +00:00
Matt
12af23b261
bump package versions (#4739) 2025-03-02 13:06:48 +00:00
Ryan Luu
f0df6153e3
Add repro section to bug report (#3765) 2025-03-01 15:46:24 +00:00
Matt
1258dc3589
remove behind production check for channels (#4730) 2025-03-01 15:44:06 +00:00
Matt
7977dba498
Add Windows 7 & 8.1 deprecation message (#4365)
* add windows 7 & 8.1 deprecation message

* remove deprecation bypass setting
2025-03-01 13:59:42 +00:00
Matt
4785464332
fix client-version fallback not handling channel errors (#4378) 2025-01-23 22:55:37 +00:00
27 changed files with 1611 additions and 126 deletions

View File

@ -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:

View File

@ -181,6 +181,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 +229,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 +310,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

View File

@ -7,8 +7,8 @@
<UseWPF>true</UseWPF>
<UseWindowsForms>True</UseWindowsForms>
<ApplicationIcon>Bloxstrap.ico</ApplicationIcon>
<Version>2.8.6</Version>
<FileVersion>2.8.6</FileVersion>
<Version>2.9.0</Version>
<FileVersion>2.9.0</FileVersion>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
@ -31,7 +31,8 @@
<ItemGroup>
<EmbeddedResource Include="Resources\CustomBootstrapperSchema.json" />
<EmbeddedResource Include="Resources\CustomBootstrapperTemplate.xml" />
<EmbeddedResource Include="Resources\CustomBootstrapperTemplate_Blank.xml" />
<EmbeddedResource Include="Resources\CustomBootstrapperTemplate_Simple.xml" />
<EmbeddedResource Include="Resources\Icon2008.ico" />
<EmbeddedResource Include="Resources\Icon2011.ico" />
<EmbeddedResource Include="Resources\Icon2017.ico" />
@ -54,10 +55,10 @@
<ItemGroup>
<PackageReference Include="AvalonEdit" Version="6.3.0.90" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106">
<PackageReference Include="Markdig" Version="0.40.0" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.183">
<!--<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>-->
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -58,6 +58,7 @@ 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 _noConnection = false;
@ -78,7 +79,15 @@ 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;
@ -179,6 +188,8 @@ 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
@ -221,6 +232,8 @@ namespace Bloxstrap
}
}
bool allModificationsApplied = true;
if (!_noConnection)
{
if (AppData.State.VersionGuid != _latestVersionGuid || _mustUpgrade)
@ -231,7 +244,7 @@ namespace Bloxstrap
// 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 +258,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();
@ -303,14 +324,6 @@ namespace Bloxstrap
clientVersion = await Deployment.GetInfo();
}
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;
@ -649,7 +662,28 @@ 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";
@ -659,6 +693,13 @@ namespace Bloxstrap
if (dirName != App.State.Prop.Player.VersionGuid && dirName != App.State.Prop.Studio.VersionGuid)
{
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);
@ -927,10 +968,12 @@ namespace Bloxstrap
_isInstalling = false;
}
private async Task ApplyModifications()
private async Task<bool> ApplyModifications()
{
const string LOG_IDENT = "Bootstrapper::ApplyModifications";
bool success = true;
SetStatus(Strings.Bootstrapper_Status_ApplyingModifications);
// handle file mods
@ -1006,7 +1049,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 +1081,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
@ -1088,7 +1139,7 @@ namespace Bloxstrap
if (package is not null)
{
if (_cancelTokenSource.IsCancellationRequested)
return;
return true;
await DownloadPackage(package);
ExtractPackage(package, entry.Value);
@ -1099,6 +1150,11 @@ namespace Bloxstrap
App.State.Save();
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)

View File

@ -0,0 +1,8 @@
namespace Bloxstrap.Enums
{
public enum CustomThemeTemplate
{
Blank,
Simple
}
}

View File

@ -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
{
/// <summary>
/// The exception message in English (for logging)
/// </summary>
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();
}
}
}

View File

@ -0,0 +1,10 @@
namespace Bloxstrap.Extensions
{
static class CustomThemeTemplateEx
{
public static string GetFileName(this CustomThemeTemplate template)
{
return $"CustomBootstrapperTemplate_{template}.xml";
}
}
}

View File

@ -12,7 +12,5 @@
public string BootstrapperVersion { get; set; } = null!;
public DateTime? Timestamp { get; set; }
public bool IsBehindDefaultChannel { get; set; }
}
}

View File

@ -1,4 +1,4 @@
<BloxstrapCustomBootstrapper Version="0" Height="320" Width="500">
<BloxstrapCustomBootstrapper Version="1" Height="320" Width="500">
<!-- Put UI elements here -->
<!-- Examples of custom bootstrappers can be found at https://github.com/bloxstraplabs/custom-bootstrapper-examples -->
</BloxstrapCustomBootstrapper>

View File

@ -0,0 +1,9 @@
<BloxstrapCustomBootstrapper Version="1" Height="320" Width="520" IgnoreTitleBarInset="True" Theme="Default" Margin="30">
<!-- Find more custom bootstrapper examples at https://github.com/bloxstraplabs/custom-bootstrapper-examples -->
<TitleBar Title="" ShowMinimize="False" ShowClose="False" />
<Image Source="{Icon}" Height="100" Width="100" HorizontalAlignment="Center" Margin="0,15,0,0" />
<TextBlock HorizontalAlignment="Center" Name="StatusText" FontSize="20" Margin="0,170,0,0" />
<ProgressBar Width="450" Height="12" Name="PrimaryProgressBar" HorizontalAlignment="Center" Margin="0,200,0,0" />
<Button Content="Cancel" Name="CancelButton" HorizontalAlignment="Center" Margin="0,225,0,0" Height="30" Width="100" />
</BloxstrapCustomBootstrapper>

View File

@ -151,6 +151,15 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// 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..
/// </summary>
public static string App_OSDeprecation_Win7_81 {
get {
return ResourceManager.GetString("App.OSDeprecation.Win7_81", resourceCulture);
}
}
/// <summary>
/// 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..
/// </summary>
@ -169,6 +178,24 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Some content may be missing. Force a Roblox reinstallation in settings to fix this..
/// </summary>
public static string Bootstrapper_ExtractionFailed_Message {
get {
return ResourceManager.GetString("Bootstrapper.ExtractionFailed.Message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to extract all files.
/// </summary>
public static string Bootstrapper_ExtractionFailed_Title {
get {
return ResourceManager.GetString("Bootstrapper.ExtractionFailed.Title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Bloxstrap tried to upgrade Roblox but can&apos;t because Roblox&apos;s files are still in use.
///
@ -198,6 +225,24 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Not all modifications will be present in the current launch..
/// </summary>
public static string Bootstrapper_ModificationsFailed_Message {
get {
return ResourceManager.GetString("Bootstrapper.ModificationsFailed.Message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to apply all modifications.
/// </summary>
public static string Bootstrapper_ModificationsFailed_Title {
get {
return ResourceManager.GetString("Bootstrapper.ModificationsFailed.Title", resourceCulture);
}
}
/// <summary>
/// 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..
/// </summary>
@ -396,6 +441,15 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Create New.
/// </summary>
public static string Common_CreateNew {
get {
return ResourceManager.GetString("Common.CreateNew", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Custom.
/// </summary>
@ -432,6 +486,15 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Edit.
/// </summary>
public static string Common_Edit {
get {
return ResourceManager.GetString("Common.Edit", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Export.
/// </summary>
@ -450,6 +513,15 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Import.
/// </summary>
public static string Common_Import {
get {
return ResourceManager.GetString("Common.Import", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import from file.
/// </summary>
@ -585,6 +657,15 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Rename.
/// </summary>
public static string Common_Rename {
get {
return ResourceManager.GetString("Common.Rename", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Reset.
/// </summary>
@ -639,6 +720,15 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Template.
/// </summary>
public static string Common_Template {
get {
return ResourceManager.GetString("Common.Template", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Value.
/// </summary>
@ -802,6 +892,385 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to File must be a ZIP.
/// </summary>
public static string CustomTheme_Add_Errors_FileNotZip {
get {
return ResourceManager.GetString("CustomTheme.Add.Errors.FileNotZip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Name cannot be empty.
/// </summary>
public static string CustomTheme_Add_Errors_NameEmpty {
get {
return ResourceManager.GetString("CustomTheme.Add.Errors.NameEmpty", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Name contains illegal characters.
/// </summary>
public static string CustomTheme_Add_Errors_NameIllegalCharacters {
get {
return ResourceManager.GetString("CustomTheme.Add.Errors.NameIllegalCharacters", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Name cannot be used.
/// </summary>
public static string CustomTheme_Add_Errors_NameReserved {
get {
return ResourceManager.GetString("CustomTheme.Add.Errors.NameReserved", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Name is already in use.
/// </summary>
public static string CustomTheme_Add_Errors_NameTaken {
get {
return ResourceManager.GetString("CustomTheme.Add.Errors.NameTaken", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unknown error.
/// </summary>
public static string CustomTheme_Add_Errors_Unknown {
get {
return ResourceManager.GetString("CustomTheme.Add.Errors.Unknown", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Invalid or corrupted ZIP file.
/// </summary>
public static string CustomTheme_Add_Errors_ZipInvalidData {
get {
return ResourceManager.GetString("CustomTheme.Add.Errors.ZipInvalidData", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Theme file could not be found in the ZIP file.
/// </summary>
public static string CustomTheme_Add_Errors_ZipMissingThemeFile {
get {
return ResourceManager.GetString("CustomTheme.Add.Errors.ZipMissingThemeFile", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add Custom Theme.
/// </summary>
public static string CustomTheme_Add_Title {
get {
return ResourceManager.GetString("CustomTheme.Add.Title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save changes to {0}?.
/// </summary>
public static string CustomTheme_Editor_ConfirmSave {
get {
return ResourceManager.GetString("CustomTheme.Editor.ConfirmSave", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open Theme Directory.
/// </summary>
public static string CustomTheme_Editor_OpenThemeDirectory {
get {
return ResourceManager.GetString("CustomTheme.Editor.OpenThemeDirectory", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Preview.
/// </summary>
public static string CustomTheme_Editor_Preview {
get {
return ResourceManager.GetString("CustomTheme.Editor.Preview", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save.
/// </summary>
public static string CustomTheme_Editor_Save {
get {
return ResourceManager.GetString("CustomTheme.Editor.Save", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to An error occurred while saving your theme..
/// </summary>
public static string CustomTheme_Editor_Save_Error {
get {
return ResourceManager.GetString("CustomTheme.Editor.Save.Error", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Theme successfully saved!.
/// </summary>
public static string CustomTheme_Editor_Save_Success {
get {
return ResourceManager.GetString("CustomTheme.Editor.Save.Success", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Editing &quot;{0}&quot;.
/// </summary>
public static string CustomTheme_Editor_Title {
get {
return ResourceManager.GetString("CustomTheme.Editor.Title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Custom dialog has already been initialised.
/// </summary>
public static string CustomTheme_Errors_DialogAlreadyInitialised {
get {
return ResourceManager.GetString("CustomTheme.Errors.DialogAlreadyInitialised", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}.{1} uses blacklisted scheme {2}.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeBlacklistedUriScheme {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeBlacklistedUriScheme", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} has invalid {1}: {2}.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeConversionError {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeConversionError", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} {1} is not a valid {2}.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeInvalidType {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeInvalidType", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Element {0} is missing the {1} attribute.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeMissing {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMissing", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}.{1} is missing it&apos;s child.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeMissingChild {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMissingChild", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}.{1} can only have one child.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeMultipleChildren {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMultipleChildren", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} can only have one {1} defined.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeMultipleDefinitions {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMultipleDefinitions", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} {1} must be larger than {2}.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeMustBeLargerThanMin {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMustBeLargerThanMin", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} {1} must be smaller than {2}.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeMustBeSmallerThanMax {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeMustBeSmallerThanMax", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}.{1} could not be parsed into a {2}.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeParseError {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeParseError", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}.{1} {2} is null.
/// </summary>
public static string CustomTheme_Errors_ElementAttributeParseErrorNull {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementAttributeParseErrorNull", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} cannot have a child of {1}.
/// </summary>
public static string CustomTheme_Errors_ElementInvalidChild {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementInvalidChild", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} can only have one child.
/// </summary>
public static string CustomTheme_Errors_ElementMultipleChildren {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementMultipleChildren", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} failed to create {1}: {2}.
/// </summary>
public static string CustomTheme_Errors_ElementTypeCreationFailed {
get {
return ResourceManager.GetString("CustomTheme.Errors.ElementTypeCreationFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Theme XML root is not {0}.
/// </summary>
public static string CustomTheme_Errors_InvalidRoot {
get {
return ResourceManager.GetString("CustomTheme.Errors.InvalidRoot", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No custom theme selected.
/// </summary>
public static string CustomTheme_Errors_NoThemeSelected {
get {
return ResourceManager.GetString("CustomTheme.Errors.NoThemeSelected", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to setup custom bootstrapper: {0}.
///Defaulting to Fluent..
/// </summary>
public static string CustomTheme_Errors_SetupFailed {
get {
return ResourceManager.GetString("CustomTheme.Errors.SetupFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Custom bootstrappers can only have a maximum of {0} elements, got {1}..
/// </summary>
public static string CustomTheme_Errors_TooManyElements {
get {
return ResourceManager.GetString("CustomTheme.Errors.TooManyElements", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unknown element {0}.
/// </summary>
public static string CustomTheme_Errors_UnknownElement {
get {
return ResourceManager.GetString("CustomTheme.Errors.UnknownElement", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} Unknown {1} {2}.
/// </summary>
public static string CustomTheme_Errors_UnknownEnumValue {
get {
return ResourceManager.GetString("CustomTheme.Errors.UnknownEnumValue", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} version is not a number.
/// </summary>
public static string CustomTheme_Errors_VersionNotNumber {
get {
return ResourceManager.GetString("CustomTheme.Errors.VersionNotNumber", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} version {1} is not recognised.
/// </summary>
public static string CustomTheme_Errors_VersionNotRecognised {
get {
return ResourceManager.GetString("CustomTheme.Errors.VersionNotRecognised", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} version is not set.
/// </summary>
public static string CustomTheme_Errors_VersionNotSet {
get {
return ResourceManager.GetString("CustomTheme.Errors.VersionNotSet", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} version {1} is no longer supported.
/// </summary>
public static string CustomTheme_Errors_VersionNotSupported {
get {
return ResourceManager.GetString("CustomTheme.Errors.VersionNotSupported", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to parse the theme file: {0}.
/// </summary>
public static string CustomTheme_Errors_XMLParseFailed {
get {
return ResourceManager.GetString("CustomTheme.Errors.XMLParseFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add Fast Flag.
/// </summary>
@ -1170,6 +1639,24 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Blank.
/// </summary>
public static string Enums_CustomThemeTemplate_Blank {
get {
return ResourceManager.GetString("Enums.CustomThemeTemplate.Blank", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Simple.
/// </summary>
public static string Enums_CustomThemeTemplate_Simple {
get {
return ResourceManager.GetString("Enums.CustomThemeTemplate.Simple", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Catmoji.
/// </summary>
@ -1923,6 +2410,33 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Failed to delete custom theme {0}: {1}.
/// </summary>
public static string Menu_Appearance_CustomThemes_DeleteFailed {
get {
return ResourceManager.GetString("Menu.Appearance.CustomThemes.DeleteFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No custom theme selected..
/// </summary>
public static string Menu_Appearance_CustomThemes_NoneSelected {
get {
return ResourceManager.GetString("Menu.Appearance.CustomThemes.NoneSelected", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to rename custom theme {0}: {1}.
/// </summary>
public static string Menu_Appearance_CustomThemes_RenameFailed {
get {
return ResourceManager.GetString("Menu.Appearance.CustomThemes.RenameFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Configure how Bloxstrap should look..
/// </summary>

View File

@ -1270,7 +1270,179 @@ Please close any applications that may be using Roblox's files, and relaunch.</v
<value>All Bloxstrap logs</value>
<comment>Label that appears next to a checkbox</comment>
</data>
<data name="App.OSDeprecation.Win7_81" xml:space="preserve">
<value>Roblox no longer supports Windows 7 or 8.1. To continue playing Roblox, please upgrade to Windows 10 or newer.</value>
</data>
<data name="Bootstrapper.ExtractionFailed.Title" xml:space="preserve">
<value>Failed to extract all files</value>
</data>
<data name="Bootstrapper.ExtractionFailed.Message" xml:space="preserve">
<value>Some content may be missing. Force a Roblox reinstallation in settings to fix this.</value>
</data>
<data name="Bootstrapper.ModificationsFailed.Title" xml:space="preserve">
<value>Failed to apply all modifications</value>
</data>
<data name="Bootstrapper.ModificationsFailed.Message" xml:space="preserve">
<value>Not all modifications will be present in the current launch.</value>
</data>
<data name="Menu.About.Licenses.Apache" xml:space="preserve">
<value>Apache License 2.0</value>
</data>
<data name="Enums.CustomThemeTemplate.Blank" xml:space="preserve">
<value>Blank</value>
</data>
<data name="Enums.CustomThemeTemplate.Simple" xml:space="preserve">
<value>Simple</value>
</data>
<data name="CustomTheme.Errors.InvalidRoot" xml:space="preserve">
<value>Theme XML root is not {0}</value>
</data>
<data name="CustomTheme.Errors.DialogAlreadyInitialised" xml:space="preserve">
<value>Custom dialog has already been initialised</value>
</data>
<data name="CustomTheme.Errors.TooManyElements" xml:space="preserve">
<value>Custom bootstrappers can only have a maximum of {0} elements, got {1}.</value>
</data>
<data name="CustomTheme.Errors.VersionNotSet" xml:space="preserve">
<value>{0} version is not set</value>
</data>
<data name="CustomTheme.Errors.VersionNotNumber" xml:space="preserve">
<value>{0} version is not a number</value>
</data>
<data name="CustomTheme.Errors.VersionNotSupported" xml:space="preserve">
<value>{0} version {1} is no longer supported</value>
</data>
<data name="CustomTheme.Errors.VersionNotRecognised" xml:space="preserve">
<value>{0} version {1} is not recognised</value>
</data>
<data name="CustomTheme.Errors.ElementInvalidChild" xml:space="preserve">
<value>{0} cannot have a child of {1}</value>
</data>
<data name="CustomTheme.Errors.UnknownElement" xml:space="preserve">
<value>Unknown element {0}</value>
</data>
<data name="CustomTheme.Errors.XMLParseFailed" xml:space="preserve">
<value>Failed to parse the theme file: {0}</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeConversionError" xml:space="preserve">
<value>{0} has invalid {1}: {2}</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeMissing" xml:space="preserve">
<value>Element {0} is missing the {1} attribute</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeInvalidType" xml:space="preserve">
<value>{0} {1} is not a valid {2}</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeMustBeLargerThanMin" xml:space="preserve">
<value>{0} {1} must be larger than {2}</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeMustBeSmallerThanMax" xml:space="preserve">
<value>{0} {1} must be smaller than {2}</value>
</data>
<data name="CustomTheme.Errors.UnknownEnumValue" xml:space="preserve">
<value>{0} Unknown {1} {2}</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeMultipleDefinitions" xml:space="preserve">
<value>{0} can only have one {1} defined</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeMultipleChildren" xml:space="preserve">
<value>{0}.{1} can only have one child</value>
</data>
<data name="CustomTheme.Errors.ElementMultipleChildren" xml:space="preserve">
<value>{0} can only have one child</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeMissingChild" xml:space="preserve">
<value>{0}.{1} is missing it's child</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeParseError" xml:space="preserve">
<value>{0}.{1} could not be parsed into a {2}</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeParseErrorNull" xml:space="preserve">
<value>{0}.{1} {2} is null</value>
</data>
<data name="CustomTheme.Errors.ElementAttributeBlacklistedUriScheme" xml:space="preserve">
<value>{0}.{1} uses blacklisted scheme {2}</value>
</data>
<data name="CustomTheme.Errors.ElementTypeCreationFailed" xml:space="preserve">
<value>{0} failed to create {1}: {2}</value>
</data>
<data name="CustomTheme.Editor.Title" xml:space="preserve">
<value>Editing "{0}"</value>
</data>
<data name="CustomTheme.Editor.Save.Success" xml:space="preserve">
<value>Theme successfully saved!</value>
</data>
<data name="CustomTheme.Editor.Save.Error" xml:space="preserve">
<value>An error occurred while saving your theme.</value>
</data>
<data name="CustomTheme.Editor.ConfirmSave" xml:space="preserve">
<value>Save changes to {0}?</value>
</data>
<data name="CustomTheme.Editor.Save" xml:space="preserve">
<value>Save</value>
</data>
<data name="CustomTheme.Editor.Preview" xml:space="preserve">
<value>Preview</value>
</data>
<data name="CustomTheme.Editor.OpenThemeDirectory" xml:space="preserve">
<value>Open Theme Directory</value>
</data>
<data name="Common.CreateNew" xml:space="preserve">
<value>Create New</value>
</data>
<data name="Common.Import" xml:space="preserve">
<value>Import</value>
</data>
<data name="CustomTheme.Add.Title" xml:space="preserve">
<value>Add Custom Theme</value>
</data>
<data name="Common.Template" xml:space="preserve">
<value>Template</value>
</data>
<data name="CustomTheme.Add.Errors.NameEmpty" xml:space="preserve">
<value>Name cannot be empty</value>
</data>
<data name="CustomTheme.Add.Errors.NameIllegalCharacters" xml:space="preserve">
<value>Name contains illegal characters</value>
</data>
<data name="CustomTheme.Add.Errors.NameReserved" xml:space="preserve">
<value>Name cannot be used</value>
</data>
<data name="CustomTheme.Add.Errors.Unknown" xml:space="preserve">
<value>Unknown error</value>
</data>
<data name="CustomTheme.Add.Errors.NameTaken" xml:space="preserve">
<value>Name is already in use</value>
</data>
<data name="CustomTheme.Add.Errors.FileNotZip" xml:space="preserve">
<value>File must be a ZIP</value>
</data>
<data name="CustomTheme.Add.Errors.ZipMissingThemeFile" xml:space="preserve">
<value>Theme file could not be found in the ZIP file</value>
</data>
<data name="CustomTheme.Add.Errors.ZipInvalidData" xml:space="preserve">
<value>Invalid or corrupted ZIP file</value>
</data>
<data name="CustomTheme.Errors.NoThemeSelected" xml:space="preserve">
<value>No custom theme selected</value>
</data>
<data name="CustomTheme.Errors.SetupFailed" xml:space="preserve">
<value>Failed to setup custom bootstrapper: {0}.
Defaulting to Fluent.</value>
</data>
<data name="Menu.Appearance.CustomThemes.NoneSelected" xml:space="preserve">
<value>No custom theme selected.</value>
</data>
<data name="Common.Rename" xml:space="preserve">
<value>Rename</value>
</data>
<data name="Common.Edit" xml:space="preserve">
<value>Edit</value>
</data>
<data name="Menu.Appearance.CustomThemes.DeleteFailed" xml:space="preserve">
<value>Failed to delete custom theme {0}: {1}</value>
</data>
<data name="Menu.Appearance.CustomThemes.RenameFailed" xml:space="preserve">
<value>Failed to rename custom theme {0}: {1}</value>
</data>
</root>

View File

@ -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<ClientVersion>("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<ClientVersion>("https://clientsettings.roblox.com" + path);
}
catch (HttpRequestException httpEx)
when (!isDefaultChannel && BadChannelCodes.Contains(httpEx.StatusCode))
{
throw new InvalidChannelException(httpEx.StatusCode);
}
}
ClientVersionCache[cacheKey] = clientVersion;

View File

@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Media;
using System.Xml;
using System.Xml.Linq;
namespace Bloxstrap.UI.Elements.Bootstrapper
@ -37,7 +38,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
}
catch (Exception ex)
{
throw new Exception($"{xmlElement.Name} has invalid {attributeName}: {ex.Message}", ex);
throw new CustomThemeException(ex, "CustomTheme.Errors.ElementAttributeConversionError", xmlElement.Name, attributeName, ex.Message);
}
}
@ -82,7 +83,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
}
catch (Exception ex)
{
throw new Exception($"{element.Name} has invalid {attributeName}: {ex.Message}", ex);
throw new CustomThemeException(ex, "CustomTheme.Errors.ElementAttributeConversionError", element.Name, attributeName, ex.Message);
}
}
}

View File

@ -5,7 +5,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
{
public partial class CustomDialog
{
const int Version = 0;
const int Version = 1;
private class DummyFrameworkElement : FrameworkElement { }
@ -59,11 +59,11 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
private static T HandleXml<T>(CustomDialog dialog, XElement xmlElement) where T : class
{
if (!_elementHandlerMap.ContainsKey(xmlElement.Name.ToString()))
throw new Exception($"Unknown element {xmlElement.Name}");
throw new CustomThemeException("CustomTheme.Errors.UnknownElement", xmlElement.Name);
var element = _elementHandlerMap[xmlElement.Name.ToString()](dialog, xmlElement);
if (element is not T)
throw new Exception($"{xmlElement.Parent!.Name} cannot have a child of {xmlElement.Name}");
throw new CustomThemeException("CustomTheme.Errors.ElementInvalidChild", xmlElement.Parent!.Name, xmlElement.Name);
return (T)element;
}
@ -78,19 +78,37 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
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 Exception("Custom dialog has already been initialised");
throw new CustomThemeException("CustomTheme.Errors.DialogAlreadyInitialised");
if (xml.Name != "BloxstrapCustomBootstrapper")
throw new Exception("XML root is not a BloxstrapCustomBootstrapper");
throw new CustomThemeException("CustomTheme.Errors.InvalidRoot", "BloxstrapCustomBootstrapper");
if (xml.Attribute("Version")?.Value != Version.ToString())
throw new Exception("Unknown BloxstrapCustomBootstrapper version");
AssertThemeVersion(xml.Attribute("Version")?.Value);
if (xml.Descendants().Count() > MaxElements)
throw new Exception($"Custom bootstrappers can have a maximum of {MaxElements} elements");
throw new CustomThemeException("CustomTheme.Errors.TooManyElements", MaxElements, xml.Descendants().Count());
_initialised = true;
@ -116,7 +134,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
}
catch (Exception ex)
{
throw new Exception($"XML parse failed: {ex.Message}", ex);
throw new CustomThemeException(ex, "CustomTheme.Errors.XMLParseFailed", ex.Message);
}
HandleXmlBase(xml);

View File

@ -150,7 +150,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
}
catch (Exception ex)
{
throw new Exception($"ImageBrush Failed to create BitmapImage: {ex.Message}", ex);
throw new CustomThemeException(ex, "CustomTheme.Errors.ElementTypeCreationFailed", "Image", "BitmapImage", ex.Message);
}
imageBrush.ImageSource = bitmapImage;
@ -217,7 +217,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
var first = brushElement.FirstNode as XElement;
if (first == null)
throw new Exception($"{xmlElement.Name} {name} is missing the brush");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissingChild", xmlElement.Name, name);
var brush = HandleXml<Brush>(dialog, first);
uiElement.SetValue(dependencyProperty, brush);
@ -620,7 +620,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
}
catch (Exception ex)
{
throw new Exception($"Image Failed to create BitmapImage: {ex.Message}", ex);
throw new CustomThemeException(ex, "CustomTheme.Errors.ElementTypeCreationFailed", "Image", "BitmapImage", ex.Message);
}
image.Source = bitmapImage;
@ -693,7 +693,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
if (element.Name == "Grid.RowDefinitions")
{
if (rowsSet)
throw new Exception("Grid can only have one RowDefinitions defined");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleDefinitions", "Grid", "RowDefinitions");
rowsSet = true;
HandleXmlElement_Grid_RowDefinitions(grid, dialog, element);
@ -701,7 +701,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
else if (element.Name == "Grid.ColumnDefinitions")
{
if (columnsSet)
throw new Exception("Grid can only have one ColumnDefinitions defined");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleDefinitions", "Grid", "ColumnDefinitions");
columnsSet = true;
HandleXmlElement_Grid_ColumnDefinitions(grid, dialog, element);
@ -760,7 +760,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
if (children.Any())
{
if (children.Count() > 1)
throw new Exception("Border can only have one child");
throw new CustomThemeException("CustomTheme.Errors.ElementMultipleChildren", "Border");
border.Child = HandleXml<UIElement>(dialog, children.First());
}

View File

@ -26,7 +26,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
if (defaultValue != null)
return defaultValue;
throw new Exception($"Element {element.Name} is missing the {attributeName} attribute");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissing", element.Name, attributeName);
}
return attribute.Value.ToString();
@ -41,12 +41,12 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
if (defaultValue != null)
return (T)defaultValue;
throw new Exception($"Element {element.Name} is missing the {attributeName} attribute");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissing", element.Name, attributeName);
}
T? parsed = ConvertValue<T>(attribute.Value);
if (parsed == null)
throw new Exception($"{element.Name} {attributeName} is not a valid {typeof(T).Name}");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeInvalidType", element.Name, attributeName, typeof(T).Name);
return (T)parsed;
}
@ -63,7 +63,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
T? parsed = ConvertValue<T>(attribute.Value);
if (parsed == null)
throw new Exception($"{element.Name} {attributeName} is not a valid {typeof(T).Name}");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeInvalidType", element.Name, attributeName, typeof(T).Name);
return (T)parsed;
}
@ -71,17 +71,17 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
private static void ValidateXmlElement(string elementName, string attributeName, int value, int? min = null, int? max = null)
{
if (min != null && value < min)
throw new Exception($"{elementName} {attributeName} must be larger than {min}");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeLargerThanMin", elementName, attributeName, min);
if (max != null && value > max)
throw new Exception($"{elementName} {attributeName} must be smaller than {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 Exception($"{elementName} {attributeName} must be larger than {min}");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMustBeLargerThanMin", elementName, attributeName, min);
if (max != null && value > max)
throw new Exception($"{elementName} {attributeName} must be smaller than {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+.
@ -136,7 +136,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
return FontWeights.UltraBlack;
default:
throw new Exception($"{element.Name} Unknown FontWeight {value}");
throw new CustomThemeException("CustomTheme.Errors.UnknownEnumValue", element.Name, "FontWeight", value);
}
}
@ -158,7 +158,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
return FontStyles.Oblique;
default:
throw new Exception($"{element.Name} Unknown FontStyle {value}");
throw new CustomThemeException("CustomTheme.Errors.UnknownEnumValue", element.Name, "FontStyle", value);
}
}
@ -183,7 +183,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
return TextDecorations.Underline;
default:
throw new Exception($"{element.Name} Unknown TextDecorations {value}");
throw new CustomThemeException("CustomTheme.Errors.UnknownEnumValue", element.Name, "TextDecorations", value);
}
}
@ -205,6 +205,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
if (sourcePath == null)
return null;
// TODO: this is bad :(
return sourcePath.Replace("theme://", $"{dialog.ThemeDir}\\");
}
@ -218,13 +219,13 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
path = GetFullPath(dialog, path)!;
if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out Uri? result))
throw new Exception($"{xmlElement.Name} failed to parse {name} as Uri");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeParseError", xmlElement.Name, name, "Uri");
if (result == null)
throw new Exception($"{xmlElement.Name} {name} Uri is null");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeParseErrorNull", xmlElement.Name, name, "Uri");
if (result.Scheme != "file")
throw new Exception($"{xmlElement.Name} {name} uses blacklisted scheme {result.Scheme}");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeBlacklistedUriScheme", xmlElement.Name, name, result.Scheme);
return new GetImageSourceDataResult { Uri = result };
}
@ -234,7 +235,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
var contentAttr = xmlElement.Attribute("Content");
var contentElement = xmlElement.Element($"{xmlElement.Name}.Content");
if (contentAttr != null && contentElement != null)
throw new Exception($"{xmlElement.Name} can only have one Content defined");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleDefinitions", xmlElement.Name, "Content");
if (contentAttr != null)
return GetTranslatedText(contentAttr.Value);
@ -244,11 +245,11 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
var children = contentElement.Elements();
if (children.Count() > 1)
throw new Exception($"{xmlElement.Name}.Content can only have one child");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleChildren", xmlElement.Name, "Content");
var first = contentElement.FirstNode as XElement;
if (first == null)
throw new Exception($"{xmlElement.Name} Content is missing the content");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMissingChild", xmlElement.Name, "Content");
var uiElement = HandleXml<UIElement>(dialog, first);
return uiElement;
@ -262,7 +263,7 @@ namespace Bloxstrap.UI.Elements.Bootstrapper
var children = effectElement.Elements();
if (children.Count() > 1)
throw new Exception($"{xmlElement.Name}.Effect can only have one child");
throw new CustomThemeException("CustomTheme.Errors.ElementAttributeMultipleChildren", xmlElement.Name, "Effect");
var child = children.FirstOrDefault();
if (child == null)

View File

@ -0,0 +1,165 @@
<base:WpfUiWindow
x:Class="Bloxstrap.UI.Elements.Dialogs.AddCustomThemeDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:base="clr-namespace:Bloxstrap.UI.Elements.Base"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:Bloxstrap.UI.Elements.Dialogs"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:resources="clr-namespace:Bloxstrap.Resources"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:viewmodels="clr-namespace:Bloxstrap.UI.ViewModels.Dialogs"
Title="Add Custom Theme"
Width="480"
MinHeight="0"
d:DataContext="{d:DesignInstance viewmodels:AddCustomThemeViewModel,
IsDesignTimeCreatable=True}"
Background="{ui:ThemeResource ApplicationBackgroundBrush}"
ExtendsContentIntoTitleBar="True"
ResizeMode="NoResize"
SizeToContent="Height"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Grid>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ui:TitleBar
Title="{x:Static resources:Strings.CustomTheme_Add_Title}"
Grid.Row="0"
Grid.ColumnSpan="2"
Padding="8"
CanMaximize="False"
KeyboardNavigation.TabNavigation="None"
ShowMaximize="False"
ShowMinimize="False" />
<TabControl
x:Name="Tabs"
Grid.Row="1"
Margin="16"
SelectedIndex="{Binding Path=SelectedTab, Mode=TwoWay}">
<TabItem Header="{x:Static resources:Strings.Common_CreateNew}">
<Grid Grid.Row="1" Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid
Grid.Row="0"
Grid.ColumnSpan="2"
Margin="0,0,0,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Row="0"
Grid.Column="0"
MinWidth="100"
VerticalAlignment="Center"
Text="{x:Static resources:Strings.Common_Name}" />
<TextBox
Grid.Row="0"
Grid.Column="1"
Text="{Binding Path=Name, Mode=TwoWay}" />
<TextBlock
Grid.Row="1"
Grid.Column="1"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Text="{Binding Path=NameError, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap"
Visibility="{Binding Path=NameErrorVisibility, Mode=OneWay}" />
</Grid>
<TextBlock
Grid.Row="1"
Grid.Column="0"
MinWidth="100"
VerticalAlignment="Center"
Text="{x:Static resources:Strings.Common_Template}" />
<ComboBox
Grid.Row="1"
Grid.Column="1"
ItemsSource="{Binding Path=Templates, Mode=OneTime}"
Text="{Binding Path=Template, Mode=TwoWay}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Path=., Converter={StaticResource EnumNameConverter}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
</TabItem>
<TabItem Header="{x:Static resources:Strings.Common_Import}">
<Grid Margin="11">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
FontSize="14"
Text="{Binding Path=FilePath}"
TextAlignment="Center"
TextWrapping="Wrap"
Visibility="{Binding Path=FilePathVisibility}" />
<ui:Button
Grid.Row="1"
Margin="4"
HorizontalAlignment="Stretch"
Click="OnImportButtonClicked"
Content="{x:Static resources:Strings.Common_ImportFromFile}"
Icon="DocumentArrowUp16" />
<TextBlock
Grid.Row="2"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Text="{Binding Path=FileError}"
TextAlignment="Center"
TextWrapping="Wrap"
Visibility="{Binding Path=FileErrorVisibility}" />
</Grid>
</TabItem>
</TabControl>
<Border
Grid.Row="2"
Margin="0,10,0,0"
Padding="15"
Background="{ui:ThemeResource SolidBackgroundFillColorSecondaryBrush}">
<StackPanel
HorizontalAlignment="Right"
FlowDirection="LeftToRight"
Orientation="Horizontal">
<Button
MinWidth="100"
Click="OnOkButtonClicked"
Content="{x:Static resources:Strings.Common_OK}" />
<Button
MinWidth="100"
Margin="12,0,0,0"
Content="{x:Static resources:Strings.Common_Cancel}"
IsCancel="True" />
</StackPanel>
</Border>
</Grid>
</Grid>
</base:WpfUiWindow>

View File

@ -0,0 +1,230 @@
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
{
/// <summary>
/// Interaction logic for AddCustomThemeDialog.xaml
/// </summary>
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();
string name = $"Custom Theme {count + 1}";
// TODO: this sucks
if (File.Exists(GetThemePath(name)))
name += " " + Random.Shared.Next(1, 100000).ToString(); // 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 = Encoding.UTF8.GetString(Resource.Get(template.GetFileName()).Result);
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;
}
}
}

View File

@ -8,6 +8,7 @@
xmlns:dmodels="clr-namespace:Bloxstrap.UI.ViewModels.Editor"
xmlns:local="clr-namespace:Bloxstrap.UI.Elements.Editor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:resources="clr-namespace:Bloxstrap.Resources"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="{Binding Path=Title, Mode=OneTime}"
Width="1000"
@ -48,7 +49,7 @@
Grid.Row="2"
Margin="10"
Command="{Binding Path=OpenThemeFolderCommand, Mode=OneTime}"
Content="Open Theme Folder" />
Content="{x:Static resources:Strings.CustomTheme_Editor_OpenThemeDirectory}" />
<Grid
Grid.Row="2"
@ -63,14 +64,14 @@
Grid.Column="0"
Margin="0,0,4,0"
Command="{Binding Path=PreviewCommand, Mode=OneTime}"
Content="Preview" />
Content="{x:Static resources:Strings.CustomTheme_Editor_Preview}" />
<ui:Button
Grid.Column="1"
Margin="4,0,0,0"
Appearance="Primary"
Command="{Binding Path=SaveCommand, Mode=OneTime}"
Content="Save" />
Content="{x:Static resources:Strings.CustomTheme_Editor_Save}" />
</Grid>
<ui:Snackbar

View File

@ -147,7 +147,7 @@ namespace Bloxstrap.UI.Elements.Editor
_viewModel.ThemeSavedCallback = ThemeSavedCallback;
_viewModel.Directory = directory;
_viewModel.Name = name;
_viewModel.Title = $"Editing \"{name}\"";
_viewModel.Title = string.Format(Strings.CustomTheme_Editor_Title, name);
_viewModel.Code = themeContents;
DataContext = _viewModel;
@ -173,9 +173,9 @@ namespace Bloxstrap.UI.Elements.Editor
private void ThemeSavedCallback(bool success, string message)
{
if (success)
Snackbar.Show("Settings saved!", message, Wpf.Ui.Common.SymbolRegular.CheckmarkCircle32, Wpf.Ui.Common.ControlAppearance.Success);
Snackbar.Show(Strings.CustomTheme_Editor_Save_Success, message, Wpf.Ui.Common.SymbolRegular.CheckmarkCircle32, Wpf.Ui.Common.ControlAppearance.Success);
else
Snackbar.Show("Error", message, Wpf.Ui.Common.SymbolRegular.ErrorCircle24, Wpf.Ui.Common.ControlAppearance.Danger);
Snackbar.Show(Strings.CustomTheme_Editor_Save_Error, message, Wpf.Ui.Common.SymbolRegular.ErrorCircle24, Wpf.Ui.Common.ControlAppearance.Danger);
}
private static string ToCRLF(string text)
@ -194,7 +194,7 @@ namespace Bloxstrap.UI.Elements.Editor
if (!_viewModel.CodeChanged)
return;
var result = Frontend.ShowMessageBox($"Save changes to {_viewModel.Name}?", MessageBoxImage.Information, MessageBoxButton.YesNoCancel);
var result = Frontend.ShowMessageBox(string.Format(Strings.CustomTheme_Editor_ConfirmSave, _viewModel.Name), MessageBoxImage.Information, MessageBoxButton.YesNoCancel);
if (result == MessageBoxResult.Cancel)
{
e.Cancel = true;

View File

@ -95,16 +95,19 @@
</StackPanel.Style>
<TextBlock Text="{x:Static resources:Strings.Common_Name}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<ui:TextBox Margin="0,4,0,0" Text="{Binding SelectedCustomThemeName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Grid Margin="0,4,0,0">
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ui:Button Grid.Column="0" Margin="0,0,2,0" Icon="Add28" Content="Rename" HorizontalAlignment="Stretch" Command="{Binding RenameCustomThemeCommand, Mode=OneTime}" />
<ui:Button Grid.Column="1" Margin="2,0,0,0" Icon="Add28" Content="Edit" HorizontalAlignment="Stretch" Command="{Binding EditCustomThemeCommand, Mode=OneTime}" />
<ui:Button Grid.Column="0" Margin="0,0,4,0" Icon="Edit28" Content="{x:Static resources:Strings.Common_Rename}" HorizontalAlignment="Stretch" Command="{Binding RenameCustomThemeCommand, Mode=OneTime}" />
<ui:Button Grid.Column="1" Margin="4,0,4,0" Icon="DesktopEdit24" Content="{x:Static resources:Strings.Common_Edit}" HorizontalAlignment="Stretch" Command="{Binding EditCustomThemeCommand, Mode=OneTime}" />
<ui:Button Grid.Column="2" Margin="4,0,0,0" Icon="ArrowExportRtl24" Content="{x:Static resources:Strings.Common_Export}" HorizontalAlignment="Stretch" Command="{Binding ExportCustomThemeCommand, Mode=OneTime}" />
</Grid>
</StackPanel>
<TextBlock Grid.Row="0" Grid.RowSpan="2" Grid.Column="1" Text="No custom theme selected." TextWrapping="Wrap" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Grid.Row="0" Grid.RowSpan="2" Grid.Column="1" Text="{x:Static resources:Strings.Menu_Appearance_CustomThemes_NoneSelected}" TextWrapping="Wrap" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock.Style>
<Style>
<Style.Triggers>

View File

@ -67,7 +67,7 @@ namespace Bloxstrap.UI
try
{
if (App.Settings.Prop.SelectedCustomTheme == null)
throw new Exception("No custom theme selected");
throw new CustomThemeException("CustomTheme.Errors.NoThemeSelected");
CustomDialog dialog = new CustomDialog();
dialog.ApplyCustomTheme(App.Settings.Prop.SelectedCustomTheme);
@ -78,7 +78,7 @@ namespace Bloxstrap.UI
App.Logger.WriteException(LOG_IDENT, ex);
if (!App.LaunchSettings.QuietFlag.Active)
Frontend.ShowMessageBox($"Failed to setup custom bootstrapper: {ex.Message}.\nDefaulting to Fluent.", MessageBoxImage.Error);
ShowMessageBox(string.Format(Strings.CustomTheme_Errors_SetupFailed, ex.Message), MessageBoxImage.Error);
return GetBootstrapperDialog(BootstrapperStyle.FluentDialog);
}
@ -110,5 +110,17 @@ namespace Bloxstrap.UI
return messagebox.Result;
}));
}
public static void ShowBalloonTip(string title, string message, System.Windows.Forms.ToolTipIcon icon = System.Windows.Forms.ToolTipIcon.None, int timeout = 5)
{
var notifyIcon = new System.Windows.Forms.NotifyIcon
{
Icon = Properties.Resources.IconBloxstrap,
Text = App.ProjectName,
Visible = true
};
notifyIcon.ShowBalloonTip(timeout, title, message, icon);
}
}
}

View File

@ -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<CustomThemeTemplate>();
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;
}
}

View File

@ -4,11 +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
{
@ -23,6 +25,7 @@ namespace Bloxstrap.UI.ViewModels.Settings
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()
{
@ -131,31 +134,6 @@ namespace Bloxstrap.UI.ViewModels.Settings
}
}
private string CreateCustomThemeName()
{
int count = Directory.GetDirectories(Paths.CustomThemes).Count();
string name = $"Custom Theme {count + 1}";
// TODO: this sucks
if (Directory.Exists(Path.Combine(Paths.CustomThemes, name))) // DUCK
name += " " + Random.Shared.Next(1, 100000).ToString(); // easy
return name;
}
private void CreateCustomThemeStructure(string name)
{
string dir = Path.Combine(Paths.CustomThemes, name);
Directory.CreateDirectory(dir);
string themeFilePath = Path.Combine(dir, "Theme.xml");
string templateContent = Encoding.UTF8.GetString(Resource.Get("CustomBootstrapperTemplate.xml").Result);
File.WriteAllText(themeFilePath, templateContent);
}
private void DeleteCustomThemeStructure(string name)
{
string dir = Path.Combine(Paths.CustomThemes, name);
@ -171,24 +149,20 @@ namespace Bloxstrap.UI.ViewModels.Settings
private void AddCustomTheme()
{
string name = CreateCustomThemeName();
var dialog = new AddCustomThemeDialog();
dialog.ShowDialog();
try
if (dialog.Created)
{
CreateCustomThemeStructure(name);
}
catch (Exception ex)
{
App.Logger.WriteException("AppearanceViewModel::AddCustomTheme", ex);
Frontend.ShowMessageBox($"Failed to create custom theme: {ex.Message}", MessageBoxImage.Error);
return;
}
CustomThemes.Add(dialog.ThemeName);
SelectedCustomThemeIndex = CustomThemes.Count - 1;
CustomThemes.Add(name);
SelectedCustomThemeIndex = CustomThemes.Count - 1;
OnPropertyChanged(nameof(SelectedCustomThemeIndex));
OnPropertyChanged(nameof(IsCustomThemeSelected));
OnPropertyChanged(nameof(SelectedCustomThemeIndex));
OnPropertyChanged(nameof(IsCustomThemeSelected));
if (dialog.OpenEditor)
EditCustomTheme();
}
}
private void DeleteCustomTheme()
@ -203,7 +177,7 @@ namespace Bloxstrap.UI.ViewModels.Settings
catch (Exception ex)
{
App.Logger.WriteException("AppearanceViewModel::DeleteCustomTheme", ex);
Frontend.ShowMessageBox($"Failed to delete custom theme {SelectedCustomTheme}: {ex.Message}", MessageBoxImage.Error);
Frontend.ShowMessageBox(string.Format(Strings.Menu_Appearance_CustomThemes_DeleteFailed, SelectedCustomTheme, ex.Message), MessageBoxImage.Error);
return;
}
@ -233,7 +207,7 @@ namespace Bloxstrap.UI.ViewModels.Settings
catch (Exception ex)
{
App.Logger.WriteException("AppearanceViewModel::RenameCustomTheme", ex);
Frontend.ShowMessageBox($"Failed to rename custom theme {SelectedCustomTheme}: {ex.Message}", MessageBoxImage.Error);
Frontend.ShowMessageBox(string.Format(Strings.Menu_Appearance_CustomThemes_RenameFailed, SelectedCustomTheme, ex.Message), MessageBoxImage.Error);
return;
}
@ -252,6 +226,48 @@ namespace Bloxstrap.UI.ViewModels.Settings
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;

View File

@ -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}");
}
}
}

View File

@ -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;
}
}
}