├── .gitmodules ├── BeatSaberModManager ├── Resources │ ├── Icons │ │ └── Icon.ico │ ├── Images │ │ └── bsmg.jpg │ ├── Localization │ │ ├── Localizations.axaml │ │ ├── en.axaml │ │ └── de.axaml │ └── Styles │ │ ├── Brushes.axaml │ │ ├── HyperlinkButton.axaml │ │ ├── FluentTheme.axaml │ │ ├── Controls.axaml │ │ ├── IconHeader.axaml │ │ └── ProgressRing.axaml ├── ViewModels │ ├── ViewModelBase.cs │ ├── ModGridItemViewModel.cs │ ├── MainWindowViewModel.cs │ ├── AssetInstallWindowViewModel.cs │ └── DashboardViewModel.cs ├── Services │ ├── Interfaces │ │ ├── IStatusProgress.cs │ │ ├── IGameLauncher.cs │ │ ├── IGameVersionProvider.cs │ │ ├── IInstallDirValidator.cs │ │ ├── IUpdater.cs │ │ ├── IGamePathsProvider.cs │ │ ├── IHashProvider.cs │ │ ├── IInstallDirLocator.cs │ │ ├── IAssetProvider.cs │ │ ├── IProtocolHandlerRegistrar.cs │ │ ├── IModInstaller.cs │ │ ├── IDependencyResolver.cs │ │ └── IModProvider.cs │ └── Implementations │ │ ├── BeatSaber │ │ ├── BeatSaberInstallDirValidator.cs │ │ ├── ModelSaber │ │ │ ├── ModelSaberAssetProvider.cs │ │ │ └── ModelSaberModelInstaller.cs │ │ ├── Playlists │ │ │ ├── PlaylistAssetProvider.cs │ │ │ └── PlaylistInstaller.cs │ │ ├── BeatSaver │ │ │ └── BeatSaverAssetProvider.cs │ │ ├── BeatSaberGamePathsProvider.cs │ │ ├── BeatMods │ │ │ └── MD5HashProvider.cs │ │ ├── BeatSaberGameLauncher.cs │ │ ├── BeatSaberGameVersionProvider.cs │ │ └── BeatSaberInstallDirLocator.cs │ │ ├── Progress │ │ └── StatusProgress.cs │ │ ├── ProtocolHandlerRegistrars │ │ ├── WindowsProtocolHandlerRegistrar.cs │ │ └── LinuxProtocolHandlerRegistrar.cs │ │ ├── DependencyManagement │ │ └── SimpleDependencyResolver.cs │ │ ├── Observables │ │ └── DirectoryExistsObservable.cs │ │ ├── Settings │ │ └── JsonSettingsProvider.cs │ │ ├── Updater │ │ └── GitHubUpdater.cs │ │ └── Http │ │ └── HttpProgressClient.cs ├── Models │ ├── Implementations │ │ ├── Json │ │ │ ├── PlaylistJsonSerializerContext.cs │ │ │ ├── BeatSaverJsonSerializerContext.cs │ │ │ ├── CommonJsonSerializerContext.cs │ │ │ ├── GitHubJsonSerializerContext.cs │ │ │ ├── BeatModsJsonSerializerContext.cs │ │ │ ├── SettingsJsonSerializerContext.cs │ │ │ └── VersionConverter.cs │ │ ├── PlatformType.cs │ │ ├── BeatSaber │ │ │ ├── BeatSaver │ │ │ │ ├── BeatSaverMapVersion.cs │ │ │ │ ├── BeatSaverMapMetaData.cs │ │ │ │ └── BeatSaverMap.cs │ │ │ ├── Playlists │ │ │ │ ├── PlaylistSong.cs │ │ │ │ └── Playlist.cs │ │ │ └── BeatMods │ │ │ │ ├── BeatModsHash.cs │ │ │ │ ├── BeatModsDependency.cs │ │ │ │ ├── BeatModsDownload.cs │ │ │ │ └── BeatModsMod.cs │ │ ├── GitHub │ │ │ ├── Asset.cs │ │ │ └── Release.cs │ │ ├── Progress │ │ │ ├── StatusType.cs │ │ │ └── ProgressInfo.cs │ │ └── Settings │ │ │ └── AppSettings.cs │ └── Interfaces │ │ ├── ISettings.cs │ │ └── IMod.cs ├── Views │ ├── Pages │ │ ├── IntroPage.axaml.cs │ │ ├── IntroPage.axaml │ │ ├── ModsPage.axaml.cs │ │ ├── SettingsPage.axaml.cs │ │ ├── DashboardPage.axaml.cs │ │ └── ModsPage.axaml │ ├── App.axaml │ ├── Converters │ │ ├── IsUpToDateColorConverter.cs │ │ ├── TopOnlyThicknessConverter.cs │ │ └── StatusTypeEnumConverter.cs │ ├── Theming │ │ ├── Theme.cs │ │ └── ThemeManager.cs │ ├── Localization │ │ ├── Language.cs │ │ └── LocalizationManager.cs │ ├── Windows │ │ ├── ExceptionWindow.axaml.cs │ │ ├── MainWindow.axaml.cs │ │ ├── InstallFolderDialogWindow.axaml.cs │ │ ├── ExceptionWindow.axaml │ │ ├── AssetInstallWindow.axaml.cs │ │ ├── InstallFolderDialogWindow.axaml │ │ ├── AssetInstallWindow.axaml │ │ └── MainWindow.axaml │ ├── Controls │ │ ├── HyperlinkButton.cs │ │ ├── DataGridFuncGroupDescription.cs │ │ ├── IconHeader.cs │ │ ├── ProgressRing.cs │ │ ├── SearchableDataGrid.cs │ │ └── HamburgerMenu.cs │ ├── Helpers │ │ └── ResourceKeyBindingHelper.cs │ └── App.axaml.cs ├── Utils │ ├── PlatformUtils.cs │ └── IOUtils.cs ├── TrimmerRoots.xml ├── Startup.cs └── BeatSaberModManager.csproj ├── LICENSE ├── PKGBUILD ├── BeatSaberModManager.sln ├── README.md └── .github └── workflows └── main.yml /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "BSIPA-Linux"] 2 | path = BSIPA-Linux 3 | url = https://github.com/geefr/BSIPA-Linux.git 4 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Icons/Icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affederaffe/BeatSaberModManager/HEAD/BeatSaberModManager/Resources/Icons/Icon.ico -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Images/bsmg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affederaffe/BeatSaberModManager/HEAD/BeatSaberModManager/Resources/Images/bsmg.jpg -------------------------------------------------------------------------------- /BeatSaberModManager/ViewModels/ViewModelBase.cs: -------------------------------------------------------------------------------- 1 | using ReactiveUI; 2 | 3 | 4 | namespace BeatSaberModManager.ViewModels 5 | { 6 | /// 7 | /// Defines a ViewModel. 8 | /// 9 | public class ViewModelBase : ReactiveObject; 10 | } 11 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Localization/Localizations.axaml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Styles/Brushes.axaml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | #FF8B0000 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IStatusProgress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using BeatSaberModManager.Models.Implementations.Progress; 4 | 5 | 6 | namespace BeatSaberModManager.Services.Interfaces 7 | { 8 | /// 9 | /// Defines a provider for progress updates. 10 | /// 11 | public interface IStatusProgress : IProgress, IProgress; 12 | } 13 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Json/PlaylistJsonSerializerContext.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | using BeatSaberModManager.Models.Implementations.BeatSaber.Playlists; 4 | 5 | 6 | namespace BeatSaberModManager.Models.Implementations.Json 7 | { 8 | [JsonSerializable(typeof(Playlist))] 9 | internal sealed partial class PlaylistJsonSerializerContext : JsonSerializerContext; 10 | } 11 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Json/BeatSaverJsonSerializerContext.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | using BeatSaberModManager.Models.Implementations.BeatSaber.BeatSaver; 4 | 5 | 6 | namespace BeatSaberModManager.Models.Implementations.Json 7 | { 8 | [JsonSerializable(typeof(BeatSaverMap))] 9 | internal sealed partial class BeatSaverJsonSerializerContext : JsonSerializerContext; 10 | } 11 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Styles/HyperlinkButton.axaml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Json/CommonJsonSerializerContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | 5 | namespace BeatSaberModManager.Models.Implementations.Json 6 | { 7 | [JsonSerializable(typeof(string[]))] 8 | [JsonSerializable(typeof(Dictionary))] 9 | internal sealed partial class CommonJsonSerializerContext : JsonSerializerContext; 10 | } 11 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Json/GitHubJsonSerializerContext.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | using BeatSaberModManager.Models.Implementations.GitHub; 4 | 5 | 6 | namespace BeatSaberModManager.Models.Implementations.Json 7 | { 8 | [JsonSerializable(typeof(Asset))] 9 | [JsonSerializable(typeof(Release))] 10 | internal sealed partial class GitHubJsonSerializerContext : JsonSerializerContext; 11 | } 12 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Json/BeatModsJsonSerializerContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | using BeatSaberModManager.Models.Implementations.BeatSaber.BeatMods; 5 | 6 | 7 | namespace BeatSaberModManager.Models.Implementations.Json 8 | { 9 | [JsonSerializable(typeof(HashSet))] 10 | internal sealed partial class BeatModsModJsonSerializerContext : JsonSerializerContext; 11 | } 12 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Styles/FluentTheme.axaml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IGameLauncher.cs: -------------------------------------------------------------------------------- 1 | namespace BeatSaberModManager.Services.Interfaces 2 | { 3 | /// 4 | /// Provides a method to launch the game. 5 | /// 6 | public interface IGameLauncher 7 | { 8 | /// 9 | /// Launches the game. 10 | /// 11 | /// The game's installation directory. 12 | void LaunchGame(string installDir); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Json/SettingsJsonSerializerContext.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | using BeatSaberModManager.Models.Implementations.Settings; 4 | 5 | 6 | namespace BeatSaberModManager.Models.Implementations.Json 7 | { 8 | [JsonSerializable(typeof(AppSettings))] 9 | [JsonSourceGenerationOptions(WriteIndented = true, IgnoreReadOnlyProperties = true)] 10 | internal sealed partial class SettingsJsonSerializerContext : JsonSerializerContext; 11 | } 12 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Pages/IntroPage.axaml.cs: -------------------------------------------------------------------------------- 1 | using Avalonia.Controls; 2 | 3 | 4 | namespace BeatSaberModManager.Views.Pages 5 | { 6 | /// 7 | /// View for informational purposes. 8 | /// 9 | public partial class IntroPage : UserControl 10 | { 11 | /// 12 | /// Initializes a new instance of the class. 13 | /// 14 | public IntroPage() 15 | { 16 | InitializeComponent(); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/PlatformType.cs: -------------------------------------------------------------------------------- 1 | namespace BeatSaberModManager.Models.Implementations 2 | { 3 | /// 4 | /// Indicates from which store the game has been installed. 5 | /// 6 | public enum PlatformType 7 | { 8 | /// 9 | /// The game was installed through Steam. 10 | /// 11 | Steam, 12 | 13 | /// 14 | /// The game was installed through the Oculus store. 15 | /// 16 | Oculus 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/BeatSaber/BeatSaver/BeatSaverMapVersion.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | 5 | namespace BeatSaberModManager.Models.Implementations.BeatSaber.BeatSaver 6 | { 7 | /// 8 | /// A version of a . 9 | /// 10 | public class BeatSaverMapVersion 11 | { 12 | /// 13 | /// The url to download the map from. 14 | /// 15 | [JsonPropertyName("downloadURL")] 16 | public required Uri DownloadUrl { get; init; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/BeatSaberInstallDirValidator.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.IO; 3 | 4 | using BeatSaberModManager.Services.Interfaces; 5 | 6 | 7 | namespace BeatSaberModManager.Services.Implementations.BeatSaber 8 | { 9 | /// 10 | public class BeatSaberInstallDirValidator : IInstallDirValidator 11 | { 12 | /// 13 | public bool ValidateInstallDir([NotNullWhen(true)] string? path) => 14 | !string.IsNullOrEmpty(path) && File.Exists(Path.Join(path, "Beat Saber.exe")); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IGameVersionProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | 4 | namespace BeatSaberModManager.Services.Interfaces 5 | { 6 | /// 7 | /// Provides a method to detect a game's version. 8 | /// 9 | public interface IGameVersionProvider 10 | { 11 | /// 12 | /// Asynchronously detects a game's version. 13 | /// 14 | /// The game's installation directory. 15 | /// The game's version, or null when failed. 16 | Task DetectGameVersionAsync(string installDir); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IInstallDirValidator.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | 4 | namespace BeatSaberModManager.Services.Interfaces 5 | { 6 | /// 7 | /// Provides a method to validate a game's installation. 8 | /// 9 | public interface IInstallDirValidator 10 | { 11 | /// 12 | /// Validates the game's installation. 13 | /// 14 | /// The path of the directory. 15 | /// True if the installation is valid, false otherwise. 16 | bool ValidateInstallDir([NotNullWhen(true)] string? path); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Pages/IntroPage.axaml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/BeatSaber/Playlists/PlaylistSong.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | 4 | namespace BeatSaberModManager.Models.Implementations.BeatSaber.Playlists 5 | { 6 | /// 7 | /// A song of a . 8 | /// 9 | public class PlaylistSong 10 | { 11 | /// 12 | /// The map's unique identifier on https://beatsaver.com. 13 | /// 14 | [JsonPropertyName("key")] 15 | public string? Id { get; init; } 16 | 17 | /// 18 | /// The map's hash. 19 | /// 20 | [JsonPropertyName("hash")] 21 | public string? Hash { get; init; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/GitHub/Asset.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | 5 | namespace BeatSaberModManager.Models.Implementations.GitHub 6 | { 7 | /// 8 | /// An asset of a . 9 | /// 10 | public class Asset 11 | { 12 | /// 13 | /// The name of the asset. 14 | /// 15 | [JsonPropertyName("name")] 16 | public required string Name { get; init; } 17 | 18 | /// 19 | /// The url to download the asset from. 20 | /// 21 | [JsonPropertyName("browser_download_url")] 22 | public required Uri DownloadUrl { get; init; } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/BeatSaber/BeatMods/BeatModsHash.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | 4 | namespace BeatSaberModManager.Models.Implementations.BeatSaber.BeatMods 5 | { 6 | /// 7 | /// A file of a and it's MD5 hash. 8 | /// 9 | public class BeatModsHash 10 | { 11 | /// 12 | /// The MD5 hash for the . 13 | /// 14 | [JsonPropertyName("hash")] 15 | public required string Hash { get; init; } 16 | 17 | /// 18 | /// The relative file path. 19 | /// 20 | [JsonPropertyName("file")] 21 | public required string File { get; init; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Interfaces/ISettings.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | 4 | namespace BeatSaberModManager.Models.Interfaces 5 | { 6 | /// 7 | /// Provides a generic getter for settings. 8 | /// 9 | /// The type of the settings class. 10 | public interface ISettings 11 | { 12 | /// 13 | /// Gets the loaded settings instance. 14 | /// 15 | T Value { get; } 16 | 17 | /// 18 | /// Asynchronously loads the config. 19 | /// 20 | Task LoadAsync(); 21 | 22 | /// 23 | /// Save the config to disk. 24 | /// 25 | Task SaveAsync(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/GitHub/Release.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | 5 | namespace BeatSaberModManager.Models.Implementations.GitHub 6 | { 7 | /// 8 | /// A release on https://github.com. 9 | /// 10 | public class Release 11 | { 12 | /// 13 | /// The tag of the . 14 | /// 15 | [JsonPropertyName("tag_name")] 16 | public required string TagName { get; init; } 17 | 18 | /// 19 | /// All assets of the . 20 | /// 21 | [JsonPropertyName("assets")] 22 | public required IReadOnlyList Assets { get; init; } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/BeatSaber/BeatSaver/BeatSaverMapMetaData.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | 4 | namespace BeatSaberModManager.Models.Implementations.BeatSaber.BeatSaver 5 | { 6 | /// 7 | /// Additional metadata for a . 8 | /// 9 | public class BeatSaverMapMetaData 10 | { 11 | /// 12 | /// The name of the mapper. 13 | /// 14 | [JsonPropertyName("levelAuthorName")] 15 | public required string LevelAuthorName { get; init; } 16 | 17 | /// 18 | /// The name of the song. 19 | /// 20 | [JsonPropertyName("songName")] 21 | public required string SongName { get; init; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/App.axaml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IUpdater.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | 4 | namespace BeatSaberModManager.Services.Interfaces 5 | { 6 | /// 7 | /// Defines a method for updating the application. 8 | /// 9 | public interface IUpdater 10 | { 11 | /// 12 | /// Asynchronously checks for an update of the application. 13 | /// 14 | /// True when a newer version is available, false otherwise. 15 | ValueTask NeedsUpdateAsync(); 16 | 17 | /// 18 | /// Asynchronously updates the application. 19 | /// 20 | /// 0 if the update succeeds, -1 otherwise. 21 | Task UpdateAsync(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Converters/IsUpToDateColorConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | 4 | using Avalonia.Data.Converters; 5 | using Avalonia.Media; 6 | 7 | 8 | namespace BeatSaberModManager.Views.Converters 9 | { 10 | /// 11 | /// Converts a boolean value to an . 12 | /// 13 | public class IsUpToDateColorConverter : IValueConverter 14 | { 15 | /// 16 | public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => 17 | value is bool b ? b ? Brushes.Green : Brushes.Red : null; 18 | 19 | /// 20 | public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => 21 | throw new NotSupportedException(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/BeatSaber/BeatMods/BeatModsDependency.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | using BeatSaberModManager.Models.Interfaces; 4 | 5 | 6 | namespace BeatSaberModManager.Models.Implementations.BeatSaber.BeatMods 7 | { 8 | /// 9 | /// Defines a dependency for a . 10 | /// 11 | public class BeatModsDependency 12 | { 13 | /// 14 | /// The name of the dependency. 15 | /// 16 | [JsonPropertyName("name")] 17 | public required string Name { get; init; } 18 | 19 | /// 20 | /// Cached to avoid finding it again by . 21 | /// 22 | [JsonIgnore] 23 | public IMod? DependingMod { get; set; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Styles/Controls.axaml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/BeatSaber/BeatMods/BeatModsDownload.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.Json.Serialization; 4 | 5 | 6 | namespace BeatSaberModManager.Models.Implementations.BeatSaber.BeatMods 7 | { 8 | /// 9 | /// Provides a download link as well as the mod's files and their hashes. 10 | /// 11 | public class BeatModsDownload 12 | { 13 | /// 14 | /// The url to download the mod from. 15 | /// 16 | [JsonPropertyName("url")] 17 | public required Uri Url { get; init; } 18 | 19 | /// 20 | /// The mod's files and their corresponding hashes. 21 | /// 22 | [JsonPropertyName("hashMd5")] 23 | public required IReadOnlyList Hashes { get; init; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Theming/Theme.cs: -------------------------------------------------------------------------------- 1 | using Avalonia.Styling; 2 | 3 | 4 | namespace BeatSaberModManager.Views.Theming 5 | { 6 | /// 7 | /// Represents a theme for Avalonia. 8 | /// 9 | public class Theme 10 | { 11 | /// 12 | /// Initializes a new instance of the class. 13 | /// 14 | public Theme(string name, ThemeVariant themeVariant) 15 | { 16 | Name = name; 17 | ThemeVariant = themeVariant; 18 | } 19 | 20 | /// 21 | /// The name of the theme. 22 | /// 23 | public string Name { get; } 24 | 25 | /// 26 | /// The associated of the theme. 27 | /// 28 | public ThemeVariant ThemeVariant { get; } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IGamePathsProvider.cs: -------------------------------------------------------------------------------- 1 | namespace BeatSaberModManager.Services.Interfaces 2 | { 3 | /// 4 | /// Defines a method to get a game's AppData directory. 5 | /// 6 | public interface IGamePathsProvider 7 | { 8 | /// 9 | /// Gets the AppData directory of a game. 10 | /// 11 | /// The game's installation directory. 12 | /// The path of the game's AppData directory. 13 | string GetAppDataPath(string installDir); 14 | 15 | /// 16 | /// Gets the Logs directory of a game. 17 | /// 18 | /// The game's installation directory. 19 | /// The path of the game's Logs directory. 20 | string GetLogsPath(string installDir); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Json/VersionConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | 6 | namespace BeatSaberModManager.Models.Implementations.Json 7 | { 8 | /// 9 | public class VersionConverter : JsonConverter 10 | { 11 | /// 12 | public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => 13 | Version.TryParse(reader.GetString() ?? string.Empty, out Version? version) ? version : new Version(0, 0, 0); 14 | 15 | /// 16 | public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options) 17 | { 18 | ArgumentNullException.ThrowIfNull(writer); 19 | ArgumentNullException.ThrowIfNull(value); 20 | writer.WriteStringValue(value.ToString()); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Progress/StatusType.cs: -------------------------------------------------------------------------------- 1 | namespace BeatSaberModManager.Models.Implementations.Progress 2 | { 3 | /// 4 | /// Represents the status of an operation. 5 | /// 6 | public enum StatusType 7 | { 8 | /// 9 | /// There is no running operation. 10 | /// 11 | None, 12 | 13 | /// 14 | /// The operation is currently installing a resource. 15 | /// 16 | Installing, 17 | 18 | /// 19 | /// The operation is currently uninstalling a resource. 20 | /// 21 | Uninstalling, 22 | 23 | /// 24 | /// The operation ran to completion successfully. 25 | /// 26 | Completed, 27 | 28 | /// 29 | /// The operation failed. 30 | /// 31 | Failed 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/BeatSaber/Playlists/Playlist.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | 5 | namespace BeatSaberModManager.Models.Implementations.BeatSaber.Playlists 6 | { 7 | /// 8 | /// A playlist of multiple songs. 9 | /// 10 | public class Playlist 11 | { 12 | /// 13 | /// The title of the playlist. 14 | /// 15 | [JsonPropertyName("playlistTitle")] 16 | public required string PlaylistTitle { get; init; } 17 | 18 | /// 19 | /// The author of the playlist. 20 | /// 21 | [JsonPropertyName("playlistAuthor")] 22 | public required string PlaylistAuthor { get; init; } 23 | 24 | /// 25 | /// The songs included in the playlist. 26 | /// 27 | [JsonPropertyName("songs")] 28 | public required IReadOnlyList Songs { get; init; } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Localization/Language.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | using Avalonia.Controls; 4 | 5 | 6 | namespace BeatSaberModManager.Views.Localization 7 | { 8 | /// 9 | /// Represents a language used for localization. 10 | /// 11 | public class Language 12 | { 13 | /// 14 | /// Initializes a new instance of the class. 15 | /// 16 | public Language(CultureInfo cultureInfo, IResourceProvider resourceProvider) 17 | { 18 | CultureInfo = cultureInfo; 19 | ResourceProvider = resourceProvider; 20 | } 21 | 22 | /// 23 | /// The of the language. 24 | /// 25 | public CultureInfo CultureInfo { get; } 26 | 27 | /// 28 | /// The resources used for localization. 29 | /// 30 | public IResourceProvider ResourceProvider { get; } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Converters/TopOnlyThicknessConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | 4 | using Avalonia; 5 | using Avalonia.Data.Converters; 6 | 7 | 8 | namespace BeatSaberModManager.Views.Converters 9 | { 10 | /// 11 | /// Converts a to only use its value. 12 | /// 13 | public class TopOnlyThicknessConverter : IValueConverter 14 | { 15 | /// 16 | public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => 17 | value switch 18 | { 19 | Thickness thickness => new Thickness(0, thickness.Top, 0, 0), 20 | double d => new Thickness(0, d, 0, 0), 21 | _ => new Thickness() 22 | }; 23 | 24 | /// 25 | public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new InvalidOperationException(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IHashProvider.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | 4 | 5 | namespace BeatSaberModManager.Services.Interfaces 6 | { 7 | /// 8 | /// Provides methods to calculate hashes. 9 | /// 10 | public interface IHashProvider 11 | { 12 | /// 13 | /// Asynchronously calculates the hash for a file. 14 | /// 15 | /// The path of the file. 16 | /// The string representation of the hash, or null when failed to read the file. 17 | Task CalculateHashForFileAsync(string path); 18 | 19 | /// 20 | /// Asynchronously calculates the hash for a . 21 | /// 22 | /// The to calculate the hash from. 23 | /// The string representation of the hash. 24 | Task CalculateHashForStreamAsync(Stream stream); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IInstallDirLocator.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | using BeatSaberModManager.Models.Implementations; 4 | 5 | 6 | namespace BeatSaberModManager.Services.Interfaces 7 | { 8 | /// 9 | /// Provides methods to locate an game's installation and detect the . 10 | /// 11 | public interface IInstallDirLocator 12 | { 13 | /// 14 | /// Asynchronously locates a game's installation directory, optionally asynchronously. 15 | /// 16 | /// The installation directory of the game if found, null otherwise. 17 | ValueTask LocateInstallDirAsync(); 18 | 19 | /// 20 | /// Detects the of an installation. 21 | /// 22 | /// The game's installation directory. 23 | /// The detected . 24 | PlatformType DetectPlatform(string installDir); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Interfaces/IMod.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | 4 | namespace BeatSaberModManager.Models.Interfaces 5 | { 6 | /// 7 | /// Defines a mod. 8 | /// 9 | public interface IMod 10 | { 11 | /// 12 | /// The mod's display name. 13 | /// 14 | string Name { get; } 15 | 16 | /// 17 | /// The mod's version. 18 | /// 19 | Version Version { get; } 20 | 21 | /// 22 | /// The description or summary of the mod. 23 | /// 24 | string Description { get; } 25 | 26 | /// 27 | /// The category of the mod. 28 | /// 29 | string Category { get; } 30 | 31 | /// 32 | /// A link which provides more resources about the mod. 33 | /// 34 | Uri MoreInfoLink { get; } 35 | 36 | /// 37 | /// Indicates if the mod must be installed. 38 | /// 39 | bool IsRequired { get; } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 affederaffe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/ModelSaber/ModelSaberAssetProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | using BeatSaberModManager.Services.Interfaces; 5 | 6 | 7 | namespace BeatSaberModManager.Services.Implementations.BeatSaber.ModelSaber 8 | { 9 | /// 10 | public class ModelSaberAssetProvider : IAssetProvider 11 | { 12 | private readonly ModelSaberModelInstaller _modelSaberModelInstaller; 13 | 14 | /// 15 | /// Initializes a new instance of the class. 16 | /// 17 | public ModelSaberAssetProvider(ModelSaberModelInstaller modelSaberModelInstaller) 18 | { 19 | _modelSaberModelInstaller = modelSaberModelInstaller; 20 | } 21 | 22 | /// 23 | public string Protocol => "modelsaber"; 24 | 25 | /// 26 | public Task InstallAssetAsync(string installDir, Uri uri, IStatusProgress? progress = null) 27 | => _modelSaberModelInstaller.InstallModelAsync(installDir, uri, progress); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IAssetProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | 5 | namespace BeatSaberModManager.Services.Interfaces 6 | { 7 | /// 8 | /// Defines a method to install additional assets like maps, models or playlists. 9 | /// 10 | public interface IAssetProvider 11 | { 12 | /// 13 | /// The protocol that the specific implementation handles. 14 | /// 15 | string Protocol { get; } 16 | 17 | /// 18 | /// Asynchronously downloads and installs an asset. 19 | /// 20 | /// The game's installation directory. 21 | /// The to download the asset from.
22 | /// The has to match . 23 | /// Optionally track the progress of the operation. 24 | /// True when the installation succeeded, false otherwise. 25 | Task InstallAssetAsync(string installDir, Uri uri, IStatusProgress? progress = null); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/Playlists/PlaylistAssetProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | using BeatSaberModManager.Services.Interfaces; 5 | 6 | 7 | namespace BeatSaberModManager.Services.Implementations.BeatSaber.Playlists 8 | { 9 | /// 10 | public class PlaylistAssetProvider : IAssetProvider 11 | { 12 | private readonly PlaylistInstaller _playlistInstaller; 13 | 14 | /// 15 | public string Protocol => "bsplaylist"; 16 | 17 | /// 18 | /// Initializes a new instance of the class. 19 | /// 20 | public PlaylistAssetProvider(PlaylistInstaller playlistInstaller) 21 | { 22 | _playlistInstaller = playlistInstaller; 23 | } 24 | 25 | /// 26 | public Task InstallAssetAsync(string installDir, Uri uri, IStatusProgress? progress = null) 27 | { 28 | ArgumentNullException.ThrowIfNull(uri); 29 | return _playlistInstaller.InstallPlaylistAsync(installDir, new Uri(uri.PathAndQuery[1..]), progress); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/BeatSaver/BeatSaverAssetProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | using BeatSaberModManager.Services.Interfaces; 5 | 6 | 7 | namespace BeatSaberModManager.Services.Implementations.BeatSaber.BeatSaver 8 | { 9 | /// 10 | public class BeatSaverAssetProvider : IAssetProvider 11 | { 12 | private readonly BeatSaverMapInstaller _beatSaverMapInstaller; 13 | 14 | /// 15 | /// Initializes a new instance of the class. 16 | /// 17 | public BeatSaverAssetProvider(BeatSaverMapInstaller beatSaverMapInstaller) 18 | { 19 | _beatSaverMapInstaller = beatSaverMapInstaller; 20 | } 21 | 22 | /// 23 | public string Protocol => "beatsaver"; 24 | 25 | /// 26 | public Task InstallAssetAsync(string installDir, Uri uri, IStatusProgress? progress = null) 27 | { 28 | ArgumentNullException.ThrowIfNull(uri); 29 | return _beatSaverMapInstaller.InstallBeatSaverMapByKeyAsync(installDir, uri.Host, progress); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname="beatsabermodmanager" 2 | _pkgname="BeatSaberModManager" 3 | pkgver="0.0.1" 4 | pkgrel="1" 5 | pkgdesc="Yet another mod installer for Beat Saber, heavily inspired by ModAssistant" 6 | arch=("x86_64") 7 | url="https://github.com/affederaffe/BeatSaberModManager" 8 | license=("MIT") 9 | depends=("dotnet-runtime") 10 | makedepends=("dotnet-sdk" "git" "imagemagick" "gendesk") 11 | options=("!strip") 12 | source=("$url/archive/v$pkgver.tar.gz") 13 | sha256sums=("e83160d6d64ebf9ca8516ce44de09a74a640b8725f5ffe3d08774655f96d2c6a") 14 | 15 | build() { 16 | cd "$_pkgname-$pkgver" 17 | git clone https://github.com/geefr/BSIPA-Linux.git 18 | dotnet publish -c Release -r linux-x64 --no-self-contained -p:EnableSingleFileAnalyzer=false --output ../$_pkgname 19 | } 20 | 21 | package() { 22 | convert "$_pkgname/Resources/Icons/Icon.ico" "$pkgname.png" 23 | gendesk -n --pkgname "$pkgname" --name "$_pkgname" --pkgdesc "$pkgdesc" --comment "$pkgdesc" --categories "Game;Utility" --icon "$pkgname.png" 24 | install -Dm755 "$_pkgname/$_pkgname" "$pkgdir/usr/bin/$pkgname" 25 | install -Dm644 "$pkgname.png" "$pkgdir/usr/share/pixmaps/$pkgname.png" 26 | install -Dm644 "$pkgname.desktop" "$pkgdir/usr/share/applications/$pkgname.desktop" 27 | } 28 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Windows/ExceptionWindow.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using Avalonia; 4 | using Avalonia.Controls; 5 | using Avalonia.Interactivity; 6 | 7 | 8 | namespace BeatSaberModManager.Views.Windows 9 | { 10 | /// 11 | /// Dialog that displays an . 12 | /// 13 | public partial class ExceptionWindow : Window 14 | { 15 | /// 16 | /// [Required by Avalonia] 17 | /// 18 | public ExceptionWindow() { } 19 | 20 | /// 21 | /// Initializes a new instance of the class. 22 | /// 23 | /// The to display. 24 | public ExceptionWindow(Exception e) 25 | { 26 | ArgumentNullException.ThrowIfNull(e); 27 | InitializeComponent(); 28 | ExtendClientAreaToDecorationsHint = !OperatingSystem.IsLinux(); 29 | Margin = ExtendClientAreaToDecorationsHint ? WindowDecorationMargin : new Thickness(); 30 | ExceptionTextBlock.Text = e.ToString(); 31 | } 32 | 33 | private void OkButtonClicked(object? sender, RoutedEventArgs e) => Close(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IProtocolHandlerRegistrar.cs: -------------------------------------------------------------------------------- 1 | namespace BeatSaberModManager.Services.Interfaces 2 | { 3 | /// 4 | /// Registers and unregister the application to handle protocols. 5 | /// 6 | public interface IProtocolHandlerRegistrar 7 | { 8 | /// 9 | /// Checks if the application is already registered as a protocol handler for the specified protocol. 10 | /// 11 | /// The protocol to check for. 12 | /// True if the application is registered as a protocol handler, false otherwise. 13 | bool IsProtocolHandlerRegistered(string protocol); 14 | 15 | /// 16 | /// Registers the application as a handler for the specified protocol. 17 | /// 18 | /// The protocol or scheme. 19 | void RegisterProtocolHandler(string protocol); 20 | 21 | /// 22 | /// Unregisters the application as a handler for the specified protocol. 23 | /// 24 | /// The protocol or scheme. 25 | void UnregisterProtocolHandler(string protocol); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Pages/ModsPage.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using Avalonia.Collections; 4 | using Avalonia.ReactiveUI; 5 | 6 | using BeatSaberModManager.ViewModels; 7 | using BeatSaberModManager.Views.Controls; 8 | 9 | 10 | namespace BeatSaberModManager.Views.Pages 11 | { 12 | /// 13 | /// View for installing and uninstalling mods. 14 | /// 15 | public partial class ModsPage : ReactiveUserControl 16 | { 17 | /// 18 | /// [Required by Avalonia] 19 | /// 20 | public ModsPage() { } 21 | 22 | /// 23 | /// Initializes a new instance of the class. 24 | /// 25 | public ModsPage(ModsViewModel viewModel) 26 | { 27 | ArgumentNullException.ThrowIfNull(viewModel); 28 | InitializeComponent(); 29 | ViewModel = viewModel; 30 | ModsDataGrid.ItemsSource = new DataGridCollectionView(viewModel.GridItems) 31 | { 32 | GroupDescriptions = 33 | { 34 | new DataGridFuncGroupDescription(static x => x.AvailableMod.Category) 35 | } 36 | }; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/BeatSaber/BeatSaver/BeatSaverMap.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | 5 | namespace BeatSaberModManager.Models.Implementations.BeatSaber.BeatSaver 6 | { 7 | /// 8 | /// A Beatmap from https://beatsaver.com. 9 | /// 10 | public class BeatSaverMap 11 | { 12 | /// 13 | /// The map's unique identifier on https://beatsaver.com. 14 | /// 15 | [JsonPropertyName("id")] 16 | public required string Id { get; init; } 17 | 18 | /// 19 | /// The name of the map. 20 | /// 21 | [JsonPropertyName("name")] 22 | public required string Name { get; init; } 23 | 24 | /// 25 | /// Additional metadata. 26 | /// 27 | [JsonPropertyName("metadata")] 28 | public required BeatSaverMapMetaData MetaData { get; init; } 29 | 30 | /// 31 | /// All versions of the map.
32 | /// The last entry corresponds to the latest version. 33 | ///
34 | [JsonPropertyName("versions")] 35 | public required IReadOnlyList Versions { get; init; } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/BeatSaberGamePathsProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Runtime.Versioning; 4 | 5 | using BeatSaberModManager.Services.Interfaces; 6 | 7 | 8 | namespace BeatSaberModManager.Services.Implementations.BeatSaber 9 | { 10 | /// 11 | public class BeatSaberGamePathsProvider : IGamePathsProvider 12 | { 13 | /// 14 | public string GetAppDataPath(string installDir) => 15 | OperatingSystem.IsWindows() ? GetWindowsAppDataPath() 16 | : OperatingSystem.IsLinux() ? GetLinuxAppDataPath(installDir) 17 | : throw new PlatformNotSupportedException(); 18 | 19 | /// 20 | public string GetLogsPath(string installDir) => Path.Join(installDir, "Logs"); 21 | 22 | [SupportedOSPlatform("windows")] 23 | private static string GetWindowsAppDataPath() => Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "Hyperbolic Magnetism"); 24 | 25 | [SupportedOSPlatform("linux")] 26 | private static string GetLinuxAppDataPath(string installDir) => Path.Join(installDir, "../../compatdata/620980/pfx/drive_c/users/steamuser/AppData/LocalLow/Hyperbolic Magnetism"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Controls/HyperlinkButton.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using Avalonia; 4 | using Avalonia.Controls; 5 | 6 | using BeatSaberModManager.Utils; 7 | 8 | 9 | namespace BeatSaberModManager.Views.Controls 10 | { 11 | /// 12 | /// A button that opens the specified when clicked. 13 | /// 14 | public class HyperlinkButton : Button 15 | { 16 | /// 17 | /// Defines the property. 18 | /// 19 | public static readonly DirectProperty UriProperty = AvaloniaProperty.RegisterDirect(nameof(Uri), static o => o.Uri, static (o, v) => o.Uri = v); 20 | 21 | /// 22 | /// The uri to open. 23 | /// 24 | public Uri? Uri 25 | { 26 | get => _uri; 27 | set => SetAndRaise(UriProperty, ref _uri, value); 28 | } 29 | 30 | private Uri? _uri; 31 | 32 | /// 33 | /// Opens the when it's valid and the is enabled. 34 | /// 35 | protected override void OnClick() 36 | { 37 | if (IsEffectivelyEnabled && Uri is not null) 38 | PlatformUtils.TryOpenUri(Uri); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BeatSaberModManager.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeatSaberModManager", "BeatSaberModManager\BeatSaberModManager.csproj", "{BFAAAF7F-EFD8-425A-A1EC-E48E08095EEA}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IPA-lib", "BSIPA-Linux\IPA-lib\IPA-lib.csproj", "{5203504D-A4DE-49CB-8C4F-D74BEA4384BF}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {BFAAAF7F-EFD8-425A-A1EC-E48E08095EEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {BFAAAF7F-EFD8-425A-A1EC-E48E08095EEA}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {BFAAAF7F-EFD8-425A-A1EC-E48E08095EEA}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {BFAAAF7F-EFD8-425A-A1EC-E48E08095EEA}.Release|Any CPU.Build.0 = Release|Any CPU 17 | {5203504D-A4DE-49CB-8C4F-D74BEA4384BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {5203504D-A4DE-49CB-8C4F-D74BEA4384BF}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {5203504D-A4DE-49CB-8C4F-D74BEA4384BF}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {5203504D-A4DE-49CB-8C4F-D74BEA4384BF}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/BeatMods/MD5HashProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.IO; 4 | using System.Security.Cryptography; 5 | using System.Threading.Tasks; 6 | 7 | using BeatSaberModManager.Services.Interfaces; 8 | using BeatSaberModManager.Utils; 9 | 10 | 11 | namespace BeatSaberModManager.Services.Implementations.BeatSaber.BeatMods 12 | { 13 | /// 14 | public class MD5HashProvider : IHashProvider 15 | { 16 | /// 17 | public async Task CalculateHashForFileAsync(string path) 18 | { 19 | #pragma warning disable CA2007 20 | await using FileStream? fileStream = IOUtils.TryOpenFile(path, FileMode.Open, FileAccess.Read); 21 | #pragma warning restore CA2007 22 | return fileStream is null ? null : await CalculateHashForStreamAsync(fileStream).ConfigureAwait(false); 23 | } 24 | 25 | /// 26 | [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] 27 | public async Task CalculateHashForStreamAsync(Stream stream) 28 | { 29 | using MD5 md5 = MD5.Create(); 30 | byte[] hash = await md5.ComputeHashAsync(stream).ConfigureAwait(false); 31 | return Convert.ToHexString(hash); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IModInstaller.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | using BeatSaberModManager.Models.Interfaces; 4 | 5 | 6 | namespace BeatSaberModManager.Services.Interfaces 7 | { 8 | /// 9 | /// Provides methods to install and uninstall mods. 10 | /// 11 | public interface IModInstaller 12 | { 13 | /// 14 | /// Asynchronously installs multiple mods. 15 | /// 16 | /// The game's installation directory. 17 | /// The mod to install. 18 | /// True if the operation succeeds, false otherwise. 19 | Task InstallModAsync(string installDir, IMod modification); 20 | 21 | /// 22 | /// Asynchronously uninstalls multiple mods. 23 | /// 24 | /// The game's installation directory. 25 | /// The mod to uninstall. 26 | Task UninstallModAsync(string installDir, IMod modification); 27 | 28 | /// 29 | /// Removes all installed mods. 30 | /// 31 | /// The game's installation directory. 32 | void RemoveAllModFiles(string installDir); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Windows/MainWindow.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reactive.Linq; 4 | 5 | using Avalonia; 6 | using Avalonia.Controls; 7 | using Avalonia.Controls.Chrome; 8 | using Avalonia.Controls.Primitives; 9 | using Avalonia.ReactiveUI; 10 | using Avalonia.VisualTree; 11 | 12 | using BeatSaberModManager.ViewModels; 13 | 14 | using ReactiveUI; 15 | 16 | 17 | namespace BeatSaberModManager.Views.Windows 18 | { 19 | /// 20 | /// Standard top-level view of the application. 21 | /// 22 | public partial class MainWindow : ReactiveWindow 23 | { 24 | /// 25 | /// [Required by Avalonia] 26 | /// 27 | public MainWindow() { } 28 | 29 | /// 30 | /// Initializes a new instance of the class. 31 | /// 32 | public MainWindow(MainWindowViewModel viewModel) 33 | { 34 | ArgumentNullException.ThrowIfNull(viewModel); 35 | InitializeComponent(); 36 | ViewModel = viewModel; 37 | ExtendClientAreaToDecorationsHint = !OperatingSystem.IsLinux(); 38 | viewModel.PickInstallDirInteraction.RegisterHandler(async context => context.SetOutput(await new InstallFolderDialogWindow().ShowDialog(this).ConfigureAwait(false))); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/Progress/StatusProgress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reactive.Subjects; 3 | 4 | using BeatSaberModManager.Models.Implementations.Progress; 5 | using BeatSaberModManager.Services.Interfaces; 6 | 7 | 8 | namespace BeatSaberModManager.Services.Implementations.Progress 9 | { 10 | /// 11 | public sealed class StatusProgress : IStatusProgress, IDisposable 12 | { 13 | private readonly Subject _progressValue = new(); 14 | private readonly Subject _progressInfo = new(); 15 | 16 | /// 17 | /// Signals when the progress value changes. 18 | /// 19 | public IObservable ProgressValue => _progressValue; 20 | 21 | /// 22 | /// Signals when the progress info changes. 23 | /// 24 | public IObservable ProgressInfo => _progressInfo; 25 | 26 | /// 27 | public void Report(double value) => _progressValue.OnNext(value * 100); 28 | 29 | /// 30 | public void Report(ProgressInfo value) => _progressInfo.OnNext(value); 31 | 32 | /// 33 | public void Dispose() 34 | { 35 | _progressValue.Dispose(); 36 | _progressInfo.Dispose(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Windows/InstallFolderDialogWindow.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reactive.Linq; 3 | 4 | using Avalonia; 5 | using Avalonia.Controls; 6 | using Avalonia.Interactivity; 7 | using Avalonia.Platform.Storage; 8 | 9 | using ReactiveUI; 10 | 11 | 12 | namespace BeatSaberModManager.Views.Windows 13 | { 14 | /// 15 | /// Dialog that asks the user to manually select the game's installation directory. 16 | /// 17 | public partial class InstallFolderDialogWindow : Window 18 | { 19 | /// 20 | /// Initializes a new instance of the class. 21 | /// 22 | public InstallFolderDialogWindow() 23 | { 24 | InitializeComponent(); 25 | ExtendClientAreaToDecorationsHint = !OperatingSystem.IsLinux(); 26 | Margin = ExtendClientAreaToDecorationsHint ? WindowDecorationMargin : new Thickness(); 27 | ContinueButton.GetObservable(Button.ClickEvent) 28 | .SelectMany(_ => StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions())) 29 | .Where(static x => x.Count == 1) 30 | .Select(static x => x[0].Path.LocalPath) 31 | .ObserveOn(RxApp.MainThreadScheduler) 32 | .Subscribe(Close); 33 | } 34 | 35 | private void OnCancelButtonClicked(object? sender, RoutedEventArgs e) => Close(null); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Controls/DataGridFuncGroupDescription.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | 5 | using Avalonia.Collections; 6 | 7 | 8 | namespace BeatSaberModManager.Views.Controls 9 | { 10 | /// 11 | /// A reflection-free . 12 | /// 13 | public class DataGridFuncGroupDescription : DataGridGroupDescription 14 | { 15 | private readonly Func _selector; 16 | 17 | /// 18 | /// Initializes a new instance of the class. 19 | /// 20 | /// A that returns the group key for an item. 21 | public DataGridFuncGroupDescription(Func selector) 22 | { 23 | _selector = selector; 24 | } 25 | 26 | /// 27 | public override object GroupKeyFromItem(object item, int level, CultureInfo culture) 28 | { 29 | object? result = null; 30 | if (item is TItem tItem) 31 | result = _selector.Invoke(tItem); 32 | return result ?? item; 33 | } 34 | 35 | /// 36 | public override bool KeysMatch(object groupKey, object itemKey) => 37 | groupKey is TKey tGroupKey && itemKey is TKey tItemKey && EqualityComparer.Default.Equals(tGroupKey, tItemKey); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/BeatSaberGameLauncher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | 4 | using BeatSaberModManager.Models.Implementations; 5 | using BeatSaberModManager.Services.Interfaces; 6 | using BeatSaberModManager.Utils; 7 | 8 | 9 | namespace BeatSaberModManager.Services.Implementations.BeatSaber 10 | { 11 | /// 12 | public class BeatSaberGameLauncher : IGameLauncher 13 | { 14 | private readonly IInstallDirLocator _installDirLocator; 15 | 16 | /// 17 | /// Initializes a new instance. 18 | /// 19 | public BeatSaberGameLauncher(IInstallDirLocator installDirLocator) 20 | { 21 | _installDirLocator = installDirLocator; 22 | } 23 | 24 | /// 25 | public void LaunchGame(string installDir) 26 | { 27 | switch (_installDirLocator.DetectPlatform(installDir)) 28 | { 29 | case PlatformType.Steam: 30 | PlatformUtils.TryOpenUri(new Uri("steam://rungameid/620980")); 31 | break; 32 | case PlatformType.Oculus: 33 | PlatformUtils.TryStartProcess(new ProcessStartInfo("Beat Saber.exe") { WorkingDirectory = installDir }, out _); 34 | break; 35 | default: 36 | throw new InvalidOperationException("Could not detect platform."); 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Windows/ExceptionWindow.axaml: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IDependencyResolver.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | using BeatSaberModManager.Models.Interfaces; 4 | 5 | 6 | namespace BeatSaberModManager.Services.Interfaces 7 | { 8 | /// 9 | /// Provides methods to manage dependencies of s. 10 | /// 11 | public interface IDependencyResolver 12 | { 13 | /// 14 | /// Checks if other s depend on . 15 | /// 16 | /// The to check. 17 | /// True if is a dependency, false otherwise. 18 | bool IsDependency(IMod modification); 19 | 20 | /// 21 | /// Resolves all dependencies for and adds it as an dependent. 22 | /// 23 | /// The to resolve the dependencies for. 24 | /// All affected dependencies of . 25 | IEnumerable ResolveDependencies(IMod modification); 26 | 27 | /// 28 | /// Resolves all dependencies for and removes it as an dependent. 29 | /// 30 | /// The to unresolve the dependencies for. 31 | /// All affected dependencies of . 32 | IEnumerable UnresolveDependencies(IMod modification); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Converters/StatusTypeEnumConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | 4 | using Avalonia.Data.Converters; 5 | 6 | using BeatSaberModManager.Models.Implementations.Progress; 7 | 8 | 9 | namespace BeatSaberModManager.Views.Converters 10 | { 11 | /// 12 | /// Converts the of a to a localized string. 13 | /// 14 | public class StatusTypeEnumConverter : IValueConverter 15 | { 16 | 17 | /// 18 | public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => 19 | value is StatusType progressInfo ? Convert(progressInfo) : null; 20 | 21 | /// 22 | /// Converts the of a to a localized string 23 | /// 24 | /// The to convert 25 | /// The localized string 26 | public static string Convert(StatusType statusType) => 27 | statusType switch 28 | { 29 | StatusType.Installing => "Status:Installing", 30 | StatusType.Uninstalling => "Status:Uninstalling", 31 | StatusType.Completed => "Status:Completed", 32 | StatusType.Failed => "Status:Failed", 33 | _ => string.Empty 34 | }; 35 | 36 | /// 37 | public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Styles/IconHeader.axaml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 19 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /BeatSaberModManager/Utils/PlatformUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Diagnostics.CodeAnalysis; 4 | 5 | 6 | namespace BeatSaberModManager.Utils 7 | { 8 | /// 9 | /// Utilities for platform specific operations. 10 | /// 11 | internal static class PlatformUtils 12 | { 13 | /// 14 | /// Attempts to use the standard program to open the . 15 | /// 16 | /// The uri to open. 17 | /// True if the operation succeeds, false otherwise. 18 | public static bool TryOpenUri(Uri uri) => 19 | OperatingSystem.IsWindows() 20 | ? TryStartProcess(new ProcessStartInfo(uri.LocalPath) { UseShellExecute = true }, out _) 21 | : OperatingSystem.IsLinux() && TryStartProcess(new ProcessStartInfo("xdg-open", $"\"{uri}\""), out _); 22 | 23 | /// 24 | /// Attempts to start a new process. 25 | /// 26 | /// The information used to start the process. 27 | /// The when the operation succeeds. 28 | /// True if the operation succeeds, false otherwise. 29 | public static bool TryStartProcess(ProcessStartInfo startInfo, [NotNullWhen(true)] out Process? process) 30 | { 31 | try 32 | { 33 | process = Process.Start(startInfo); 34 | return process is not null; 35 | } 36 | catch (InvalidOperationException) { } 37 | catch (PlatformNotSupportedException) { } 38 | 39 | process = null; 40 | return false; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /BeatSaberModManager/TrimmerRoots.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Pages/SettingsPage.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | using Avalonia.Controls; 6 | using Avalonia.Platform.Storage; 7 | using Avalonia.ReactiveUI; 8 | 9 | using BeatSaberModManager.ViewModels; 10 | using BeatSaberModManager.Views.Localization; 11 | using BeatSaberModManager.Views.Theming; 12 | 13 | 14 | namespace BeatSaberModManager.Views.Pages 15 | { 16 | /// 17 | /// View for user settings. 18 | /// 19 | public partial class SettingsPage : ReactiveUserControl 20 | { 21 | /// 22 | /// [Required by Avalonia] 23 | /// 24 | public SettingsPage() { } 25 | 26 | /// 27 | /// Initializes a new instance of the class. 28 | /// 29 | public SettingsPage(SettingsViewModel viewModel, Window window, LocalizationManager localizationManager, ThemeManager themeManager) 30 | { 31 | ArgumentNullException.ThrowIfNull(viewModel); 32 | InitializeComponent(); 33 | LanguagesComboBox.DataContext = localizationManager; 34 | ThemesComboBox.DataContext = themeManager; 35 | ViewModel = viewModel; 36 | viewModel.PickInstallDirInteraction.RegisterHandler(async context => context.SetOutput(await SelectInstallDirAsync(window).ConfigureAwait(false))); 37 | } 38 | 39 | private static async Task SelectInstallDirAsync(TopLevel window) 40 | { 41 | IReadOnlyList folders = await window.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions()).ConfigureAwait(false); 42 | return folders.Count == 1 ? folders[0].TryGetLocalPath() : null; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Settings/AppSettings.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Globalization; 3 | 4 | 5 | namespace BeatSaberModManager.Models.Implementations.Settings 6 | { 7 | /// 8 | /// Application-wide settings. 9 | /// 10 | public sealed class AppSettings 11 | { 12 | /// 13 | /// The index of the tab that was last open. 14 | /// 15 | public int TabIndex { get; set; } 16 | 17 | /// 18 | /// The name of the theme used. 19 | /// 20 | public string? ThemeName { get; set; } 21 | 22 | /// 23 | /// The of the language used. 24 | /// 25 | public string? LanguageCode { get; set; } 26 | 27 | /// 28 | /// True if already installed mods should be reinstalled, false otherwise. 29 | /// 30 | public bool ForceReinstallMods { get; set; } 31 | 32 | /// 33 | /// True if the OneClick installation window should automatically close, false otherwise. 34 | /// 35 | public bool CloseOneClickWindow { get; set; } = true; 36 | 37 | /// 38 | /// True if selected mods should be saved and restored on restart, false otherwise. 39 | /// 40 | public bool SaveSelectedMods { get; set; } = true; 41 | 42 | /// 43 | /// The game's installation directory. 44 | /// 45 | public string? InstallDir { get; set; } 46 | 47 | /// 48 | /// A collection of all selected mods. 49 | /// 50 | public HashSet SelectedMods => _selectedMods ??= new HashSet(); 51 | private HashSet? _selectedMods; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Controls/IconHeader.cs: -------------------------------------------------------------------------------- 1 | using Avalonia; 2 | using Avalonia.Controls; 3 | using Avalonia.Controls.Primitives; 4 | using Avalonia.Media; 5 | 6 | 7 | namespace BeatSaberModManager.Views.Controls 8 | { 9 | /// 10 | /// A Header with a dockable icon. 11 | /// 12 | public class IconHeader : TemplatedControl 13 | { 14 | /// 15 | public static readonly StyledProperty ContentProperty = ContentControl.ContentProperty.AddOwner(); 16 | 17 | /// 18 | /// Defines the IconDataProperty. 19 | /// 20 | public static readonly StyledProperty IconDataProperty = AvaloniaProperty.Register(nameof(IconData)); 21 | 22 | /// 23 | /// Defines the TextPositionProperty. 24 | /// 25 | public static readonly StyledProperty IconPlacementProperty = AvaloniaProperty.Register(nameof(IconPlacement), Dock.Top); 26 | 27 | /// 28 | public object? Content 29 | { 30 | get => GetValue(ContentProperty); 31 | set => SetValue(ContentProperty, value); 32 | } 33 | 34 | /// 35 | /// Gets or sets the icon data of the IconHeader. 36 | /// 37 | public Geometry IconData 38 | { 39 | get => GetValue(IconDataProperty); 40 | set => SetValue(IconDataProperty, value); 41 | } 42 | 43 | /// 44 | /// Gets or sets the content placement of the IconHeader. 45 | /// 46 | public Dock IconPlacement 47 | { 48 | get => GetValue(IconPlacementProperty); 49 | set => SetValue(IconPlacementProperty, value); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/BeatSaber/BeatMods/BeatModsMod.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.Json.Serialization; 4 | 5 | using BeatSaberModManager.Models.Implementations.Json; 6 | using BeatSaberModManager.Models.Interfaces; 7 | 8 | 9 | namespace BeatSaberModManager.Models.Implementations.BeatSaber.BeatMods 10 | { 11 | /// 12 | public class BeatModsMod : IMod 13 | { 14 | /// 15 | [JsonPropertyName("name")] 16 | public required string Name { get; init; } 17 | 18 | /// 19 | [JsonPropertyName("version")] 20 | [JsonConverter(typeof(VersionConverter))] 21 | public required Version Version { get; init; } 22 | 23 | /// 24 | [JsonPropertyName("description")] 25 | public required string Description { get; init; } 26 | 27 | /// 28 | [JsonPropertyName("category")] 29 | public required string Category { get; init; } 30 | 31 | /// 32 | [JsonPropertyName("link")] 33 | public required Uri MoreInfoLink { get; init; } 34 | 35 | /// 36 | [JsonPropertyName("required")] 37 | public required bool IsRequired { get; init; } 38 | 39 | /// 40 | /// The downloads for the mod. 41 | /// 42 | [JsonPropertyName("downloads")] 43 | public required IReadOnlyList Downloads { get; init; } 44 | 45 | /// 46 | /// The dependencies of the mod. 47 | /// 48 | [JsonPropertyName("dependencies")] 49 | public required IReadOnlyList Dependencies { get; init; } 50 | 51 | /// 52 | public override int GetHashCode() => HashCode.Combine(Name, Version); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /BeatSaberModManager/Models/Implementations/Progress/ProgressInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | 4 | namespace BeatSaberModManager.Models.Implementations.Progress 5 | { 6 | /// 7 | /// Represents information about the current operation. 8 | /// 9 | public readonly struct ProgressInfo : IEquatable 10 | { 11 | /// 12 | /// Initializes a new instance of the struct. 13 | /// 14 | public ProgressInfo(StatusType statusType, string? text) 15 | { 16 | StatusType = statusType; 17 | Text = text; 18 | } 19 | 20 | /// 21 | /// The status of the current operation. 22 | /// 23 | public StatusType StatusType { get; } 24 | 25 | /// 26 | /// The message to display. 27 | /// 28 | public string? Text { get; } 29 | 30 | /// 31 | public override bool Equals(object? obj) => obj is ProgressInfo progressInfo && this == progressInfo; 32 | 33 | /// 34 | public override int GetHashCode() => HashCode.Combine((int)StatusType, Text); 35 | 36 | /// 37 | /// 38 | /// 39 | /// 40 | /// 41 | /// 42 | public static bool operator ==(ProgressInfo left, ProgressInfo right) => left.Equals(right); 43 | 44 | /// 45 | /// 46 | /// 47 | /// 48 | /// 49 | /// 50 | public static bool operator !=(ProgressInfo left, ProgressInfo right) => !(left == right); 51 | 52 | /// 53 | public bool Equals(ProgressInfo other) => StatusType == other.StatusType && Text == other.Text; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Windows/AssetInstallWindow.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reactive.Linq; 3 | 4 | using Avalonia; 5 | using Avalonia.Controls; 6 | using Avalonia.ReactiveUI; 7 | 8 | using BeatSaberModManager.ViewModels; 9 | using BeatSaberModManager.Views.Converters; 10 | 11 | using ReactiveUI; 12 | 13 | 14 | namespace BeatSaberModManager.Views.Windows 15 | { 16 | /// 17 | /// Top-level view to display progress information when using the '--install' flag. 18 | /// 19 | public partial class AssetInstallWindow : ReactiveWindow 20 | { 21 | /// 22 | /// [Required by Avalonia] 23 | /// 24 | public AssetInstallWindow() { } 25 | 26 | /// 27 | /// Initializes a new instance of the class. 28 | /// 29 | public AssetInstallWindow(AssetInstallWindowViewModel viewModel) 30 | { 31 | ArgumentNullException.ThrowIfNull(viewModel); 32 | InitializeComponent(); 33 | ViewModel = viewModel; 34 | ExtendClientAreaToDecorationsHint = !OperatingSystem.IsLinux(); 35 | Margin = ExtendClientAreaToDecorationsHint ? WindowDecorationMargin : new Thickness(); 36 | viewModel.ProgressInfoObservable 37 | .Select(x => $"{(this.TryFindResource(StatusTypeEnumConverter.Convert(x.StatusType), out object? value) ? value : null)}: {x.Text}") 38 | .ObserveOn(RxApp.MainThreadScheduler) 39 | .Subscribe(x => ViewModel.Log.Insert(0, x)); 40 | IObservable executeObservable = ViewModel.InstallCommand.Execute(); 41 | if (viewModel.CloseOneClickWindow) 42 | executeObservable.Delay(TimeSpan.FromMilliseconds(2000)).ObserveOn(RxApp.MainThreadScheduler).Subscribe(_ => Close()); 43 | else 44 | executeObservable.Subscribe(); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Pages/DashboardPage.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | using Avalonia.Controls; 6 | using Avalonia.Platform.Storage; 7 | using Avalonia.ReactiveUI; 8 | 9 | using BeatSaberModManager.ViewModels; 10 | 11 | 12 | namespace BeatSaberModManager.Views.Pages 13 | { 14 | /// 15 | /// View for additional information and tools. 16 | /// 17 | public partial class DashboardPage : ReactiveUserControl 18 | { 19 | private FilePickerOpenOptions? _filePickerOpenOptions; 20 | private FilePickerOpenOptions FilePickerOpenOptions => _filePickerOpenOptions ??= new FilePickerOpenOptions 21 | { 22 | FileTypeFilter = new[] 23 | { 24 | new FilePickerFileType("BeatSaber Playlist") 25 | { 26 | Patterns = new [] { "*.bplist" } 27 | } 28 | } 29 | }; 30 | 31 | /// 32 | /// [Required by Avalonia] 33 | /// 34 | public DashboardPage() { } 35 | 36 | /// 37 | /// Initializes a new instance of the class. 38 | /// 39 | public DashboardPage(DashboardViewModel viewModel, Window window) 40 | { 41 | ArgumentNullException.ThrowIfNull(viewModel); 42 | InitializeComponent(); 43 | ViewModel = viewModel; 44 | viewModel.PickPlaylistInteraction.RegisterHandler(async context => context.SetOutput(await SelectPlaylistFileAsync(window).ConfigureAwait(false))); 45 | } 46 | 47 | private async Task SelectPlaylistFileAsync(TopLevel window) 48 | { 49 | IReadOnlyList files = await window.StorageProvider.OpenFilePickerAsync(FilePickerOpenOptions).ConfigureAwait(false); 50 | return files.Count == 1 ? files[0].TryGetLocalPath() : null; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Windows/InstallFolderDialogWindow.axaml: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 23 | 24 | 25 | 29 | 35 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BeatSaberModManager 2 | 3 | ### About 4 | BeatSaberModManager is yet another mod installer for Beat Saber, heavily inspired by [ModAssistant](https://github.com/Assistant/ModAssistant). \ 5 | It strives to look more visually appealing and support both Windows and Linux, while still being as feature-rich as ModAssistant. 6 | 7 | ### Features 8 | - Windows and Linux support 9 | - Dependency resolution 10 | - Installed mod detection 11 | - Mod uninstallation 12 | - OneClick™ support for [BeatSaver](https://beatsaver.com), [ModelSaber](https://modelsaber.com) and [Playlists](https://bsaber.com/category/playlists) 13 | 14 | ### Installation 15 | - Download the latest version for you platform from [Releases](https://github.com/affederaffe/BeatSaberModManager/releases) 16 | - Run the executable 17 | 18 | ### Usage 19 | 1. **Run the game at least once before trying to mod the game!** 20 | 2. Now check / uncheck the mods you wish to install or uninstall and press Update Mods. 21 | Unlike ModAssistant, there is no extra Uninstall button, installed mods that are unchecked will automatically be uninstalled. 22 | 3. Mods are installed to `IPA/Pending` until the game is run. Boot the game to complete mod installation. 23 | 24 | ### Common issues 25 | **I hit install, but I don't see anything in game!** \ 26 | Double check that you followed the [Usage](#Usage) instructions correctly. 27 | Make sure you're looking in the right place. Sometimes mod menus move as modding libraries/practices change. 28 | 29 | **I don't see a certain mod in the mods list!** \ 30 | Mod Assistant uses mods from [BeatMods](https://beatmods.com/) and shows whatever is available for download. If you need to install a mod manually, please refer to the [Beat Saber Modding Group Wiki](https://bsmg.wiki/pc-modding.html#manual-installation). 31 | 32 | **I hit install but now my game won't launch, I can't click any buttons, I only see a black screen, etc** \ 33 | Please visit the [Beat Saber Modding Group](https://discord.gg/beatsabermods) `#pc-help` channels. Check the pinned messages or ask for help and see if you can work things out. 34 | 35 | ### Credits 36 | ModAssistant by Assistant: https://github.com/Assistant/ModAssistant \ 37 | BSIPA-Linux by geefr: https://github.com/geefr/BSIPA-Linux 38 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Helpers/ResourceKeyBindingHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using Avalonia; 4 | using Avalonia.Controls; 5 | using Avalonia.Markup.Xaml.MarkupExtensions; 6 | 7 | 8 | namespace BeatSaberModManager.Views.Helpers 9 | { 10 | /// 11 | /// Helper for using a with a as the key. 12 | /// 13 | public sealed class ResourceKeyBindingHelper 14 | { 15 | /// 16 | /// Defines the SourceResourceKeyProperty property. 17 | /// 18 | public static readonly AttachedProperty SourceResourceKeyProperty = AvaloniaProperty.RegisterAttached("SourceResourceKey"); 19 | 20 | static ResourceKeyBindingHelper() 21 | { 22 | SourceResourceKeyProperty.Changed.AddClassHandler(OnSourceResourceKeyChanged); 23 | } 24 | 25 | private static void OnSourceResourceKeyChanged(ContentControl element, AvaloniaPropertyChangedEventArgs args) 26 | { 27 | if (args.NewValue is not null) 28 | element[!ContentControl.ContentProperty] = new DynamicResourceExtension(args.NewValue); 29 | } 30 | 31 | /// 32 | /// Helper for setting SourceResourceKey property on a Control. 33 | /// 34 | /// Control to set SourceResourceKey property on. 35 | /// SourceResourceKey property value. 36 | public static void SetSourceResourceKey(ContentControl? element, object value) 37 | { 38 | ArgumentNullException.ThrowIfNull(element); 39 | element.SetValue(SourceResourceKeyProperty, value); 40 | } 41 | 42 | /// 43 | /// Helper for reading Column property from a Control. 44 | /// 45 | /// Control to read SourceResourceKey property from. 46 | /// Column property value. 47 | public static object GetSourceResourceKey(ContentControl element) 48 | { 49 | ArgumentNullException.ThrowIfNull(element); 50 | return element.GetValue(SourceResourceKeyProperty); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Theming/ThemeManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | using Avalonia; 6 | using Avalonia.Styling; 7 | 8 | using BeatSaberModManager.Models.Implementations.Settings; 9 | using BeatSaberModManager.Models.Interfaces; 10 | 11 | using ReactiveUI; 12 | 13 | 14 | namespace BeatSaberModManager.Views.Theming 15 | { 16 | /// 17 | /// Load and apply internal and external s. 18 | /// 19 | public class ThemeManager : ReactiveObject 20 | { 21 | private readonly ISettings _appSettings; 22 | 23 | /// 24 | /// Initializes a new instance of the class. 25 | /// 26 | public ThemeManager(ISettings appSettings) 27 | { 28 | _appSettings = appSettings; 29 | Themes = new Theme[] 30 | { 31 | new("Themes:System", ThemeVariant.Default), 32 | new("Themes:Light", ThemeVariant.Light), 33 | new("Themes:Dark", ThemeVariant.Dark) 34 | }; 35 | 36 | _selectedTheme = Themes.FirstOrDefault(x => x.Name == appSettings.Value.ThemeName) ?? Themes[0]; 37 | } 38 | 39 | /// 40 | /// A collection of all available s. 41 | /// 42 | public IReadOnlyList Themes { get; } 43 | 44 | /// 45 | /// The currently selected . 46 | /// 47 | public Theme SelectedTheme 48 | { 49 | get => _selectedTheme!; 50 | set => this.RaiseAndSetIfChanged(ref _selectedTheme, value); 51 | } 52 | 53 | private Theme? _selectedTheme; 54 | 55 | /// 56 | /// Initializes theming for an . 57 | /// 58 | /// The to style. 59 | public void Initialize(Application application) 60 | { 61 | this.WhenAnyValue(static x => x.SelectedTheme).Subscribe(x => 62 | { 63 | application.RequestedThemeVariant = x.ThemeVariant; 64 | _appSettings.Value.ThemeName = x.Name; 65 | }); 66 | } 67 | 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/ProtocolHandlerRegistrars/WindowsProtocolHandlerRegistrar.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.Versioning; 3 | 4 | using BeatSaberModManager.Services.Interfaces; 5 | 6 | using Microsoft.Win32; 7 | 8 | 9 | namespace BeatSaberModManager.Services.Implementations.ProtocolHandlerRegistrars 10 | { 11 | /// 12 | [SupportedOSPlatform("windows")] 13 | public class WindowsProtocolHandlerRegistrar : IProtocolHandlerRegistrar 14 | { 15 | /// 16 | public bool IsProtocolHandlerRegistered(string protocol) 17 | { 18 | RegistryKey? protocolKey = Registry.CurrentUser.OpenSubKey("Software")?.OpenSubKey("Classes")?.OpenSubKey(protocol); 19 | string? commandValue = protocolKey?.OpenSubKey("shell")?.OpenSubKey("open")?.OpenSubKey("command")?.GetValue(string.Empty)?.ToString(); 20 | if (commandValue is null || Environment.ProcessPath is null) 21 | return false; 22 | int end = Environment.ProcessPath.Length + 1; 23 | return commandValue.Length >= end && commandValue[1..end] == Environment.ProcessPath; 24 | } 25 | 26 | /// 27 | public void RegisterProtocolHandler(string protocol) 28 | { 29 | using RegistryKey protocolKey = Registry.CurrentUser.CreateSubKey("Software").CreateSubKey("Classes").CreateSubKey(protocol, true); 30 | using RegistryKey commandKey = protocolKey.CreateSubKey("shell").CreateSubKey("open").CreateSubKey("command"); 31 | protocolKey.SetValue(string.Empty, $"URL:{protocol} Protocol", RegistryValueKind.String); 32 | protocolKey.SetValue("URL Protocol", string.Empty, RegistryValueKind.String); 33 | protocolKey.SetValue("OneClick-Provider", Program.Product, RegistryValueKind.String); 34 | commandKey.SetValue(string.Empty, $"\"{Environment.ProcessPath}\" \"--install\" \"%1\""); 35 | } 36 | 37 | /// 38 | public void UnregisterProtocolHandler(string protocol) 39 | { 40 | using RegistryKey? protocolKey = Registry.CurrentUser.OpenSubKey("Software")?.OpenSubKey("Classes")?.OpenSubKey(protocol, true); 41 | string? registeredProviderName = protocolKey?.GetValue("OneClick-Provider")?.ToString(); 42 | if (registeredProviderName == Program.Product) 43 | protocolKey?.DeleteSubKeyTree(string.Empty, false); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/App.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Reactive; 4 | using System.Threading.Tasks; 5 | 6 | using Avalonia; 7 | using Avalonia.Controls; 8 | using Avalonia.Controls.ApplicationLifetimes; 9 | using Avalonia.Controls.Templates; 10 | using Avalonia.Markup.Xaml; 11 | 12 | using BeatSaberModManager.Views.Localization; 13 | using BeatSaberModManager.Views.Theming; 14 | using BeatSaberModManager.Views.Windows; 15 | 16 | using ReactiveUI; 17 | 18 | using Serilog; 19 | 20 | 21 | namespace BeatSaberModManager.Views 22 | { 23 | /// 24 | public class App : Application 25 | { 26 | private readonly ILogger _logger; 27 | private readonly LocalizationManager _localizationManager; 28 | private readonly ThemeManager _themeManager; 29 | private readonly Lazy _mainWindow; 30 | 31 | /// 32 | public App(IEnumerable viewTemplates, ILogger logger, LocalizationManager localizationManager, ThemeManager themeManager, Lazy mainWindow) 33 | { 34 | _logger = logger; 35 | _localizationManager = localizationManager; 36 | _themeManager = themeManager; 37 | _mainWindow = mainWindow; 38 | DataTemplates.AddRange(viewTemplates); 39 | if (Program.IsProduction) 40 | RxApp.DefaultExceptionHandler = Observer.Create(e => _ = ShowExceptionAsync(e)); 41 | } 42 | 43 | /// 44 | public override void Initialize() 45 | { 46 | AvaloniaXamlLoader.Load(this); 47 | _localizationManager.Initialize(this); 48 | _themeManager.Initialize(this); 49 | } 50 | 51 | /// 52 | public override void OnFrameworkInitializationCompleted() 53 | { 54 | if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) 55 | lifetime.MainWindow = _mainWindow.Value; 56 | } 57 | 58 | private async Task ShowExceptionAsync(Exception e) 59 | { 60 | _logger.Fatal(e, "Application crashed"); 61 | if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: not null } lifetime) 62 | return; 63 | lifetime.MainWindow.Show(); 64 | await new ExceptionWindow(e).ShowDialog(lifetime.MainWindow).ConfigureAwait(true); 65 | lifetime.Shutdown(-1); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /BeatSaberModManager/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | using Avalonia; 5 | using Avalonia.ReactiveUI; 6 | 7 | using BeatSaberModManager.Models.Implementations.Settings; 8 | using BeatSaberModManager.Models.Interfaces; 9 | using BeatSaberModManager.Services.Interfaces; 10 | 11 | 12 | namespace BeatSaberModManager 13 | { 14 | /// 15 | /// Handles the applications start, e.g. updating and configuring Avalonia. 16 | /// 17 | public class Startup 18 | { 19 | private readonly string[] _args; 20 | private readonly Lazy _application; 21 | private readonly ISettings _appSettings; 22 | private readonly IInstallDirValidator _installDirValidator; 23 | private readonly IInstallDirLocator _installDirLocator; 24 | private readonly IUpdater _updater; 25 | 26 | /// 27 | /// Initializes a new instance of the class. 28 | /// 29 | public Startup(string[] args, Lazy application, ISettings appSettings, IInstallDirValidator installDirValidator, IInstallDirLocator installDirLocator, IUpdater updater) 30 | { 31 | _args = args; 32 | _application = application; 33 | _appSettings = appSettings; 34 | _installDirValidator = installDirValidator; 35 | _installDirLocator = installDirLocator; 36 | _updater = updater; 37 | } 38 | 39 | /// 40 | /// Asynchronously starts the application. 41 | /// 42 | public async Task RunAsync() 43 | { 44 | if (await _updater.NeedsUpdateAsync().ConfigureAwait(false)) 45 | return await _updater.UpdateAsync().ConfigureAwait(false); 46 | await _appSettings.LoadAsync().ConfigureAwait(false); 47 | if (_args is ["--path", { } installDir]) 48 | _appSettings.Value.InstallDir = installDir; 49 | if (!_installDirValidator.ValidateInstallDir(_appSettings.Value.InstallDir)) 50 | _appSettings.Value.InstallDir = await _installDirLocator.LocateInstallDirAsync().ConfigureAwait(false); 51 | return RunAvaloniaApp(); 52 | } 53 | 54 | private int RunAvaloniaApp() => 55 | AppBuilder.Configure(() => _application.Value) 56 | .UsePlatformDetect() 57 | .UseReactiveUI() 58 | .StartWithClassicDesktopLifetime(null!); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Windows/AssetInstallWindow.axaml: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 35 | 36 | 39 | 40 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Interfaces/IModProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO.Compression; 3 | using System.Threading.Tasks; 4 | 5 | using BeatSaberModManager.Models.Interfaces; 6 | 7 | 8 | namespace BeatSaberModManager.Services.Interfaces 9 | { 10 | /// 11 | /// Provides methods to download mods, get dependencies and check for installed ones. 12 | /// 13 | public interface IModProvider 14 | { 15 | /// 16 | /// The result of . 17 | /// 18 | IReadOnlyCollection? InstalledMods { get; } 19 | 20 | /// 21 | /// The result of . 22 | /// 23 | IReadOnlyCollection? AvailableMods { get; } 24 | 25 | /// 26 | /// Checks if the provided is the mod loader. 27 | /// 28 | /// The to check. 29 | /// True if the provided is the mod loader, false otherwise. 30 | bool IsModLoader(IMod? modification); 31 | 32 | /// 33 | /// Gets the dependencies of an . 34 | /// 35 | /// The mod to get the dependencies from. 36 | /// The mod's dependencies. 37 | IEnumerable GetDependencies(IMod modification); 38 | 39 | /// 40 | /// Asynchronously loads all currently installed mods. 41 | /// 42 | /// The game's installation directory. 43 | Task LoadInstalledModsAsync(string installDir); 44 | 45 | /// 46 | /// Asynchronously loads all available mods for the current version of the game. 47 | /// 48 | /// The game's installation directory 49 | Task LoadAvailableModsForCurrentVersionAsync(string installDir); 50 | 51 | /// 52 | /// Asynchronously loads all available mods for the specified version of the game 53 | /// 54 | /// The version of the game 55 | Task LoadAvailableModsForVersionAsync(string version); 56 | 57 | /// 58 | /// Asynchronously downloads multiple mods. 59 | /// 60 | /// The mod to download. 61 | /// The mod's file as . 62 | Task DownloadModAsync(IMod modification); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /BeatSaberModManager/BeatSaberModManager.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | preview 6 | 0.0.7 7 | false 8 | true 9 | enable 10 | true 11 | true 12 | false 13 | preview-All 14 | true 15 | true 16 | true 17 | Resources/Icons/Icon.ico 18 | true 19 | 20 | 21 | 22 | Exe 23 | 24 | 25 | 26 | WinExe 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Controls/ProgressRing.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using Avalonia; 4 | using Avalonia.Controls; 5 | using Avalonia.Controls.Primitives; 6 | using Avalonia.Controls.Shapes; 7 | 8 | 9 | namespace BeatSaberModManager.Views.Controls 10 | { 11 | /// 12 | /// A control used to indicate the progress of an operation. 13 | /// 14 | public class ProgressRing : RangeBase 15 | { 16 | /// 17 | public static readonly StyledProperty IsIndeterminateProperty = ProgressBar.IsIndeterminateProperty.AddOwner(); 18 | 19 | /// 20 | public static readonly StyledProperty StrokeThicknessProperty = Shape.StrokeThicknessProperty.AddOwner(); 21 | 22 | /// 23 | public static readonly StyledProperty StartAngleProperty = Arc.StartAngleProperty.AddOwner(); 24 | 25 | /// 26 | public static readonly StyledProperty SweepAngleProperty = Arc.SweepAngleProperty.AddOwner(); 27 | 28 | static ProgressRing() 29 | { 30 | MaximumProperty.Changed.Subscribe(CalibrateAngles); 31 | MinimumProperty.Changed.Subscribe(CalibrateAngles); 32 | ValueProperty.Changed.Subscribe(CalibrateAngles); 33 | StrokeThicknessProperty.OverrideDefaultValue(20); 34 | AffectsRender(StartAngleProperty, SweepAngleProperty); 35 | } 36 | 37 | /// 38 | public bool IsIndeterminate 39 | { 40 | get => GetValue(IsIndeterminateProperty); 41 | set => SetValue(IsIndeterminateProperty, value); 42 | } 43 | 44 | /// 45 | public double StrokeThickness 46 | { 47 | get => GetValue(StrokeThicknessProperty); 48 | set => SetValue(StrokeThicknessProperty, value); 49 | } 50 | 51 | /// 52 | public double StartAngle 53 | { 54 | get => GetValue(StartAngleProperty); 55 | set => SetValue(StartAngleProperty, value); 56 | } 57 | 58 | /// 59 | public double SweepAngle 60 | { 61 | get => GetValue(SweepAngleProperty); 62 | private set => SetValue(SweepAngleProperty, value); 63 | } 64 | 65 | private static void CalibrateAngles(AvaloniaPropertyChangedEventArgs e) 66 | { 67 | if (e.Sender is ProgressRing pr) 68 | pr.SweepAngle = (pr.Value - pr.Minimum) / (pr.Maximum - pr.Minimum) * 360; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/DependencyManagement/SimpleDependencyResolver.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | using BeatSaberModManager.Models.Interfaces; 4 | using BeatSaberModManager.Services.Interfaces; 5 | 6 | 7 | namespace BeatSaberModManager.Services.Implementations.DependencyManagement 8 | { 9 | /// 10 | public class SimpleDependencyResolver : IDependencyResolver 11 | { 12 | private readonly IModProvider _modProvider; 13 | private readonly Dictionary> _dependencyRegistry; 14 | 15 | /// 16 | /// Initializes a new instance of the class. 17 | /// 18 | public SimpleDependencyResolver(IModProvider modProvider) 19 | { 20 | _modProvider = modProvider; 21 | _dependencyRegistry = new Dictionary>(); 22 | } 23 | 24 | /// 25 | public bool IsDependency(IMod modification) => _dependencyRegistry.TryGetValue(modification, out HashSet? dependents) && dependents.Count != 0; 26 | 27 | /// 28 | public IEnumerable ResolveDependencies(IMod modification) 29 | { 30 | HashSet dependencies = new(); 31 | ResolveDependencies(modification, dependencies); 32 | return dependencies; 33 | } 34 | 35 | /// 36 | public IEnumerable UnresolveDependencies(IMod modification) 37 | { 38 | HashSet dependencies = new(); 39 | UnresolveDependencies(modification, dependencies); 40 | return dependencies; 41 | } 42 | 43 | private void ResolveDependencies(IMod modification, HashSet dependencies) 44 | { 45 | foreach (IMod dependency in _modProvider.GetDependencies(modification)) 46 | { 47 | if (_dependencyRegistry.TryGetValue(dependency, out HashSet? dependents)) 48 | dependents.Add(modification); 49 | else 50 | _dependencyRegistry.Add(dependency, new HashSet { modification }); 51 | dependencies.Add(dependency); 52 | ResolveDependencies(dependency, dependencies); 53 | } 54 | } 55 | 56 | private void UnresolveDependencies(IMod modification, HashSet dependencies) 57 | { 58 | foreach (IMod dependency in _modProvider.GetDependencies(modification)) 59 | { 60 | if (!_dependencyRegistry.TryGetValue(dependency, out HashSet? dependents)) 61 | continue; 62 | dependents.Remove(modification); 63 | dependencies.Add(dependency); 64 | if (dependencies.Count == 0) 65 | UnresolveDependencies(dependency, dependencies); 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /BeatSaberModManager/ViewModels/ModGridItemViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reactive.Linq; 3 | 4 | using BeatSaberModManager.Models.Implementations.Settings; 5 | using BeatSaberModManager.Models.Interfaces; 6 | 7 | using ReactiveUI; 8 | 9 | 10 | namespace BeatSaberModManager.ViewModels 11 | { 12 | /// 13 | /// ViewModel that represents an which can be selected an deselected. 14 | /// 15 | public sealed class ModGridItemViewModel : ViewModelBase 16 | { 17 | private readonly ObservableAsPropertyHelper _isUpToDate; 18 | 19 | /// 20 | /// Initializes a new instance of the class. 21 | /// 22 | public ModGridItemViewModel(IMod availableMod, IMod? installedMod, ISettings appSettings) 23 | { 24 | ArgumentNullException.ThrowIfNull(availableMod); 25 | ArgumentNullException.ThrowIfNull(appSettings); 26 | _availableMod = availableMod; 27 | _installedMod = installedMod; 28 | _isCheckBoxChecked = installedMod is not null || availableMod.IsRequired || (appSettings.Value.SaveSelectedMods && appSettings.Value.SelectedMods.Contains(availableMod.Name)); 29 | this.WhenAnyValue(static x => x.AvailableMod, static x => x.InstalledMod) 30 | .Select(static x => x.Item1.Version.CompareTo(x.Item2?.Version) <= 0) 31 | .ToProperty(this, nameof(IsUpToDate), out _isUpToDate); 32 | } 33 | 34 | /// 35 | /// Compares the available version to the installed one. 36 | /// 37 | public bool IsUpToDate => _isUpToDate.Value; 38 | 39 | /// 40 | /// The available in the repository. 41 | /// 42 | public IMod AvailableMod 43 | { 44 | get => _availableMod; 45 | set => this.RaiseAndSetIfChanged(ref _availableMod, value); 46 | } 47 | 48 | private IMod _availableMod; 49 | 50 | /// 51 | /// The currently installed version of the mod. 52 | /// 53 | public IMod? InstalledMod 54 | { 55 | get => _installedMod; 56 | set => this.RaiseAndSetIfChanged(ref _installedMod, value); 57 | } 58 | 59 | private IMod? _installedMod; 60 | 61 | /// 62 | /// Enables or disables the checkbox control. 63 | /// 64 | public bool IsCheckBoxEnabled 65 | { 66 | get => _isCheckBoxEnabled; 67 | set => this.RaiseAndSetIfChanged(ref _isCheckBoxEnabled, value); 68 | } 69 | 70 | private bool _isCheckBoxEnabled = true; 71 | 72 | /// 73 | /// Checks or unchecks the checkbox control. 74 | /// 75 | public bool IsCheckBoxChecked 76 | { 77 | get => _isCheckBoxChecked; 78 | set => this.RaiseAndSetIfChanged(ref _isCheckBoxChecked, value); 79 | } 80 | 81 | private bool _isCheckBoxChecked; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/Observables/DirectoryExistsObservable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reactive.Subjects; 4 | 5 | 6 | namespace BeatSaberModManager.Services.Implementations.Observables 7 | { 8 | /// 9 | /// An that signals when the directory at the specified becomes valid or invalid. 10 | /// 11 | public sealed class DirectoryExistsObservable : IObservable, IDisposable 12 | { 13 | private readonly FileSystemWatcher _fileSystemWatcher = new(); 14 | private readonly BehaviorSubject _subject = new(false); 15 | 16 | private string? _path; 17 | 18 | /// 19 | /// Initializes a new instance of the class. 20 | /// 21 | public DirectoryExistsObservable() 22 | { 23 | _fileSystemWatcher.Created += OnCreated; 24 | _fileSystemWatcher.Renamed += OnRenamed; 25 | _fileSystemWatcher.Deleted += OnDeleted; 26 | _fileSystemWatcher.NotifyFilter = NotifyFilters.DirectoryName; 27 | } 28 | 29 | /// 30 | public string? Path 31 | { 32 | get => _path; 33 | set 34 | { 35 | if (value is null || !Directory.Exists(value)) 36 | { 37 | _path = null; 38 | _fileSystemWatcher.EnableRaisingEvents = false; 39 | _subject.OnNext(false); 40 | } 41 | else 42 | { 43 | DirectoryInfo directoryInfo = new(value); 44 | _path = System.IO.Path.TrimEndingDirectorySeparator(directoryInfo.FullName); 45 | _fileSystemWatcher.Path = directoryInfo.Parent?.FullName ?? throw new InvalidOperationException("Cannot watch root directory."); 46 | _fileSystemWatcher.EnableRaisingEvents = true; 47 | _subject.OnNext(true); 48 | } 49 | } 50 | } 51 | 52 | /// 53 | public IDisposable Subscribe(IObserver observer) => _subject.Subscribe(observer); 54 | 55 | private void OnCreated(object sender, FileSystemEventArgs e) 56 | { 57 | if (e.FullPath == _path) 58 | _subject.OnNext(true); 59 | } 60 | 61 | private void OnRenamed(object sender, RenamedEventArgs e) 62 | { 63 | if (e.OldFullPath == _path) 64 | _subject.OnNext(false); 65 | if (e.FullPath == _path) 66 | _subject.OnNext(true); 67 | } 68 | 69 | private void OnDeleted(object sender, FileSystemEventArgs e) 70 | { 71 | if (e.FullPath == _path) 72 | _subject.OnNext(false); 73 | } 74 | 75 | /// 76 | public void Dispose() 77 | { 78 | _fileSystemWatcher.Created -= OnCreated; 79 | _fileSystemWatcher.Deleted -= OnDeleted; 80 | _fileSystemWatcher.Dispose(); 81 | _subject.Dispose(); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/ProtocolHandlerRegistrars/LinuxProtocolHandlerRegistrar.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.Versioning; 5 | 6 | using BeatSaberModManager.Services.Interfaces; 7 | using BeatSaberModManager.Utils; 8 | 9 | 10 | namespace BeatSaberModManager.Services.Implementations.ProtocolHandlerRegistrars 11 | { 12 | /// 13 | [SupportedOSPlatform("linux")] 14 | public class LinuxProtocolHandlerRegistrar : IProtocolHandlerRegistrar 15 | { 16 | private readonly object _lock; 17 | private readonly string _localAppDataPath; 18 | 19 | /// 20 | /// Initializes a new instance of the class. 21 | /// 22 | public LinuxProtocolHandlerRegistrar() 23 | { 24 | _lock = new object(); 25 | string appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); 26 | _localAppDataPath = Path.Join(appDataPath, "applications"); 27 | } 28 | 29 | /// 30 | public bool IsProtocolHandlerRegistered(string protocol) 31 | { 32 | string handlerPath = GetHandlerPathForProtocol(protocol); 33 | using FileStream? fileStream = IOUtils.TryOpenFile(handlerPath, FileMode.Open, FileAccess.Read); 34 | if (fileStream is null) 35 | return false; 36 | using StreamReader streamReader = new(fileStream); 37 | while (streamReader.ReadLine() is { } line) 38 | { 39 | if (line.StartsWith($"Exec={Program.Product}", StringComparison.Ordinal)) 40 | return true; 41 | } 42 | 43 | return false; 44 | } 45 | 46 | /// 47 | public void RegisterProtocolHandler(string protocol) 48 | { 49 | string handlerName = GetHandlerNameForProtocol(protocol); 50 | string handlerPath = Path.Join(_localAppDataPath, handlerName); 51 | File.WriteAllText(handlerPath, GetDesktopFileContent(protocol)); 52 | lock (_lock) 53 | { 54 | if (PlatformUtils.TryStartProcess(new ProcessStartInfo("xdg-mime", $"\"default\" \"{handlerName}\" \"x-scheme-handler/{protocol}\""), out Process? process)) 55 | process.WaitForExit(); 56 | } 57 | } 58 | 59 | /// 60 | public void UnregisterProtocolHandler(string protocol) 61 | { 62 | string handlerPath = GetHandlerPathForProtocol(protocol); 63 | IOUtils.TryDeleteFile(handlerPath); 64 | } 65 | 66 | private string GetHandlerPathForProtocol(string protocol) => Path.Join(_localAppDataPath, GetHandlerNameForProtocol(protocol)); 67 | 68 | private static string GetHandlerNameForProtocol(string protocol) => $"{Program.Product}-url-{protocol}.desktop"; 69 | 70 | private static string GetDesktopFileContent(string protocol) => 71 | @$"[Desktop Entry] 72 | Name={Program.Product} 73 | Comment=URL:{protocol} Protocol 74 | Type=Application 75 | Categories=Utility 76 | Exec='{Program.Product}' --install %u 77 | Terminal=false 78 | NoDisplay=true 79 | MimeType=x-scheme-handler/{protocol} 80 | "; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: [ "**" ] 5 | paths: 6 | - "BeatSaberModManager/**" 7 | - "BSIPA-Linux/**" 8 | - ".github/workflows/main.yml" 9 | pull_request: 10 | branches: [ "**" ] 11 | paths: 12 | - "BeatSaberModManager/**" 13 | - "BSIPA-Linux/**" 14 | - ".github/workflows/main.yml" 15 | 16 | jobs: 17 | build_linux: 18 | name: Build Linux 19 | runs-on: ubuntu-latest 20 | env: 21 | DOTNET_CLI_TELEMETRY_OPTOUT: true 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | submodules: recursive 26 | - name: Setup .NET SDK 27 | uses: actions/setup-dotnet@v4 28 | with: 29 | dotnet-version: "8.0.x" 30 | 31 | - name: Build Self Contained 32 | run: dotnet publish ./BeatSaberModManager/BeatSaberModManager.csproj -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true 33 | - name: Upload Artifact 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: BeatSaberModManager-linux-x64-self-contained 37 | path: BeatSaberModManager/bin/Release/linux-x64/publish/BeatSaberModManager 38 | 39 | - name: Build Framework Dependent 40 | run: dotnet publish ./BeatSaberModManager/BeatSaberModManager.csproj -c Release -r linux-x64 --no-self-contained -p:PublishSingleFile=true 41 | - name: Upload Artifact 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: BeatSaberModManager-linux-x64-framework-dependent 45 | path: BeatSaberModManager/bin/Release/linux-x64/publish/BeatSaberModManager 46 | 47 | build_windows: 48 | name: Build Windows 49 | runs-on: windows-latest 50 | env: 51 | DOTNET_CLI_TELEMETRY_OPTOUT: true 52 | steps: 53 | - uses: actions/checkout@v4 54 | with: 55 | submodules: recursive 56 | - name: Setup .NET SDK 57 | uses: actions/setup-dotnet@v4 58 | with: 59 | dotnet-version: "8.0.x" 60 | 61 | - name: Build Self Contained 62 | run: dotnet publish ./BeatSaberModManager/BeatSaberModManager.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true 63 | - name: Upload Artifact 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: BeatSaberModManager-win-x64-self-contained 67 | path: BeatSaberModManager/bin/Release/win-x64/publish/BeatSaberModManager.exe 68 | 69 | - name: Build Framework Dependent 70 | run: dotnet publish ./BeatSaberModManager/BeatSaberModManager.csproj -c Release -r win-x64 --no-self-contained -p:PublishSingleFile=true 71 | - name: Upload Artifact 72 | uses: actions/upload-artifact@v4 73 | with: 74 | name: BeatSaberModManager-win-x64-framework-dependent 75 | path: BeatSaberModManager/bin/Release/win-x64/publish/BeatSaberModManager.exe 76 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/Settings/JsonSettingsProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization.Metadata; 5 | using System.Threading.Tasks; 6 | 7 | using BeatSaberModManager.Models.Interfaces; 8 | using BeatSaberModManager.Utils; 9 | 10 | using Serilog; 11 | 12 | 13 | namespace BeatSaberModManager.Services.Implementations.Settings 14 | { 15 | /// 16 | /// Automatically loads and saves as a json file. 17 | /// 18 | /// The type of the settings class. 19 | public sealed class JsonSettingsProvider : ISettings, IAsyncDisposable where T : class, new() 20 | { 21 | private readonly ILogger _logger; 22 | private readonly JsonTypeInfo _jsonTypeInfo; 23 | private readonly string _saveDirPath; 24 | private readonly string _saveFilePath; 25 | 26 | /// 27 | /// Initializes a new instance of the class. 28 | /// 29 | public JsonSettingsProvider(ILogger logger, JsonTypeInfo jsonTypeInfo) 30 | { 31 | _logger = logger; 32 | _jsonTypeInfo = jsonTypeInfo; 33 | string appDataFolderPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); 34 | _saveDirPath = Path.Join(appDataFolderPath, Program.Product); 35 | _saveFilePath = Path.Join(_saveDirPath, $"{typeof(T).Name}.json"); 36 | } 37 | 38 | /// 39 | /// The instance of the loaded setting . 40 | /// 41 | public T Value { get; private set; } = null!; 42 | 43 | /// 44 | public async Task LoadAsync() 45 | { 46 | if (!IOUtils.TryCreateDirectory(_saveDirPath)) 47 | { 48 | Value = new T(); 49 | return; 50 | } 51 | 52 | #pragma warning disable CA2007 53 | await using FileStream? fileStream = IOUtils.TryOpenFile(_saveFilePath, FileMode.Open, FileAccess.Read); 54 | #pragma warning restore CA2007 55 | if (fileStream is null) 56 | { 57 | Value = new T(); 58 | return; 59 | } 60 | 61 | try 62 | { 63 | Value = await JsonSerializer.DeserializeAsync(fileStream, _jsonTypeInfo).ConfigureAwait(false) ?? new T(); 64 | } 65 | catch (JsonException e) 66 | { 67 | _logger.Warning(e, "Invalid config, deleting"); 68 | Value = new T(); 69 | IOUtils.TryDeleteFile(_saveFilePath); 70 | } 71 | } 72 | 73 | /// 74 | public async Task SaveAsync() 75 | { 76 | if (!IOUtils.TryCreateDirectory(_saveDirPath)) 77 | return; 78 | #pragma warning disable CA2007 79 | await using FileStream? fileStream = IOUtils.TryOpenFile(_saveFilePath, FileMode.Create, FileAccess.Write); 80 | #pragma warning restore CA2007 81 | if (fileStream is not null) 82 | await JsonSerializer.SerializeAsync(fileStream, Value, _jsonTypeInfo).ConfigureAwait(false); 83 | } 84 | 85 | /// 86 | public async ValueTask DisposeAsync() => await SaveAsync().ConfigureAwait(false); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Localization/LocalizationManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | 6 | using Avalonia; 7 | using Avalonia.Markup.Xaml.Styling; 8 | 9 | using BeatSaberModManager.Models.Implementations.Settings; 10 | using BeatSaberModManager.Models.Interfaces; 11 | 12 | using ReactiveUI; 13 | 14 | 15 | namespace BeatSaberModManager.Views.Localization 16 | { 17 | /// 18 | /// Load and apply localization settings. 19 | /// 20 | public class LocalizationManager : ReactiveObject 21 | { 22 | private readonly ISettings _appSettings; 23 | 24 | private bool _isInitialized; 25 | 26 | /// 27 | /// Initializes a new instance of the class. 28 | /// 29 | public LocalizationManager(ISettings appSettings) 30 | { 31 | _appSettings = appSettings; 32 | } 33 | 34 | /// 35 | /// A collection of all available s. 36 | /// 37 | public IReadOnlyList? Languages { get; private set; } 38 | 39 | /// 40 | /// The currently selected . 41 | /// 42 | public Language SelectedLanguage 43 | { 44 | get => _selectedLanguage!; 45 | set => _appSettings.Value.LanguageCode = this.RaiseAndSetIfChanged(ref _selectedLanguage!, value).CultureInfo.Name; 46 | } 47 | 48 | private Language? _selectedLanguage; 49 | 50 | /// 51 | /// Start listening to changes of and apply it to the given 's . 52 | /// 53 | /// The application to apply the to. 54 | public void Initialize(Application application) 55 | { 56 | Languages = _supportedLanguageCodes.Select(l => LoadLanguage(application, l)).ToArray(); 57 | _selectedLanguage = Languages.FirstOrDefault(x => x.CultureInfo.Name == _appSettings.Value.LanguageCode) ?? 58 | Languages.FirstOrDefault(static x => x.CultureInfo.Name == CultureInfo.CurrentCulture.Name) ?? 59 | Languages[0]; 60 | IObservable selectedLanguageObservable = this.WhenAnyValue(static x => x.SelectedLanguage); 61 | selectedLanguageObservable.Subscribe(l => 62 | { 63 | if (!_isInitialized) 64 | { 65 | application.Resources.MergedDictionaries.Insert(0, l.ResourceProvider); 66 | _isInitialized = true; 67 | } 68 | else 69 | { 70 | application.Resources.MergedDictionaries[0] = l.ResourceProvider; 71 | } 72 | }); 73 | } 74 | 75 | private static Language LoadLanguage(Application application, string languageCode) 76 | { 77 | CultureInfo cultureInfo = CultureInfo.GetCultureInfo(languageCode); 78 | return new Language(cultureInfo, (application.Resources[languageCode] as MergeResourceInclude)!); 79 | } 80 | 81 | private static readonly string[] _supportedLanguageCodes = { "en", "de" }; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Styles/ProgressRing.axaml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 35 | 36 | 50 | 51 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/ModelSaber/ModelSaberModelInstaller.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Threading.Tasks; 7 | 8 | using BeatSaberModManager.Models.Implementations.Progress; 9 | using BeatSaberModManager.Services.Implementations.Http; 10 | using BeatSaberModManager.Services.Interfaces; 11 | using BeatSaberModManager.Utils; 12 | 13 | 14 | namespace BeatSaberModManager.Services.Implementations.BeatSaber.ModelSaber 15 | { 16 | /// 17 | /// Download and install models from https://modelsaber.com. 18 | /// 19 | public class ModelSaberModelInstaller 20 | { 21 | private readonly HttpProgressClient _httpClient; 22 | 23 | /// 24 | /// Initializes a new instance of the class. 25 | /// 26 | public ModelSaberModelInstaller(HttpProgressClient httpClient) 27 | { 28 | _httpClient = httpClient; 29 | } 30 | 31 | /// 32 | /// Asynchronously downloads and installs a model from https://modelsaber.com. 33 | /// 34 | /// The game's installation directory. 35 | /// The to download the model from. 36 | /// Optionally track the progress of the operation. 37 | /// True if the operation succeeds, false otherwise. 38 | public async Task InstallModelAsync(string installDir, Uri uri, IStatusProgress? progress = null) 39 | { 40 | ArgumentNullException.ThrowIfNull(uri); 41 | string? folderName = GetFolderName(uri); 42 | if (folderName is null) 43 | return false; 44 | string folderPath = Path.Join(installDir, folderName); 45 | if (!IOUtils.TryCreateDirectory(folderPath)) 46 | return false; 47 | string modelName = WebUtility.UrlDecode(uri.Segments.Last()); 48 | progress?.Report(new ProgressInfo(StatusType.Installing, modelName)); 49 | using HttpResponseMessage response = await _httpClient.TryGetAsync(new Uri($"https://modelsaber.com/files/{uri.Host}{uri.LocalPath}"), progress).ConfigureAwait(false); 50 | if (!response.IsSuccessStatusCode) 51 | return false; 52 | string filePath = Path.Join(folderPath, modelName); 53 | #pragma warning disable CA2007 54 | await using Stream contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); 55 | await using Stream? writeStream = IOUtils.TryOpenFile(filePath, FileMode.Create, FileAccess.Write); 56 | #pragma warning restore CA2007 57 | if (writeStream is null) 58 | return false; 59 | await contentStream.CopyToAsync(writeStream).ConfigureAwait(false); 60 | return true; 61 | } 62 | 63 | /// 64 | /// Maps the type of the model to it's respective directory name. 65 | /// 66 | /// The to download the model from. 67 | /// The name of the model types directory. 68 | private static string? GetFolderName(Uri uri) => uri.Host switch 69 | { 70 | "avatar" => "CustomAvatars", 71 | "saber" => "CustomSabers", 72 | "platform" => "CustomPlatforms", 73 | "bloq" => "CustomNotes", 74 | _ => null 75 | }; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/Updater/GitHubUpdater.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.IO.Compression; 6 | using System.Linq; 7 | using System.Net.Http; 8 | using System.Text.Json; 9 | using System.Threading.Tasks; 10 | 11 | using BeatSaberModManager.Models.Implementations.GitHub; 12 | using BeatSaberModManager.Models.Implementations.Json; 13 | using BeatSaberModManager.Services.Implementations.Http; 14 | using BeatSaberModManager.Services.Interfaces; 15 | using BeatSaberModManager.Utils; 16 | 17 | 18 | namespace BeatSaberModManager.Services.Implementations.Updater 19 | { 20 | /// 21 | public class GitHubUpdater : IUpdater 22 | { 23 | private readonly IReadOnlyList _args; 24 | private readonly HttpProgressClient _httpClient; 25 | private readonly Version _version; 26 | 27 | private Release? _release; 28 | 29 | /// 30 | /// Initializes a new instance of the class. 31 | /// 32 | public GitHubUpdater(IReadOnlyList args, HttpProgressClient httpClient) 33 | { 34 | _args = args; 35 | _httpClient = httpClient; 36 | _version = new Version(Program.Version); 37 | } 38 | 39 | /// 40 | public async ValueTask NeedsUpdateAsync() 41 | { 42 | if (!Program.IsProduction || OperatingSystem.IsLinux()) 43 | return false; 44 | using HttpResponseMessage response = await _httpClient.TryGetAsync(new Uri("https://api.github.com/repos/affederaffe/BeatSaberModManager/releases/latest")).ConfigureAwait(false); 45 | if (!response.IsSuccessStatusCode) 46 | return false; 47 | #pragma warning disable CA2007 48 | await using Stream contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); 49 | #pragma warning restore CA2007 50 | _release = await JsonSerializer.DeserializeAsync(contentStream, GitHubJsonSerializerContext.Default.Release).ConfigureAwait(false); 51 | return _release is not null && Version.TryParse(_release.TagName.AsSpan(1, 5), out Version? version) && version > _version; 52 | } 53 | 54 | /// 55 | public async Task UpdateAsync() 56 | { 57 | Asset? asset = _release?.Assets.FirstOrDefault(static x => x.Name.Contains("win-x64", StringComparison.Ordinal)); 58 | if (asset is null) 59 | return -1; 60 | using HttpResponseMessage response = await _httpClient.TryGetAsync(asset.DownloadUrl).ConfigureAwait(false); 61 | if (!response.IsSuccessStatusCode) 62 | return -1; 63 | #pragma warning disable CA2007 64 | await using Stream stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); 65 | #pragma warning restore CA2007 66 | using ZipArchive archive = new(stream); 67 | if (archive.Entries.Count != 1) 68 | return -1; 69 | string processPath = Environment.ProcessPath!; 70 | string newPath = $"{processPath}.new"; 71 | archive.Entries[0].ExtractToFile(newPath, true); 72 | string oldPath = processPath.Replace(".exe", ".old.exe", StringComparison.Ordinal); 73 | IOUtils.TryDeleteFile(oldPath); 74 | if (!IOUtils.TryMoveFile(processPath, oldPath)) 75 | return -1; 76 | if (!IOUtils.TryMoveFile(newPath, processPath)) 77 | return -1; 78 | ProcessStartInfo processStartInfo = new(processPath); 79 | foreach (string arg in _args) 80 | processStartInfo.ArgumentList.Add(arg); 81 | return PlatformUtils.TryStartProcess(processStartInfo, out _) ? 0 : -1; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Controls/SearchableDataGrid.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using Avalonia; 4 | using Avalonia.Collections; 5 | using Avalonia.Controls; 6 | using Avalonia.Controls.Chrome; 7 | using Avalonia.Controls.Primitives; 8 | using Avalonia.Data; 9 | using Avalonia.VisualTree; 10 | 11 | 12 | namespace BeatSaberModManager.Views.Controls 13 | { 14 | /// 15 | /// A which is searchable through a 16 | /// 17 | public class SearchableDataGrid : DataGrid 18 | { 19 | /// 20 | public static readonly StyledProperty WatermarkProperty = TextBox.WatermarkProperty.AddOwner(); 21 | 22 | /// 23 | /// Defines the QueryProperty. 24 | /// 25 | public static readonly StyledProperty QueryProperty = AvaloniaProperty.Register(nameof(Query), defaultBindingMode: BindingMode.TwoWay); 26 | 27 | /// 28 | /// Defines the IsSearchEnabledProperty. 29 | /// 30 | public static readonly StyledProperty IsSearchEnabledProperty = AvaloniaProperty.Register(nameof(IsSearchEnabled), defaultBindingMode: BindingMode.TwoWay); 31 | 32 | /// 33 | /// Defines the SearchIconProperty. 34 | /// 35 | public static readonly StyledProperty SearchIconProperty = AvaloniaProperty.Register(nameof(SearchIcon)); 36 | 37 | private TextBox? _searchTextBox; 38 | 39 | /// 40 | public string? Query 41 | { 42 | get => GetValue(QueryProperty); 43 | set => SetValue(QueryProperty, value); 44 | } 45 | 46 | /// 47 | /// Gets or sets a value indicating whether the search text box is enabled for user interaction. 48 | /// 49 | public bool IsSearchEnabled 50 | { 51 | get => GetValue(IsSearchEnabledProperty); 52 | set => SetValue(IsSearchEnabledProperty, value); 53 | } 54 | 55 | /// 56 | public string? Watermark 57 | { 58 | get => GetValue(WatermarkProperty); 59 | set => SetValue(WatermarkProperty, value); 60 | } 61 | 62 | /// 63 | /// Gets or sets the icon of the search icon. 64 | /// 65 | public PathIcon SearchIcon 66 | { 67 | get => GetValue(SearchIconProperty); 68 | set => SetValue(SearchIconProperty, value); 69 | } 70 | 71 | /// 72 | protected override void OnApplyTemplate(TemplateAppliedEventArgs e) 73 | { 74 | ArgumentNullException.ThrowIfNull(e); 75 | base.OnApplyTemplate(e); 76 | _searchTextBox = e.NameScope.Get("PART_SearchTextBox"); 77 | CaptionButtons? captionButtons = ChromeOverlayLayer.GetOverlayLayer(this)?.FindDescendantOfType(); 78 | if (captionButtons is not null) 79 | e.NameScope.Get("PART_SearchToggleButton").Margin = new Thickness(0, 0, captionButtons.Bounds.Width, 0); 80 | } 81 | 82 | /// 83 | protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) 84 | { 85 | ArgumentNullException.ThrowIfNull(change); 86 | base.OnPropertyChanged(change); 87 | if (change.Property == IsSearchEnabledProperty && change.GetNewValue()) 88 | _searchTextBox?.Focus(); 89 | } 90 | 91 | /// 92 | protected override void OnInitialized() 93 | { 94 | if (ItemsSource is DataGridCollectionView dataGridCollectionView) 95 | dataGridCollectionView.MoveCurrentTo(null); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/BeatSaberGameVersionProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.IO; 3 | using System.Text; 4 | using System.Text.RegularExpressions; 5 | using System.Threading.Tasks; 6 | 7 | using BeatSaberModManager.Services.Interfaces; 8 | using BeatSaberModManager.Utils; 9 | 10 | 11 | namespace BeatSaberModManager.Services.Implementations.BeatSaber 12 | { 13 | /// 14 | public partial class BeatSaberGameVersionProvider : IGameVersionProvider 15 | { 16 | /// 17 | public async Task DetectGameVersionAsync(string installDir) 18 | { 19 | string filePath = Path.Join(installDir, "Beat Saber_Data", "globalgamemanagers"); 20 | #pragma warning disable CA2007 21 | await using FileStream? fileStream = IOUtils.TryOpenFile(filePath, FileMode.Open, FileAccess.Read); 22 | #pragma warning restore CA2007 23 | if (fileStream is null) 24 | return null; 25 | 26 | using BinaryReader reader = new(fileStream, Encoding.UTF8); 27 | const string key = "public.app-category.games"; 28 | 29 | if (!TrySeekToKey(reader, key)) 30 | return null; 31 | 32 | if (!TryFindVersion(reader, out string? version)) 33 | return null; 34 | 35 | Regex regex = VersionRegex(); 36 | Match match = regex.Match(version); 37 | return !match.Success ? null : match.Value; 38 | } 39 | 40 | private static bool TrySeekToKey(BinaryReader reader, string key) 41 | { 42 | int pos = 0; 43 | Stream stream = reader.BaseStream; 44 | while (stream.Position < stream.Length && pos < key.Length) 45 | { 46 | if (reader.ReadByte() == key[pos]) 47 | pos++; 48 | else 49 | pos = 0; 50 | } 51 | 52 | return stream.Position != stream.Length; // we went through the entire stream without finding the key 53 | } 54 | 55 | private static bool TryFindVersion(BinaryReader reader, [NotNullWhen(true)] out string? version) 56 | { 57 | Stream stream = reader.BaseStream; 58 | long streamLength = stream.Length; 59 | while (stream.Position < streamLength) 60 | { 61 | char current = (char)reader.ReadByte(); 62 | if (!char.IsDigit(current)) 63 | continue; 64 | 65 | long startPos = stream.Position - 1; 66 | int dotCount = 0; 67 | 68 | // for each first occurrence of a digit, try if it is a version of the form Major.Minor.Patch, i.e. with 2 dots 69 | while (stream.Position < streamLength) 70 | { 71 | current = (char)reader.ReadByte(); 72 | if (char.IsDigit(current)) 73 | { 74 | if (dotCount != 2 || stream.Position >= streamLength || char.IsDigit((char)reader.PeekChar())) 75 | continue; 76 | 77 | // found a version of the form Major.Minor.Patch 78 | int length = (int)(stream.Position - startPos); 79 | stream.Seek(-length, SeekOrigin.Current); // rewind to the string length 80 | byte[] bytes = reader.ReadBytes(length); 81 | version = Encoding.UTF8.GetString(bytes); 82 | return true; 83 | } 84 | 85 | if (current == '.') 86 | dotCount++; 87 | else 88 | break; // digit occurence is only a numeral literal, no version 89 | } 90 | } 91 | 92 | version = null; 93 | return false; 94 | } 95 | 96 | // There is one version ending in "p1" on BeatMods 97 | [GeneratedRegex(@"[\d]+.[\d]+.[\d]+(p1)?")] 98 | private static partial Regex VersionRegex(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Windows/MainWindow.axaml: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 53 | 58 | 59 | 60 | 61 | 64 | 65 | 68 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /BeatSaberModManager/ViewModels/MainWindowViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reactive; 3 | using System.Reactive.Disposables; 4 | using System.Reactive.Linq; 5 | 6 | using BeatSaberModManager.Models.Implementations.Progress; 7 | using BeatSaberModManager.Services.Implementations.Progress; 8 | using BeatSaberModManager.Utils; 9 | 10 | using ReactiveUI; 11 | 12 | 13 | namespace BeatSaberModManager.ViewModels 14 | { 15 | /// 16 | /// ViewModel for . 17 | /// 18 | public sealed class MainWindowViewModel : ViewModelBase, IActivatableViewModel 19 | { 20 | private readonly ObservableAsPropertyHelper _progressInfo; 21 | private readonly ObservableAsPropertyHelper _progressValue; 22 | 23 | /// 24 | /// Initializes a new instance of the class. 25 | /// 26 | public MainWindowViewModel(DashboardViewModel dashboardViewModel, ModsViewModel modsViewModel, SettingsViewModel settingsViewModel, StatusProgress statusProgress) 27 | { 28 | ArgumentNullException.ThrowIfNull(modsViewModel); 29 | ArgumentNullException.ThrowIfNull(statusProgress); 30 | DashboardViewModel = dashboardViewModel; 31 | ModsViewModel = modsViewModel; 32 | SettingsViewModel = settingsViewModel; 33 | Activator = new ViewModelActivator(); 34 | PickInstallDirInteraction = new Interaction(); 35 | InstallCommand = ReactiveCommand.CreateFromTask(modsViewModel.UpdateModsAsync, modsViewModel.IsSuccessObservable); 36 | IObservable canOpenMoreInfoLink = modsViewModel.WhenAnyValue(static x => x.SelectedGridItem).Select(static x => x?.AvailableMod.MoreInfoLink is not null); 37 | MoreInfoCommand = ReactiveCommand.Create(() => PlatformUtils.TryOpenUri(modsViewModel.SelectedGridItem!.AvailableMod.MoreInfoLink), canOpenMoreInfoLink); 38 | statusProgress.ProgressInfo.ToProperty(this, nameof(ProgressInfo), out _progressInfo, scheduler: RxApp.MainThreadScheduler); 39 | statusProgress.ProgressValue.ToProperty(this, nameof(ProgressValue), out _progressValue, scheduler: RxApp.MainThreadScheduler); 40 | this.WhenActivated(disposable => 41 | { 42 | settingsViewModel.WhenAnyValue(static x => x.InstallDir) 43 | .FirstAsync() 44 | .Where(static x => x is null) 45 | .SelectMany(PickInstallDirInteraction.Handle(Unit.Default)) 46 | .Subscribe(x => settingsViewModel.InstallDir = x) 47 | .DisposeWith(disposable); 48 | settingsViewModel.ValidatedInstallDirObservable.InvokeCommand(modsViewModel.InitializeCommand).DisposeWith(disposable); 49 | }); 50 | } 51 | 52 | /// 53 | public ViewModelActivator Activator { get; } 54 | 55 | /// 56 | /// The ViewModel for a dashboard view. 57 | /// 58 | public DashboardViewModel DashboardViewModel { get; } 59 | 60 | /// 61 | /// The ViewModel for a mods view. 62 | /// 63 | public ModsViewModel ModsViewModel { get; } 64 | 65 | /// 66 | /// The ViewModel for a settings view. 67 | /// 68 | public SettingsViewModel SettingsViewModel { get; } 69 | 70 | /// 71 | /// Invokes 72 | /// 73 | public ReactiveCommand InstallCommand { get; } 74 | 75 | /// 76 | /// Opens the of the 77 | /// 78 | public ReactiveCommand MoreInfoCommand { get; } 79 | 80 | /// 81 | /// Ask the user to pick an installation directory. 82 | /// 83 | public Interaction PickInstallDirInteraction { get; } 84 | 85 | /// 86 | /// The current information of the operation. 87 | /// 88 | public ProgressInfo ProgressInfo => _progressInfo.Value; 89 | 90 | /// 91 | /// The current progress of the operation. 92 | /// 93 | public double ProgressValue => _progressValue.Value; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Controls/HamburgerMenu.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using Avalonia; 4 | using Avalonia.Controls; 5 | using Avalonia.Controls.Primitives; 6 | using Avalonia.Media; 7 | 8 | 9 | namespace BeatSaberModManager.Views.Controls 10 | { 11 | /// 12 | /// Advanced with taggable side bar 13 | /// 14 | public class HamburgerMenu : TabControl 15 | { 16 | private SplitView? _splitView; 17 | 18 | /// 19 | /// Defines the property. 20 | /// 21 | public static readonly StyledProperty PaneBackgroundProperty = SplitView.PaneBackgroundProperty.AddOwner(); 22 | 23 | /// 24 | /// Defines the property. 25 | /// 26 | public static readonly StyledProperty ContentBackgroundProperty = AvaloniaProperty.Register(nameof(ContentBackground)); 27 | 28 | /// 29 | /// Defines the property. 30 | /// 31 | public static readonly StyledProperty ExpandedModeThresholdWidthProperty = AvaloniaProperty.Register(nameof(ExpandedModeThresholdWidth), 1008); 32 | 33 | /// 34 | /// Defines the property. 35 | /// 36 | public static readonly StyledProperty ContentMarginProperty = AvaloniaProperty.Register(nameof(ContentMargin)); 37 | 38 | /// 39 | /// Gets or sets the brush used to draw the pane's background. 40 | /// 41 | public IBrush? PaneBackground 42 | { 43 | get => GetValue(PaneBackgroundProperty); 44 | set => SetValue(PaneBackgroundProperty, value); 45 | } 46 | 47 | /// 48 | /// Gets or sets the brush used to draw the content's background. 49 | /// 50 | public IBrush? ContentBackground 51 | { 52 | get => GetValue(ContentBackgroundProperty); 53 | set => SetValue(ContentBackgroundProperty, value); 54 | } 55 | 56 | /// 57 | /// Gets or sets the width necessary to toggle the expanded mode. 58 | /// 59 | public int ExpandedModeThresholdWidth 60 | { 61 | get => GetValue(ExpandedModeThresholdWidthProperty); 62 | set => SetValue(ExpandedModeThresholdWidthProperty, value); 63 | } 64 | 65 | /// 66 | /// Gets or sets margin of the content. 67 | /// 68 | public Thickness ContentMargin 69 | { 70 | get => GetValue(ContentMarginProperty); 71 | set => SetValue(ContentMarginProperty, value); 72 | } 73 | 74 | /// 75 | protected override void OnApplyTemplate(TemplateAppliedEventArgs e) 76 | { 77 | ArgumentNullException.ThrowIfNull(e); 78 | base.OnApplyTemplate(e); 79 | _splitView = e.NameScope.Find("PART_NavigationPane"); 80 | } 81 | 82 | /// 83 | protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) 84 | { 85 | ArgumentNullException.ThrowIfNull(change); 86 | base.OnPropertyChanged(change); 87 | if (change.Property != BoundsProperty || _splitView is null) 88 | return; 89 | (Rect oldBounds, Rect newBounds) = change.GetOldAndNewValue(); 90 | EnsureSplitViewMode(oldBounds, newBounds); 91 | } 92 | 93 | private void EnsureSplitViewMode(Rect oldBounds, Rect newBounds) 94 | { 95 | if (_splitView is null) 96 | return; 97 | int threshold = ExpandedModeThresholdWidth; 98 | if (newBounds.Width >= threshold && oldBounds.Width < threshold) 99 | { 100 | _splitView.DisplayMode = SplitViewDisplayMode.Inline; 101 | _splitView.IsPaneOpen = true; 102 | } 103 | else if (newBounds.Width < threshold && oldBounds.Width >= threshold) 104 | { 105 | _splitView.DisplayMode = SplitViewDisplayMode.Overlay; 106 | _splitView.IsPaneOpen = false; 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /BeatSaberModManager/ViewModels/AssetInstallWindowViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Linq; 5 | using System.Reactive; 6 | using System.Reactive.Linq; 7 | using System.Threading.Tasks; 8 | 9 | using BeatSaberModManager.Models.Implementations.Progress; 10 | using BeatSaberModManager.Models.Implementations.Settings; 11 | using BeatSaberModManager.Models.Interfaces; 12 | using BeatSaberModManager.Services.Implementations.Progress; 13 | using BeatSaberModManager.Services.Interfaces; 14 | 15 | using ReactiveUI; 16 | 17 | 18 | namespace BeatSaberModManager.ViewModels 19 | { 20 | /// 21 | /// ViewModel for . 22 | /// 23 | public sealed class AssetInstallWindowViewModel : ViewModelBase 24 | { 25 | private readonly Uri _uri; 26 | private readonly ISettings _appSettings; 27 | private readonly IStatusProgress _progress; 28 | private readonly IInstallDirValidator _installDirValidator; 29 | private readonly IEnumerable _assetProviders; 30 | private readonly ObservableAsPropertyHelper _isExecuting; 31 | private readonly ObservableAsPropertyHelper _isSuccess; 32 | private readonly ObservableAsPropertyHelper _isFailed; 33 | private readonly ObservableAsPropertyHelper _progressValue; 34 | 35 | /// 36 | /// Initializes a new instance of the class. 37 | /// 38 | public AssetInstallWindowViewModel(Uri uri, StatusProgress statusProgress, ISettings appSettings, IInstallDirValidator installDirValidator, IEnumerable assetProviders) 39 | { 40 | ArgumentNullException.ThrowIfNull(statusProgress); 41 | ArgumentNullException.ThrowIfNull(assetProviders); 42 | _uri = uri; 43 | _progress = statusProgress; 44 | _appSettings = appSettings; 45 | _installDirValidator = installDirValidator; 46 | _assetProviders = assetProviders; 47 | ProgressInfoObservable = statusProgress.ProgressInfo; 48 | Log = new ObservableCollection(); 49 | InstallCommand = ReactiveCommand.CreateFromTask(InstallAssetAsync); 50 | InstallCommand.IsExecuting.ToProperty(this, nameof(IsExecuting), out _isExecuting); 51 | InstallCommand.ToProperty(this, nameof(IsSuccess), out _isSuccess); 52 | InstallCommand.CombineLatest(InstallCommand.IsExecuting) 53 | .Select(static x => x is (false, false)) 54 | .ToProperty(this, nameof(IsFailed), out _isFailed); 55 | statusProgress.ProgressValue.ToProperty(this, nameof(ProgressValue), out _progressValue); 56 | } 57 | 58 | /// 59 | public IObservable ProgressInfoObservable { get; } 60 | 61 | /// 62 | /// Downloads and installs an asset. 63 | /// 64 | public ReactiveCommand InstallCommand { get; } 65 | 66 | /// 67 | /// Collection of log messages. 68 | /// 69 | public ObservableCollection Log { get; } 70 | 71 | /// 72 | public bool CloseOneClickWindow => _appSettings.Value.CloseOneClickWindow; 73 | 74 | /// 75 | /// True if the operation is currently executing, false otherwise. 76 | /// 77 | public bool IsExecuting => _isExecuting.Value; 78 | 79 | /// 80 | /// True if the operation successfully ran to completion, false otherwise. 81 | /// 82 | public bool IsSuccess => _isSuccess.Value; 83 | 84 | /// 85 | /// True if the operation faulted, false otherwise. 86 | /// 87 | public bool IsFailed => _isFailed.Value; 88 | 89 | /// 90 | /// The current progress of the operation. 91 | /// 92 | public double ProgressValue => _progressValue.Value; 93 | 94 | private async Task InstallAssetAsync() 95 | { 96 | if (!_installDirValidator.ValidateInstallDir(_appSettings.Value.InstallDir)) 97 | return false; 98 | IAssetProvider? assetProvider = _assetProviders.FirstOrDefault(x => x.Protocol == _uri.Scheme); 99 | return assetProvider is not null && await assetProvider.InstallAssetAsync(_appSettings.Value.InstallDir, _uri, _progress).ConfigureAwait(false); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/Http/HttpProgressClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.IO; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Net.Http.Headers; 7 | using System.Threading.Tasks; 8 | 9 | using Serilog; 10 | 11 | 12 | namespace BeatSaberModManager.Services.Implementations.Http 13 | { 14 | /// 15 | /// A custom that provides additional overloads that track the progress. 16 | /// 17 | public class HttpProgressClient : HttpClient 18 | { 19 | private readonly ILogger _logger; 20 | 21 | /// 22 | /// Set the default proxy to an empty one to avoid loading UnityDoorstep's winhttp. 23 | /// 24 | static HttpProgressClient() 25 | { 26 | DefaultProxy = new WebProxy(); 27 | } 28 | 29 | /// 30 | /// Initializes a new instance of the class with a UserAgent and a default timeout. 31 | /// 32 | public HttpProgressClient(ILogger logger) 33 | { 34 | _logger = logger; 35 | DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(new ProductHeaderValue(Program.Product, Program.Version))); 36 | Timeout = TimeSpan.FromMinutes(3); 37 | } 38 | 39 | /// 40 | /// Try to send a GET request to the specified Uri with an HTTP completion option as an asynchronous operation. 41 | /// 42 | /// The uri the request is sent to. 43 | /// Optionally track the progress of the operation. 44 | /// The task object representing the asynchronous operation. 45 | public async Task TryGetAsync(Uri uri, IProgress? progress) 46 | { 47 | HttpResponseMessage response = await TryGetAsync(uri, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); 48 | if (!response.IsSuccessStatusCode) 49 | return response; 50 | long total = 0; 51 | long? length = response.Content.Headers.ContentLength; 52 | byte[] buffer = ArrayPool.Shared.Rent(8192); 53 | MemoryStream ms = length.HasValue ? new MemoryStream((int)length.Value) : new MemoryStream(); 54 | Stream stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); 55 | while (true) 56 | { 57 | int read = await stream.ReadAsync(buffer).ConfigureAwait(false); 58 | if (read <= 0) 59 | break; 60 | await ms.WriteAsync(buffer.AsMemory(0, read)).ConfigureAwait(false); 61 | if (!length.HasValue) 62 | continue; 63 | total += read; 64 | progress?.Report(((double)total + 1) / length.Value); 65 | } 66 | 67 | ArrayPool.Shared.Return(buffer); 68 | ms.Position = 0; 69 | response.Content = new StreamContent(ms); 70 | return response; 71 | } 72 | 73 | /// 74 | /// Try to send a GET request to the specified Uri with an HTTP completion option as an asynchronous operation. 75 | /// 76 | /// The uri the request is sent to. 77 | /// The task object representing the asynchronous operation. 78 | public async Task TryGetAsync(Uri uri) 79 | { 80 | try 81 | { 82 | return await GetAsync(uri).ConfigureAwait(false); 83 | } 84 | catch (HttpRequestException httpRequestException) 85 | { 86 | _logger.Error(httpRequestException, "Http GET request {Uri} failed", uri); 87 | } 88 | catch (TimeoutException timeoutException) 89 | { 90 | _logger.Error(timeoutException, "Http GET request {Uri} timed out", uri); 91 | } 92 | 93 | return new HttpResponseMessage(0); 94 | } 95 | 96 | /// 97 | /// Try to send a GET request to the specified Uri with an HTTP completion option as an asynchronous operation. 98 | /// 99 | /// The uri the request is sent to. 100 | /// An HTTP completion option value that indicates when the operation should be considered completed. 101 | /// The task object representing the asynchronous operation. 102 | public async Task TryGetAsync(Uri uri, HttpCompletionOption completionOption) 103 | { 104 | try 105 | { 106 | return await GetAsync(uri, completionOption).ConfigureAwait(false); 107 | } 108 | catch (HttpRequestException httpRequestException) 109 | { 110 | _logger.Error(httpRequestException, "Http GET request {Uri} failed", uri); 111 | } 112 | catch (TimeoutException timeoutException) 113 | { 114 | _logger.Error(timeoutException, "Http GET request {Uri} timed out", uri); 115 | } 116 | 117 | return new HttpResponseMessage(0); 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Localization/en.axaml: -------------------------------------------------------------------------------- 1 | 4 | 5 | BeatSaberModManager 6 | Intro 7 | Dashboard 8 | Mods 9 | Settings 10 | More Info 11 | Update Mods 12 | 13 | 14 | Beat Saber Mod Manager 15 | 16 | Please read this page entirely and carefully 17 | By using this program attest to have read and agree to the following terms: 18 | Beat Saber does not natively support mods. This means: 19 | Mods will break every update. This is normal, and not Beat Games' fault. 20 | Mods will cause bugs and performance issues. This is not Beat Games' fault. 21 | Mods are made for free by people in their free time. Please be patient and understanding. 22 | DO NOT leave negative reviews because mods broke. This is not Beat Games' fault. 23 | They are not trying to kill mods. 24 | 25 | 26 | Application Version 27 | Game Version 28 | Installed mods 29 | Tools 30 | Install Playlist 31 | Play 32 | Open AppData 33 | Open Logs 34 | Uninstall BSIPA 35 | Uninstall All Mods 36 | Useful Links 37 | BSMG Wiki 38 | BSMG Discord 39 | GitHub 40 | 41 | Name 42 | Installed 43 | Latest 44 | Description 45 | Failed to load mods 46 | No mods available for this version yet 47 | Search... 48 | 49 | Installation 50 | Open Folder 51 | Select Folder 52 | OneClick™ Support 53 | BeatSaver 54 | ModelSaber 55 | Playlist 56 | Language 57 | Miscellaneous 58 | Reinstall installed mods 59 | Automatically close OneClick™ window 60 | Save selected mods 61 | Themes 62 | 63 | System 64 | Light 65 | Dark 66 | 67 | It looks like you haven't set your installation directory yet, would you like to do so? 68 | Select 69 | Cancel 70 | 71 | Application crashed 72 | Ok 73 | 74 | Installing: 75 | Uninstalling: 76 | Installation completed 77 | Installation failed 78 | 79 | 80 | -------------------------------------------------------------------------------- /BeatSaberModManager/Utils/IOUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.IO.Compression; 4 | using System.Threading.Tasks; 5 | 6 | 7 | namespace BeatSaberModManager.Utils 8 | { 9 | /// 10 | /// Utilities for various IO-related operations. 11 | /// 12 | public static class IOUtils 13 | { 14 | /// 15 | /// Attempts to delete a file. 16 | /// 17 | /// The file to delete. 18 | public static void TryDeleteFile(string path) 19 | { 20 | try 21 | { 22 | File.Delete(path); 23 | } 24 | catch (ArgumentException) { } 25 | catch (IOException) { } 26 | catch (UnauthorizedAccessException) { } 27 | } 28 | 29 | /// 30 | /// Attempts to move a file. 31 | /// 32 | /// The file to move. 33 | /// The new path for the file. 34 | public static bool TryMoveFile(string path, string dest) 35 | { 36 | try 37 | { 38 | File.Move(path, dest); 39 | return true; 40 | } 41 | catch (ArgumentException) { } 42 | catch (IOException) { } 43 | catch (UnauthorizedAccessException) { } 44 | return false; 45 | } 46 | 47 | /// 48 | /// Attempts to create all directories and subdirectories in the specified path unless they already exist. 49 | /// 50 | /// The directory to create. 51 | /// True if the operation succeeds, false otherwise. 52 | public static bool TryCreateDirectory(string path) 53 | { 54 | try 55 | { 56 | Directory.CreateDirectory(path); 57 | return true; 58 | } 59 | catch (ArgumentException) { } 60 | catch (IOException) { } 61 | catch (UnauthorizedAccessException) { } 62 | return false; 63 | } 64 | 65 | /// 66 | /// Attempts to delete the specified directory and, if indicated, any subdirectories and files in the directory. 67 | /// 68 | /// The name of the directory to remove. 69 | /// True to remove directories, subdirectories, and files in path, false otherwise. 70 | public static void TryDeleteDirectory(string path, bool recursive) 71 | { 72 | try 73 | { 74 | Directory.Delete(path, recursive); 75 | } 76 | catch (ArgumentException) { } 77 | catch (IOException) { } 78 | catch (UnauthorizedAccessException) { } 79 | } 80 | 81 | /// 82 | /// Attempts to extract all of the files in the archive to a directory on the file system. 83 | /// 84 | /// The archive to extract. 85 | /// The path to the destination directory on the file system. 86 | /// True to overwrite existing files, false otherwise. 87 | /// True if the operation succeeds, false otherwise. 88 | public static bool TryExtractArchive(ZipArchive archive, string path, bool overrideFiles) 89 | { 90 | try 91 | { 92 | archive.ExtractToDirectory(path, overrideFiles); 93 | return true; 94 | } 95 | catch (ArgumentException) { } 96 | catch (IOException) { } 97 | catch (UnauthorizedAccessException) { } 98 | return false; 99 | } 100 | 101 | /// 102 | /// Attempts to open a o the specified path, having the specified mode with read, write, or read/write access and the specified sharing option. 103 | /// 104 | /// The file to open. 105 | /// A value that specifies whether a file is created if one does not exist, and determines whether the contents of existing files are retained or overwritten. 106 | /// A value that specifies the operations that can be performed on the file. 107 | /// The if the operation succeeds, null otherwise. 108 | public static FileStream? TryOpenFile(string path, FileMode mode, FileAccess access) 109 | { 110 | try 111 | { 112 | return File.Open(path, mode, access); 113 | } 114 | catch (ArgumentException) { } 115 | catch (IOException) { } 116 | catch (UnauthorizedAccessException) { } 117 | return null; 118 | } 119 | 120 | /// 121 | /// Attempts to read the lines of a file. 122 | /// 123 | /// The file to read. 124 | /// All the lines of the file if the operation succeeds, null otherwise. 125 | public static async Task TryReadAllLinesAsync(string path) 126 | { 127 | try 128 | { 129 | return await File.ReadAllLinesAsync(path).ConfigureAwait(false); 130 | } 131 | catch (ArgumentException) { } 132 | catch (IOException) { } 133 | catch (UnauthorizedAccessException) { } 134 | return null; 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /BeatSaberModManager/Resources/Localization/de.axaml: -------------------------------------------------------------------------------- 1 | 4 | 5 | BeatSaberModManager 6 | Intro 7 | Übersicht 8 | Mods 9 | Einstellungen 10 | Weitere Infos 11 | Mods aktualisieren 12 | 13 | 14 | Beat Saber Mod Manager 15 | 16 | Bitte lies diese Seite vollständig und aufmerksam 17 | Durch Nutzung dieses Programmes stimmst du den folgenden Bedingungen zu: 18 | Beat Saber unterstützt normalerweise keine Mods. Das heißt: 19 | Mods werden nach jedem Update kaputt gehen. Das ist normal und nicht Beat Games' Schuld. 20 | Mods werden Fehler und Leistungsprobleme verursachen. Das ist nicht Beat Games' Schuld. 21 | Mods werden kostenlos von Leuten in ihrer Freizeit erstellt. Bitte sei geduldig und verständnisvoll. 22 | Bitte vergib KEINE schlechte Bewertung, weil die Mods nicht funktionieren. Das ist nicht Beat Games' Schuld. 23 | Sie versuchen nicht, Mods zu unterbinden. 24 | 25 | 26 | Anwendungsversion 27 | Spielversion 28 | Installierte Mods 29 | Werkzeuge 30 | Playlist installieren 31 | Spielen 32 | AppData öffnen 33 | Logs öffnen 34 | Deinstalliere BSIPA 35 | Deinstalliere alle Mods 36 | Nützliche Links 37 | BSMG Wiki 38 | BSMG Discord 39 | GitHub 40 | 41 | Name 42 | Installiert 43 | Neuste 44 | Beschreibung 45 | Laden der Mods fehlgeschlagen 46 | Noch keine Mods für diese Version verfügbar 47 | Suche... 48 | 49 | Installationsordner 50 | Ordner öffnen 51 | Ordner auswählen 52 | OneClick™ Unterstützung 53 | BeatSaver 54 | ModelSaber 55 | Playlist 56 | Sprache 57 | Verschiedenes 58 | Installierte Mods neu installieren 59 | OneClick™ Fenster automatisch schließen 60 | Ausgewählte mods speichern 61 | Themen 62 | 63 | System 64 | Hell 65 | Dunkel 66 | 67 | Es sieht so aus, als hättest du noch keinen Installationsordner angegeben, möchtest du es jetzt tun? 68 | Auswählen 69 | Abbrechen 70 | 71 | Anwendung abgestürzt 72 | Ok 73 | 74 | Installiere: 75 | Deinstalliere: 76 | Installation abgeschlossen 77 | Installation fehlgeschlagen 78 | 79 | 80 | -------------------------------------------------------------------------------- /BeatSaberModManager/Views/Pages/ModsPage.axaml: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 23 | 24 | 25 | 32 | 33 | 40 | 41 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /BeatSaberModManager/ViewModels/DashboardViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reactive; 3 | using System.Reactive.Linq; 4 | using System.Threading.Tasks; 5 | 6 | using BeatSaberModManager.Models.Implementations.Progress; 7 | using BeatSaberModManager.Services.Implementations.BeatSaber.Playlists; 8 | using BeatSaberModManager.Services.Implementations.Observables; 9 | using BeatSaberModManager.Services.Interfaces; 10 | using BeatSaberModManager.Utils; 11 | 12 | using ReactiveUI; 13 | 14 | 15 | namespace BeatSaberModManager.ViewModels 16 | { 17 | /// 18 | /// ViewModel for . 19 | /// 20 | public sealed class DashboardViewModel : ViewModelBase, IDisposable 21 | { 22 | private readonly SettingsViewModel _settingsViewModel; 23 | private readonly IStatusProgress _statusProgress; 24 | private readonly PlaylistInstaller _playlistInstaller; 25 | private readonly ObservableAsPropertyHelper _gameVersion; 26 | private readonly DirectoryExistsObservable _appDataDirExistsObservable; 27 | private readonly DirectoryExistsObservable _logsDirExistsObservable; 28 | 29 | /// 30 | /// Initializes a new instance of the class. 31 | /// 32 | public DashboardViewModel(ModsViewModel modsViewModel, SettingsViewModel settingsViewModel, IGameVersionProvider gameVersionProvider, IGameLauncher gameLauncher, IGamePathsProvider gamePathsProvider, IStatusProgress statusProgress, PlaylistInstaller playlistInstaller) 33 | { 34 | ArgumentNullException.ThrowIfNull(modsViewModel); 35 | ArgumentNullException.ThrowIfNull(settingsViewModel); 36 | ArgumentNullException.ThrowIfNull(gameVersionProvider); 37 | ArgumentNullException.ThrowIfNull(gameLauncher); 38 | ArgumentNullException.ThrowIfNull(gamePathsProvider); 39 | ArgumentNullException.ThrowIfNull(statusProgress); 40 | ArgumentNullException.ThrowIfNull(playlistInstaller); 41 | _settingsViewModel = settingsViewModel; 42 | _statusProgress = statusProgress; 43 | _playlistInstaller = playlistInstaller; 44 | AppVersion = Program.Version; 45 | ModsViewModel = modsViewModel; 46 | PickPlaylistInteraction = new Interaction(); 47 | settingsViewModel.ValidatedInstallDirObservable.SelectMany(gameVersionProvider.DetectGameVersionAsync).ToProperty(this, nameof(GameVersion), out _gameVersion); 48 | _appDataDirExistsObservable = new DirectoryExistsObservable(); 49 | settingsViewModel.ValidatedInstallDirObservable.Select(gamePathsProvider.GetAppDataPath).Subscribe(x => _appDataDirExistsObservable.Path = x); 50 | OpenAppDataCommand = ReactiveCommand.Create(_ => PlatformUtils.TryOpenUri(new Uri(_appDataDirExistsObservable.Path!)), _appDataDirExistsObservable.ObserveOn(RxApp.MainThreadScheduler)); 51 | _logsDirExistsObservable = new DirectoryExistsObservable(); 52 | settingsViewModel.ValidatedInstallDirObservable.Select(gamePathsProvider.GetLogsPath).Subscribe(x => _logsDirExistsObservable.Path = x); 53 | OpenLogsCommand = ReactiveCommand.Create(_ => PlatformUtils.TryOpenUri(new Uri(_logsDirExistsObservable.Path!)), _logsDirExistsObservable.ObserveOn(RxApp.MainThreadScheduler)); 54 | UninstallModLoaderCommand = ReactiveCommand.CreateFromTask(() => modsViewModel.UninstallModLoaderAsync(settingsViewModel.InstallDir!), modsViewModel.IsSuccessObservable); 55 | UninstallAllModsCommand = ReactiveCommand.CreateFromTask(() => modsViewModel.UninstallAllModsAsync(settingsViewModel.InstallDir!), modsViewModel.IsSuccessObservable); 56 | LaunchGameCommand = ReactiveCommand.Create(() => gameLauncher.LaunchGame(settingsViewModel.InstallDir!), settingsViewModel.IsInstallDirValidObservable); 57 | InstallPlaylistCommand = ReactiveCommand.CreateFromObservable(() => PickPlaylistInteraction.Handle(Unit.Default) 58 | .WhereNotNull() 59 | .SelectMany(InstallPlaylistAsync), settingsViewModel.IsInstallDirValidObservable); 60 | } 61 | 62 | private async Task InstallPlaylistAsync(string x) 63 | { 64 | bool result = await _playlistInstaller.InstallPlaylistAsync(_settingsViewModel.InstallDir!, x, _statusProgress).ConfigureAwait(false); 65 | _statusProgress.Report(new ProgressInfo(result ? StatusType.Completed : StatusType.Failed, null)); 66 | return result; 67 | } 68 | 69 | /// 70 | /// The version of the application. 71 | /// 72 | public string AppVersion { get; } 73 | 74 | /// 75 | /// The ViewModel for a mods view. 76 | /// 77 | public ModsViewModel ModsViewModel { get; } 78 | 79 | /// 80 | /// Opens the game's AppData directory in the file explorer. 81 | /// 82 | public ReactiveCommand OpenAppDataCommand { get; } 83 | 84 | /// 85 | /// Opens the game's Logs directory in the file explorer. 86 | /// 87 | public ReactiveCommand OpenLogsCommand { get; } 88 | 89 | /// 90 | /// Uninstalls the mod loader. 91 | /// 92 | public ReactiveCommand UninstallModLoaderCommand { get; } 93 | 94 | /// 95 | /// Uninstalls all installed mods. 96 | /// 97 | public ReactiveCommand UninstallAllModsCommand { get; } 98 | 99 | /// 100 | /// Launches the game. 101 | /// 102 | public ReactiveCommand LaunchGameCommand { get; } 103 | 104 | /// 105 | /// Install a see cref="BeatSaberModManager.Models.Implementations.BeatSaber.Playlists.Playlist"/>. 106 | /// 107 | public ReactiveCommand InstallPlaylistCommand { get; } 108 | 109 | /// 110 | /// Asks the user to select a playlist file to install. 111 | /// 112 | public Interaction PickPlaylistInteraction { get; } 113 | 114 | /// 115 | /// The version of the game. 116 | /// 117 | public string? GameVersion => _gameVersion.Value; 118 | 119 | /// 120 | public void Dispose() 121 | { 122 | _gameVersion.Dispose(); 123 | _appDataDirExistsObservable.Dispose(); 124 | _logsDirExistsObservable.Dispose(); 125 | OpenAppDataCommand.Dispose(); 126 | OpenLogsCommand.Dispose(); 127 | UninstallModLoaderCommand.Dispose(); 128 | UninstallAllModsCommand.Dispose(); 129 | LaunchGameCommand.Dispose(); 130 | InstallPlaylistCommand.Dispose(); 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/Playlists/PlaylistInstaller.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.IO.Compression; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Net.Http; 7 | using System.Text.Json; 8 | using System.Threading.Tasks; 9 | 10 | using BeatSaberModManager.Models.Implementations.BeatSaber.BeatSaver; 11 | using BeatSaberModManager.Models.Implementations.BeatSaber.Playlists; 12 | using BeatSaberModManager.Models.Implementations.Json; 13 | using BeatSaberModManager.Models.Implementations.Progress; 14 | using BeatSaberModManager.Services.Implementations.BeatSaber.BeatSaver; 15 | using BeatSaberModManager.Services.Implementations.Http; 16 | using BeatSaberModManager.Services.Interfaces; 17 | using BeatSaberModManager.Utils; 18 | 19 | 20 | namespace BeatSaberModManager.Services.Implementations.BeatSaber.Playlists 21 | { 22 | /// 23 | /// Download and install playlists of s. 24 | /// 25 | public class PlaylistInstaller 26 | { 27 | private readonly HttpProgressClient _httpClient; 28 | private readonly BeatSaverMapInstaller _beatSaverMapInstaller; 29 | 30 | /// 31 | /// Initializes a new instance of the class. 32 | /// 33 | public PlaylistInstaller(HttpProgressClient httpClient, BeatSaverMapInstaller beatSaverMapInstaller) 34 | { 35 | _httpClient = httpClient; 36 | _beatSaverMapInstaller = beatSaverMapInstaller; 37 | } 38 | 39 | /// 40 | /// Asynchronously downloads and installs a from an . 41 | /// 42 | /// The game's installation directory. 43 | /// The to download the playlist from. 44 | /// Optionally track the progress of the operation. 45 | /// True if the operation succeeds, false otherwise. 46 | public async Task InstallPlaylistAsync(string installDir, Uri uri, IStatusProgress? progress = null) 47 | { 48 | ArgumentNullException.ThrowIfNull(uri); 49 | using HttpResponseMessage response = await _httpClient.TryGetAsync(uri).ConfigureAwait(false); 50 | if (!response.IsSuccessStatusCode) 51 | return false; 52 | string playlistsDirPath = Path.Join(installDir, "Playlists"); 53 | string fileName = WebUtility.UrlDecode(uri.Segments.Last()); 54 | string filePath = Path.Join(playlistsDirPath, fileName); 55 | if (!IOUtils.TryCreateDirectory(playlistsDirPath)) 56 | return false; 57 | #pragma warning disable CA2007 58 | await using Stream contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); 59 | await using Stream? writeStream = IOUtils.TryOpenFile(filePath, FileMode.Create, FileAccess.Write); 60 | #pragma warning restore CA2007 61 | if (writeStream is null) 62 | return false; 63 | await contentStream.CopyToAsync(writeStream).ConfigureAwait(false); 64 | contentStream.Seek(0, SeekOrigin.Begin); 65 | Playlist? playlist = await JsonSerializer.DeserializeAsync(contentStream, PlaylistJsonSerializerContext.Default.Playlist).ConfigureAwait(false); 66 | return playlist is not null && await InstallPlaylistAsync(installDir, playlist, progress).ConfigureAwait(false); 67 | } 68 | 69 | /// 70 | /// Asynchronously installs a from a file. 71 | /// 72 | /// The game's installation directory. 73 | /// The path of the 's file. 74 | /// Optionally track the progress of the operation. 75 | /// True if the operation succeeds, false otherwise. 76 | public async Task InstallPlaylistAsync(string installDir, string filePath, IStatusProgress? progress = null) 77 | { 78 | string fileName = Path.GetFileName(filePath); 79 | string playlistsDirPath = Path.Join(installDir, "Playlists"); 80 | string destFilePath = Path.Join(playlistsDirPath, fileName); 81 | if (!IOUtils.TryCreateDirectory(playlistsDirPath)) 82 | return false; 83 | #pragma warning disable CA2007 84 | await using FileStream? contentStream = IOUtils.TryOpenFile(filePath, FileMode.Open, FileAccess.Read); 85 | if (contentStream is null) 86 | return false; 87 | await using FileStream? writeStream = IOUtils.TryOpenFile(destFilePath, FileMode.Create, FileAccess.Write); 88 | #pragma warning restore CA2007 89 | if (writeStream is null) 90 | return false; 91 | await contentStream.CopyToAsync(writeStream).ConfigureAwait(false); 92 | contentStream.Seek(0, SeekOrigin.Begin); 93 | Playlist? playlist = await JsonSerializer.DeserializeAsync(contentStream, PlaylistJsonSerializerContext.Default.Playlist).ConfigureAwait(false); 94 | return playlist is not null && await InstallPlaylistAsync(installDir, playlist, progress).ConfigureAwait(false); 95 | } 96 | 97 | private async Task InstallPlaylistAsync(string installDir, Playlist playlist, IStatusProgress? progress = null) 98 | { 99 | progress?.Report(0); 100 | BeatSaverMap[] maps = await GetMapsAsync(playlist).ConfigureAwait(false); 101 | for (int i = 0; i < maps.Length; i++) 102 | { 103 | progress?.Report(new ProgressInfo(StatusType.Installing, maps[i].Name)); 104 | if (maps[i].Versions.Count <= 0) continue; 105 | HttpResponseMessage response = await _httpClient.TryGetAsync(maps[i].Versions[^1].DownloadUrl).ConfigureAwait(false); 106 | if (!response.IsSuccessStatusCode) continue; 107 | Stream stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); 108 | using ZipArchive archive = new(stream); 109 | bool success = BeatSaverMapInstaller.TryExtractBeatSaverMapToDir(installDir, maps[i], archive); 110 | if (!success) progress?.Report(new ProgressInfo(StatusType.Failed, maps[i].Name)); 111 | progress?.Report(((double)i + 1) / maps.Length); 112 | } 113 | 114 | return true; 115 | } 116 | 117 | private async Task GetMapsAsync(Playlist playlist) 118 | { 119 | BeatSaverMap?[] maps = new BeatSaverMap?[playlist.Songs.Count]; 120 | for (int i = 0; i < maps.Length; i++) 121 | { 122 | if (!string.IsNullOrEmpty(playlist.Songs[i].Id)) 123 | maps[i] = await _beatSaverMapInstaller.GetBeatSaverMapByKeyAsync(playlist.Songs[i].Id!).ConfigureAwait(false); 124 | else if (!string.IsNullOrEmpty(playlist.Songs[i].Hash)) 125 | maps[i] = await _beatSaverMapInstaller.GetBeatSaverMapByHashAsync(playlist.Songs[i].Hash!).ConfigureAwait(false); 126 | } 127 | 128 | return maps.Where(static x => x?.Versions.Count > 0).ToArray()!; 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /BeatSaberModManager/Services/Implementations/BeatSaber/BeatSaberInstallDirLocator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Runtime.Versioning; 5 | using System.Text.RegularExpressions; 6 | using System.Threading.Tasks; 7 | 8 | using BeatSaberModManager.Models.Implementations; 9 | using BeatSaberModManager.Services.Interfaces; 10 | using BeatSaberModManager.Utils; 11 | 12 | using Microsoft.Win32; 13 | 14 | 15 | namespace BeatSaberModManager.Services.Implementations.BeatSaber 16 | { 17 | /// 18 | public partial class BeatSaberInstallDirLocator : IInstallDirLocator 19 | { 20 | private readonly IInstallDirValidator _installDirValidator; 21 | 22 | /// 23 | /// Initializes a new instance of the class. 24 | /// 25 | public BeatSaberInstallDirLocator(IInstallDirValidator installDirValidator) 26 | { 27 | _installDirValidator = installDirValidator; 28 | } 29 | 30 | /// 31 | public ValueTask LocateInstallDirAsync() => 32 | OperatingSystem.IsWindows() ? LocateWindowsInstallDirAsync() 33 | : OperatingSystem.IsLinux() ? LocateLinuxSteamInstallDirAsync() 34 | : throw new PlatformNotSupportedException(); 35 | 36 | /// 37 | public PlatformType DetectPlatform(string installDir) 38 | { 39 | string pluginsDir = Path.Join(installDir, "Beat Saber_Data", "Plugins"); 40 | return File.Exists(Path.Join(pluginsDir, "steam_api64.dll")) || File.Exists(Path.Join(pluginsDir, "x86_64", "steam_api64.dll")) ? PlatformType.Steam : PlatformType.Oculus; 41 | } 42 | 43 | [SupportedOSPlatform("windows")] 44 | private ValueTask LocateWindowsInstallDirAsync() 45 | { 46 | string? steamInstallDir = LocateWindowsSteamInstallDir(); 47 | return steamInstallDir is null ? ValueTask.FromResult(LocateOculusBeatSaberInstallDir()) : LocateSteamBeatSaberInstallDirAsync(steamInstallDir); 48 | } 49 | 50 | [SupportedOSPlatform("linux")] 51 | private ValueTask LocateLinuxSteamInstallDirAsync() 52 | { 53 | string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 54 | string steamInstallDir = Path.Join(homeDir, ".steam", "root"); 55 | return LocateSteamBeatSaberInstallDirAsync(steamInstallDir); 56 | } 57 | 58 | private async ValueTask LocateSteamBeatSaberInstallDirAsync(string steamInstallDir) 59 | { 60 | await foreach (string libPath in EnumerateSteamLibraryPathsAsync(steamInstallDir).ConfigureAwait(false)) 61 | { 62 | string? installDir = await MatchSteamBeatSaberInstallDirAsync(libPath).ConfigureAwait(false); 63 | if (installDir is not null) 64 | return Path.GetFullPath(installDir); 65 | } 66 | 67 | return null; 68 | } 69 | 70 | private async Task MatchSteamBeatSaberInstallDirAsync(string path) 71 | { 72 | string acf = Path.Join(path, "appmanifest_620980.acf"); 73 | #pragma warning disable CA2007 74 | await using FileStream? fileStream = IOUtils.TryOpenFile(acf, FileMode.Open, FileAccess.Read); 75 | #pragma warning restore CA2007 76 | if (fileStream is null) 77 | return null; 78 | Regex regex = InstallDirRegex(); 79 | using StreamReader reader = new(fileStream); 80 | while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) 81 | { 82 | Match match = regex.Match(line); 83 | if (!match.Success) 84 | continue; 85 | string installDir = Path.Join(path, "common", match.Groups[1].Value); 86 | if (_installDirValidator.ValidateInstallDir(installDir)) 87 | return installDir; 88 | } 89 | 90 | return null; 91 | } 92 | 93 | [SupportedOSPlatform("windows")] 94 | private string? LocateOculusBeatSaberInstallDir() 95 | { 96 | using RegistryKey? oculusInstallDirKey = Registry.LocalMachine.OpenSubKey("Software")?.OpenSubKey("Wow6432Node")?.OpenSubKey("Oculus VR, LLC")?.OpenSubKey("Oculus")?.OpenSubKey("Config"); 97 | string? oculusInstallDir = oculusInstallDirKey?.GetValue("InitialAppLibrary")?.ToString(); 98 | if (string.IsNullOrEmpty(oculusInstallDir)) 99 | return null; 100 | string finalPath = Path.Join(oculusInstallDir, "Software", "hyperbolic-magnetism-beat-saber"); 101 | string? installDir = _installDirValidator.ValidateInstallDir(finalPath) ? finalPath : LocateInOculusLibrary(); 102 | return installDir is null ? null : Path.GetFullPath(installDir); 103 | } 104 | 105 | [SupportedOSPlatform("windows")] 106 | private string? LocateInOculusLibrary() 107 | { 108 | using RegistryKey? librariesKey = Registry.CurrentUser.OpenSubKey("Software")?.OpenSubKey("Oculus VR, LLC")?.OpenSubKey("Oculus")?.OpenSubKey("Libraries"); 109 | if (librariesKey is null) 110 | return null; 111 | foreach (string libraryKeyName in librariesKey.GetSubKeyNames()) 112 | { 113 | using RegistryKey? libraryKey = librariesKey.OpenSubKey(libraryKeyName); 114 | string? libraryPath = libraryKey?.GetValue("Path")?.ToString(); 115 | if (libraryPath is null) 116 | continue; 117 | string finalPath = Path.Join(libraryPath, "Software", "hyperbolic-magnetism-beat-saber"); 118 | if (_installDirValidator.ValidateInstallDir(finalPath)) 119 | return finalPath; 120 | } 121 | 122 | return null; 123 | } 124 | 125 | [SupportedOSPlatform("windows")] 126 | private static string? LocateWindowsSteamInstallDir() 127 | { 128 | using RegistryKey? steamInstallDirKey = Registry.LocalMachine.OpenSubKey("Software")?.OpenSubKey("Wow6432Node")?.OpenSubKey("Valve")?.OpenSubKey("Steam"); 129 | return steamInstallDirKey?.GetValue("InstallPath")?.ToString(); 130 | } 131 | 132 | private static async IAsyncEnumerable EnumerateSteamLibraryPathsAsync(string path) 133 | { 134 | yield return path; 135 | string vdf = Path.Join(path, "steamapps", "libraryfolders.vdf"); 136 | #pragma warning disable CA2007 137 | await using FileStream? fileStream = IOUtils.TryOpenFile(vdf, FileMode.Open, FileAccess.Read); 138 | #pragma warning restore CA2007 139 | if (fileStream is null) 140 | yield break; 141 | Regex regex = LibraryPathRegex(); 142 | using StreamReader vdfReader = new(fileStream); 143 | while (await vdfReader.ReadLineAsync().ConfigureAwait(false) is { } line) 144 | { 145 | Match match = regex.Match(line); 146 | if (match.Success) 147 | yield return Path.Join(match.Groups[1].Value.Replace(@"\\", "/", StringComparison.Ordinal), "steamapps"); 148 | } 149 | } 150 | 151 | [GeneratedRegex("\\s\"installdir\"\\s+\"(.+)\"")] 152 | private static partial Regex InstallDirRegex(); 153 | 154 | [GeneratedRegex("\\s\"(?:\\d|path)\"\\s+\"(.+)\"")] 155 | private static partial Regex LibraryPathRegex(); 156 | } 157 | } 158 | --------------------------------------------------------------------------------