From d27bad77470980fb9387e42ff047ef46c3324b89 Mon Sep 17 00:00:00 2001 From: sewer56lol Date: Sat, 13 Jun 2026 21:58:09 +0100 Subject: [PATCH 1/2] Fixed: Directory scanner skipping OneDrive cloud folders - WindowsDirectorySearcher now only skips name-surrogate reparse points (symlinks/junctions). OneDrive cloud folders use a different reparse tag and are no longer incorrectly skipped during directory enumeration, which caused mod/user configs inside them to go undetected. - Detect OneDrive paths via environment variables instead of substring matching, fixing false positives/negatives in installer and launcher. --- source/Reloaded.Mod.Installer.Lib/Settings.cs | 33 +++++++- .../Application/AddApplicationCommand.cs | 2 +- .../Utility/PathUtility.cs | 42 ++++++++++ source/Reloaded.Mod.Launcher/App.xaml.cs | 4 +- .../Windows/WindowsDirectorySearcher.cs | 84 +++++++++++++++++-- 5 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs diff --git a/source/Reloaded.Mod.Installer.Lib/Settings.cs b/source/Reloaded.Mod.Installer.Lib/Settings.cs index 8c253c2b..fc4a0fbc 100644 --- a/source/Reloaded.Mod.Installer.Lib/Settings.cs +++ b/source/Reloaded.Mod.Installer.Lib/Settings.cs @@ -36,7 +36,7 @@ private static string GetSafeInstallPath() { var installPath = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); bool hasNonAsciiChars = installPath.Any(c => c > 127); - if (installPath.Contains("OneDrive") || hasNonAsciiChars) + if (IsPathInOneDrive(installPath) || hasNonAsciiChars) { var driveRoot = Path.GetPathRoot(Environment.SystemDirectory); if (driveRoot == null) @@ -46,4 +46,35 @@ private static string GetSafeInstallPath() } return installPath; } + + /// + /// Checks whether a given path is inside a OneDrive-managed folder. + /// Uses the OneDrive environment variables. + /// + private static bool IsPathInOneDrive(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + + foreach (var envVar in new[] { "OneDrive", "OneDriveCommercial" }) + { + var root = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrEmpty(root)) + continue; + + try + { + var fullRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullPath = Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return true; + } + catch { /* malformed path - skip */ } + } + + return false; + } } \ No newline at end of file diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs index da7a1041..123a91cf 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs @@ -64,7 +64,7 @@ static string GetProductName(string exePath) // Warn if OneDrive or NonAsciiChars detected in Game Path bool hasNonAsciiChars = exePath.Any(c => c > 127); - if (exePath.Contains("OneDrive") || hasNonAsciiChars) + if (PathUtility.IsPathInOneDrive(exePath) || hasNonAsciiChars) { var confirmAddAnyway = Actions.DisplayMessagebox.Invoke(Resources.ProblematicPathTitle.Get(), Resources.ProblematicPathAppDescription.Get(), new Actions.DisplayMessageBoxParams() { diff --git a/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs b/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs new file mode 100644 index 00000000..c9054d92 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs @@ -0,0 +1,42 @@ +namespace Reloaded.Mod.Launcher.Lib.Utility; + +/// +/// Utility methods for inspecting file system paths. +/// +public static class PathUtility +{ + /// + /// Checks whether a given path is inside a OneDrive-managed folder. + /// Uses the OneDrive / OneDriveCommercial environment variables. + /// + /// The path to check. + /// True if the path is inside a OneDrive root; false otherwise. + public static bool IsPathInOneDrive(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + + foreach (var envVar in s_oneDriveEnvVars) + { + var root = System.Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrEmpty(root)) + continue; + + try + { + var fullRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullPath = Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return true; + } + catch { /* malformed path - skip */ } + } + + return false; + } + + private static readonly string[] s_oneDriveEnvVars = { "OneDrive", "OneDriveCommercial" }; +} diff --git a/source/Reloaded.Mod.Launcher/App.xaml.cs b/source/Reloaded.Mod.Launcher/App.xaml.cs index 3478489d..b56f3ff5 100644 --- a/source/Reloaded.Mod.Launcher/App.xaml.cs +++ b/source/Reloaded.Mod.Launcher/App.xaml.cs @@ -44,7 +44,7 @@ private void OnStartup(object sender, StartupEventArgs e) // Warn if OneDrive or NonAsciiChars detected in Reloaded-II directory bool reloadedPathHasNonAsciiChars = AppContext.BaseDirectory.Any(c => c > 127); - if (AppContext.BaseDirectory.Contains("OneDrive") || reloadedPathHasNonAsciiChars) + if (PathUtility.IsPathInOneDrive(AppContext.BaseDirectory) || reloadedPathHasNonAsciiChars) { Actions.DisplayMessagebox.Invoke(Lib.Static.Resources.ProblematicPathTitle.Get(), Lib.Static.Resources.ProblematicPathReloadedDescription.Get(), new Actions.DisplayMessageBoxParams() { @@ -59,7 +59,7 @@ private void OnStartup(object sender, StartupEventArgs e) if (modsDirectory != null) { bool modsDirectoryPathHasNonAsciiChars = modsDirectory.Any(c => c > 127); - if (modsDirectory.Contains("OneDrive") || modsDirectoryPathHasNonAsciiChars) + if (PathUtility.IsPathInOneDrive(modsDirectory) || modsDirectoryPathHasNonAsciiChars) { Actions.DisplayMessagebox.Invoke(Lib.Static.Resources.ProblematicPathTitle.Get(), Lib.Static.Resources.ProblematicPathModsDescription.Get(), new Actions.DisplayMessageBoxParams() { diff --git a/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs b/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs index 57c53890..ab72ad47 100644 --- a/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs +++ b/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs @@ -174,17 +174,21 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List< { info = (FILE_DIRECTORY_INFORMATION*)currentBufferPtr; - // Not symlink or symlink to offline file. - if ((info->FileAttributes & FileAttributes.ReparsePoint) != 0 && - (info->FileAttributes & FileAttributes.Offline) == 0) - goto nextfile; - var fileName = Marshal.PtrToStringUni(currentBufferPtr + sizeof(FILE_DIRECTORY_INFORMATION), (int)info->FileNameLength / 2); if (fileName == "." || fileName == "..") goto nextfile; var isDirectory = (info->FileAttributes & FileAttributes.Directory) > 0; + + // Skip symlinks/junctions that point elsewhere (prevents infinite recursion). + // Cloud folders (e.g. OneDrive) use different reparse tags, so they are + // treated as normal directories and enumerated. Only checked for directories; + // files with reparse points are always enumerated. + if (isDirectory && (info->FileAttributes & FileAttributes.ReparsePoint) != 0 && + IsNameSurrogateReparsePoint($@"{originalDirPath}\{fileName}")) + goto nextfile; + if (isDirectory) { directories.Add(new DirectoryInformation @@ -193,7 +197,7 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List< LastWriteTime = info->LastWriteTime.ToDateTime() }); } - else if (!isDirectory) + else { files.Add(new FileInformation { @@ -218,6 +222,63 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List< return true; } + /// + /// Returns true if the reparse point at is a 'name surrogate' + /// (a symlink or junction/mount-point that redirects the path). Such entries are skipped + /// during enumeration to prevent following them into cycles. + /// + /// Cloud providers (e.g. OneDrive) also use reparse points on real, locally-available + /// folders; those tags do not carry the name-surrogate bit, so this returns false for them + /// and the directory is enumerated normally. + /// + /// On any failure to determine the tag we conservatively return false (do not skip), since + /// wrongly skipping a real directory is the original bug this guards against. A genuine + /// symlink whose tag cannot be read would still be bounded by maxDepth. + /// + private static unsafe bool IsNameSurrogateReparsePoint(string fullPath) + { + const uint FSCTL_GET_REPARSE_POINT = 0x000900A8; + + // Bit set in the reparse tag of entries that substitute/redirect the name + // (symlinks, junctions/mount-points). Cloud reparse tags do not set this bit. + const uint REPARSE_TAG_NAME_SURROGATE = 0x80000000; + + const uint GENERIC_READ = 0x80000000; + const uint FILE_SHARE_READ = 0x00000001; + const uint FILE_SHARE_WRITE = 0x00000002; + const uint OPEN_EXISTING = 3; + + // BACKUP_SEMANTICS allows opening directories; OPEN_REPARSE_POINT opens the + // reparse entry itself instead of resolving through it. + const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + + const int BufferSize = 1024 * 16; + + var handle = CreateFileW(fullPath, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + + if (handle == new IntPtr(-1)) + return false; + + try + { + byte* buffer = stackalloc byte[BufferSize]; + if (!DeviceIoControl(handle, FSCTL_GET_REPARSE_POINT, IntPtr.Zero, 0, + (IntPtr)buffer, BufferSize, out _, IntPtr.Zero)) + { + return false; + } + + // REPARSE_DATA_BUFFER.ReparseTag is the first DWORD. + return (*(uint*)buffer & REPARSE_TAG_NAME_SURROGATE) != 0; + } + finally + { + CloseHandle(handle); + } + } + internal struct MultithreadedDirectorySearcher : IDisposable { private Thread[] _threads; @@ -315,6 +376,17 @@ public void Dispose() [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] static extern IntPtr GetProcAddress(IntPtr hModule, string procName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern IntPtr CreateFileW(string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool DeviceIoControl(IntPtr hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CloseHandle(IntPtr hObject); #endregion #region Native Import Wrappers From ba760d1fc6f976269d83d4ee2af2643ff7109347 Mon Sep 17 00:00:00 2001 From: sewer56lol Date: Sat, 13 Jun 2026 22:52:54 +0100 Subject: [PATCH 2/2] Changed: Generalize cloud folder detection beyond OneDrive - Replace OneDrive-only substring/env-var checks with layered detection (OneDrive env vars, Cloud Files API via CfGetSyncRootInfoByPath, known cloud folder names) so Dropbox, Google Drive, iCloud, etc. are caught. - cldapi missing on older than Windows 10 1709 falls back gracefully. - Installer keeps two tiers (Desktop is only redirected by OneDrive). --- source/Reloaded.Mod.Installer.Lib/Settings.cs | 85 ++++++++++-- .../Application/AddApplicationCommand.cs | 4 +- .../Utility/PathUtility.cs | 121 ++++++++++++++++-- source/Reloaded.Mod.Launcher/App.xaml.cs | 8 +- .../Windows/WindowsDirectorySearcher.cs | 2 +- 5 files changed, 193 insertions(+), 27 deletions(-) diff --git a/source/Reloaded.Mod.Installer.Lib/Settings.cs b/source/Reloaded.Mod.Installer.Lib/Settings.cs index fc4a0fbc..52b6e6fe 100644 --- a/source/Reloaded.Mod.Installer.Lib/Settings.cs +++ b/source/Reloaded.Mod.Installer.Lib/Settings.cs @@ -35,8 +35,7 @@ public static Settings GetSettings(string[] args) private static string GetSafeInstallPath() { var installPath = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); - bool hasNonAsciiChars = installPath.Any(c => c > 127); - if (IsPathInOneDrive(installPath) || hasNonAsciiChars) + if (IsPathInCloudSyncFolder(installPath)) { var driveRoot = Path.GetPathRoot(Environment.SystemDirectory); if (driveRoot == null) @@ -48,15 +47,36 @@ private static string GetSafeInstallPath() } /// - /// Checks whether a given path is inside a OneDrive-managed folder. - /// Uses the OneDrive environment variables. + /// Checks whether a given path is inside a cloud sync folder (OneDrive, Dropbox, Google Drive, + /// iCloud, Box, MEGA, etc.). Reloaded installed into such folders is avoided because many mods + /// do not tolerate cloud offload/locking, and load times are poor. + /// + /// Detection is layered, checked in order: + /// + /// - OneDrive environment variables (OneDrive / OneDriveCommercial). + /// + /// - The Windows Cloud Files API (CfGetSyncRootInfoByPath, available on Windows 10 1709+), + /// which detects any provider that registers a sync root. + /// + /// Desktop is only ever redirected by OneDrive, so these two tiers are sufficient for the install-path check. /// - private static bool IsPathInOneDrive(string path) + private static bool IsPathInCloudSyncFolder(string path) { if (string.IsNullOrEmpty(path)) return false; - foreach (var envVar in new[] { "OneDrive", "OneDriveCommercial" }) + string fullPath; + try { fullPath = Path.GetFullPath(path); } + catch { return false; } + + return IsInOneDrive(fullPath) + || IsInRegisteredCloudSyncRoot(fullPath); + } + + // --- Tier 1: OneDrive environment variables --- + private static bool IsInOneDrive(string fullPath) + { + foreach (var envVar in s_oneDriveEnvVars) { var root = Environment.GetEnvironmentVariable(envVar); if (string.IsNullOrEmpty(root)) @@ -66,15 +86,58 @@ private static bool IsPathInOneDrive(string path) { var fullRoot = Path.GetFullPath(root) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var fullPath = Path.GetFullPath(path) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - if (fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + if (IsUnder(fullPath, fullRoot)) return true; } - catch { /* malformed path - skip */ } + catch { /* malformed path/env - skip */ } } return false; } + + // --- Tier 2: Cloud Files API (cldapi.dll), Windows 10 1709+ --- + // A single native call reports whether the path is inside any registered cloud sync root, + // regardless of provider. No ancestor walk or hydration-state dependence. + private static bool IsInRegisteredCloudSyncRoot(string fullPath) + { + try + { + // CF_SYNC_ROOT_INFO_CLASS.CfSyncRootInfoBasic == 0. + // A small buffer is plenty for the basic info struct; we only care about the HRESULT. + var buffer = new byte[256]; + int hr = CfGetSyncRootInfoByPath(fullPath, 0, buffer, buffer.Length, out _); + return hr >= 0; // S_OK (0) => the path resolves to a registered sync root. + } + catch + { + // cldapi.dll missing (older than Windows 10 1709) or call failed: rely on other tiers. + return false; + } + } + + /// + /// True if equals or is a descendant of . + /// Case-insensitive. The separator is appended on the prefix check so a root named + /// e.g. "OneDrive" does not match a sibling like "OneDriveBackup". + /// + private static bool IsUnder(string fullPath, string root) + { + var trimmedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (trimmedRoot.Length == 0) + return false; + + return fullPath.Equals(trimmedRoot, StringComparison.OrdinalIgnoreCase) + || fullPath.StartsWith(trimmedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + [DllImport("cldapi.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.I4)] + private static extern int CfGetSyncRootInfoByPath( + string filePath, + int infoClass, + [Out] byte[] infoBuffer, + int infoBufferSize, + out int returnedLength); + + private static readonly string[] s_oneDriveEnvVars = { "OneDrive", "OneDriveCommercial" }; } \ No newline at end of file diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs index 123a91cf..638d2acc 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs @@ -62,9 +62,9 @@ static string GetProductName(string exePath) try { exePath = SymlinkResolver.GetFinalPathName(exePath); } catch (Exception e) { Errors.HandleException(e, Resources.ErrorAddApplicationCantReadSymlink.Get()); } - // Warn if OneDrive or NonAsciiChars detected in Game Path + // Warn if the Game Path is inside a cloud sync folder (OneDrive, Dropbox, ...) or has NonAsciiChars bool hasNonAsciiChars = exePath.Any(c => c > 127); - if (PathUtility.IsPathInOneDrive(exePath) || hasNonAsciiChars) + if (PathUtility.IsPathInCloudSyncFolder(exePath) || hasNonAsciiChars) { var confirmAddAnyway = Actions.DisplayMessagebox.Invoke(Resources.ProblematicPathTitle.Get(), Resources.ProblematicPathAppDescription.Get(), new Actions.DisplayMessageBoxParams() { diff --git a/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs b/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs index c9054d92..b31332ea 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs @@ -1,3 +1,6 @@ +using System.Runtime.InteropServices; +using Environment = System.Environment; + namespace Reloaded.Mod.Launcher.Lib.Utility; /// @@ -6,19 +9,42 @@ namespace Reloaded.Mod.Launcher.Lib.Utility; public static class PathUtility { /// - /// Checks whether a given path is inside a OneDrive-managed folder. - /// Uses the OneDrive / OneDriveCommercial environment variables. + /// Checks whether a given path is inside a cloud sync folder (OneDrive, Dropbox, Google Drive, + /// iCloud, Box, MEGA, etc.). Reloaded and games inside such folders are avoided because many mods + /// do not tolerate cloud offload/locking, and load times are poor. + /// + /// Detection is layered, checked in order: + /// + /// - OneDrive environment variables (OneDrive / OneDriveCommercial). + /// + /// - The Windows Cloud Files API (CfGetSyncRootInfoByPath, available on Windows 10 1709+), + /// which detects any provider that registers a sync root (OneDrive, Dropbox, iCloud, Box, ...). + /// + /// - Known cloud folder names under the user profile, as a fallback for older systems or + /// providers that do not register a sync root. /// /// The path to check. - /// True if the path is inside a OneDrive root; false otherwise. - public static bool IsPathInOneDrive(string path) + /// True if the path is inside a cloud-synced folder; false otherwise. + public static bool IsPathInCloudSyncFolder(string path) { if (string.IsNullOrEmpty(path)) return false; + string fullPath; + try { fullPath = Path.GetFullPath(path); } + catch { return false; } + + return IsInOneDrive(fullPath) + || IsInRegisteredCloudSyncRoot(fullPath) + || IsInKnownCloudFolder(fullPath); + } + + // --- Tier 1: OneDrive environment variables --- + private static bool IsInOneDrive(string fullPath) + { foreach (var envVar in s_oneDriveEnvVars) { - var root = System.Environment.GetEnvironmentVariable(envVar); + var root = Environment.GetEnvironmentVariable(envVar); if (string.IsNullOrEmpty(root)) continue; @@ -26,17 +52,94 @@ public static bool IsPathInOneDrive(string path) { var fullRoot = Path.GetFullPath(root) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var fullPath = Path.GetFullPath(path) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (IsUnder(fullPath, fullRoot)) + return true; + } + catch { /* malformed path/env - skip */ } + } - if (fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return false; + } + + // --- Tier 2: Cloud Files API (cldapi.dll), Windows 10 1709+ --- + // A single native call reports whether the path is inside any registered cloud sync root, + // regardless of provider. No ancestor walk or hydration-state dependence. + private static bool IsInRegisteredCloudSyncRoot(string fullPath) + { + try + { + // CF_SYNC_ROOT_INFO_CLASS.CfSyncRootInfoBasic == 0. + // A small buffer is plenty for the basic info struct; we only care about the HRESULT. + var buffer = new byte[256]; + int hr = CfGetSyncRootInfoByPath(fullPath, 0, buffer, buffer.Length, out _); + return hr >= 0; // S_OK (0) => the path resolves to a registered sync root. + } + catch + { + // cldapi.dll missing (older than Windows 10 1709) or call failed: rely on other tiers. + return false; + } + } + + // --- Tier 3: known cloud folder names under the user profile --- + private static bool IsInKnownCloudFolder(string fullPath) + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(userProfile)) + return false; + + string fullProfile; + try { fullProfile = Path.GetFullPath(userProfile).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } + catch { return false; } + + foreach (var folder in s_knownCloudFolders) + { + try + { + if (IsUnder(fullPath, Path.Combine(fullProfile, folder))) return true; } - catch { /* malformed path - skip */ } + catch { /* malformed name - skip */ } } return false; } + /// + /// True if equals or is a descendant of . + /// Case-insensitive. The separator is appended on the prefix check so a root named + /// e.g. "OneDrive" does not match a sibling like "OneDriveBackup". + /// + private static bool IsUnder(string fullPath, string root) + { + var trimmedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (trimmedRoot.Length == 0) + return false; + + return fullPath.Equals(trimmedRoot, StringComparison.OrdinalIgnoreCase) + || fullPath.StartsWith(trimmedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + [DllImport("cldapi.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.I4)] + private static extern int CfGetSyncRootInfoByPath( + string filePath, + int infoClass, + [Out] byte[] infoBuffer, + int infoBufferSize, + out int returnedLength); + private static readonly string[] s_oneDriveEnvVars = { "OneDrive", "OneDriveCommercial" }; + + private static readonly string[] s_knownCloudFolders = + { + "Dropbox", + "Google Drive", + "GoogleDrive", + "iCloudDrive", + "Box Sync", + "Box", + "MEGA", + "pCloud" + }; } diff --git a/source/Reloaded.Mod.Launcher/App.xaml.cs b/source/Reloaded.Mod.Launcher/App.xaml.cs index b56f3ff5..1df2724c 100644 --- a/source/Reloaded.Mod.Launcher/App.xaml.cs +++ b/source/Reloaded.Mod.Launcher/App.xaml.cs @@ -42,9 +42,9 @@ private void OnStartup(object sender, StartupEventArgs e) // Need to construct MainWindow before invoking any dialog, otherwise Shutdown will be called on closing the dialog var window = new MainWindow(); - // Warn if OneDrive or NonAsciiChars detected in Reloaded-II directory + // Warn if the Reloaded-II directory is inside a cloud sync folder (OneDrive, Dropbox, ...) or has NonAsciiChars bool reloadedPathHasNonAsciiChars = AppContext.BaseDirectory.Any(c => c > 127); - if (PathUtility.IsPathInOneDrive(AppContext.BaseDirectory) || reloadedPathHasNonAsciiChars) + if (PathUtility.IsPathInCloudSyncFolder(AppContext.BaseDirectory) || reloadedPathHasNonAsciiChars) { Actions.DisplayMessagebox.Invoke(Lib.Static.Resources.ProblematicPathTitle.Get(), Lib.Static.Resources.ProblematicPathReloadedDescription.Get(), new Actions.DisplayMessageBoxParams() { @@ -54,12 +54,12 @@ private void OnStartup(object sender, StartupEventArgs e) } else // We only do this check if the Reloaded-II directory check passed { - // Warn if OneDrive or NonAsciiChars detected in Mods directory + // Warn if the Mods directory is inside a cloud sync folder (OneDrive, Dropbox, ...) or has NonAsciiChars var modsDirectory = Lib.IoC.Get().GetModConfigDirectory(); if (modsDirectory != null) { bool modsDirectoryPathHasNonAsciiChars = modsDirectory.Any(c => c > 127); - if (PathUtility.IsPathInOneDrive(modsDirectory) || modsDirectoryPathHasNonAsciiChars) + if (PathUtility.IsPathInCloudSyncFolder(modsDirectory) || modsDirectoryPathHasNonAsciiChars) { Actions.DisplayMessagebox.Invoke(Lib.Static.Resources.ProblematicPathTitle.Get(), Lib.Static.Resources.ProblematicPathModsDescription.Get(), new Actions.DisplayMessageBoxParams() { diff --git a/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs b/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs index ab72ad47..7084748f 100644 --- a/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs +++ b/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs @@ -241,7 +241,7 @@ private static unsafe bool IsNameSurrogateReparsePoint(string fullPath) // Bit set in the reparse tag of entries that substitute/redirect the name // (symlinks, junctions/mount-points). Cloud reparse tags do not set this bit. - const uint REPARSE_TAG_NAME_SURROGATE = 0x80000000; + const uint REPARSE_TAG_NAME_SURROGATE = 0x20000000; const uint GENERIC_READ = 0x80000000; const uint FILE_SHARE_READ = 0x00000001;