diff --git a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs
index 829d9ca..647c9b7 100644
--- a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs
+++ b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs
@@ -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;
+ ///
+ /// Whether a layout or theme is currently being uploaded to mod.io.
+ ///
+ public bool IsSharing
+ {
+ get => isSharing;
+ set
+ {
+ isSharing = value;
+ OnPropertyChanged();
+ }
+ }
+
public DesktopMagicSettings Settings
{
get => settings;
diff --git a/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs b/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs
index 896985a..c5c4b64 100644
--- a/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs
+++ b/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs
@@ -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 AllPlugins { get; } = [];
public ObservableCollection InstalledPlugins { get; } = [];
+ // Layouts or themes, depending on the selected category.
+ public ObservableCollection AllShared { get; } = [];
+ public ObservableCollection 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 ShownAll => category == ModCategory.Plugins ? AllPlugins : AllShared;
+
+ public ObservableCollection 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));
}
-}
\ No newline at end of file
+}
+
+internal enum ModCategory
+{
+ Plugins,
+ Layouts,
+ Themes
+}
diff --git a/src/DesktopMagic/DesktopMagic.csproj b/src/DesktopMagic/DesktopMagic.csproj
index 3ed9fc5..7749395 100644
--- a/src/DesktopMagic/DesktopMagic.csproj
+++ b/src/DesktopMagic/DesktopMagic.csproj
@@ -49,7 +49,6 @@
-
@@ -60,6 +59,7 @@
+
diff --git a/src/DesktopMagic/Helpers/ModIoService.cs b/src/DesktopMagic/Helpers/ModIoService.cs
new file mode 100644
index 0000000..3061364
--- /dev/null
+++ b/src/DesktopMagic/Helpers/ModIoService.cs
@@ -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;
+
+///
+/// Shared mod.io access: authentication and sharing/syncing of layouts and themes.
+///
+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();
+ }
+
+ ///
+ /// The shared api.mod.io host is deprecated, requests have to go to the game specific host.
+ ///
+ private static Client.Builder CreateBuilder()
+ {
+ return Client.GetBuilder(ApiKey).WithGameHost(GameId);
+ }
+
+ ///
+ /// Whether the mod is a plugin (as opposed to a shared layout or theme).
+ ///
+ public static bool IsPluginMod(Mod mod)
+ {
+ return !mod.Tags.Any(tag => tag.Name is SharePackage.LayoutTag or SharePackage.ThemeTag);
+ }
+
+ ///
+ /// Runs the e-mail security code login. Returns false when cancelled.
+ ///
+ public static async Task 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();
+ }
+
+ ///
+ /// Compares the local content with the last published or downloaded version.
+ ///
+ 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,
+ };
+ }
+
+ ///
+ /// Publishes the item as a new mod, uploads a new version of the linked mod, or opens the mod page when nothing changed.
+ ///
+ /// Shows or hides the caller's waiting overlay while the upload runs.
+ public static async Task ShareAsync(IModIoShareable item, Window? owner, Action 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);
+ }
+ }
+
+ ///
+ /// Unsubscribes from the mod an item is linked to, so deleting it locally does not bring it back on the next sync.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ public static async Task SubscribeToLinkedItemsAsync()
+ {
+ if (!IsAuthenticated)
+ {
+ return;
+ }
+
+ Client client = CreateClient();
+
+ foreach (ModIoLink link in GetSharedItems().Select(item => item.ModIo).OfType().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 GetSharedItems()
+ {
+ DesktopMagicSettings settings = AppSettings;
+ return settings.Layouts.Cast().Concat(settings.Themes);
+ }
+
+ public static IModIoShareable? FindLinkedItem(uint modId)
+ {
+ return GetSharedItems().FirstOrDefault(item => item.ModIo?.ModId == modId);
+ }
+
+ ///
+ /// Downloads a layout or theme from mod.io and subscribes to it when logged in.
+ ///
+ /// Installs the mod.io plugin with the given ID if it is missing.
+ /// Whether the item was installed.
+ public static async Task InstallAsync(Mod mod, Func 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;
+ }
+ }
+
+ ///
+ /// Updates a linked layout or theme when mod.io has a newer version.
+ ///
+ /// Whether the item changed.
+ public static async Task UpdateAsync(IModIoShareable item, Mod mod, Func 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;
+ }
+ }
+
+ ///
+ /// Deletes a layout or theme locally after confirmation and unsubscribes from its mod.
+ /// The mod itself stays on mod.io.
+ ///
+ /// Whether the item was removed.
+ public static async Task 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;
+ }
+
+ ///
+ /// Downloads subscribed layouts and themes that are new or have a newer version on mod.io.
+ ///
+ /// The user's subscribed mods for this game.
+ /// Installs the mod.io plugin with the given ID if it is missing.
+ /// Whether any layout or theme changed.
+ public static async Task SyncSubscriptionsAsync(IReadOnlyList subscriptions, Func ensurePluginInstalled)
+ {
+ if (!IsAuthenticated)
+ {
+ return false;
+ }
+
+ bool changed = false;
+
+ List 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;
+ }
+
+ ///
+ /// Downloads the current version of the mod into a new item, or into when it is outdated.
+ /// Asks before overwriting local changes. Throws when the download or import fails.
+ ///
+ /// Whether anything changed.
+ private static async Task ApplyLatestVersionAsync(Mod mod, IModIoShareable? local, Func 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 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 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 requiredPlugins)
+ {
+ try
+ {
+ DependenciesClient dependencies = client.Games[GameId].Mods[modId].Dependencies;
+ List current = (await dependencies.Get(false)).Select(dependency => dependency.ModId).ToList();
+
+ List removed = current.Except(requiredPlugins).ToList();
+ List 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 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;
+ }
+}
diff --git a/src/DesktopMagic/Helpers/SharePackage.cs b/src/DesktopMagic/Helpers/SharePackage.cs
new file mode 100644
index 0000000..9f0782e
--- /dev/null
+++ b/src/DesktopMagic/Helpers/SharePackage.cs
@@ -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;
+
+///
+/// Converts layouts and themes to and from the portable JSON content that is uploaded to mod.io.
+///
+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() }
+ };
+
+ /// The content written to .
+ /// Hash of the shared content, used to detect local changes.
+ /// mod.io plugin IDs the layout uses.
+ /// Names of locally developed plugins the layout uses, which others cannot install.
+ public sealed record Package(JsonObject Json, string Hash, IReadOnlyList RequiredPlugins, IReadOnlyList 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 installedPlugins = Manager.Instance.Plugins;
+
+ Layout copy = new(layout.Name)
+ {
+ ScreenAspectRatio = layout.ScreenAspectRatio,
+ };
+
+ List themeNames = [layout.Theme.Name];
+ List requiredPlugins = [];
+ List 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() ?? 0;
+ if (format is < 1 or > FormatVersion)
+ {
+ throw new FormatException($"Unsupported content format {format}. Please update DesktopMagic.");
+ }
+
+ return root;
+ }
+
+ public static IReadOnlyList GetRequiredPlugins(JsonObject root)
+ {
+ return root["requiredPlugins"]?.AsArray().Select(node => node!.GetValue()).ToList() ?? [];
+ }
+
+ ///
+ /// Adds the downloaded content to the settings, or updates with it.
+ ///
+ public static IModIoShareable Apply(JsonObject root, IModIoShareable? existing)
+ {
+ DesktopMagicSettings settings = Manager.Instance.Settings;
+ string type = root["type"]?.GetValue() ?? string.Empty;
+
+ List themes = root["themes"]?.AsArray()
+ .Select(node => node.Deserialize(jsonOptions))
+ .OfType()
+ .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(jsonOptions) ?? throw new FormatException("The downloaded layout is invalid.");
+ string layoutName = existing?.Name ?? GetUniqueName(incomingLayout.Name, settings.Layouts.Select(l => l.Name));
+
+ Dictionary 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? mappedLayoutThemeName = layoutThemeName is not null && themeNameMap.TryGetValue(layoutThemeName, out string? mapped) ? mapped : layoutThemeName;
+
+ Layout target = existing as Layout ?? new Layout(layoutName);
+
+ Dictionary 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;
+ }
+
+ ///
+ /// Adds a theme that is embedded in a layout and returns the local name it is available under.
+ ///
+ 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 existingNames)
+ {
+ HashSet 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);
+ }
+}
diff --git a/src/DesktopMagic/Helpers/SharePreviewRenderer.cs b/src/DesktopMagic/Helpers/SharePreviewRenderer.cs
new file mode 100644
index 0000000..884d5a9
--- /dev/null
+++ b/src/DesktopMagic/Helpers/SharePreviewRenderer.cs
@@ -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;
+
+///
+/// Renders the logo image used for layouts and themes on mod.io (mod.io requires one, at least 512x288).
+///
+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);
+ }
+}
diff --git a/src/DesktopMagic/Manager.cs b/src/DesktopMagic/Manager.cs
index 2e08308..8bcea2d 100644
--- a/src/DesktopMagic/Manager.cs
+++ b/src/DesktopMagic/Manager.cs
@@ -461,6 +461,27 @@ public sealed class Manager
SaveSettings();
}
+ ///
+ /// Deletes the layout, drops the screen bindings pointing to it and reloads the screens.
+ ///
+ public void RemoveLayout(Layout layout)
+ {
+ _ = Settings.Layouts.Remove(layout);
+
+ List 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();
+ }
+
///
/// Gets all plugin windows currently shown on the given screen.
///
diff --git a/src/DesktopMagic/Pages/MainPage.xaml b/src/DesktopMagic/Pages/MainPage.xaml
index 138b40a..ac9dd0a 100644
--- a/src/DesktopMagic/Pages/MainPage.xaml
+++ b/src/DesktopMagic/Pages/MainPage.xaml
@@ -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">
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
diff --git a/src/DesktopMagic/Pages/MainPage.xaml.cs b/src/DesktopMagic/Pages/MainPage.xaml.cs
index 8234516..7cf5450 100644
--- a/src/DesktopMagic/Pages/MainPage.xaml.cs
+++ b/src/DesktopMagic/Pages/MainPage.xaml.cs
@@ -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;
+ }
}
///
@@ -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 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();
}
diff --git a/src/DesktopMagic/Pages/ThemePage.xaml b/src/DesktopMagic/Pages/ThemePage.xaml
index 9da1ee5..9bbe023 100644
--- a/src/DesktopMagic/Pages/ThemePage.xaml
+++ b/src/DesktopMagic/Pages/ThemePage.xaml
@@ -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">
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/DesktopMagic/Pages/ThemePage.xaml.cs b/src/DesktopMagic/Pages/ThemePage.xaml.cs
index a2c5877..410097f 100644
--- a/src/DesktopMagic/Pages/ThemePage.xaml.cs
+++ b/src/DesktopMagic/Pages/ThemePage.xaml.cs
@@ -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);
}
}
diff --git a/src/DesktopMagic/Plugins/PluginManager.xaml b/src/DesktopMagic/Plugins/PluginManager.xaml
index d27c0fb..b29c32a 100644
--- a/src/DesktopMagic/Plugins/PluginManager.xaml
+++ b/src/DesktopMagic/Plugins/PluginManager.xaml
@@ -31,19 +31,26 @@
+
+
-
-
+
+
+
+
+
+
+
-
+
@@ -56,7 +63,7 @@
-
+
@@ -69,7 +76,7 @@
-
+
@@ -82,7 +89,7 @@
-
+
diff --git a/src/DesktopMagic/Plugins/PluginManager.xaml.cs b/src/DesktopMagic/Plugins/PluginManager.xaml.cs
index 38bf2cb..3779b7b 100644
--- a/src/DesktopMagic/Plugins/PluginManager.xaml.cs
+++ b/src/DesktopMagic/Plugins/PluginManager.xaml.cs
@@ -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;
///
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 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 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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
+ }
+
+ ///
+ /// Fills the lists of the selected layout or theme category.
+ ///
+ private async Task PopulateShared()
+ {
+ pluginManagerDataContext.InstalledShared.Clear();
+ pluginManagerDataContext.AllShared.Clear();
+
+ if (pluginManagerDataContext.Category == ModCategory.Plugins)
{
- foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.InstalledPlugins)
+ return;
+ }
+
+ IEnumerable 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 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 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 mods = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList();
+ IReadOnlyList subscriptions = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList();
+ List mods = subscriptions.Where(ModIoService.IsPluginMod).ToList();
App.Logger.LogInfo($"Found {mods.Count} subscribed plugins on mod.io", source: "PluginManager");
List 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);
diff --git a/src/DesktopMagic/Plugins/SettingElement.cs b/src/DesktopMagic/Plugins/SettingElement.cs
index 5b2beba..7778177 100644
--- a/src/DesktopMagic/Plugins/SettingElement.cs
+++ b/src/DesktopMagic/Plugins/SettingElement.cs
@@ -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; }
+ ///
+ /// 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.
+ ///
+ 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]
diff --git a/src/DesktopMagic/Plugins/Theme.cs b/src/DesktopMagic/Plugins/Theme.cs
index 9f96709..8fadc22 100644
--- a/src/DesktopMagic/Plugins/Theme.cs
+++ b/src/DesktopMagic/Plugins/Theme.cs
@@ -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
{
diff --git a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml
index 4c31fd7..b0cccff 100644
--- a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml
+++ b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml
@@ -70,6 +70,30 @@
Veröffentlicht: {0}
Release Notes:
Release-Seite öffnen
+ Schließen
+ Überschreiben
+ Meine behalten
+ Auf mod.io teilen
+ Auf mod.io aktualisieren
+ Auf mod.io ansehen
+ Als neu auf mod.io teilen
+ Kurze Zusammenfassung für mod.io eingeben:
+ Was hat sich geändert? (optional)
+ 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.
+ Jeder auf mod.io kann dieses Theme herunterladen.
+ Diese Widgets sind lokale Plugins, die andere nicht installieren können: {0}
+ "{0}" wurde auf mod.io veröffentlicht.
+ Das Update von "{0}" wurde auf mod.io veröffentlicht.
+ Teilen auf mod.io fehlgeschlagen: {0}
+ Ihre mod.io-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.
+ "{0}" wurde auf mod.io aktualisiert, aber Sie haben es auch lokal geändert. Ihre Änderungen mit der neuen Version überschreiben?
+ Das leere Layout kann nicht geteilt werden!
+ Layouts
+ mod.io durchsuchen...
+ Installierte durchsuchen...
+ Nichts gefunden!
+ Installation von "{0}" fehlgeschlagen: {1}
+ Wird auf mod.io hochgeladen...
Unten
Mitte
diff --git a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml
index 6ab7306..ca28a1d 100644
--- a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml
+++ b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml
@@ -72,6 +72,30 @@
Published: {0}
Release notes:
Open release page
+ Close
+ Overwrite
+ Keep mine
+ Share on mod.io
+ Update on mod.io
+ View on mod.io
+ Share as new on mod.io
+ Enter a short summary shown on mod.io:
+ What changed? (optional)
+ 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.
+ Everyone on mod.io will be able to download this theme.
+ These widgets are local plugins that others cannot install: {0}
+ "{0}" was published on mod.io.
+ The update of "{0}" was published on mod.io.
+ Sharing on mod.io failed: {0}
+ Your mod.io session has expired. Please log in again.
+ "{0}" was updated on mod.io, but you also changed it locally. Overwrite your changes with the new version?
+ The empty layout cannot be shared!
+ Layouts
+ Search mod.io...
+ Search installed...
+ Nothing found!
+ Installing "{0}" failed: {1}
+ Uploading to mod.io...
Bottom
Middle
diff --git a/src/DesktopMagic/Settings/Layout.cs b/src/DesktopMagic/Settings/Layout.cs
index 60e157c..8191793 100644
--- a/src/DesktopMagic/Settings/Layout.cs
+++ b/src/DesktopMagic/Settings/Layout.cs
@@ -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 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
diff --git a/src/DesktopMagic/Settings/ModIoLink.cs b/src/DesktopMagic/Settings/ModIoLink.cs
new file mode 100644
index 0000000..80cd8cd
--- /dev/null
+++ b/src/DesktopMagic/Settings/ModIoLink.cs
@@ -0,0 +1,48 @@
+using System;
+
+namespace DesktopMagic.Settings;
+
+///
+/// Connects a local layout or theme to the mod.io mod it was published as or downloaded from.
+///
+public class ModIoLink
+{
+ public uint ModId { get; set; }
+
+ ///
+ /// The modfile (version) the local copy is based on.
+ ///
+ public uint ModfileId { get; set; }
+
+ ///
+ /// Hash of the shareable content at the last publish or download, used to detect local changes.
+ ///
+ public string? PublishedHash { get; set; }
+
+ ///
+ /// Whether the logged in mod.io user submitted the mod and is therefore allowed to upload new versions.
+ ///
+ public bool IsOwner { get; set; }
+
+ public Uri? ProfileUri { get; set; }
+}
+
+public enum ModIoShareState
+{
+ NotShared,
+ UpToDate,
+ Modified,
+ NotOwner
+}
+
+///
+/// A local item (layout or theme) that can be shared on mod.io.
+///
+public interface IModIoShareable
+{
+ string Name { get; }
+
+ ModIoLink? ModIo { get; set; }
+
+ ModIoShareState ShareState { get; set; }
+}