Allow sharting of themes and layouts via mod.io

This commit is contained in:
Stone_Red
2026-10-04 19:04:50 +02:00
parent 1507f7a36d
commit 1aa06238c2
19 changed files with 1887 additions and 207 deletions
@@ -18,6 +18,7 @@ internal class MainWindowDataContext : INotifyPropertyChanged
private string? selectedLayoutName;
private bool isLoading = true;
private bool isSharing;
private string? pluginsSearchText;
public string Title =>
@@ -29,6 +30,19 @@ internal class MainWindowDataContext : INotifyPropertyChanged
public string AppName => App.AppName;
/// <summary>
/// Whether a layout or theme is currently being uploaded to mod.io.
/// </summary>
public bool IsSharing
{
get => isSharing;
set
{
isSharing = value;
OnPropertyChanged();
}
}
public DesktopMagicSettings Settings
{
get => settings;
@@ -17,9 +17,38 @@ internal class PluginManagerDataContext : INotifyPropertyChanged
private bool isLoading = true;
private bool isSearching;
private bool isAuthenticated = false;
private ModCategory category = ModCategory.Plugins;
public ObservableCollection<PluginEntryDataContext> AllPlugins { get; } = [];
public ObservableCollection<PluginEntryDataContext> InstalledPlugins { get; } = [];
// Layouts or themes, depending on the selected category.
public ObservableCollection<PluginEntryDataContext> AllShared { get; } = [];
public ObservableCollection<PluginEntryDataContext> InstalledShared { get; } = [];
public ModCategory Category
{
get => category;
set
{
category = value;
OnPropertyChanged();
OnPropertyChanged(nameof(CategoryIndex));
OnPropertyChanged(nameof(ShownAll));
OnPropertyChanged(nameof(ShownInstalled));
}
}
public int CategoryIndex
{
get => (int)category;
set => Category = (ModCategory)value;
}
public ObservableCollection<PluginEntryDataContext> ShownAll => category == ModCategory.Plugins ? AllPlugins : AllShared;
public ObservableCollection<PluginEntryDataContext> ShownInstalled => category == ModCategory.Plugins ? InstalledPlugins : InstalledShared;
public bool IsLoading
{
get => isLoading;
@@ -97,4 +126,11 @@ internal class PluginManagerDataContext : INotifyPropertyChanged
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
}
internal enum ModCategory
{
Plugins,
Layouts,
Themes
}
+1 -1
View File
@@ -49,7 +49,6 @@
<PackageReference Include="Interop.IWshRuntimeLibrary" Version="1.0.1" />
<PackageReference Include="Ical.Net" Version="4.3.1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3650.58" />
<PackageReference Include="Modio" Version="1.0.0" />
<PackageReference Include="NAudio" Version="2.2.1" />
<PackageReference Include="SkiaSharp" Version="4.148.0" />
<PackageReference Include="System.Management" Version="8.0.0" />
@@ -60,6 +59,7 @@
<ItemGroup>
<ProjectReference Include="..\DesktopMagicPluginAPI\DesktopMagic.Api.csproj" />
<ProjectReference Include="..\..\lib\modio.net\Modio\Modio.csproj" />
</ItemGroup>
<ItemGroup>
+725
View File
@@ -0,0 +1,725 @@
using DesktopMagic.Dialogs;
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using Modio;
using Modio.Filters;
using Modio.Models;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using System.Windows;
using File = System.IO.File;
using Path = System.IO.Path;
namespace DesktopMagic.Helpers;
/// <summary>
/// Shared mod.io access: authentication and sharing/syncing of layouts and themes.
/// </summary>
internal static class ModIoService
{
public const uint GameId = 5665;
public const string ApiKey = "88e6ea774c3a502b06114e7fee0829ac";
private const string Source = "ModIo";
private const int MaxSummaryLength = 250;
private static readonly HttpClient httpClient = new();
private static (string Token, uint UserId)? currentUser;
public static bool IsAuthenticated => AppSettings.ModIoAccessToken is not null;
private static DesktopMagicSettings AppSettings => Manager.Instance.Settings;
public static Client CreateClient()
{
Client.Builder builder = CreateBuilder();
if (AppSettings.ModIoAccessToken is string token)
{
_ = builder.WithToken(token);
}
return builder.Build();
}
/// <summary>
/// The shared api.mod.io host is deprecated, requests have to go to the game specific host.
/// </summary>
private static Client.Builder CreateBuilder()
{
return Client.GetBuilder(ApiKey).WithGameHost(GameId);
}
/// <summary>
/// Whether the mod is a plugin (as opposed to a shared layout or theme).
/// </summary>
public static bool IsPluginMod(Mod mod)
{
return !mod.Tags.Any(tag => tag.Name is SharePackage.LayoutTag or SharePackage.ThemeTag);
}
/// <summary>
/// Runs the e-mail security code login. Returns false when cancelled.
/// </summary>
public static async Task<bool> LogInAsync(Window? owner, string title)
{
App.Logger.LogInfo("Starting mod.io authentication", source: Source);
Client client = CreateBuilder().Build();
InputDialog inputDialog = new(GetString("enterModIoEmail"), title)
{
Owner = owner,
};
if (inputDialog.ShowDialog() != true)
{
App.Logger.LogInfo("Authentication cancelled by user", source: Source);
return false;
}
await client.Auth.RequestCode(ApiKey, inputDialog.ResponseText);
inputDialog = new(GetString("enterModIoAccessToken"), title)
{
Owner = owner,
};
if (inputDialog.ShowDialog() != true)
{
App.Logger.LogInfo("Authentication cancelled by user", source: Source);
return false;
}
AccessToken accessToken = await client.Auth.SecurityCode(ApiKey, inputDialog.ResponseText);
if (accessToken.Value is null)
{
return false;
}
AppSettings.ModIoAccessToken = accessToken.Value;
Manager.Instance.SaveSettings();
App.Logger.LogInfo("Successfully authenticated with mod.io", source: Source);
return true;
}
public static void LogOut()
{
App.Logger.LogInfo("Logging out from mod.io", source: Source);
AppSettings.ModIoAccessToken = null;
currentUser = null;
Manager.Instance.SaveSettings();
}
/// <summary>
/// Compares the local content with the last published or downloaded version.
/// </summary>
public static void RefreshShareState(IModIoShareable item)
{
item.ShareState = item.ModIo switch
{
null => ModIoShareState.NotShared,
{ IsOwner: false } => ModIoShareState.NotOwner,
{ PublishedHash: string hash } when hash == SharePackage.Build(item).Hash => ModIoShareState.UpToDate,
_ => ModIoShareState.Modified,
};
}
/// <summary>
/// Publishes the item as a new mod, uploads a new version of the linked mod, or opens the mod page when nothing changed.
/// </summary>
/// <param name="setBusy">Shows or hides the caller's waiting overlay while the upload runs.</param>
public static async Task ShareAsync(IModIoShareable item, Window? owner, Action<bool> setBusy)
{
if (item is Layout { Name: Manager.EmptyLayoutName })
{
await ShowMessageAsync(GetString("cannotShareEmptyLayout"));
return;
}
RefreshShareState(item);
if (item.ShareState == ModIoShareState.UpToDate)
{
OpenProfile(item.ModIo?.ProfileUri);
return;
}
if (!IsAuthenticated && !await LogInAsync(owner, App.AppName))
{
return;
}
SharePackage.Package package = SharePackage.Build(item);
bool isUpdate = item.ModIo is { IsOwner: true };
string? summary = null;
string? changelog = null;
if (isUpdate)
{
InputDialog changelogDialog = new(GetString("enterModIoChangelog")) { Owner = owner };
if (changelogDialog.ShowDialog() != true)
{
return;
}
changelog = changelogDialog.ResponseText.Trim();
}
else
{
string warning = GetString(item is Layout ? "modIoShareLayoutWarning" : "modIoShareThemeWarning");
if (package.LocalPlugins.Count > 0)
{
warning += Environment.NewLine + Environment.NewLine + string.Format(GetString("modIoLocalPluginsWarning"), string.Join(", ", package.LocalPlugins));
}
if (!await ConfirmAsync(warning, GetString("shareOnModIo")))
{
return;
}
InputDialog summaryDialog = new(GetString("enterModIoSummary")) { Owner = owner };
if (summaryDialog.ShowDialog() != true)
{
return;
}
summary = summaryDialog.ResponseText.Trim();
if (string.IsNullOrWhiteSpace(summary))
{
summary = item.Name;
}
if (summary.Length > MaxSummaryLength)
{
summary = summary[..MaxSummaryLength];
}
}
string workingDirectory = Path.Combine(Path.GetTempPath(), App.AppName, Guid.NewGuid().ToString());
setBusy(true);
try
{
_ = Directory.CreateDirectory(workingDirectory);
string contentDirectory = Path.Combine(workingDirectory, "content");
_ = Directory.CreateDirectory(contentDirectory);
await File.WriteAllTextAsync(Path.Combine(contentDirectory, SharePackage.ContentFileName), package.Json.ToJsonString());
string zipPath = Path.Combine(workingDirectory, "content.zip");
ZipFile.CreateFromDirectory(contentDirectory, zipPath);
string logoPath = Path.Combine(workingDirectory, "logo.png");
SharePreviewRenderer.Render(item, logoPath);
Client client = CreateClient();
ModIoLink link;
if (isUpdate)
{
link = item.ModIo!;
}
else
{
App.Logger.LogInfo($"Creating mod.io mod for \"{item.Name}\"", source: Source);
NewMod newMod = new(item.Name, summary!, new FileInfo(logoPath))
{
Visible = Modio.Models.Visibility.Public,
};
newMod.Tags.Add(SharePackage.GetTag(item));
Mod mod = await client.Games[GameId].Mods.Add(newMod);
// A downloaded item shared as a new mod no longer follows the original, which would otherwise be downloaded again.
if (item.ModIo is { IsOwner: false } originalLink)
{
await UnsubscribeAsync(originalLink.ModId);
}
// Save the link right away, so a failed upload is retried on the same mod instead of creating another one.
link = new ModIoLink
{
ModId = mod.Id,
IsOwner = true,
ProfileUri = mod.ProfileUrl,
};
item.ModIo = link;
Manager.Instance.SaveSettings();
}
App.Logger.LogInfo($"Uploading new version of \"{item.Name}\" (mod {link.ModId})", source: Source);
Modio.Models.File modfile = await client.Games[GameId].Mods[link.ModId].Files.Add(new NewFile(new FileInfo(zipPath))
{
Version = DateTime.UtcNow.ToString("yyyy.MM.dd.HHmm"),
Changelog = string.IsNullOrWhiteSpace(changelog) ? null : changelog,
Active = true,
}, new UploadOptions());
link.ModfileId = modfile.Id;
link.PublishedHash = package.Hash;
Manager.Instance.SaveSettings();
if (isUpdate)
{
await TryUpdateLogo(client, link.ModId, logoPath);
}
if (item is Layout)
{
await TryUpdateDependencies(client, link.ModId, package.RequiredPlugins);
}
RefreshShareState(item);
App.Logger.LogInfo($"Published \"{item.Name}\" on mod.io", source: Source);
setBusy(false);
if (await ConfirmAsync(string.Format(GetString(isUpdate ? "modIoUpdateSuccess" : "modIoShareSuccess"), item.Name), GetString("open"), GetString("close")))
{
OpenProfile(link.ProfileUri);
}
}
catch (UnauthorizedException ex)
{
App.Logger.LogError($"Sharing \"{item.Name}\" failed, not authorized: {ex.Message}", source: Source);
LogOut();
setBusy(false);
await ShowMessageAsync(GetString("modIoSessionExpired"));
}
catch (Exception ex)
{
App.Logger.LogError($"Sharing \"{item.Name}\" failed: {ex.Message}", source: Source);
setBusy(false);
await ShowMessageAsync(string.Format(GetString("modIoShareFailed"), ex.Message));
}
finally
{
setBusy(false);
RefreshShareState(item);
TryDeleteDirectory(workingDirectory);
}
}
/// <summary>
/// Unsubscribes from the mod an item is linked to, so deleting it locally does not bring it back on the next sync.
/// </summary>
public static async Task UnsubscribeAsync(IModIoShareable item)
{
if (item.ModIo is not null)
{
await UnsubscribeAsync(item.ModIo.ModId);
}
}
private static async Task UnsubscribeAsync(uint modId)
{
if (!IsAuthenticated)
{
return;
}
try
{
await CreateClient().Games[GameId].Mods.Unsubscribe(modId);
App.Logger.LogInfo($"Unsubscribed from mod {modId}", source: Source);
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to unsubscribe from mod {modId}: {ex.Message}", source: Source);
}
}
/// <summary>
/// Subscribes to the mods of downloaded layouts and themes, e.g. after logging in,
/// so the subscription sync keeps them instead of treating them as unsubscribed.
/// </summary>
public static async Task SubscribeToLinkedItemsAsync()
{
if (!IsAuthenticated)
{
return;
}
Client client = CreateClient();
foreach (ModIoLink link in GetSharedItems().Select(item => item.ModIo).OfType<ModIoLink>().Where(link => !link.IsOwner).ToList())
{
try
{
await client.Games[GameId].Mods.Subscribe(link.ModId);
App.Logger.LogInfo($"Subscribed to mod {link.ModId}", source: Source);
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to subscribe to mod {link.ModId}: {ex.Message}", source: Source);
}
}
}
public static IEnumerable<IModIoShareable> GetSharedItems()
{
DesktopMagicSettings settings = AppSettings;
return settings.Layouts.Cast<IModIoShareable>().Concat(settings.Themes);
}
public static IModIoShareable? FindLinkedItem(uint modId)
{
return GetSharedItems().FirstOrDefault(item => item.ModIo?.ModId == modId);
}
/// <summary>
/// Downloads a layout or theme from mod.io and subscribes to it when logged in.
/// </summary>
/// <param name="ensurePluginInstalled">Installs the mod.io plugin with the given ID if it is missing.</param>
/// <returns>Whether the item was installed.</returns>
public static async Task<bool> InstallAsync(Mod mod, Func<uint, Task> ensurePluginInstalled)
{
try
{
if (!await ApplyLatestVersionAsync(mod, FindLinkedItem(mod.Id), ensurePluginInstalled))
{
return false;
}
if (IsAuthenticated)
{
try
{
await CreateClient().Games[GameId].Mods.Subscribe(mod.Id);
App.Logger.LogInfo($"Subscribed to mod {mod.Id}", source: Source);
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to subscribe to mod {mod.Id}: {ex.Message}", source: Source);
}
}
Manager.Instance.SaveSettings();
return true;
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to install \"{mod.Name}\" (mod {mod.Id}): {ex.Message}", source: Source);
await ShowMessageAsync(string.Format(GetString("modIoInstallFailed"), mod.Name, ex.Message));
return false;
}
}
/// <summary>
/// Updates a linked layout or theme when mod.io has a newer version.
/// </summary>
/// <returns>Whether the item changed.</returns>
public static async Task<bool> UpdateAsync(IModIoShareable item, Mod mod, Func<uint, Task> ensurePluginInstalled)
{
try
{
if (!await ApplyLatestVersionAsync(mod, item, ensurePluginInstalled))
{
return false;
}
Manager.Instance.SaveSettings();
return true;
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to update \"{item.Name}\" (mod {mod.Id}): {ex.Message}", source: Source);
return false;
}
}
/// <summary>
/// Deletes a layout or theme locally after confirmation and unsubscribes from its mod.
/// The mod itself stays on mod.io.
/// </summary>
/// <returns>Whether the item was removed.</returns>
public static async Task<bool> RemoveAsync(IModIoShareable item)
{
DesktopMagicSettings settings = AppSettings;
string? blocker = item switch
{
Layout { Name: Manager.EmptyLayoutName } => "cannotDeleteEmptyLayout",
Layout when settings.Layouts.Count <= 1 => "cannotDeleteLastLayout",
Theme when settings.Themes.Count <= 1 => "cannotDeleteLastTheme",
_ => null
};
if (blocker is not null)
{
await ShowMessageAsync(GetString(blocker));
return false;
}
if (!await ConfirmAsync(GetString(item is Layout ? "confirmDeleteLayout" : "confirmDeleteTheme"), "Yes", "No"))
{
return false;
}
await UnsubscribeAsync(item);
switch (item)
{
case Layout layout:
Manager.Instance.RemoveLayout(layout);
break;
case Theme theme:
_ = settings.Themes.Remove(theme);
Manager.Instance.SaveSettings();
break;
}
App.Logger.LogInfo($"Removed \"{item.Name}\"", source: Source);
return true;
}
/// <summary>
/// Downloads subscribed layouts and themes that are new or have a newer version on mod.io.
/// </summary>
/// <param name="subscriptions">The user's subscribed mods for this game.</param>
/// <param name="ensurePluginInstalled">Installs the mod.io plugin with the given ID if it is missing.</param>
/// <returns>Whether any layout or theme changed.</returns>
public static async Task<bool> SyncSubscriptionsAsync(IReadOnlyList<Mod> subscriptions, Func<uint, Task> ensurePluginInstalled)
{
if (!IsAuthenticated)
{
return false;
}
bool changed = false;
List<Mod> sharedMods = subscriptions.Where(mod => !IsPluginMod(mod)).ToList();
App.Logger.LogInfo($"Found {sharedMods.Count} subscribed layouts and themes on mod.io", source: Source);
foreach (Mod mod in sharedMods)
{
try
{
changed |= await ApplyLatestVersionAsync(mod, FindLinkedItem(mod.Id), ensurePluginInstalled);
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to install \"{mod.Name}\" (mod {mod.Id}): {ex.Message}", source: Source);
}
}
// Downloaded items whose subscription was removed on mod.io stay as local copies.
foreach (IModIoShareable item in GetSharedItems().Where(item => item.ModIo is { IsOwner: false } && !subscriptions.Any(mod => mod.Id == item.ModIo.ModId)).ToList())
{
App.Logger.LogInfo($"\"{item.Name}\" is no longer subscribed, keeping it as a local copy", source: Source);
item.ModIo = null;
RefreshShareState(item);
changed = true;
}
if (changed)
{
Manager.Instance.SaveSettings();
}
return changed;
}
/// <summary>
/// Downloads the current version of the mod into a new item, or into <paramref name="local"/> when it is outdated.
/// Asks before overwriting local changes. Throws when the download or import fails.
/// </summary>
/// <returns>Whether anything changed.</returns>
private static async Task<bool> ApplyLatestVersionAsync(Mod mod, IModIoShareable? local, Func<uint, Task> ensurePluginInstalled)
{
if (mod.Modfile?.Download?.BinaryUrl is null)
{
throw new InvalidOperationException("The mod has no file to download.");
}
if (local?.ModIo?.ModfileId == mod.Modfile.Id)
{
return false;
}
if (local?.ModIo is ModIoLink localLink && localLink.PublishedHash != SharePackage.Build(local).Hash)
{
bool overwrite = await ConfirmAsync(string.Format(GetString("modIoUpdateConflict"), local.Name), GetString("overwrite"), GetString("keepMine"));
if (!overwrite)
{
App.Logger.LogInfo($"Keeping local changes of \"{local.Name}\" instead of updating to mod {mod.Id} version {mod.Modfile.Id}", source: Source);
if (localLink.IsOwner)
{
// The owner can publish their version over the remote one.
localLink.ModfileId = mod.Modfile.Id;
}
else
{
// Detach the local copy so it is no longer replaced by updates.
await UnsubscribeAsync(local);
local.ModIo = null;
}
return true;
}
}
App.Logger.LogInfo($"Downloading \"{mod.Name}\" (mod {mod.Id}, version {mod.Modfile.Id})", source: Source);
JsonObject content = await DownloadContentAsync(mod.Modfile.Download.BinaryUrl);
foreach (uint pluginId in SharePackage.GetRequiredPlugins(content).Where(id => !Manager.Instance.Plugins.ContainsKey(id)))
{
await ensurePluginInstalled(pluginId);
}
uint? userId = IsAuthenticated ? await GetCurrentUserIdAsync(CreateClient()) : null;
IModIoShareable item = SharePackage.Apply(content, local);
item.ModIo = new ModIoLink
{
ModId = mod.Id,
ModfileId = mod.Modfile.Id,
IsOwner = userId is not null && mod.SubmittedBy?.Id == userId,
ProfileUri = mod.ProfileUrl,
};
item.ModIo.PublishedHash = SharePackage.Build(item).Hash;
RefreshShareState(item);
App.Logger.LogInfo($"Installed \"{item.Name}\" from mod.io", source: Source);
return true;
}
private static async Task<JsonObject> DownloadContentAsync(Uri url)
{
using MemoryStream zipStream = new();
using (Stream download = await httpClient.GetStreamAsync(url))
{
await download.CopyToAsync(zipStream);
}
zipStream.Position = 0;
using ZipArchive archive = new(zipStream, ZipArchiveMode.Read);
ZipArchiveEntry entry = archive.GetEntry(SharePackage.ContentFileName) ?? throw new FormatException($"The download does not contain \"{SharePackage.ContentFileName}\".");
using StreamReader reader = new(entry.Open());
return SharePackage.Parse(await reader.ReadToEndAsync());
}
private static async Task<uint> GetCurrentUserIdAsync(Client client)
{
string token = AppSettings.ModIoAccessToken ?? string.Empty;
if (currentUser is not { } user || user.Token != token)
{
User me = await client.User.GetCurrentUser();
user = (token, me.Id);
currentUser = user;
}
return user.UserId;
}
private static async Task TryUpdateLogo(Client client, uint modId, string logoPath)
{
try
{
await client.Games[GameId].Mods[modId].AddMedia(new NewModMedia { Logo = new FileInfo(logoPath) });
}
catch (Exception ex)
{
App.Logger.LogWarn($"Failed to update logo of mod {modId}: {ex.Message}", source: Source);
}
}
private static async Task TryUpdateDependencies(Client client, uint modId, IReadOnlyList<uint> requiredPlugins)
{
try
{
DependenciesClient dependencies = client.Games[GameId].Mods[modId].Dependencies;
List<uint> current = (await dependencies.Get(false)).Select(dependency => dependency.ModId).ToList();
List<uint> removed = current.Except(requiredPlugins).ToList();
List<uint> added = requiredPlugins.Except(current).ToList();
if (removed.Count > 0)
{
await dependencies.Delete(removed);
}
if (added.Count > 0)
{
await dependencies.Add(added);
}
}
catch (Exception ex)
{
App.Logger.LogWarn($"Failed to update dependencies of mod {modId}: {ex.Message}", source: Source);
}
}
private static void OpenProfile(Uri? profileUri)
{
if (profileUri is null)
{
return;
}
_ = Process.Start(new ProcessStartInfo
{
UseShellExecute = true,
FileName = profileUri.ToString()
});
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, true);
}
}
catch (Exception ex)
{
App.Logger.LogWarn($"Failed to delete temporary directory {path}: {ex.Message}", source: Source);
}
}
private static async Task ShowMessageAsync(string content)
{
Wpf.Ui.Controls.MessageBox messageBox = new()
{
Title = App.AppName,
Content = content,
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
}
private static async Task<bool> ConfirmAsync(string content, string confirmText, string? cancelText = null)
{
Wpf.Ui.Controls.MessageBox messageBox = new()
{
Title = App.AppName,
Content = content,
PrimaryButtonText = confirmText,
CloseButtonText = cancelText ?? GetString("cancel"),
};
return await messageBox.ShowDialogAsync() == Wpf.Ui.Controls.MessageBoxResult.Primary;
}
private static string GetString(string key)
{
return App.LanguageDictionary[key] as string ?? key;
}
}
+327
View File
@@ -0,0 +1,327 @@
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace DesktopMagic.Helpers;
/// <summary>
/// Converts layouts and themes to and from the portable JSON content that is uploaded to mod.io.
/// </summary>
internal static class SharePackage
{
public const int FormatVersion = 1;
public const string LayoutTag = "Layout";
public const string ThemeTag = "Theme";
public const string ContentFileName = "content.json";
private static readonly JsonSerializerOptions jsonOptions = new()
{
Converters = { new ColorJsonConverter() }
};
/// <param name="Json">The content written to <see cref="ContentFileName"/>.</param>
/// <param name="Hash">Hash of the shared content, used to detect local changes.</param>
/// <param name="RequiredPlugins">mod.io plugin IDs the layout uses.</param>
/// <param name="LocalPlugins">Names of locally developed plugins the layout uses, which others cannot install.</param>
public sealed record Package(JsonObject Json, string Hash, IReadOnlyList<uint> RequiredPlugins, IReadOnlyList<string> LocalPlugins);
public static string GetTag(IModIoShareable item) => item is Layout ? LayoutTag : ThemeTag;
public static Package Build(IModIoShareable item)
{
return item switch
{
Layout layout => BuildLayout(layout),
Theme theme => BuildTheme(theme),
_ => throw new ArgumentException($"Unsupported item type {item.GetType().Name}", nameof(item))
};
}
private static Package BuildTheme(Theme theme)
{
JsonObject root = new()
{
["format"] = FormatVersion,
["type"] = ThemeTag,
["themes"] = new JsonArray(SerializeTheme(theme)),
};
return new Package(root, ComputeHash(root), [], []);
}
private static Package BuildLayout(Layout layout)
{
DesktopMagicSettings settings = Manager.Instance.Settings;
IReadOnlyDictionary<uint, InternalPluginData> installedPlugins = Manager.Instance.Plugins;
Layout copy = new(layout.Name)
{
ScreenAspectRatio = layout.ScreenAspectRatio,
};
List<string> themeNames = [layout.Theme.Name];
List<uint> requiredPlugins = [];
List<string> localPlugins = [];
// Only enabled widgets are shared. Disabled entries are just placeholders for every installed plugin.
foreach ((uint pluginId, PluginSettings pluginSettings) in layout.Plugins.Where(kvp => kvp.Value.Enabled).OrderBy(kvp => kvp.Key))
{
copy.Plugins[pluginId] = new PluginSettings
{
Enabled = true,
Position = pluginSettings.Position,
Size = pluginSettings.Size,
CurrentThemeName = pluginSettings.CurrentThemeName,
Settings = pluginSettings.Settings.Where(setting => setting.Shareable).ToList(),
};
if (pluginSettings.CurrentThemeName is not null && settings.Themes.Any(t => t.Name == pluginSettings.CurrentThemeName) && !themeNames.Contains(pluginSettings.CurrentThemeName))
{
themeNames.Add(pluginSettings.CurrentThemeName);
}
// Built-in plugins have no directory and are available everywhere.
if (installedPlugins.TryGetValue(pluginId, out InternalPluginData? pluginData) && !string.IsNullOrEmpty(pluginData.DirectoryPath))
{
if (pluginData.Metadata.IsLocalPlugin)
{
localPlugins.Add(pluginData.Metadata.Name);
}
else
{
requiredPlugins.Add(pluginId);
}
}
}
copy.CurrentThemeName = layout.Theme.Name;
JsonObject layoutNode = JsonSerializer.SerializeToNode(copy, jsonOptions)!.AsObject();
_ = layoutNode.Remove(nameof(Layout.ModIo));
JsonArray themesNode = [];
foreach (string themeName in themeNames)
{
Theme? theme = settings.Themes.FirstOrDefault(t => t.Name == themeName);
if (theme is not null)
{
themesNode.Add(SerializeTheme(theme));
}
}
JsonObject root = new()
{
["format"] = FormatVersion,
["type"] = LayoutTag,
["layout"] = layoutNode,
["themes"] = themesNode,
};
// Required plugins are derived from the layout and depend on what is currently loaded, so they are not part of the hash.
string hash = ComputeHash(root);
root["requiredPlugins"] = new JsonArray(requiredPlugins.Select(id => (JsonNode)id).ToArray());
return new Package(root, hash, requiredPlugins, localPlugins);
}
public static JsonObject Parse(string json)
{
JsonObject root = JsonNode.Parse(json)?.AsObject() ?? throw new FormatException("The downloaded content is empty.");
int format = root["format"]?.GetValue<int>() ?? 0;
if (format is < 1 or > FormatVersion)
{
throw new FormatException($"Unsupported content format {format}. Please update DesktopMagic.");
}
return root;
}
public static IReadOnlyList<uint> GetRequiredPlugins(JsonObject root)
{
return root["requiredPlugins"]?.AsArray().Select(node => node!.GetValue<uint>()).ToList() ?? [];
}
/// <summary>
/// Adds the downloaded content to the settings, or updates <paramref name="existing"/> with it.
/// </summary>
public static IModIoShareable Apply(JsonObject root, IModIoShareable? existing)
{
DesktopMagicSettings settings = Manager.Instance.Settings;
string type = root["type"]?.GetValue<string>() ?? string.Empty;
List<Theme> themes = root["themes"]?.AsArray()
.Select(node => node.Deserialize<Theme>(jsonOptions))
.OfType<Theme>()
.ToList() ?? [];
if (type == ThemeTag)
{
Theme incoming = themes.FirstOrDefault() ?? throw new FormatException("The downloaded theme is empty.");
incoming.ModIo = null;
if (existing is Theme existingTheme)
{
CopyThemeValues(incoming, existingTheme);
return existingTheme;
}
incoming.Name = GetUniqueName(incoming.Name, settings.Themes.Select(t => t.Name));
settings.Themes.Add(incoming);
return incoming;
}
if (type != LayoutTag)
{
throw new FormatException($"Unknown content type \"{type}\".");
}
JsonObject layoutNode = root["layout"]?.AsObject() ?? throw new FormatException("The downloaded layout is empty.");
Layout incomingLayout = layoutNode.Deserialize<Layout>(jsonOptions) ?? throw new FormatException("The downloaded layout is invalid.");
string layoutName = existing?.Name ?? GetUniqueName(incomingLayout.Name, settings.Layouts.Select(l => l.Name));
Dictionary<string, string> themeNameMap = [];
foreach (Theme theme in themes)
{
themeNameMap[theme.Name] = AddEmbeddedTheme(theme, layoutName);
}
// Read the raw name, the getter falls back to another theme when the name does not exist yet.
string? layoutThemeName = layoutNode[nameof(Layout.CurrentThemeName)]?.GetValue<string>();
string? mappedLayoutThemeName = layoutThemeName is not null && themeNameMap.TryGetValue(layoutThemeName, out string? mapped) ? mapped : layoutThemeName;
Layout target = existing as Layout ?? new Layout(layoutName);
Dictionary<uint, PluginSettings> plugins = [];
foreach ((uint pluginId, PluginSettings incomingPlugin) in incomingLayout.Plugins)
{
if (incomingPlugin.CurrentThemeName is not null && themeNameMap.TryGetValue(incomingPlugin.CurrentThemeName, out string? pluginThemeName))
{
incomingPlugin.CurrentThemeName = pluginThemeName;
}
// Keep values that are never shared (e.g. URLs or locations) and saved widget data from the local copy.
if (target.Plugins.TryGetValue(pluginId, out PluginSettings? localPlugin))
{
incomingPlugin.State = localPlugin.State;
incomingPlugin.Settings = [.. incomingPlugin.Settings, .. localPlugin.Settings.Where(local => !incomingPlugin.Settings.Exists(s => s.Id == local.Id))];
}
incomingPlugin.Owner = target;
plugins[pluginId] = incomingPlugin;
}
// Widgets the new version no longer uses are disabled, keeping their settings.
foreach ((uint pluginId, PluginSettings localPlugin) in target.Plugins.Where(kvp => !plugins.ContainsKey(kvp.Key)))
{
localPlugin.Enabled = false;
plugins[pluginId] = localPlugin;
}
target.ScreenAspectRatio = incomingLayout.ScreenAspectRatio;
target.Plugins = plugins;
target.CurrentThemeName = mappedLayoutThemeName;
if (existing is null)
{
settings.Layouts.Add(target);
}
return target;
}
/// <summary>
/// Adds a theme that is embedded in a layout and returns the local name it is available under.
/// </summary>
private static string AddEmbeddedTheme(Theme theme, string layoutName)
{
DesktopMagicSettings settings = Manager.Instance.Settings;
theme.ModIo = null;
Theme? sameName = settings.Themes.FirstOrDefault(t => t.Name == theme.Name);
if (sameName is null)
{
settings.Themes.Add(theme);
return theme.Name;
}
if (HasSameValues(sameName, theme))
{
return sameName.Name;
}
// A different theme with the same name exists (e.g. "Default"). Use a name tied to the layout,
// which later updates of the same layout overwrite instead of adding more copies.
string layoutThemeName = $"{theme.Name} ({layoutName})";
Theme? layoutTheme = settings.Themes.FirstOrDefault(t => t.Name == layoutThemeName);
if (layoutTheme is null)
{
theme.Name = layoutThemeName;
settings.Themes.Add(theme);
}
else
{
CopyThemeValues(theme, layoutTheme);
}
return layoutThemeName;
}
private static void CopyThemeValues(Theme source, Theme target)
{
target.PrimaryColor = source.PrimaryColor;
target.SecondaryColor = source.SecondaryColor;
target.BackgroundColor = source.BackgroundColor;
target.Font = source.Font;
target.CornerRadius = source.CornerRadius;
target.Margin = source.Margin;
}
private static bool HasSameValues(Theme a, Theme b)
{
JsonObject nodeA = SerializeTheme(a);
JsonObject nodeB = SerializeTheme(b);
_ = nodeA.Remove(nameof(Theme.Name));
_ = nodeB.Remove(nameof(Theme.Name));
return JsonNode.DeepEquals(nodeA, nodeB);
}
private static JsonObject SerializeTheme(Theme theme)
{
JsonObject node = JsonSerializer.SerializeToNode(theme, jsonOptions)!.AsObject();
_ = node.Remove(nameof(Theme.ModIo));
return node;
}
private static string GetUniqueName(string name, IEnumerable<string> existingNames)
{
HashSet<string> names = existingNames.ToHashSet();
if (!names.Contains(name))
{
return name;
}
int index = 2;
while (names.Contains($"{name} ({index})"))
{
index++;
}
return $"{name} ({index})";
}
private static string ComputeHash(JsonObject root)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(root.ToJsonString()));
return Convert.ToHexString(hash);
}
}
@@ -0,0 +1,148 @@
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Point = System.Windows.Point;
namespace DesktopMagic.Helpers;
/// <summary>
/// Renders the logo image used for layouts and themes on mod.io (mod.io requires one, at least 512x288).
/// </summary>
internal static class SharePreviewRenderer
{
private const int Width = 1280;
private const int Height = 720;
private static readonly Color canvasColor = Color.FromRgb(0x1C, 0x1C, 0x24);
private static readonly Color screenColor = Color.FromRgb(0x2E, 0x2E, 0x3A);
public static void Render(IModIoShareable item, string path)
{
DrawingVisual visual = new();
using (DrawingContext context = visual.RenderOpen())
{
context.DrawRectangle(new SolidColorBrush(canvasColor), null, new Rect(0, 0, Width, Height));
switch (item)
{
case Layout layout:
DrawLayout(context, layout);
break;
case Theme theme:
DrawTheme(context, theme);
break;
}
}
RenderTargetBitmap bitmap = new(Width, Height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
PngBitmapEncoder encoder = new();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using FileStream stream = File.Create(path);
encoder.Save(stream);
}
private static void DrawLayout(DrawingContext context, Layout layout)
{
const double padding = 60;
double aspectRatio = layout.ScreenAspectRatio > 0 ? layout.ScreenAspectRatio : 16.0 / 9.0;
double availableWidth = Width - (padding * 2);
double availableHeight = Height - (padding * 2);
double screenWidth = Math.Min(availableWidth, availableHeight * aspectRatio);
double screenHeight = screenWidth / aspectRatio;
Rect screen = new((Width - screenWidth) / 2, (Height - screenHeight) / 2, screenWidth, screenHeight);
context.DrawRoundedRectangle(new SolidColorBrush(screenColor), null, screen, 12, 12);
foreach ((uint pluginId, PluginSettings pluginSettings) in layout.Plugins.Where(kvp => kvp.Value.Enabled))
{
Theme theme = pluginSettings.Theme;
Rect widget = new(
screen.X + (pluginSettings.Position.X * screen.Width),
screen.Y + (pluginSettings.Position.Y * screen.Height),
Math.Max(4, pluginSettings.Size.X * screen.Width),
Math.Max(4, pluginSettings.Size.Y * screen.Height));
widget.Intersect(screen);
if (widget.IsEmpty)
{
continue;
}
Color background = MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor);
if (background.A < 0x30)
{
background = Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF);
}
Color primary = MultiColorConverter.ConvertToMediaColor(theme.PrimaryColor);
double radius = Math.Min(theme.CornerRadius * screen.Width / 1920, Math.Min(widget.Width, widget.Height) / 2);
context.DrawRoundedRectangle(new SolidColorBrush(background), new Pen(new SolidColorBrush(primary), 2), widget, radius, radius);
string name = Manager.Instance.Plugins.TryGetValue(pluginId, out InternalPluginData? pluginData) ? pluginData.Metadata.Name : pluginSettings.Metadata.Name;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
FormattedText text = CreateText(name, theme.Font, Math.Clamp(widget.Height / 4, 10, 28), primary);
text.MaxTextWidth = Math.Max(1, widget.Width - 12);
text.MaxTextHeight = Math.Max(1, widget.Height - 8);
text.Trimming = TextTrimming.CharacterEllipsis;
context.DrawText(text, new Point(widget.X + 6, widget.Y + ((widget.Height - Math.Min(text.Height, text.MaxTextHeight)) / 2)));
}
}
private static void DrawTheme(DrawingContext context, Theme theme)
{
Color background = MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor);
Color primary = MultiColorConverter.ConvertToMediaColor(theme.PrimaryColor);
Color secondary = MultiColorConverter.ConvertToMediaColor(theme.SecondaryColor);
Rect card = new(120, 100, Width - 240, Height - 200);
double radius = Math.Clamp(theme.CornerRadius * 2, 0, card.Height / 2);
context.DrawRoundedRectangle(new SolidColorBrush(screenColor), null, card, radius, radius);
context.DrawRoundedRectangle(new SolidColorBrush(background), null, card, radius, radius);
FormattedText title = CreateText(theme.Name, theme.Font, 96, primary);
title.MaxTextWidth = card.Width - 120;
title.MaxLineCount = 1;
title.Trimming = TextTrimming.CharacterEllipsis;
context.DrawText(title, new Point(card.X + 60, card.Y + 60));
FormattedText subtitle = CreateText(theme.Font, theme.Font, 48, secondary);
subtitle.MaxTextWidth = card.Width - 120;
subtitle.MaxLineCount = 1;
subtitle.Trimming = TextTrimming.CharacterEllipsis;
context.DrawText(subtitle, new Point(card.X + 60, card.Y + 60 + title.Height + 10));
Color[] swatches = [primary, secondary, background];
for (int i = 0; i < swatches.Length; i++)
{
Point center = new(card.X + 110 + (i * 130), card.Bottom - 110);
context.DrawEllipse(new SolidColorBrush(swatches[i]), new Pen(new SolidColorBrush(Color.FromArgb(0x60, 0x80, 0x80, 0x80)), 3), center, 50, 50);
}
}
private static FormattedText CreateText(string text, string font, double size, Color color)
{
return new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, new Typeface(font), size, new SolidColorBrush(color), 1.0);
}
}
+21
View File
@@ -461,6 +461,27 @@ public sealed class Manager
SaveSettings();
}
/// <summary>
/// Deletes the layout, drops the screen bindings pointing to it and reloads the screens.
/// </summary>
public void RemoveLayout(Layout layout)
{
_ = Settings.Layouts.Remove(layout);
List<string> boundScreens = Settings.ScreenLayouts
.Where(kvp => kvp.Value == layout.Name)
.Select(kvp => kvp.Key)
.ToList();
foreach (string screenId in boundScreens)
{
_ = Settings.ScreenLayouts.Remove(screenId);
}
SaveSettings();
LoadLayout();
}
/// <summary>
/// Gets all plugin windows currently shown on the given screen.
/// </summary>
+110 -87
View File
@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:DesktopMagic.Pages"
@@ -12,97 +13,119 @@
ScrollViewer.CanContentScroll="False"
Title="MainPage">
<Grid Margin="{StaticResource DefaultMargin}">
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<busyIndicator:BusyMask IsBusy="{Binding IsSharing}" IndicatorType="ThreeDots" BusyContent="{DynamicResource uploadingToModIo}" BusyContentMargin="0,20,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
<Grid Margin="{StaticResource DefaultMargin}">
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<ui:Card Padding="5" Margin="0 0 0 5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="10" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<ui:ToggleSwitch x:Name="editCheckBox" Height="30" VerticalAlignment="Center" Margin="5 0 0 0" Content="{DynamicResource editLayout}" Click="EditCheckBox_Click" />
<ui:TextBox Grid.Column="2" Text="{Binding PluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalledPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
</Grid>
</ui:Card>
<ScrollViewer Background="#FFBBBBBB" PreviewMouseWheel="ScrollViewer_PreviewMouseWheel" Grid.Row="1" VerticalAlignment="Stretch" VerticalContentAlignment="Top">
<ItemsControl x:Name="pluginsItemsControl" ItemsSource="{Binding SelectedLayout.Plugins}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ui:CardExpander Tag="{Binding}" Margin="0 0 0 5" Expanded="OptionsCardExpander_Expanded">
<ui:CardExpander.Header>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBlock Grid.Row="0" Grid.Column="0" FontTypography="Body" Text="{Binding Value.Metadata.Name}" />
<Label Grid.Row="1" Grid.Column="0" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" FontSize="12" ContentStringFormat="{DynamicResource authorFormat}">
<Label.Style>
<Style TargetType="Label" BasedOn="{StaticResource {x:Type Label}}">
<Setter Property="Content" Value="{Binding Value.Metadata.Author, Converter={StaticResource UnderscoreEscapingConverter}}" />
<Style.Triggers>
<DataTrigger Binding="{Binding Value.Metadata.Author}" Value="{x:Null}">
<Setter Property="Content" Value="{DynamicResource unknown}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Label.Style>
</Label>
<ui:ToggleSwitch Grid.Row="0" Grid.RowSpan="2" Grid.Column="1" Click="PluginCheckBox_Click" IsChecked="{Binding Value.Enabled}" Tag="{Binding Key}" Margin="0,0,16,0" VerticalAlignment="Center" />
</Grid>
</ui:CardExpander.Header>
</ui:CardExpander>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<ui:Card Grid.Row="2" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="5" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<ui:Button x:Name="screenSelectorButton" VerticalAlignment="Stretch" Padding="10,4" Click="ScreenSelectorButton_Click">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="screenSelectorButtonText" VerticalAlignment="Center" Text="{Binding SelectedScreen.DisplayName}" />
<ui:SymbolIcon Symbol="ChevronDown24" Margin="8,0,0,0" FontSize="12" />
</StackPanel>
</ui:Button>
<ComboBox x:Name="layoutsComboBox" Grid.Column="2" ItemsSource="{Binding Settings.Layouts}" DisplayMemberPath="Name" SelectedValue="{Binding SelectedLayoutName}" SelectedValuePath="Name" VerticalAlignment="Center" SelectionChanged="LayoutsComboBox_SelectionChanged" />
<Grid Grid.Row="2" Grid.ColumnSpan="3">
<ui:Card Padding="5" Margin="0 0 0 5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="10" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<ui:ToggleSwitch x:Name="editCheckBox" Height="30" VerticalAlignment="Center" Margin="5 0 0 0" Content="{DynamicResource editLayout}" Click="EditCheckBox_Click" />
<ui:TextBox Grid.Column="2" Text="{Binding PluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalledPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
</Grid>
</ui:Card>
<ScrollViewer Background="#FFBBBBBB" PreviewMouseWheel="ScrollViewer_PreviewMouseWheel" Grid.Row="1" VerticalAlignment="Stretch" VerticalContentAlignment="Top">
<ItemsControl x:Name="pluginsItemsControl" ItemsSource="{Binding SelectedLayout.Plugins}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ui:CardExpander Tag="{Binding}" Margin="0 0 0 5" Expanded="OptionsCardExpander_Expanded">
<ui:CardExpander.Header>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBlock Grid.Row="0" Grid.Column="0" FontTypography="Body" Text="{Binding Value.Metadata.Name}" />
<Label Grid.Row="1" Grid.Column="0" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" FontSize="12" ContentStringFormat="{DynamicResource authorFormat}">
<Label.Style>
<Style TargetType="Label" BasedOn="{StaticResource {x:Type Label}}">
<Setter Property="Content" Value="{Binding Value.Metadata.Author, Converter={StaticResource UnderscoreEscapingConverter}}" />
<Style.Triggers>
<DataTrigger Binding="{Binding Value.Metadata.Author}" Value="{x:Null}">
<Setter Property="Content" Value="{DynamicResource unknown}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Label.Style>
</Label>
<ui:ToggleSwitch Grid.Row="0" Grid.RowSpan="2" Grid.Column="1" Click="PluginCheckBox_Click" IsChecked="{Binding Value.Enabled}" Tag="{Binding Key}" Margin="0,0,16,0" VerticalAlignment="Center" />
</Grid>
</ui:CardExpander.Header>
</ui:CardExpander>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<ui:Card Grid.Row="2" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="5" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
</Grid.ColumnDefinitions>
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="NewLayoutButton_Click" FontWeight="Regular" />
<ui:Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="RemoveLayoutButton_Click" FontWeight="Regular" />
<ui:Button x:Name="screenSelectorButton" VerticalAlignment="Stretch" Padding="10,4" Click="ScreenSelectorButton_Click">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="screenSelectorButtonText" VerticalAlignment="Center" Text="{Binding SelectedScreen.DisplayName}" />
<ui:SymbolIcon Symbol="ChevronDown24" Margin="8,0,0,0" FontSize="12" />
</StackPanel>
</ui:Button>
<ComboBox x:Name="layoutsComboBox" Grid.Column="2" ItemsSource="{Binding Settings.Layouts}" DisplayMemberPath="Name" SelectedValue="{Binding SelectedLayoutName}" SelectedValuePath="Name" VerticalAlignment="Center" SelectionChanged="LayoutsComboBox_SelectionChanged" />
<Grid Grid.Row="2" Grid.ColumnSpan="3">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
</Grid.ColumnDefinitions>
<ui:Button x:Name="shareLayoutButton" Icon="{ui:SymbolIcon Share24}" Grid.Column="0" HorizontalAlignment="Stretch" Click="ShareLayoutButton_Click" FontWeight="Regular">
<TextBlock>
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{DynamicResource shareOnModIo}" />
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedLayout.ShareState}" Value="Modified">
<Setter Property="Text" Value="{DynamicResource updateOnModIo}" />
</DataTrigger>
<DataTrigger Binding="{Binding SelectedLayout.ShareState}" Value="UpToDate">
<Setter Property="Text" Value="{DynamicResource sharedOnModIo}" />
</DataTrigger>
<DataTrigger Binding="{Binding SelectedLayout.ShareState}" Value="NotOwner">
<Setter Property="Text" Value="{DynamicResource shareCopyOnModIo}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</ui:Button>
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="NewLayoutButton_Click" FontWeight="Regular" />
<ui:Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="RemoveLayoutButton_Click" FontWeight="Regular" />
</Grid>
</Grid>
</Grid>
</ui:Card>
</Grid>
</ui:Card>
</Grid>
</busyIndicator:BusyMask>
</Page>
+32 -14
View File
@@ -35,6 +35,7 @@ public partial class MainPage : Page
// Subscribe to manager events
_manager.PluginsChanged += OnPluginsChanged;
_manager.EditModeChanged += OnEditModeChanged;
_manager.SettingsChanged += OnSettingsChanged;
Loaded += MainPage_Loaded;
Unloaded += MainPage_Unloaded;
@@ -46,6 +47,7 @@ public partial class MainPage : Page
editCheckBox.IsChecked = _manager.IsEditMode;
_dataContext.RefreshScreens();
RefreshShareState();
}
private void MainPage_Unloaded(object sender, RoutedEventArgs e)
@@ -53,6 +55,7 @@ public partial class MainPage : Page
// Unsubscribe from events
_manager.PluginsChanged -= OnPluginsChanged;
_manager.EditModeChanged -= OnEditModeChanged;
_manager.SettingsChanged -= OnSettingsChanged;
}
private void OnPluginsChanged()
@@ -61,9 +64,20 @@ public partial class MainPage : Page
{
_dataContext.Settings = _manager.Settings;
ApplyPluginsFilter();
RefreshShareState();
});
}
private void OnSettingsChanged()
{
Dispatcher.Invoke(RefreshShareState);
}
private void RefreshShareState()
{
ModIoService.RefreshShareState(_manager.SelectedLayout);
}
private void OnEditModeChanged(bool editMode)
{
Dispatcher.Invoke(() =>
@@ -157,6 +171,21 @@ public partial class MainPage : Page
private void LayoutsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
ApplySelectedLayout();
RefreshShareState();
}
private async void ShareLayoutButton_Click(object sender, RoutedEventArgs e)
{
shareLayoutButton.IsEnabled = false;
try
{
await ModIoService.ShareAsync(_manager.SelectedLayout, Window.GetWindow(this), isBusy => _dataContext.IsSharing = isBusy);
}
finally
{
shareLayoutButton.IsEnabled = true;
}
}
/// <summary>
@@ -273,21 +302,10 @@ public partial class MainPage : Page
return;
}
_ = _manager.Settings.Layouts.Remove(layout);
// A subscribed layout would be downloaded again on the next sync.
await ModIoService.UnsubscribeAsync(layout);
// Remove any screen bindings pointing to the deleted layout
List<string> boundScreens = _manager.Settings.ScreenLayouts
.Where(kvp => kvp.Value == layout.Name)
.Select(kvp => kvp.Key)
.ToList();
foreach (string screenId in boundScreens)
{
_ = _manager.Settings.ScreenLayouts.Remove(screenId);
}
_manager.SaveSettings();
_manager.LoadLayout();
_manager.RemoveLayout(layout);
_dataContext.RefreshSelection();
}
+59 -36
View File
@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:DesktopMagic.Pages"
@@ -9,44 +10,66 @@
d:DesignHeight="450" d:DesignWidth="800"
Title="ThemePage">
<Grid Margin="{StaticResource DefaultMargin}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<busyIndicator:BusyMask IsBusy="{Binding IsSharing}" IndicatorType="ThreeDots" BusyContent="{DynamicResource uploadingToModIo}" BusyContentMargin="0,20,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
<Grid Margin="{StaticResource DefaultMargin}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<ui:ListView x:Name="themesListBox" Grid.Row="0" Grid.ColumnSpan="1" ItemsSource="{Binding Settings.Themes}" SelectedValue="{Binding SelectedLayout.CurrentThemeName}" SelectedValuePath="Name" SelectedIndex="2" SelectionMode="Single">
<ui:ListView.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Margin="8" VerticalAlignment="Center" FontWeight="SemiBold" Text="{Binding Name, Mode=OneWay}" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Orientation="Horizontal" Margin="{StaticResource DefaultMargin}">
<ui:Button x:Name="editThemeButton" Content="{DynamicResource editTheme}" Icon="{ui:SymbolIcon Color24}" HorizontalAlignment="Stretch" Click="EditThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
<ui:ListView x:Name="themesListBox" Grid.Row="0" Grid.ColumnSpan="1" ItemsSource="{Binding Settings.Themes}" SelectedValue="{Binding SelectedLayout.CurrentThemeName}" SelectedValuePath="Name" SelectedIndex="2" SelectionMode="Single">
<ui:ListView.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Margin="8" VerticalAlignment="Center" FontWeight="SemiBold" Text="{Binding Name, Mode=OneWay}" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Orientation="Horizontal" Margin="{StaticResource DefaultMargin}">
<ui:Button x:Name="shareThemeButton" Icon="{ui:SymbolIcon Share24}" Margin="0 0 5 0" Click="ShareThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
<TextBlock>
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{DynamicResource shareOnModIo}" />
<Style.Triggers>
<DataTrigger Binding="{Binding ShareState}" Value="Modified">
<Setter Property="Text" Value="{DynamicResource updateOnModIo}" />
</DataTrigger>
<DataTrigger Binding="{Binding ShareState}" Value="UpToDate">
<Setter Property="Text" Value="{DynamicResource sharedOnModIo}" />
</DataTrigger>
<DataTrigger Binding="{Binding ShareState}" Value="NotOwner">
<Setter Property="Text" Value="{DynamicResource shareCopyOnModIo}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</ui:Button>
<ui:Button x:Name="editThemeButton" Content="{DynamicResource editTheme}" Icon="{ui:SymbolIcon Color24}" HorizontalAlignment="Stretch" Click="EditThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
</ui:Button>
</StackPanel>
</Grid>
</DataTemplate>
</ui:ListView.ItemTemplate>
</ui:ListView>
</ui:Button>
</StackPanel>
</Grid>
</DataTemplate>
</ui:ListView.ItemTemplate>
</ui:ListView>
<ui:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
<Grid >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
</Grid.ColumnDefinitions>
<ui:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
<Grid >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
</Grid.ColumnDefinitions>
<ui:Button x:Name="newThemeButton" Content="{DynamicResource newTheme}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="AddThemeButton_Click" FontWeight="Regular"/>
<ui:Button x:Name="deleteThemeButton" Content="{DynamicResource deleteTheme}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="DeleteThemeButton_Click" FontWeight="Regular"/>
</Grid>
</ui:Card>
</Grid>
<ui:Button x:Name="newThemeButton" Content="{DynamicResource newTheme}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="AddThemeButton_Click" FontWeight="Regular"/>
<ui:Button x:Name="deleteThemeButton" Content="{DynamicResource deleteTheme}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="DeleteThemeButton_Click" FontWeight="Regular"/>
</Grid>
</ui:Card>
</Grid>
</busyIndicator:BusyMask>
</Page>
+39
View File
@@ -1,5 +1,6 @@
using DesktopMagic.DataContexts;
using DesktopMagic.Dialogs;
using DesktopMagic.Helpers;
using DesktopMagic.Plugins;
using System.Linq;
@@ -30,9 +31,15 @@ public partial class ThemePage : Page
// Subscribe to manager events
_manager.SettingsChanged += OnSettingsChanged;
Loaded += ThemePage_Loaded;
Unloaded += ThemePage_Unloaded;
}
private void ThemePage_Loaded(object sender, RoutedEventArgs e)
{
RefreshShareStates();
}
private void ThemePage_Unloaded(object sender, RoutedEventArgs e)
{
// Unsubscribe from events
@@ -44,9 +51,37 @@ public partial class ThemePage : Page
Dispatcher.Invoke(() =>
{
_dataContext.Settings = _manager.Settings;
RefreshShareStates();
});
}
private void RefreshShareStates()
{
foreach (Theme theme in _manager.Settings.Themes)
{
ModIoService.RefreshShareState(theme);
}
}
private async void ShareThemeButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not System.Windows.Controls.Button button || button.Tag is not Theme theme)
{
return;
}
button.IsEnabled = false;
try
{
await ModIoService.ShareAsync(theme, Window.GetWindow(this), isBusy => _dataContext.IsSharing = isBusy);
}
finally
{
button.IsEnabled = true;
}
}
private async void AddThemeButton_Click(object sender, RoutedEventArgs e)
{
InputDialog inputDialog = new((string)FindResource("enterThemeName"))
@@ -104,6 +139,9 @@ public partial class ThemePage : Page
if (themesListBox.SelectedItem is Theme theme)
{
// A subscribed theme would be downloaded again on the next sync.
await ModIoService.UnsubscribeAsync(theme);
_ = _manager.Settings.Themes.Remove(theme);
_manager.SaveSettings();
}
@@ -122,5 +160,6 @@ public partial class ThemePage : Page
};
_ = themeDialog.ShowDialog();
ModIoService.RefreshShareState(theme);
}
}
+13 -6
View File
@@ -31,19 +31,26 @@
<ui:Card Grid.ColumnSpan="3" Padding="5" Margin="0 0 0 5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="10" />
<ColumnDefinition />
<ColumnDefinition Width="10" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<ui:TextBox Grid.Column="0" Text="{Binding AllPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchAllPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
<ui:TextBox Grid.Column="2" Text="{Binding InstalledPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="InstalledPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalledPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
<ComboBox x:Name="categoryComboBox" Grid.Column="0" MinWidth="130" VerticalAlignment="Center" SelectedIndex="{Binding CategoryIndex, Mode=TwoWay}" SelectionChanged="CategoryComboBox_SelectionChanged">
<ComboBoxItem Content="{DynamicResource plugins}" />
<ComboBoxItem Content="{DynamicResource layouts}" />
<ComboBoxItem Content="{DynamicResource themes}" />
</ComboBox>
<ui:TextBox Grid.Column="2" Text="{Binding AllPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchModIo}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
<ui:TextBox Grid.Column="4" Text="{Binding InstalledPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="InstalledPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalled}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
</Grid>
</ui:Card>
<busyIndicator:BusyMask Grid.Row="2" IsBusy="{Binding IsSearching}" IndicatorType="ThreeDots" BusyContent="Searching..." BusyContentMargin="0,50,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
<ScrollViewer VerticalScrollBarVisibility="Auto" VerticalAlignment="Stretch" Padding="0" VerticalContentAlignment="Top" Margin="0 0 2.5 0">
<ItemsControl ItemsSource="{Binding AllPlugins}">
<ItemsControl ItemsSource="{Binding ShownAll}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<local:ModEntry Margin="0 0 0 5" />
@@ -56,7 +63,7 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<TextBlock Text="No plugins found!" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
<TextBlock Text="{DynamicResource nothingFound}" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
</ControlTemplate>
</Setter.Value>
</Setter>
@@ -69,7 +76,7 @@
</busyIndicator:BusyMask>
<ScrollViewer VerticalScrollBarVisibility="Auto" Grid.Row="2" Grid.Column="2" VerticalAlignment="Stretch" Padding="0" VerticalContentAlignment="Top">
<ItemsControl ItemsSource="{Binding InstalledPlugins}" Margin="2.5 0 0 0">
<ItemsControl ItemsSource="{Binding ShownInstalled}" Margin="2.5 0 0 0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<local:ModEntry Margin="0 0 0 5"/>
@@ -82,7 +89,7 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<TextBlock Text="No plugins found!" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
<TextBlock Text="{DynamicResource nothingFound}" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
</ControlTemplate>
</Setter.Value>
</Setter>
+211 -60
View File
@@ -1,6 +1,7 @@
using DesktopMagic.DataContexts;
using DesktopMagic.Dialogs;
using DesktopMagic.Helpers;
using DesktopMagic.Settings;
using Modio;
using Modio.Filters;
@@ -32,8 +33,7 @@ namespace DesktopMagic.Plugins;
/// </summary>
public partial class PluginManager : Page
{
private const int ModIoGameId = 5665;
private const string ModIoApiKey = "88e6ea774c3a502b06114e7fee0829ac";
private const uint ModIoGameId = ModIoService.GameId;
private readonly HttpClient httpClient = new();
private readonly PluginManagerDataContext pluginManagerDataContext = new();
private readonly string pluginsPath = Path.Combine(App.ApplicationDataPath, "Plugins");
@@ -48,23 +48,16 @@ public partial class PluginManager : Page
private bool changed = false;
private Client modIoClient;
// mod.io data of the local layouts and themes linked to a mod, by mod ID.
private readonly Dictionary<uint, Mod> linkedMods = [];
public PluginManager()
{
Resources.MergedDictionaries.Add(App.LanguageDictionary);
string? modIoAccessToken = MainWindowDataContext.GetSettings().ModIoAccessToken;
if (modIoAccessToken is null)
{
modIoClient = new Client(new Credentials(ModIoApiKey));
App.Logger.LogInfo("Initialized mod.io client without authentication", source: "PluginManager");
}
else
{
modIoClient = new Client(new Credentials(ModIoApiKey, modIoAccessToken));
pluginManagerDataContext.IsAuthenticated = true;
App.Logger.LogInfo("Initialized mod.io client with authentication", source: "PluginManager");
}
modIoClient = ModIoService.CreateClient();
pluginManagerDataContext.IsAuthenticated = ModIoService.IsAuthenticated;
App.Logger.LogInfo($"Initialized mod.io client {(ModIoService.IsAuthenticated ? "with" : "without")} authentication", source: "PluginManager");
InitializeComponent();
@@ -72,7 +65,15 @@ public partial class PluginManager : Page
searchTimer.Tick += async (sender, e) =>
{
searchTimer.Stop();
await SearchAllPlugins(pluginManagerDataContext.AllPluginsSearchText);
if (pluginManagerDataContext.Category == ModCategory.Plugins)
{
await SearchAllPlugins(pluginManagerDataContext.AllPluginsSearchText);
}
else
{
await SearchShared(pluginManagerDataContext.AllPluginsSearchText);
}
};
Loaded += PluginManager_Loaded;
@@ -222,7 +223,7 @@ public partial class PluginManager : Page
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
await foreach (Mod mod in mods)
{
if (pluginIds.Contains(mod.Id))
if (pluginIds.Contains(mod.Id) || !ModIoService.IsPluginMod(mod))
{
continue;
}
@@ -237,6 +238,8 @@ public partial class PluginManager : Page
}
await SyncPlugins();
await UpdateLinkedItems();
await PopulateShared();
pluginManagerDataContext.IsLoading = false;
App.Logger.LogInfo("Plugin Manager initialization complete", source: "PluginManager");
@@ -353,20 +356,177 @@ public partial class PluginManager : Page
private void InstalledPluginsSearchTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
{
if (string.IsNullOrWhiteSpace(pluginManagerDataContext.InstalledPluginsSearchText))
FilterInstalled();
}
private void FilterInstalled()
{
string searchText = pluginManagerDataContext.InstalledPluginsSearchText;
foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.ShownInstalled)
{
foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.InstalledPlugins)
pluginEntryDataContext.IsVisible = string.IsNullOrWhiteSpace(searchText) || pluginEntryDataContext.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase);
}
}
private async void CategoryComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!IsLoaded)
{
return;
}
pluginManagerDataContext.CategoryIndex = categoryComboBox.SelectedIndex;
pluginManagerDataContext.IsLoading = true;
await PopulateShared();
FilterInstalled();
pluginManagerDataContext.IsLoading = false;
}
/// <summary>
/// Fetches the mods of all linked layouts and themes and applies newer versions.
/// Subscribed items are also updated by the subscription sync, this covers items installed while logged out.
/// </summary>
private async Task UpdateLinkedItems()
{
linkedMods.Clear();
foreach (IModIoShareable item in ModIoService.GetSharedItems().Where(item => item.ModIo is not null).ToList())
{
uint modId = item.ModIo!.ModId;
try
{
pluginEntryDataContext.IsVisible = true;
Mod mod = await modIoClient.Games[ModIoGameId].Mods[modId].Get();
linkedMods[modId] = mod;
if (await ModIoService.UpdateAsync(item, mod, EnsurePluginInstalled))
{
changed = true;
}
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to check \"{item.Name}\" (mod {modId}) for updates: {ex.Message}", source: "PluginManager");
}
}
else
}
/// <summary>
/// Fills the lists of the selected layout or theme category.
/// </summary>
private async Task PopulateShared()
{
pluginManagerDataContext.InstalledShared.Clear();
pluginManagerDataContext.AllShared.Clear();
if (pluginManagerDataContext.Category == ModCategory.Plugins)
{
foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.InstalledPlugins)
return;
}
IEnumerable<IModIoShareable> items = pluginManagerDataContext.Category == ModCategory.Layouts
? _manager.Settings.Layouts
: _manager.Settings.Themes;
foreach (IModIoShareable item in items.Where(item => item.ModIo is not null).ToList())
{
uint modId = item.ModIo!.ModId;
if (!linkedMods.TryGetValue(modId, out Mod? mod))
{
pluginEntryDataContext.IsVisible = pluginEntryDataContext.Name.Contains(pluginManagerDataContext.InstalledPluginsSearchText, StringComparison.OrdinalIgnoreCase);
try
{
mod = await modIoClient.Games[ModIoGameId].Mods[modId].Get();
linkedMods[modId] = mod;
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to fetch mod {modId} of \"{item.Name}\": {ex.Message}", source: "PluginManager");
}
}
// Show the local name, it can differ from the mod name (e.g. "Default (2)").
PluginMetadata metadata = mod is null ? new PluginMetadata(item.Name, modId) : new PluginMetadata(mod) { Name = item.Name };
pluginManagerDataContext.InstalledShared.Add(new PluginEntryDataContext(metadata, new CommandHandler(async () => await RemoveShared(item)), PluginEntryDataContext.Mode.Uninstall));
}
await SearchShared(pluginManagerDataContext.AllPluginsSearchText);
}
private async Task SearchShared(string searchString)
{
pluginManagerDataContext.AllShared.Clear();
if (pluginManagerDataContext.Category == ModCategory.Plugins)
{
pluginManagerDataContext.IsSearching = false;
return;
}
string tag = pluginManagerDataContext.Category == ModCategory.Layouts ? SharePackage.LayoutTag : SharePackage.ThemeTag;
Filter filter = ModFilter.Tags.Eq(tag);
if (!string.IsNullOrWhiteSpace(searchString))
{
if (!searchString.Contains('*'))
{
searchString = $"*{searchString.Trim()}*";
}
filter = filter.And(ModFilter.Name.Like(searchString));
}
filter = filter.And(ModFilter.Popular.Desc()).Limit(100);
try
{
App.Logger.LogInfo($"Fetching shared {tag} mods from mod.io", source: "PluginManager");
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
await foreach (Mod mod in mods)
{
if (ModIoService.FindLinkedItem(mod.Id) is not null)
{
continue;
}
pluginManagerDataContext.AllShared.Add(new PluginEntryDataContext(new(mod), new CommandHandler(async () => await InstallShared(mod)), PluginEntryDataContext.Mode.Install));
}
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to fetch shared {tag} mods from mod.io: {ex.Message}", source: "PluginManager");
}
pluginManagerDataContext.IsSearching = false;
}
private async Task InstallShared(Mod mod)
{
pluginManagerDataContext.IsLoading = true;
if (await ModIoService.InstallAsync(mod, EnsurePluginInstalled))
{
linkedMods[mod.Id] = mod;
changed = true;
}
await PopulateShared();
FilterInstalled();
pluginManagerDataContext.IsLoading = false;
}
private async Task RemoveShared(IModIoShareable item)
{
if (!await ModIoService.RemoveAsync(item))
{
return;
}
pluginManagerDataContext.IsLoading = true;
await PopulateShared();
FilterInstalled();
pluginManagerDataContext.IsLoading = false;
}
private async Task SearchAllPlugins(string searchString)
@@ -386,7 +546,7 @@ public partial class PluginManager : Page
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
await foreach (Mod mod in mods)
{
if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == mod.Id))
if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == mod.Id) || !ModIoService.IsPluginMod(mod))
{
continue;
}
@@ -674,52 +834,21 @@ public class {pluginSafeName}Plugin : Plugin
{
if (pluginManagerDataContext.IsAuthenticated)
{
App.Logger.LogInfo("Logging out from mod.io", source: "PluginManager");
MainWindowDataContext.GetSettings().ModIoAccessToken = null;
ModIoService.LogOut();
modIoClient = ModIoService.CreateClient();
pluginManagerDataContext.IsAuthenticated = false;
return;
}
App.Logger.LogInfo("Starting mod.io authentication", source: "PluginManager");
try
{
InputDialog inputDialog = new((string)FindResource("enterModIoEmail"), "Plugin Manager")
if (!await ModIoService.LogInAsync(Window.GetWindow(this), "Plugin Manager"))
{
Owner = Window.GetWindow(this),
};
if (inputDialog.ShowDialog() != true)
{
App.Logger.LogInfo("Authentication cancelled by user", source: "PluginManager");
return;
}
App.Logger.LogInfo($"Requesting authentication code for email: {inputDialog.ResponseText}", source: "PluginManager");
await modIoClient.Auth.RequestCode(ModIoApiKey, inputDialog.ResponseText);
inputDialog = new((string)FindResource("enterModIoAccessToken"), "Plugin Manager")
{
Owner = Window.GetWindow(this),
};
if (inputDialog.ShowDialog() != true)
{
App.Logger.LogInfo("Authentication cancelled by user", source: "PluginManager");
return;
}
pluginManagerDataContext.IsLoading = true;
AccessToken accessToken = await modIoClient.Auth.SecurityCode(ModIoApiKey, inputDialog.ResponseText);
if (accessToken.Value is not null)
{
modIoClient = new Client(new Credentials(ModIoApiKey, accessToken.Value));
App.Logger.LogInfo("Successfully authenticated with mod.io", source: "PluginManager");
}
MainWindowDataContext.GetSettings().ModIoAccessToken = accessToken.Value;
modIoClient = ModIoService.CreateClient();
pluginManagerDataContext.IsAuthenticated = true;
App.Logger.LogInfo("Subscribing to installed plugins on mod.io", source: "PluginManager");
@@ -739,7 +868,9 @@ public class {pluginSafeName}Plugin : Plugin
}
}
await ModIoService.SubscribeToLinkedItemsAsync();
await SyncPlugins();
await PopulateShared();
}
catch (Exception ex)
{
@@ -767,7 +898,8 @@ public class {pluginSafeName}Plugin : Plugin
try
{
IReadOnlyList<Mod> mods = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList();
IReadOnlyList<Mod> subscriptions = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList();
List<Mod> mods = subscriptions.Where(ModIoService.IsPluginMod).ToList();
App.Logger.LogInfo($"Found {mods.Count} subscribed plugins on mod.io", source: "PluginManager");
List<PluginEntryDataContext> pluginsToRemove = [];
@@ -800,6 +932,12 @@ public class {pluginSafeName}Plugin : Plugin
}
App.Logger.LogInfo("Plugin sync completed", source: "PluginManager");
// Shared layouts and themes are synced after the plugins, so the plugins they use are installed first.
if (await ModIoService.SyncSubscriptionsAsync(subscriptions, EnsurePluginInstalled))
{
changed = true;
}
}
catch (Exception ex)
{
@@ -807,6 +945,19 @@ public class {pluginSafeName}Plugin : Plugin
}
}
private async Task EnsurePluginInstalled(uint pluginId)
{
if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == pluginId))
{
return;
}
App.Logger.LogInfo($"Installing plugin {pluginId} required by a shared layout", source: "PluginManager");
Mod mod = await modIoClient.Games[ModIoGameId].Mods[pluginId].Get();
await Install(mod);
pluginManagerDataContext.IsLoading = true;
}
private string GetCsprojPath(string pluginName)
{
string pluginSafeName = GetPluginSafeName(pluginName);
@@ -7,6 +7,7 @@ namespace DesktopMagic.Plugins;
public class SettingElement
{
private string jsonValue = string.Empty;
private bool shareable;
[JsonIgnore]
public Setting? Input { get; set; }
@@ -16,6 +17,17 @@ public class SettingElement
public string Id { get; set; }
/// <summary>
/// Whether the value may be included when sharing a layout on mod.io.
/// Free-text values (URLs, locations, file paths, ...) can contain personal data and are never shared.
/// Persisted so the decision stays stable while the plugin is not loaded.
/// </summary>
public bool Shareable
{
get => Input is null ? shareable : IsShareableInput(Input);
set => shareable = value;
}
public string JsonValue
{
get => Input?.GetJsonValue() ?? jsonValue;
@@ -40,6 +52,11 @@ public class SettingElement
OrderIndex = orderIndex;
}
private static bool IsShareableInput(Setting input)
{
return input is CheckBox or ColorPicker or ComboBox or IntegerUpDown or Slider;
}
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
[JsonConstructor]
+19 -1
View File
@@ -1,5 +1,6 @@
using DesktopMagic.Api;
using DesktopMagic.Helpers;
using DesktopMagic.Settings;
using System.ComponentModel;
using System.Runtime.CompilerServices;
@@ -11,7 +12,7 @@ using Color = System.Drawing.Color;
namespace DesktopMagic.Plugins;
public class Theme(string name) : ITheme, INotifyPropertyChanged
public class Theme(string name) : ITheme, INotifyPropertyChanged, IModIoShareable
{
public event PropertyChangedEventHandler? PropertyChanged;
@@ -21,6 +22,23 @@ public class Theme(string name) : ITheme, INotifyPropertyChanged
private string font = "Segoe UI";
private int cornerRadius;
private int margin;
private ModIoShareState shareState;
public ModIoLink? ModIo { get; set; }
[JsonIgnore]
public ModIoShareState ShareState
{
get => shareState;
set
{
if (shareState != value)
{
shareState = value;
OnPropertyChanged();
}
}
}
public string Name
{
@@ -70,6 +70,30 @@
<system:String x:Key="releaseInfoPublishedFormat">Veröffentlicht: {0}</system:String>
<system:String x:Key="releaseInfoBodyLabel">Release Notes:</system:String>
<system:String x:Key="openReleasePage">Release-Seite öffnen</system:String>
<system:String x:Key="close">Schließen</system:String>
<system:String x:Key="overwrite">Überschreiben</system:String>
<system:String x:Key="keepMine">Meine behalten</system:String>
<system:String x:Key="shareOnModIo">Auf mod.io teilen</system:String>
<system:String x:Key="updateOnModIo">Auf mod.io aktualisieren</system:String>
<system:String x:Key="sharedOnModIo">Auf mod.io ansehen</system:String>
<system:String x:Key="shareCopyOnModIo">Als neu auf mod.io teilen</system:String>
<system:String x:Key="enterModIoSummary">Kurze Zusammenfassung für mod.io eingeben:</system:String>
<system:String x:Key="enterModIoChangelog">Was hat sich geändert? (optional)</system:String>
<system:String x:Key="modIoShareLayoutWarning">Jeder auf mod.io kann dieses Layout herunterladen. Es enthält die aktivierten Widgets, ihre Positionen, die verwendeten Themes und ihre Nicht-Text-Einstellungen. Text-Einstellungen (z. B. URLs, Orte oder Dateipfade) und gespeicherte Widget-Daten werden nicht geteilt.</system:String>
<system:String x:Key="modIoShareThemeWarning">Jeder auf mod.io kann dieses Theme herunterladen.</system:String>
<system:String x:Key="modIoLocalPluginsWarning">Diese Widgets sind lokale Plugins, die andere nicht installieren können: {0}</system:String>
<system:String x:Key="modIoShareSuccess">"{0}" wurde auf mod.io veröffentlicht.</system:String>
<system:String x:Key="modIoUpdateSuccess">Das Update von "{0}" wurde auf mod.io veröffentlicht.</system:String>
<system:String x:Key="modIoShareFailed">Teilen auf mod.io fehlgeschlagen: {0}</system:String>
<system:String x:Key="modIoSessionExpired">Ihre mod.io-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.</system:String>
<system:String x:Key="modIoUpdateConflict">"{0}" wurde auf mod.io aktualisiert, aber Sie haben es auch lokal geändert. Ihre Änderungen mit der neuen Version überschreiben?</system:String>
<system:String x:Key="cannotShareEmptyLayout">Das leere Layout kann nicht geteilt werden!</system:String>
<system:String x:Key="layouts">Layouts</system:String>
<system:String x:Key="searchModIo">mod.io durchsuchen...</system:String>
<system:String x:Key="searchInstalled">Installierte durchsuchen...</system:String>
<system:String x:Key="nothingFound">Nichts gefunden!</system:String>
<system:String x:Key="modIoInstallFailed">Installation von "{0}" fehlgeschlagen: {1}</system:String>
<system:String x:Key="uploadingToModIo">Wird auf mod.io hochgeladen...</system:String>
<col:ArrayList x:Key="musicVisualizerOptionsComboboxItems">
<system:String>Unten</system:String>
<system:String>Mitte</system:String>
@@ -72,6 +72,30 @@
<system:String x:Key="releaseInfoPublishedFormat">Published: {0}</system:String>
<system:String x:Key="releaseInfoBodyLabel">Release notes:</system:String>
<system:String x:Key="openReleasePage">Open release page</system:String>
<system:String x:Key="close">Close</system:String>
<system:String x:Key="overwrite">Overwrite</system:String>
<system:String x:Key="keepMine">Keep mine</system:String>
<system:String x:Key="shareOnModIo">Share on mod.io</system:String>
<system:String x:Key="updateOnModIo">Update on mod.io</system:String>
<system:String x:Key="sharedOnModIo">View on mod.io</system:String>
<system:String x:Key="shareCopyOnModIo">Share as new on mod.io</system:String>
<system:String x:Key="enterModIoSummary">Enter a short summary shown on mod.io:</system:String>
<system:String x:Key="enterModIoChangelog">What changed? (optional)</system:String>
<system:String x:Key="modIoShareLayoutWarning">Everyone on mod.io will be able to download this layout. It includes the enabled widgets, their positions, the used themes and their non-text settings. Text settings (e.g. URLs, locations or file paths) and saved widget data are not shared.</system:String>
<system:String x:Key="modIoShareThemeWarning">Everyone on mod.io will be able to download this theme.</system:String>
<system:String x:Key="modIoLocalPluginsWarning">These widgets are local plugins that others cannot install: {0}</system:String>
<system:String x:Key="modIoShareSuccess">"{0}" was published on mod.io.</system:String>
<system:String x:Key="modIoUpdateSuccess">The update of "{0}" was published on mod.io.</system:String>
<system:String x:Key="modIoShareFailed">Sharing on mod.io failed: {0}</system:String>
<system:String x:Key="modIoSessionExpired">Your mod.io session has expired. Please log in again.</system:String>
<system:String x:Key="modIoUpdateConflict">"{0}" was updated on mod.io, but you also changed it locally. Overwrite your changes with the new version?</system:String>
<system:String x:Key="cannotShareEmptyLayout">The empty layout cannot be shared!</system:String>
<system:String x:Key="layouts">Layouts</system:String>
<system:String x:Key="searchModIo">Search mod.io...</system:String>
<system:String x:Key="searchInstalled">Search installed...</system:String>
<system:String x:Key="nothingFound">Nothing found!</system:String>
<system:String x:Key="modIoInstallFailed">Installing "{0}" failed: {1}</system:String>
<system:String x:Key="uploadingToModIo">Uploading to mod.io...</system:String>
<col:ArrayList x:Key="musicVisualizerOptionsComboboxItems">
<system:String>Bottom</system:String>
<system:String>Middle</system:String>
+18 -1
View File
@@ -9,7 +9,7 @@ using System.Text.Json.Serialization;
namespace DesktopMagic.Settings;
public class Layout(string name) : INotifyPropertyChanged
public class Layout(string name) : INotifyPropertyChanged, IModIoShareable
{
public event PropertyChangedEventHandler? PropertyChanged;
@@ -17,6 +17,23 @@ public class Layout(string name) : INotifyPropertyChanged
private string? currentThemeName = null;
private Dictionary<uint, PluginSettings> plugins = [];
private double screenAspectRatio = 0;
private ModIoShareState shareState;
public ModIoLink? ModIo { get; set; }
[JsonIgnore]
public ModIoShareState ShareState
{
get => shareState;
set
{
if (shareState != value)
{
shareState = value;
OnPropertyChanged();
}
}
}
[JsonIgnore]
public Theme Theme
+48
View File
@@ -0,0 +1,48 @@
using System;
namespace DesktopMagic.Settings;
/// <summary>
/// Connects a local layout or theme to the mod.io mod it was published as or downloaded from.
/// </summary>
public class ModIoLink
{
public uint ModId { get; set; }
/// <summary>
/// The modfile (version) the local copy is based on.
/// </summary>
public uint ModfileId { get; set; }
/// <summary>
/// Hash of the shareable content at the last publish or download, used to detect local changes.
/// </summary>
public string? PublishedHash { get; set; }
/// <summary>
/// Whether the logged in mod.io user submitted the mod and is therefore allowed to upload new versions.
/// </summary>
public bool IsOwner { get; set; }
public Uri? ProfileUri { get; set; }
}
public enum ModIoShareState
{
NotShared,
UpToDate,
Modified,
NotOwner
}
/// <summary>
/// A local item (layout or theme) that can be shared on mod.io.
/// </summary>
public interface IModIoShareable
{
string Name { get; }
ModIoLink? ModIo { get; set; }
ModIoShareState ShareState { get; set; }
}