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