Skip to content

Commit

Permalink
Merge #4129 Option to clone smaller instances with junction points (W…
Browse files Browse the repository at this point in the history
…indows) or symbolic links (Unix)
  • Loading branch information
HebaruSan committed Aug 2, 2024
2 parents 701afa1 + 87e5efa commit a37eca5
Show file tree
Hide file tree
Showing 15 changed files with 329 additions and 75 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file.
- [Multiple] Store GitHub Discussions links and display in UIs (#4111 by: HebaruSan)
- [GUI] Chinese translation fixes (#4115, #4116, #4131 by: zhouyiqing0304; reviewed: HebaruSan)
- [Multiple] Visually indicate to users that they should click Refresh (#4133 by: HebaruSan)
- [Multiple] Option to clone smaller instances with junction points (Windows) or symbolic links (Unix) (#4129 by: HebaruSan)

### Bugfixes

Expand Down
7 changes: 5 additions & 2 deletions Cmdline/Action/GameInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ internal class AddOptions : CommonOptions

internal class CloneOptions : CommonOptions
{
[Option("share-stock", DefaultValue = false, HelpText = "Use junction points (Windows) or symbolic links (Unix) for stock dirs instead of copying")]
public bool shareStock { get; set; }

[ValueOption(0)] public string nameOrPath { get; set; }
[ValueOption(1)] public string new_name { get; set; }
[ValueOption(2)] public string new_path { get; set; }
Expand Down Expand Up @@ -341,7 +344,7 @@ private int CloneInstall(CloneOptions options)
if (instance.Name == instanceNameOrPath)
{
// Found it, now clone it.
Manager.CloneInstance(instance, newName, newPath);
Manager.CloneInstance(instance, newName, newPath, options.shareStock);
break;
}
}
Expand All @@ -350,7 +353,7 @@ private int CloneInstall(CloneOptions options)
// If it's valid, go on.
else if (Manager.InstanceAt(instanceNameOrPath) is CKAN.GameInstance instance && instance.Valid)
{
Manager.CloneInstance(instance, newName, newPath);
Manager.CloneInstance(instance, newName, newPath, options.shareStock);
}
// There is no instance with this name or at this path.
else
Expand Down
167 changes: 167 additions & 0 deletions Core/DirectoryLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;

using ChinhDo.Transactions.FileManager;

namespace CKAN
{
/// <summary>
/// Junctions on Windows, symbolic links on Unix
/// </summary>
public static class DirectoryLink
{
public static void Create(string target, string link, TxFileManager txMgr)
{
if (!CreateImpl(target, link, txMgr))
{
throw new Kraken(Platform.IsWindows
? $"Failed to create junction at {link}: {Marshal.GetLastWin32Error()}"
: $"Failed to create symbolic link at {link}");
}
}

private static bool CreateImpl(string target, string link, TxFileManager txMgr)
=> Platform.IsWindows ? CreateJunction(link, target, txMgr)
: symlink(target, link) == 0;

[DllImport("libc")]
private static extern int symlink(string target, string link);

private static bool CreateJunction(string link, string target, TxFileManager txMgr)
{
// A junction is a directory with some extra magic attached
if (!txMgr.DirectoryExists(link))
{
txMgr.CreateDirectory(link);
}
using (var h = CreateFile(link, GenericWrite, FileShare.Read | FileShare.Write, IntPtr.Zero,
FileMode.Open, BackupSemantics | OpenReparsePoint, IntPtr.Zero))
{
if (!h.IsInvalid)
{
var junctionInfo = ReparseDataBuffer.FromPath(target, out int byteCount);
return DeviceIoControl(h, FSCTL_SET_REPARSE_POINT,
ref junctionInfo, byteCount + 20,
null, 0,
out _, IntPtr.Zero);
}
}
return false;
}

public static bool TryGetTarget(string link, out string target)
{
target = null;
var fi = new DirectoryInfo(link);
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
{
if (Platform.IsWindows)
{
var h = CreateFile(link, 0, FileShare.Read, IntPtr.Zero,
FileMode.Open, BackupSemantics | OpenReparsePoint, IntPtr.Zero);
if (!h.IsInvalid)
{
if (DeviceIoControl(h, FSCTL_GET_REPARSE_POINT,
null, 0,
out ReparseDataBuffer junctionInfo, Marshal.SizeOf(typeof(ReparseDataBuffer)),
out _, IntPtr.Zero))
{
if (junctionInfo.ReparseTag == IO_REPARSE_TAG_MOUNT_POINT)
{
target = junctionInfo.PathBuffer.TrimStart("\\\\?\\");
}
}
h.Close();
}
}
else
{
var bytes = new byte[1024];
var result = readlink(link, bytes, bytes.Length);
if (result > 0)
{
target = Encoding.UTF8.GetString(bytes);
}
}
}
return !string.IsNullOrEmpty(target);
}

private const uint GenericWrite = 0x40000000u;
private const uint BackupSemantics = 0x02000000u;
private const uint OpenReparsePoint = 0x00200000u;
private const uint FSCTL_SET_REPARSE_POINT = 0x000900A4u;
private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003u;
private const uint FSCTL_GET_REPARSE_POINT = 0x000900A8u;

[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern bool DeviceIoControl(SafeFileHandle hDevice,
uint IoControlCode,
ref ReparseDataBuffer InBuffer,
int nInBufferSize,
byte[] OutBuffer,
int nOutBufferSize,
out int pBytesReturned,
IntPtr Overlapped);

[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern bool DeviceIoControl(SafeFileHandle hDevice,
uint IoControlCode,
byte[] InBuffer,
int nInBufferSize,
out ReparseDataBuffer OutBuffer,
int nOutBufferSize,
out int pBytesReturned,
IntPtr Overlapped);

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct ReparseDataBuffer
{
public uint ReparseTag;
public ushort ReparseDataLength;
private readonly ushort Reserved;
public ushort SubstituteNameOffset;
public ushort SubstituteNameLength;
public ushort PrintNameOffset;
public ushort PrintNameLength;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 8184)]
public string PathBuffer;

public static ReparseDataBuffer FromPath(string target, out int byteCount)
{
var fullTarget = $@"\??\{Path.GetFullPath(target)}";
byteCount = Encoding.Unicode.GetByteCount(fullTarget);
return new ReparseDataBuffer
{
ReparseTag = IO_REPARSE_TAG_MOUNT_POINT,
ReparseDataLength = (ushort)(byteCount + 12),
SubstituteNameOffset = 0,
SubstituteNameLength = (ushort)byteCount,
PrintNameOffset = (ushort)(byteCount + 2),
PrintNameLength = 0,
PathBuffer = fullTarget,
};
}
}

[DllImport("libc")]
private static extern int readlink(string link, byte[] buf, int bufsize);

[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern SafeFileHandle CreateFile([MarshalAs(UnmanagedType.LPTStr)] string filename,
[MarshalAs(UnmanagedType.U4)] uint access,
[MarshalAs(UnmanagedType.U4)] FileShare share,
IntPtr securityAttributes,
[MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
[MarshalAs(UnmanagedType.U4)] uint flagsAndAttributes,
IntPtr templateFile);

private static string TrimStart(this string orig, string toRemove)
=> orig.StartsWith(toRemove) ? orig.Remove(0, toRemove.Length)
: orig;

}
}
13 changes: 9 additions & 4 deletions Core/GameInstanceManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,10 @@ public GameInstance AddInstance(string path, string name, IUser user)
/// <exception cref="DirectoryNotFoundKraken">Thrown by CopyDirectory() if directory doesn't exist. Should never be thrown here.</exception>
/// <exception cref="PathErrorKraken">Thrown by CopyDirectory() if the target folder already exists and is not empty.</exception>
/// <exception cref="IOException">Thrown by CopyDirectory() if something goes wrong during the process.</exception>
public void CloneInstance(GameInstance existingInstance, string newName, string newPath)
public void CloneInstance(GameInstance existingInstance,
string newName,
string newPath,
bool shareStockFolders = false)
{
if (HasInstance(newName))
{
Expand All @@ -252,11 +255,13 @@ public void CloneInstance(GameInstance existingInstance, string newName, string
}

log.Debug("Copying directory.");
Utilities.CopyDirectory(existingInstance.GameDir(), newPath, true);
Utilities.CopyDirectory(existingInstance.GameDir(), newPath,
shareStockFolders ? existingInstance.game.StockFolders
: Array.Empty<string>(),
existingInstance.game.LeaveEmptyInClones);

// Add the new instance to the config
GameInstance new_instance = new GameInstance(existingInstance.game, newPath, newName, User);
AddInstance(new_instance);
AddInstance(new GameInstance(existingInstance.game, newPath, newName, User));
}

/// <summary>
Expand Down
9 changes: 5 additions & 4 deletions Core/Games/IGame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ public interface IGame
string PrimaryModDirectoryRelative { get; }
string[] AlternateModDirectoriesRelative { get; }
string PrimaryModDirectory(GameInstance inst);
string[] StockFolders { get; }
string[] ReservedPaths { get; }
string[] CreateableDirs { get; }
string[] AutoRemovableDirs { get; }
string[] StockFolders { get; }
string[] LeaveEmptyInClones { get; }
string[] ReservedPaths { get; }
string[] CreateableDirs { get; }
string[] AutoRemovableDirs { get; }
bool IsReservedDirectory(GameInstance inst, string path);
bool AllowInstallationIn(string name, out string path);
void RebuildSubdirectories(string absGameRoot);
Expand Down
11 changes: 11 additions & 0 deletions Core/Games/KerbalSpaceProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ public string PrimaryModDirectory(GameInstance inst)
"PDLauncher",
};

public string[] LeaveEmptyInClones => new string[]
{
"saves",
"Screenshots",
"thumbs",
"Missions",
"Logs",
"CKAN/history",
"CKAN/downloads",
};

public string[] ReservedPaths => new string[]
{
"GameData", "Ships", "Missions"
Expand Down
6 changes: 6 additions & 0 deletions Core/Games/KerbalSpaceProgram2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ public string PrimaryModDirectory(GameInstance inst)
"PDLauncher",
};

public string[] LeaveEmptyInClones => new string[]
{
"CKAN/history",
"CKAN/downloads",
};

public string[] ReservedPaths => Array.Empty<string>();

public string[] CreateableDirs => new string[]
Expand Down
Loading

0 comments on commit a37eca5

Please sign in to comment.