diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 56964aa8b2..446eb72b04 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -564,7 +564,7 @@ namespace osu.Game.Tests.Database var imported = await importer.Import( progressNotification, - new ImportTask(zipStream, string.Empty) + new[] { new ImportTask(zipStream, string.Empty) } ); realm.Run(r => r.Refresh()); @@ -1052,7 +1052,7 @@ namespace osu.Game.Tests.Database { string? temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack); - var importedSet = await importer.Import(new ImportTask(temp), batchImport); + var importedSet = await importer.Import(new ImportTask(temp), new ImportParameters { Batch = batchImport }); Assert.NotNull(importedSet); Debug.Assert(importedSet != null); diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index e1d8e08c5e..585fd516bd 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -226,12 +226,12 @@ namespace osu.Game.Tests.Online this.testBeatmapManager = testBeatmapManager; } - public override Live ImportModel(BeatmapSetInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default) + public override Live ImportModel(BeatmapSetInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) { if (!testBeatmapManager.AllowImport.Wait(TimeSpan.FromSeconds(10), cancellationToken)) throw new TimeoutException("Timeout waiting for import to be allowed."); - return (testBeatmapManager.CurrentImport = base.ImportModel(item, archive, batchImport, cancellationToken)); + return (testBeatmapManager.CurrentImport = base.ImportModel(item, archive, parameters, cancellationToken)); } } } diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index 5c20f46787..0bd40e9962 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -360,7 +360,7 @@ namespace osu.Game.Tests.Skins.IO private async Task> loadSkinIntoOsu(OsuGameBase osu, ImportTask import, bool batchImport = false) { var skinManager = osu.Dependencies.Get(); - return await skinManager.Import(import, batchImport); + return await skinManager.Import(import, new ImportParameters { Batch = batchImport }); } } } diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index bcb1d7f961..8a6315fc65 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -44,7 +44,7 @@ namespace osu.Game.Beatmaps public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) { - var imported = await Import(notification, importTask); + var imported = await Import(notification, new[] { importTask }); if (!imported.Any()) return null; @@ -203,10 +203,10 @@ namespace osu.Game.Beatmaps } } - protected override void PostImport(BeatmapSetInfo model, Realm realm, bool batchImport) + protected override void PostImport(BeatmapSetInfo model, Realm realm, ImportParameters parameters) { - base.PostImport(model, realm, batchImport); - ProcessBeatmap?.Invoke((model, batchImport)); + base.PostImport(model, realm, parameters); + ProcessBeatmap?.Invoke((model, parameters.Batch)); } private void validateOnlineIds(BeatmapSetInfo beatmapSet, Realm realm) diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 965cc43815..f0533f27be 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -456,15 +456,15 @@ namespace osu.Game.Beatmaps public Task Import(params string[] paths) => beatmapImporter.Import(paths); - public Task Import(params ImportTask[] tasks) => beatmapImporter.Import(tasks); + public Task Import(ImportTask[] tasks, ImportParameters parameters = default) => beatmapImporter.Import(tasks, parameters); - public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) => beatmapImporter.Import(notification, tasks); + public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => beatmapImporter.Import(notification, tasks, parameters); - public Task?> Import(ImportTask task, bool batchImport = false, CancellationToken cancellationToken = default) => - beatmapImporter.Import(task, batchImport, cancellationToken); + public Task?> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => + beatmapImporter.Import(task, parameters, cancellationToken); public Live? Import(BeatmapSetInfo item, ArchiveReader? archive = null, CancellationToken cancellationToken = default) => - beatmapImporter.ImportModel(item, archive, false, cancellationToken); + beatmapImporter.ImportModel(item, archive, default, cancellationToken); public IEnumerable HandledExtensions => beatmapImporter.HandledExtensions; diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index adb5f8c433..e6f96330e7 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -141,6 +141,9 @@ namespace osu.Game.Beatmaps try { string fileStorePath = BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path); + + // TODO: check validity of file + var stream = GetStream(fileStorePath); if (stream == null) diff --git a/osu.Game/Database/ICanAcceptFiles.cs b/osu.Game/Database/ICanAcceptFiles.cs index 3ce343249b..da970a29d4 100644 --- a/osu.Game/Database/ICanAcceptFiles.cs +++ b/osu.Game/Database/ICanAcceptFiles.cs @@ -31,7 +31,8 @@ namespace osu.Game.Database /// This will post notifications tracking progress. /// /// The import tasks from which the files should be imported. - Task Import(params ImportTask[] tasks); + /// Parameters to further configure the import process. + Task Import(ImportTask[] tasks, ImportParameters parameters = default); /// /// An array of accepted file extensions (in the standard format of ".abc"). diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index 4085f122d0..dcbbad0d35 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -20,8 +20,9 @@ namespace osu.Game.Database /// /// The notification to update. /// The import tasks. + /// Parameters to further configure the import process. /// The imported models. - Task>> Import(ProgressNotification notification, params ImportTask[] tasks); + Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default); /// /// Process a single import as an update for an existing model. diff --git a/osu.Game/Database/ImportParameters.cs b/osu.Game/Database/ImportParameters.cs new file mode 100644 index 0000000000..83ca0ac694 --- /dev/null +++ b/osu.Game/Database/ImportParameters.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Database +{ + public struct ImportParameters + { + /// + /// Whether this import is part of a larger batch. + /// + /// + /// May skip intensive pre-import checks in favour of faster processing. + /// + /// More specifically, imports will be skipped before they begin, given an existing model matches on hash and filenames. Should generally only be used for large batch imports, as it may defy user expectations when updating an existing model. + /// + /// Will also change scheduling behaviour to run at a lower priority. + /// + public bool Batch { get; set; } + + /// + /// Whether this import should use hard links rather than file copy operations if available. + /// + public bool PreferHardLinks { get; set; } + } +} diff --git a/osu.Game/Database/LegacyImportManager.cs b/osu.Game/Database/LegacyImportManager.cs index b5338fbe1f..3f1ddb1eda 100644 --- a/osu.Game/Database/LegacyImportManager.cs +++ b/osu.Game/Database/LegacyImportManager.cs @@ -54,6 +54,19 @@ namespace osu.Game.Database public void UpdateStorage(string stablePath) => cachedStorage = new StableStorage(stablePath, gameHost as DesktopGameHost); + public bool CheckHardLinkAvailability() + { + var stableStorage = GetCurrentStableStorage(); + + if (stableStorage == null || gameHost is not DesktopGameHost desktopGameHost) + return false; + + string testExistingPath = stableStorage.GetFullPath(string.Empty); + string testDestinationPath = desktopGameHost.Storage.GetFullPath(string.Empty); + + return HardLinkHelper.CheckAvailability(testDestinationPath, testExistingPath); + } + public virtual async Task GetImportCount(StableContent content, CancellationToken cancellationToken) { var stableStorage = GetCurrentStableStorage(); diff --git a/osu.Game/Database/LegacyModelImporter.cs b/osu.Game/Database/LegacyModelImporter.cs index df354a856e..29386a1103 100644 --- a/osu.Game/Database/LegacyModelImporter.cs +++ b/osu.Game/Database/LegacyModelImporter.cs @@ -57,7 +57,12 @@ namespace osu.Game.Database return Task.CompletedTask; } - return Task.Run(async () => await Importer.Import(GetStableImportPaths(storage).ToArray()).ConfigureAwait(false)); + return Task.Run(async () => + { + var tasks = GetStableImportPaths(storage).Select(p => new ImportTask(p)).ToArray(); + + await Importer.Import(tasks, new ImportParameters { Batch = true, PreferHardLinks = true }).ConfigureAwait(false); + }); } /// diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index 3e2d034937..2f082306d0 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -73,7 +73,7 @@ namespace osu.Game.Database if (originalModel != null) importSuccessful = (await importer.ImportAsUpdate(notification, new ImportTask(filename), originalModel)) != null; else - importSuccessful = (await importer.Import(notification, new ImportTask(filename))).Any(); + importSuccessful = (await importer.Import(notification, new[] { new ImportTask(filename) })).Any(); // for now a failed import will be marked as a failed download for simplicity. if (!importSuccessful) diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index 0286815569..4e8b27e0b4 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -81,16 +81,16 @@ namespace osu.Game.Database public Task Import(params string[] paths) => Import(paths.Select(p => new ImportTask(p)).ToArray()); - public Task Import(params ImportTask[] tasks) + public Task Import(ImportTask[] tasks, ImportParameters parameters = default) { var notification = new ProgressNotification { State = ProgressNotificationState.Active }; PostNotification?.Invoke(notification); - return Import(notification, tasks); + return Import(notification, tasks, parameters); } - public async Task>> Import(ProgressNotification notification, params ImportTask[] tasks) + public async Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) { if (tasks.Length == 0) { @@ -106,7 +106,7 @@ namespace osu.Game.Database var imported = new List>(); - bool isBatchImport = tasks.Length >= minimum_items_considered_batch_import; + parameters.Batch |= tasks.Length >= minimum_items_considered_batch_import; await Task.WhenAll(tasks.Select(async task => { @@ -115,7 +115,7 @@ namespace osu.Game.Database try { - var model = await Import(task, isBatchImport, notification.CancellationToken).ConfigureAwait(false); + var model = await Import(task, parameters, notification.CancellationToken).ConfigureAwait(false); lock (imported) { @@ -176,16 +176,16 @@ namespace osu.Game.Database /// Note that this bypasses the UI flow and should only be used for special cases or testing. /// /// The containing data about the to import. - /// Whether this import is part of a larger batch. + /// Parameters to further configure the import process. /// An optional cancellation token. /// The imported model, if successful. - public async Task?> Import(ImportTask task, bool batchImport = false, CancellationToken cancellationToken = default) + public async Task?> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Live? import; using (ArchiveReader reader = task.GetReader()) - import = await importFromArchive(reader, batchImport, cancellationToken).ConfigureAwait(false); + import = await importFromArchive(reader, parameters, cancellationToken).ConfigureAwait(false); // We may or may not want to delete the file depending on where it is stored. // e.g. reconstructing/repairing database with items from default storage. @@ -211,9 +211,9 @@ namespace osu.Game.Database /// This method also handled queueing the import task on a relevant import thread pool. /// /// The archive to be imported. - /// Whether this import is part of a larger batch. + /// Parameters to further configure the import process. /// An optional cancellation token. - private async Task?> importFromArchive(ArchiveReader archive, bool batchImport = false, CancellationToken cancellationToken = default) + private async Task?> importFromArchive(ArchiveReader archive, ImportParameters parameters = default, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -236,10 +236,10 @@ namespace osu.Game.Database return null; } - var scheduledImport = Task.Factory.StartNew(() => ImportModel(model, archive, batchImport, cancellationToken), + var scheduledImport = Task.Factory.StartNew(() => ImportModel(model, archive, parameters, cancellationToken), cancellationToken, TaskCreationOptions.HideScheduler, - batchImport ? import_scheduler_batch : import_scheduler); + parameters.Batch ? import_scheduler_batch : import_scheduler); return await scheduledImport.ConfigureAwait(false); } @@ -249,15 +249,15 @@ namespace osu.Game.Database /// /// The model to be imported. /// An optional archive to use for model population. - /// If true, imports will be skipped before they begin, given an existing model matches on hash and filenames. Should generally only be used for large batch imports, as it may defy user expectations when updating an existing model. + /// Parameters to further configure the import process. /// An optional cancellation token. - public virtual Live? ImportModel(TModel item, ArchiveReader? archive = null, bool batchImport = false, CancellationToken cancellationToken = default) => Realm.Run(realm => + public virtual Live? ImportModel(TModel item, ArchiveReader? archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => Realm.Run(realm => { cancellationToken.ThrowIfCancellationRequested(); TModel? existing; - if (batchImport && archive != null) + if (parameters.Batch && archive != null) { // this is a fast bail condition to improve large import performance. item.Hash = computeHashFast(archive); @@ -303,7 +303,7 @@ namespace osu.Game.Database foreach (var filenames in getShortenedFilenames(archive)) { using (Stream s = archive.GetStream(filenames.original)) - files.Add(new RealmNamedFileUsage(Files.Add(s, realm, false), filenames.shortened)); + files.Add(new RealmNamedFileUsage(Files.Add(s, realm, false, parameters.PreferHardLinks), filenames.shortened)); } } @@ -358,7 +358,7 @@ namespace osu.Game.Database // import to store realm.Add(item); - PostImport(item, realm, batchImport); + PostImport(item, realm, parameters); transaction.Commit(); } @@ -493,8 +493,8 @@ namespace osu.Game.Database /// /// The model prepared for import. /// The current realm context. - /// Whether the import was part of a batch. - protected virtual void PostImport(TModel model, Realm realm, bool batchImport) + /// Parameters to further configure the import process. + protected virtual void PostImport(TModel model, Realm realm, ImportParameters parameters) { } diff --git a/osu.Game/Database/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs index 036b15ea17..04b503b808 100644 --- a/osu.Game/Database/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.cs @@ -4,12 +4,14 @@ using System; using System.IO; using System.Linq; +using osu.Framework; using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Extensions; +using osu.Game.IO; using osu.Game.Models; using Realms; @@ -41,7 +43,8 @@ namespace osu.Game.Database /// The file data stream. /// The realm instance to add to. Should already be in a transaction. /// Whether the should immediately be added to the underlying realm. If false is provided here, the instance must be manually added. - public RealmFile Add(Stream data, Realm realm, bool addToRealm = true) + /// Whether this import should use hard links rather than file copy operations if available. + public RealmFile Add(Stream data, Realm realm, bool addToRealm = true, bool preferHardLinks = false) { string hash = data.ComputeSHA2Hash(); @@ -50,7 +53,7 @@ namespace osu.Game.Database var file = existing ?? new RealmFile { Hash = hash }; if (!checkFileExistsAndMatchesHash(file)) - copyToStore(file, data); + copyToStore(file, data, preferHardLinks); if (addToRealm && !file.IsManaged) realm.Add(file); @@ -58,8 +61,15 @@ namespace osu.Game.Database return file; } - private void copyToStore(RealmFile file, Stream data) + private void copyToStore(RealmFile file, Stream data, bool preferHardLinks) { + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows && data is FileStream fs && preferHardLinks) + { + // attempt to do a fast hard link rather than copy. + if (HardLinkHelper.CreateHardLink(Storage.GetFullPath(file.GetStoragePath(), true), fs.Name, IntPtr.Zero)) + return; + } + data.Seek(0, SeekOrigin.Begin); using (var output = Storage.CreateFileSafely(file.GetStoragePath())) diff --git a/osu.Game/IO/HardLinkHelper.cs b/osu.Game/IO/HardLinkHelper.cs new file mode 100644 index 0000000000..1393bf26fd --- /dev/null +++ b/osu.Game/IO/HardLinkHelper.cs @@ -0,0 +1,108 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using Microsoft.Win32.SafeHandles; +using osu.Framework; + +namespace osu.Game.IO +{ + internal static class HardLinkHelper + { + public static bool CheckAvailability(string testDestinationPath, string testSourcePath) + { + // We can support other operating systems quite easily in the future. + // Let's handle the most common one for now, though. + if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) + return false; + + const string test_filename = "_hard_link_test"; + + testDestinationPath = Path.Combine(testDestinationPath, test_filename); + testSourcePath = Path.Combine(testSourcePath, test_filename); + + cleanupFiles(); + + try + { + File.WriteAllText(testSourcePath, string.Empty); + + // Test availability by creating an arbitrary hard link between the source and destination paths. + return CreateHardLink(testDestinationPath, testSourcePath, IntPtr.Zero); + } + catch + { + return false; + } + finally + { + cleanupFiles(); + } + + void cleanupFiles() + { + try + { + File.Delete(testDestinationPath); + File.Delete(testSourcePath); + } + catch + { + } + } + } + + // For future use (to detect if a file is a hard link with other references existing on disk). + public static int GetFileLinkCount(string filePath) + { + int result = 0; + SafeFileHandle handle = CreateFile(filePath, FileAccess.Read, FileShare.Read, IntPtr.Zero, FileMode.Open, FileAttributes.Archive, IntPtr.Zero); + + ByHandleFileInformation fileInfo; + + if (GetFileInformationByHandle(handle, out fileInfo)) + result = (int)fileInfo.NumberOfLinks; + CloseHandle(handle); + + return result; + } + + [DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + [MarshalAs(UnmanagedType.U4)] FileAccess dwDesiredAccess, + [MarshalAs(UnmanagedType.U4)] FileShare dwShareMode, + IntPtr lpSecurityAttributes, + [MarshalAs(UnmanagedType.U4)] FileMode dwCreationDisposition, + [MarshalAs(UnmanagedType.U4)] FileAttributes dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out ByHandleFileInformation lpFileInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(SafeHandle hObject); + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public readonly uint FileAttributes; + public readonly FILETIME CreationTime; + public readonly FILETIME LastAccessTime; + public readonly FILETIME LastWriteTime; + public readonly uint VolumeSerialNumber; + public readonly uint FileSizeHigh; + public readonly uint FileSizeLow; + public readonly uint NumberOfLinks; + public readonly uint FileIndexHigh; + public readonly uint FileIndexLow; + } + } +} diff --git a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs index deac7d8628..f0620245c3 100644 --- a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs +++ b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs @@ -15,10 +15,10 @@ namespace osu.Game.Localisation public static LocalisableString Header => new TranslatableString(getKey(@"header"), @"Import"); /// - /// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will create a copy, and not affect your existing installation." + /// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way." /// public static LocalisableString Description => new TranslatableString(getKey(@"description"), - @"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will create a copy, and not affect your existing installation."); + @"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way."); /// /// "previous osu! install" diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 5565fa7ef3..8fb6806c27 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -616,14 +616,14 @@ namespace osu.Game }, validScreens: validScreens); } - public override Task Import(params ImportTask[] imports) + public override Task Import(ImportTask[] imports, ImportParameters parameters = default) { // encapsulate task as we don't want to begin the import process until in a ready state. // ReSharper disable once AsyncVoidLambda // TODO: This is bad because `new Task` doesn't have a Func override. // Only used for android imports and a bit of a mess. Probably needs rethinking overall. - var importTask = new Task(async () => await base.Import(imports).ConfigureAwait(false)); + var importTask = new Task(async () => await base.Import(imports, parameters).ConfigureAwait(false)); waitForReady(() => this, _ => importTask.Start()); diff --git a/osu.Game/OsuGameBase_Importing.cs b/osu.Game/OsuGameBase_Importing.cs index e34e48f21d..cf65460bab 100644 --- a/osu.Game/OsuGameBase_Importing.cs +++ b/osu.Game/OsuGameBase_Importing.cs @@ -44,13 +44,13 @@ namespace osu.Game } } - public virtual async Task Import(params ImportTask[] tasks) + public virtual async Task Import(ImportTask[] tasks, ImportParameters parameters = default) { var tasksPerExtension = tasks.GroupBy(t => Path.GetExtension(t.Path).ToLowerInvariant()); await Task.WhenAll(tasksPerExtension.Select(taskGroup => { var importer = fileImporters.FirstOrDefault(i => i.HandledExtensions.Contains(taskGroup.Key)); - return importer?.Import(taskGroup.ToArray()) ?? Task.CompletedTask; + return importer?.Import(taskGroup.ToArray(), parameters) ?? Task.CompletedTask; })).ConfigureAwait(false); } diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs index 29cf3824fd..04aa976ff1 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -14,12 +15,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Framework.Screens; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays.Settings; +using osu.Game.Overlays.Settings.Sections.Maintenance; using osu.Game.Screens.Edit.Setup; using osuTK; @@ -39,6 +43,8 @@ namespace osu.Game.Overlays.FirstRunSetup private StableLocatorLabelledTextBox stableLocatorTextBox = null!; + private LinkFlowContainer copyInformation = null!; + private IEnumerable contentCheckboxes => Content.Children.OfType(); [BackgroundDependencyLoader(permitNulls: true)] @@ -46,7 +52,7 @@ namespace osu.Game.Overlays.FirstRunSetup { Content.Children = new Drawable[] { - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + new LinkFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Colour = OverlayColourProvider.Content1, Text = FirstRunOverlayImportFromStableScreenStrings.Description, @@ -62,6 +68,12 @@ namespace osu.Game.Overlays.FirstRunSetup new ImportCheckbox(CommonStrings.Scores, StableContent.Scores), new ImportCheckbox(CommonStrings.Skins, StableContent.Skins), new ImportCheckbox(CommonStrings.Collections, StableContent.Collections), + copyInformation = new LinkFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + { + Colour = OverlayColourProvider.Content1, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, importButton = new ProgressRoundedButton { Size = button_size, @@ -83,6 +95,9 @@ namespace osu.Game.Overlays.FirstRunSetup stableLocatorTextBox.Current.BindValueChanged(_ => updateStablePath(), true); } + [Resolved(canBeNull: true)] + private OsuGame? game { get; set; } + private void updateStablePath() { var storage = legacyImportManager.GetCurrentStableStorage(); @@ -105,6 +120,25 @@ namespace osu.Game.Overlays.FirstRunSetup toggleInteraction(true); stableLocatorTextBox.Current.Value = storage.GetFullPath(string.Empty); importButton.Enabled.Value = true; + + bool available = legacyImportManager.CheckHardLinkAvailability(); + Logger.Log($"Hard link support is {available}"); + + if (available) + { + copyInformation.Text = "Data migration will use \"hard links\". No extra disk space will be used, and you can delete either data folder at any point without affecting the other installation."; + } + else if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) + copyInformation.Text = "Lightweight linking of files is not supported on your operating system yet, so a copy of all files will be made during import."; + else + { + copyInformation.Text = + "A second copy of all files will be made during import. To avoid this, please make sure the lazer data folder is on the same drive as your previous osu! install (and the file system is NTFS). "; + copyInformation.AddLink(GeneralSettingsStrings.ChangeFolderLocation, () => + { + game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())); + }); + } } private void runImport() @@ -235,7 +269,7 @@ namespace osu.Game.Overlays.FirstRunSetup return Task.CompletedTask; } - Task ICanAcceptFiles.Import(params ImportTask[] tasks) => throw new NotImplementedException(); + Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 5c8e21014c..4656f02897 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -145,9 +145,9 @@ namespace osu.Game.Scoring #pragma warning restore CS0618 } - protected override void PostImport(ScoreInfo model, Realm realm, bool batchImport) + protected override void PostImport(ScoreInfo model, Realm realm, ImportParameters parameters) { - base.PostImport(model, realm, batchImport); + base.PostImport(model, realm, parameters); var userRequest = new GetUserRequest(model.RealmUser.Username); diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index b2944ad219..5ec96a951b 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -169,18 +169,18 @@ namespace osu.Game.Scoring public Task Import(params string[] paths) => scoreImporter.Import(paths); - public Task Import(params ImportTask[] tasks) => scoreImporter.Import(tasks); + public Task Import(ImportTask[] imports, ImportParameters parameters = default) => scoreImporter.Import(imports, parameters); public override bool IsAvailableLocally(ScoreInfo model) => Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID)); public IEnumerable HandledExtensions => scoreImporter.HandledExtensions; - public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) => scoreImporter.Import(notification, tasks); + public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => scoreImporter.Import(notification, tasks); public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, ScoreInfo original) => scoreImporter.ImportAsUpdate(notification, task, original); - public Live Import(ScoreInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default) => - scoreImporter.ImportModel(item, archive, batchImport, cancellationToken); + public Live Import(ScoreInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => + scoreImporter.ImportModel(item, archive, parameters, cancellationToken); /// /// Populates the for a given . diff --git a/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs b/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs index 57d28824b1..d14357e875 100644 --- a/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs +++ b/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs @@ -91,7 +91,7 @@ namespace osu.Game.Screens.Edit.Setup return Task.CompletedTask; } - Task ICanAcceptFiles.Import(params ImportTask[] tasks) => throw new NotImplementedException(); + Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index 1dd9c93845..0ed4e5afd2 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -411,7 +411,7 @@ namespace osu.Game.Skinning.Editor return Task.CompletedTask; } - public Task Import(params ImportTask[] tasks) => throw new NotImplementedException(); + Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); public IEnumerable HandledExtensions => new[] { ".jpg", ".jpeg", ".png" }; diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 4a5277f3bf..2ad62dbb61 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -270,15 +270,18 @@ namespace osu.Game.Skinning public Task Import(params string[] paths) => skinImporter.Import(paths); - public Task Import(params ImportTask[] tasks) => skinImporter.Import(tasks); + public Task Import(ImportTask[] imports, ImportParameters parameters = default) => skinImporter.Import(imports, parameters); public IEnumerable HandledExtensions => skinImporter.HandledExtensions; - public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) => skinImporter.Import(notification, tasks); + public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => + skinImporter.Import(notification, tasks, parameters); - public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) => skinImporter.ImportAsUpdate(notification, task, original); + public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) => + skinImporter.ImportAsUpdate(notification, task, original); - public Task> Import(ImportTask task, bool batchImport = false, CancellationToken cancellationToken = default) => skinImporter.Import(task, batchImport, cancellationToken); + public Task> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => + skinImporter.Import(task, parameters, cancellationToken); #endregion