diff --git a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs index 80436a892e..81cd8d28a8 100644 --- a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs +++ b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs @@ -1,4 +1,5 @@ using DynamicData; +using DynamicData.Kernel; using LibHac; using LibHac.Common; using LibHac.Fs; @@ -741,6 +742,8 @@ namespace Ryujinx.UI.App.Common foreach (var application in applications) { it.AddOrUpdate(application); + LoadTitleUpdatesForApplication(application); + LoadDlcForApplication(application); } }); @@ -776,6 +779,71 @@ namespace Ryujinx.UI.App.Common } } + private void LoadTitleUpdatesForApplication(ApplicationData application) + { + _titleUpdates.Edit(it => + { + var savedUpdates = + TitleUpdatesHelper.LoadTitleUpdatesJson(_virtualFileSystem, application.IdBase); + it.AddOrUpdate(savedUpdates); + + var selectedUpdate = savedUpdates.FirstOrOptional(update => update.IsSelected); + + if (TryGetTitleUpdatesFromFile(application.Path, out var bundledUpdates)) + { + var savedUpdateLookup = savedUpdates.Select(update => update.Item1).ToHashSet(); + + bool addedNewUpdate = false; + foreach (var update in bundledUpdates) + { + if (!savedUpdateLookup.Contains(update)) + { + addedNewUpdate = true; + it.AddOrUpdate((update, false)); + } + } + + if (addedNewUpdate) + { + var gameUpdates = it.Items.Where(update => update.TitleUpdate.TitleIdBase == application.IdBase).ToList(); + TitleUpdatesHelper.SaveTitleUpdatesJson(_virtualFileSystem, application.IdBase, gameUpdates); + } + } + }); + } + + private void LoadDlcForApplication(ApplicationData application) + { + _downloadableContents.Edit(it => + { + var savedDlc = + DownloadableContentsHelper.LoadDownloadableContentsJson(_virtualFileSystem, application.IdBase); + it.AddOrUpdate(savedDlc); + + if (TryGetDownloadableContentFromFile(application.Path, out var bundledDlc)) + { + var savedDlcLookup = savedDlc.Select(dlc => dlc.Item1).ToHashSet(); + + bool addedNewDlc = false; + foreach (var dlc in bundledDlc) + { + if (!savedDlcLookup.Contains(dlc)) + { + addedNewDlc = true; + it.AddOrUpdate((dlc, true)); + } + } + + if (addedNewDlc) + { + var gameDlcs = it.Items.Where(dlc => dlc.Dlc.TitleIdBase == application.IdBase).ToList(); + DownloadableContentsHelper.SaveDownloadableContentsJson(_virtualFileSystem, application.IdBase, + gameDlcs); + } + } + }); + } + public void LoadTitleUpdates() { return; @@ -833,6 +901,23 @@ namespace Ryujinx.UI.App.Common }); } + private void SaveTitleUpdatesForGame(ulong titleIdBase) + { + var updates = TitleUpdates.Items.Where(update => update.TitleUpdate.TitleIdBase == titleIdBase).ToList(); + TitleUpdatesHelper.SaveTitleUpdatesJson(_virtualFileSystem, titleIdBase, updates); + } + + public void SaveTitleUpdatesForGame(ApplicationData application, List<(TitleUpdateModel, bool IsSelected)> updates) + { + _titleUpdates.Edit(it => + { + TitleUpdatesHelper.SaveTitleUpdatesJson(_virtualFileSystem, application.IdBase, updates); + + it.Remove(it.Items.Where(item => item.TitleUpdate.TitleIdBase == application.IdBase)); + it.AddOrUpdate(updates); + }); + } + public int AutoLoadDownloadableContents(List appDirs) { _cancellationToken = new CancellationTokenSource(); diff --git a/src/Ryujinx.UI.Common/Helper/TitleUpdatesHelper.cs b/src/Ryujinx.UI.Common/Helper/TitleUpdatesHelper.cs new file mode 100644 index 0000000000..9dc3d4f733 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/TitleUpdatesHelper.cs @@ -0,0 +1,162 @@ +using LibHac.Common; +using LibHac.Common.Keys; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.Ncm; +using LibHac.Ns; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Logging; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.Loaders.Processes.Extensions; +using Ryujinx.HLE.Utilities; +using Ryujinx.UI.Common.Configuration; +using Ryujinx.UI.Common.Models; +using System; +using System.Collections.Generic; +using System.IO; +using ContentType = LibHac.Ncm.ContentType; +using Path = System.IO.Path; +using SpanHelpers = LibHac.Common.SpanHelpers; +using TitleUpdateMetadata = Ryujinx.Common.Configuration.TitleUpdateMetadata; + +namespace Ryujinx.UI.Common.Helper +{ + public static class TitleUpdatesHelper + { + private static readonly TitleUpdateMetadataJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + + public static List<(TitleUpdateModel, bool IsSelected)> LoadTitleUpdatesJson(VirtualFileSystem vfs, ulong applicationIdBase) + { + var titleUpdatesJsonPath = PathToGameUpdatesJson(applicationIdBase); + + if (!File.Exists(titleUpdatesJsonPath)) + { + return []; + } + + try + { + var titleUpdateWindowData = JsonHelper.DeserializeFromFile(titleUpdatesJsonPath, _serializerContext.TitleUpdateMetadata); + return LoadTitleUpdates(vfs, titleUpdateWindowData, applicationIdBase); + } + catch + { + Logger.Warning?.Print(LogClass.Application, $"Failed to deserialize title update data for {applicationIdBase:x16} at {titleUpdatesJsonPath}"); + return []; + } + } + + public static void SaveTitleUpdatesJson(VirtualFileSystem vfs, ulong applicationIdBase, List<(TitleUpdateModel, bool IsSelected)> updates) + { + var titleUpdateWindowData = new TitleUpdateMetadata + { + Selected = "", + Paths = [], + }; + + foreach ((TitleUpdateModel update, bool isSelected) in updates) + { + titleUpdateWindowData.Paths.Add(update.Path); + if (isSelected) + { + if (!string.IsNullOrEmpty(titleUpdateWindowData.Selected)) + { + Logger.Error?.Print(LogClass.Application, + $"Tried to save two updates as 'IsSelected' for {applicationIdBase:x16}"); + return; + } + + titleUpdateWindowData.Selected = update.Path; + } + } + + var titleUpdatesJsonPath = PathToGameUpdatesJson(applicationIdBase); + JsonHelper.SerializeToFile(titleUpdatesJsonPath, titleUpdateWindowData, _serializerContext.TitleUpdateMetadata); + } + + private static List<(TitleUpdateModel, bool IsSelected)> LoadTitleUpdates(VirtualFileSystem vfs, TitleUpdateMetadata titleUpdateMetadata, ulong applicationIdBase) + { + var result = new List<(TitleUpdateModel, bool IsSelected)>(); + + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + + foreach (string path in titleUpdateMetadata.Paths) + { + if (!File.Exists(path)) + { + continue; + } + + try + { + using IFileSystem pfs = PartitionFileSystemUtils.OpenApplicationFileSystem(path, vfs); + + Dictionary updates = + pfs.GetContentData(ContentMetaType.Patch, vfs, checkLevel); + + Nca patchNca = null; + Nca controlNca = null; + + if (!updates.TryGetValue(applicationIdBase, out ContentMetaData content)) + { + continue; + } + + patchNca = content.GetNcaByType(vfs.KeySet, ContentType.Program); + controlNca = content.GetNcaByType(vfs.KeySet, ContentType.Control); + + if (controlNca == null || patchNca == null) + { + continue; + } + + ApplicationControlProperty controlData = new(); + + using UniqueRef nacpFile = new(); + + controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None) + .OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure(); + nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None) + .ThrowIfFailure(); + + var displayVersion = controlData.DisplayVersionString.ToString(); + var update = new TitleUpdateModel(content.ApplicationId, content.Version.Version, + displayVersion, path); + + result.Add((update, path == titleUpdateMetadata.Selected)); + } + catch (MissingKeyException exception) + { + Logger.Warning?.Print(LogClass.Application, + $"Your key set is missing a key with the name: {exception.Name}"); + } + catch (InvalidDataException) + { + Logger.Warning?.Print(LogClass.Application, + $"The header key is incorrect or missing and therefore the NCA header content type check has failed. Errored File: {path}"); + } + catch (IOException exception) + { + Logger.Warning?.Print(LogClass.Application, exception.Message); + } + catch (Exception exception) + { + Logger.Warning?.Print(LogClass.Application, + $"The file encountered was not of a valid type. File: '{path}' Error: {exception}"); + } + } + + return result; + } + + private static string PathToGameUpdatesJson(ulong applicationIdBase) + { + return Path.Combine(AppDataManager.GamesDirPath, applicationIdBase.ToString("x16"), "updates.json"); + } + } +} diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json index b9ba51d5df..6018b4a3cd 100644 --- a/src/Ryujinx/Assets/Locales/en_US.json +++ b/src/Ryujinx/Assets/Locales/en_US.json @@ -717,7 +717,8 @@ "DlcWindowTitle": "Manage Downloadable Content for {0} ({1})", "ModWindowTitle": "Manage Mods for {0} ({1})", "UpdateWindowTitle": "Title Update Manager", - "UpdateWindowDlcAddedMessage": "{0} new update(s) added", + "UpdateWindowUpdateAddedMessage": "{0} new update(s) added", + "UpdateWindowBundledContentNotice": "Bundled updates cannot be removed, only disabled.", "CheatWindowHeading": "Cheats Available for {0} [{1}]", "BuildId": "BuildId:", "DlcWindowBundledContentNotice": "Bundled DLC cannot be removed, only disabled.", diff --git a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs index ab833218bb..0689686504 100644 --- a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs +++ b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs @@ -86,7 +86,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { - await TitleUpdateWindow.Show(viewModel.VirtualFileSystem, viewModel.ApplicationLibrary, viewModel.SelectedApplication); + await TitleUpdateWindow.Show(viewModel.ApplicationLibrary, viewModel.SelectedApplication); } } @@ -96,7 +96,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { - await DownloadableContentManagerWindow.Show(viewModel.VirtualFileSystem, viewModel.ApplicationLibrary, viewModel.SelectedApplication); + await DownloadableContentManagerWindow.Show(viewModel.ApplicationLibrary, viewModel.SelectedApplication); } } diff --git a/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs b/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs index 18e17ff64b..18424df51b 100644 --- a/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs @@ -23,6 +23,7 @@ namespace Ryujinx.Ava.UI.ViewModels private AvaloniaList _downloadableContents = new(); private AvaloniaList _selectedDownloadableContents = new(); private AvaloniaList _views = new(); + private bool _showBundledContentNotice = false; private string _search; private readonly ApplicationData _applicationData; @@ -76,7 +77,17 @@ namespace Ryujinx.Ava.UI.ViewModels get => string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowHeading], DownloadableContents.Count); } - public DownloadableContentManagerViewModel(VirtualFileSystem virtualFileSystem, ApplicationLibrary applicationLibrary, ApplicationData applicationData) + public bool ShowBundledContentNotice + { + get => _showBundledContentNotice; + set + { + _showBundledContentNotice = value; + OnPropertyChanged(); + } + } + + public DownloadableContentManagerViewModel(ApplicationLibrary applicationLibrary, ApplicationData applicationData) { _applicationLibrary = applicationLibrary; @@ -94,9 +105,12 @@ namespace Ryujinx.Ava.UI.ViewModels { var dlcs = _applicationLibrary.DownloadableContents.Items .Where(it => it.Dlc.TitleIdBase == _applicationData.IdBase); + + bool hasBundledContent = false; foreach ((DownloadableContentModel dlc, bool isEnabled) in dlcs) { DownloadableContents.Add(dlc); + hasBundledContent = hasBundledContent || dlc.IsBundled; if (isEnabled) { @@ -106,6 +120,8 @@ namespace Ryujinx.Ava.UI.ViewModels OnPropertyChanged(nameof(UpdateCount)); } + ShowBundledContentNotice = hasBundledContent; + Sort(); } @@ -187,12 +203,18 @@ namespace Ryujinx.Ava.UI.ViewModels return false; } - if (!_applicationLibrary.TryGetDownloadableContentFromFile(path, out var dlcs)) + if (!_applicationLibrary.TryGetDownloadableContentFromFile(path, out var dlcs) || dlcs.Count == 0) { return false; } - foreach (var dlc in dlcs.Where(dlc => dlc.TitleIdBase == _applicationData.IdBase)) + var dlcsForThisGame = dlcs.Where(it => it.TitleIdBase == _applicationData.IdBase); + if (!dlcsForThisGame.Any()) + { + return false; + } + + foreach (var dlc in dlcsForThisGame) { if (!DownloadableContents.Contains(dlc)) { diff --git a/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs b/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs index 1af4552010..516908a6e8 100644 --- a/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs @@ -2,22 +2,17 @@ using Avalonia.Collections; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform.Storage; using Avalonia.Threading; -using DynamicData.Kernel; +using FluentAvalonia.UI.Controls; using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.UI.Helpers; -using Ryujinx.Common.Configuration; -using Ryujinx.Common.Logging; -using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; using Ryujinx.UI.App.Common; using Ryujinx.UI.Common.Models; -using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using Application = Avalonia.Application; -using Path = System.IO.Path; namespace Ryujinx.Ava.UI.ViewModels { @@ -25,18 +20,13 @@ namespace Ryujinx.Ava.UI.ViewModels public class TitleUpdateViewModel : BaseModel { - - public TitleUpdateMetadata TitleUpdateWindowData; - public readonly string TitleUpdateJsonPath; - private VirtualFileSystem VirtualFileSystem { get; } private ApplicationLibrary ApplicationLibrary { get; } private ApplicationData ApplicationData { get; } private AvaloniaList _titleUpdates = new(); private AvaloniaList _views = new(); private object _selectedUpdate = new TitleUpdateViewNoUpdateSentinal(); - - private static readonly TitleUpdateMetadataJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + private bool _showBundledContentNotice = false; public AvaloniaList TitleUpdates { @@ -68,11 +58,20 @@ namespace Ryujinx.Ava.UI.ViewModels } } + public bool ShowBundledContentNotice + { + get => _showBundledContentNotice; + set + { + _showBundledContentNotice = value; + OnPropertyChanged(); + } + } + public IStorageProvider StorageProvider; - public TitleUpdateViewModel(VirtualFileSystem virtualFileSystem, ApplicationLibrary applicationLibrary, ApplicationData applicationData) + public TitleUpdateViewModel(ApplicationLibrary applicationLibrary, ApplicationData applicationData) { - VirtualFileSystem = virtualFileSystem; ApplicationLibrary = applicationLibrary; ApplicationData = applicationData; @@ -82,43 +81,29 @@ namespace Ryujinx.Ava.UI.ViewModels StorageProvider = desktop.MainWindow.StorageProvider; } - TitleUpdateJsonPath = Path.Combine(AppDataManager.GamesDirPath, ApplicationData.IdBaseString, "updates.json"); - - try - { - TitleUpdateWindowData = JsonHelper.DeserializeFromFile(TitleUpdateJsonPath, _serializerContext.TitleUpdateMetadata); - } - catch - { - Logger.Warning?.Print(LogClass.Application, $"Failed to deserialize title update data for {ApplicationData.IdBaseString} at {TitleUpdateJsonPath}"); - - TitleUpdateWindowData = new TitleUpdateMetadata - { - Selected = "", - Paths = new List(), - }; - - Save(); - } - LoadUpdates(); } private void LoadUpdates() { - // Try to load updates from PFS first - AddUpdate(ApplicationData.Path, true); + var updates = ApplicationLibrary.TitleUpdates.Items + .Where(it => it.TitleUpdate.TitleIdBase == ApplicationData.IdBase); - foreach (string path in TitleUpdateWindowData.Paths) + bool hasBundledContent = false; + SelectedUpdate = new TitleUpdateViewNoUpdateSentinal(); + foreach ((TitleUpdateModel update, bool isSelected) in updates) { - AddUpdate(path); + TitleUpdates.Add(update); + hasBundledContent = hasBundledContent || update.IsBundled; + + if (isSelected) + { + SelectedUpdate = update; + } } - var selected = TitleUpdates.FirstOrOptional(x => x.Path == TitleUpdateWindowData.Selected); - SelectedUpdate = selected.HasValue ? selected.Value : new TitleUpdateViewNoUpdateSentinal(); + ShowBundledContentNotice = hasBundledContent; - // NOTE: Save the list again to remove leftovers. - Save(); SortUpdates(); } @@ -126,65 +111,76 @@ namespace Ryujinx.Ava.UI.ViewModels { var sortedUpdates = TitleUpdates.OrderByDescending(update => update.Version); + // NOTE(jpr): this works around a bug where calling Views.Clear also clears SelectedUpdate for + // some reason. so we save the item here and restore it after + var selected = SelectedUpdate; + Views.Clear(); Views.Add(new TitleUpdateViewNoUpdateSentinal()); Views.AddRange(sortedUpdates); + SelectedUpdate = selected; + if (SelectedUpdate is TitleUpdateViewNoUpdateSentinal) { SelectedUpdate = Views[0]; } + // this is mainly to handle a scenario where the user removes the selected update else if (!TitleUpdates.Contains((TitleUpdateModel)SelectedUpdate)) { SelectedUpdate = Views.Count > 1 ? Views[1] : Views[0]; } } - private void AddUpdate(string path, bool ignoreNotFound = false, bool selected = false) + private bool AddUpdate(string path, out int numUpdatesAdded) { - if (!File.Exists(path) || TitleUpdates.Any(x => x.Path == path)) + numUpdatesAdded = 0; + + if (!File.Exists(path)) { - return; + return false; } - try + if (!ApplicationLibrary.TryGetTitleUpdatesFromFile(path, out var updates)) { - if (!ApplicationLibrary.TryGetTitleUpdatesFromFile(path, out var titleUpdates)) + return false; + } + + var updatesForThisGame = updates.Where(it => it.TitleIdBase == ApplicationData.Id); + if (!updatesForThisGame.Any()) + { + return false; + } + + foreach (var update in updatesForThisGame) + { + if (!TitleUpdates.Contains(update)) { - if (!ignoreNotFound) - { - Dispatcher.UIThread.InvokeAsync(() => - ContentDialogHelper.CreateErrorDialog( - LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage])); - } + TitleUpdates.Add(update); + SelectedUpdate = update; - return; - } - - foreach (var titleUpdate in titleUpdates) - { - if (titleUpdate.TitleIdBase != ApplicationData.Id) - { - continue; - } - - TitleUpdates.Add(titleUpdate); - - if (selected) - { - Dispatcher.UIThread.InvokeAsync(() => SelectedUpdate = titleUpdate); - } + numUpdatesAdded++; } } - catch (Exception ex) + + if (numUpdatesAdded > 0) { - Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogLoadFileErrorMessage, ex.Message, path))); + SortUpdates(); } + + return true; } public void RemoveUpdate(TitleUpdateModel update) { - TitleUpdates.Remove(update); + if (!update.IsBundled) + { + TitleUpdates.Remove(update); + } + else if (update == SelectedUpdate as TitleUpdateModel) + { + SelectedUpdate = new TitleUpdateViewNoUpdateSentinal(); + } SortUpdates(); } @@ -205,30 +201,36 @@ namespace Ryujinx.Ava.UI.ViewModels }, }); + var totalUpdatesAdded = 0; foreach (var file in result) { - AddUpdate(file.Path.LocalPath, selected: true); + if (!AddUpdate(file.Path.LocalPath, out var newUpdatesAdded)) + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage]); + } + + totalUpdatesAdded += newUpdatesAdded; } - SortUpdates(); + if (totalUpdatesAdded > 0) + { + await ShowNewUpdatesAddedDialog(totalUpdatesAdded); + } } public void Save() { - TitleUpdateWindowData.Paths.Clear(); - TitleUpdateWindowData.Selected = ""; + var updates = TitleUpdates.Select(it => (it, it == SelectedUpdate as TitleUpdateModel)).ToList(); + ApplicationLibrary.SaveTitleUpdatesForGame(ApplicationData, updates); + } - foreach (TitleUpdateModel update in TitleUpdates) + private Task ShowNewUpdatesAddedDialog(int numAdded) + { + var msg = string.Format(LocaleManager.Instance[LocaleKeys.UpdateWindowUpdateAddedMessage], numAdded); + return Dispatcher.UIThread.InvokeAsync(async () => { - TitleUpdateWindowData.Paths.Add(update.Path); - - if (update == SelectedUpdate as TitleUpdateModel) - { - TitleUpdateWindowData.Selected = update.Path; - } - } - - JsonHelper.SerializeToFile(TitleUpdateJsonPath, TitleUpdateWindowData, _serializerContext.TitleUpdateMetadata); + await ContentDialogHelper.ShowTextDialog(LocaleManager.Instance[LocaleKeys.DialogConfirmationTitle], msg, "", "", "", LocaleManager.Instance[LocaleKeys.InputDialogOk], (int)Symbol.Checkmark); + }); } } } diff --git a/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml b/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml index 440d1bd6b9..d53074499b 100644 --- a/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml +++ b/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml @@ -28,7 +28,8 @@ Grid.Row="0" Margin="0 0 0 10" Spacing="5" - Orientation="Horizontal"> + Orientation="Horizontal" + IsVisible="{Binding ShowBundledContentNotice}"> + - + + + + +