645 lines
21 KiB
C#
645 lines
21 KiB
C#
using System.Management;
|
|
using System.Runtime.Versioning;
|
|
using System.Text.Json;
|
|
using DokanNet;
|
|
using DokanNet.Logging;
|
|
|
|
namespace DevDrive;
|
|
|
|
/// <summary>
|
|
/// Entry point for the DevDrive application.
|
|
/// Uses Dokan to mount a virtual drive with fake capacity and real mklink symlinks.
|
|
/// </summary>
|
|
[SupportedOSPlatform("windows")]
|
|
public static class Program
|
|
{
|
|
private const string DefaultDriveLetter = "A:";
|
|
private const string DefaultConfigPath = "C:\\PhysicalDrive\\";
|
|
private const string ConfigFile = "index.json";
|
|
private const string LogPrefix = "[DevDrive]";
|
|
private const string DevDriveFolderName = "DevDriveRoot";
|
|
|
|
private static readonly ManualResetEvent ShutdownEvent = new(false);
|
|
private static string _driveLetter = DefaultDriveLetter;
|
|
private static string _mountPoint = "A:\\";
|
|
private static string _configPath = DefaultConfigPath;
|
|
private static string _configFilePath = "";
|
|
private static string _devDrivePath = "";
|
|
private static string _devConfigPath = "";
|
|
private static DevDriveConfig? _lastConfig;
|
|
private static readonly object SyncLock = new();
|
|
private static FileSystemWatcher? _devConfigWatcher;
|
|
private static Dokan? _dokan;
|
|
private static DokanInstance? _dokanInstance;
|
|
private static MirrorFileSystem? _mirrorFs;
|
|
private static bool _isMounted;
|
|
private static bool _isUpdatingDevConfig;
|
|
private static bool _isConfigDriveLetter;
|
|
|
|
/// <summary>
|
|
/// Main entry point.
|
|
/// </summary>
|
|
/// <param name="args">Command line arguments. [0] = drive letter (e.g. "A:"), [1] = config path.</param>
|
|
public static void Main(string[] args)
|
|
{
|
|
_driveLetter = args.Length > 0 ? args[0].TrimEnd('\\') : DefaultDriveLetter;
|
|
_configPath = args.Length > 1 ? args[1] : DefaultConfigPath;
|
|
|
|
if (!_driveLetter.EndsWith(":"))
|
|
{
|
|
_driveLetter += ":";
|
|
}
|
|
_mountPoint = _driveLetter + "\\";
|
|
|
|
if (!_configPath.EndsWith("\\"))
|
|
{
|
|
_configPath += "\\";
|
|
}
|
|
|
|
var exeDir = AppContext.BaseDirectory;
|
|
_devDrivePath = Path.Combine(exeDir, DevDriveFolderName);
|
|
_devConfigPath = Path.Combine(_devDrivePath, "devDriveConfig.json");
|
|
_configFilePath = Path.Combine(_configPath, ConfigFile);
|
|
_isConfigDriveLetter = _configPath.Length <= 3 && _configPath[1] == ':';
|
|
|
|
Console.WriteLine($"{LogPrefix} DevDrive (Dokan + mklink mode)");
|
|
Console.WriteLine($"{LogPrefix} ==============================");
|
|
Console.WriteLine($"{LogPrefix} Drive letter: {_driveLetter}");
|
|
Console.WriteLine($"{LogPrefix} Config file: {_configFilePath}");
|
|
Console.WriteLine($"{LogPrefix} Root folder: {_devDrivePath}");
|
|
Console.WriteLine();
|
|
Console.WriteLine($"{LogPrefix} Press Ctrl+C to exit.");
|
|
Console.WriteLine();
|
|
|
|
Console.CancelKeyPress += (sender, eventArgs) =>
|
|
{
|
|
eventArgs.Cancel = true;
|
|
Console.WriteLine($"{LogPrefix} Shutting down...");
|
|
ShutdownEvent.Set();
|
|
};
|
|
|
|
try
|
|
{
|
|
_dokan = new Dokan(new ConsoleLogger(LogPrefix));
|
|
EnsureRootFolder();
|
|
SetupDevConfigWatcher();
|
|
|
|
if (_isConfigDriveLetter)
|
|
{
|
|
StartWmiWatchers();
|
|
}
|
|
else
|
|
{
|
|
StartVolumeWatchers();
|
|
}
|
|
|
|
CheckAndMountIfReady();
|
|
|
|
ShutdownEvent.WaitOne();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Fatal error: {ex.Message}");
|
|
Environment.Exit(1);
|
|
}
|
|
finally
|
|
{
|
|
CleanupSymlinks();
|
|
UnmountDrive();
|
|
_devConfigWatcher?.Dispose();
|
|
_dokan?.Dispose();
|
|
}
|
|
}
|
|
|
|
private static void EnsureRootFolder()
|
|
{
|
|
if (!Directory.Exists(_devDrivePath))
|
|
{
|
|
Directory.CreateDirectory(_devDrivePath);
|
|
Console.WriteLine($"{LogPrefix} Created root folder: {_devDrivePath}");
|
|
}
|
|
}
|
|
|
|
private static bool IsConfigAccessible()
|
|
{
|
|
try
|
|
{
|
|
return Directory.Exists(_configPath) && File.Exists(_configFilePath);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static void StartWmiWatchers()
|
|
{
|
|
var driveLetter = _configPath.TrimEnd('\\');
|
|
|
|
var insertQuery = new WqlEventQuery(
|
|
"SELECT * FROM __InstanceCreationEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_LogicalDisk'"
|
|
);
|
|
var insertWatcher = new ManagementEventWatcher(insertQuery);
|
|
insertWatcher.EventArrived += OnDriveInserted;
|
|
insertWatcher.Start();
|
|
|
|
var removeQuery = new WqlEventQuery(
|
|
"SELECT * FROM __InstanceDeletionEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_LogicalDisk'"
|
|
);
|
|
var removeWatcher = new ManagementEventWatcher(removeQuery);
|
|
removeWatcher.EventArrived += OnDriveRemoved;
|
|
removeWatcher.Start();
|
|
|
|
Console.WriteLine($"{LogPrefix} WMI watchers started (event-based, no polling)");
|
|
Console.WriteLine($"{LogPrefix} Watching for drive: {driveLetter}");
|
|
}
|
|
|
|
private static void StartVolumeWatchers()
|
|
{
|
|
try
|
|
{
|
|
var insertQuery = new WqlEventQuery(
|
|
"SELECT * FROM __InstanceCreationEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_Volume'"
|
|
);
|
|
var insertWatcher = new ManagementEventWatcher(insertQuery);
|
|
insertWatcher.EventArrived += (s, e) =>
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Volume added, checking config...");
|
|
Thread.Sleep(1000);
|
|
CheckAndMountIfReady();
|
|
};
|
|
insertWatcher.Start();
|
|
|
|
var removeQuery = new WqlEventQuery(
|
|
"SELECT * FROM __InstanceDeletionEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_Volume'"
|
|
);
|
|
var removeWatcher = new ManagementEventWatcher(removeQuery);
|
|
removeWatcher.EventArrived += (s, e) =>
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Volume removed");
|
|
lock (SyncLock)
|
|
{
|
|
if (_isMounted && !IsConfigAccessible())
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Config drive no longer accessible, unmounting...");
|
|
UnmountDrive();
|
|
_lastConfig = null;
|
|
}
|
|
}
|
|
};
|
|
removeWatcher.Start();
|
|
|
|
Console.WriteLine($"{LogPrefix} Volume watchers started for {_configPath}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Warning: Could not start WMI volume watchers: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static void OnDriveInserted(object sender, EventArrivedEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
var targetInstance = (ManagementBaseObject)e.NewEvent["TargetInstance"];
|
|
var deviceId = targetInstance["DeviceID"]?.ToString();
|
|
|
|
if (string.IsNullOrEmpty(deviceId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var insertedDrive = deviceId + "\\";
|
|
|
|
if (string.Equals(insertedDrive, _configPath, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine($"{LogPrefix} Drive {deviceId} inserted!");
|
|
|
|
Thread.Sleep(500);
|
|
CheckAndMountIfReady();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Error handling drive insert: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static void OnDriveRemoved(object sender, EventArrivedEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
var targetInstance = (ManagementBaseObject)e.NewEvent["TargetInstance"];
|
|
var deviceId = targetInstance["DeviceID"]?.ToString();
|
|
|
|
if (string.IsNullOrEmpty(deviceId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var removedDrive = deviceId + "\\";
|
|
|
|
if (string.Equals(removedDrive, _configPath, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine($"{LogPrefix} Drive {deviceId} removed!");
|
|
|
|
lock (SyncLock)
|
|
{
|
|
if (_isMounted)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Config drive disconnected, unmounting...");
|
|
UnmountDrive();
|
|
_lastConfig = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Error handling drive removal: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static void CheckAndMountIfReady()
|
|
{
|
|
lock (SyncLock)
|
|
{
|
|
var driveExists = Directory.Exists(_configPath);
|
|
var configExists = driveExists && File.Exists(_configFilePath);
|
|
|
|
if (!driveExists)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Waiting for {_configPath} to be connected...");
|
|
return;
|
|
}
|
|
|
|
if (!configExists)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Drive present but {ConfigFile} not found");
|
|
return;
|
|
}
|
|
|
|
var config = ReadConfig(_configFilePath);
|
|
|
|
if (config == null || config.Paths.Count == 0)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Config empty or invalid");
|
|
|
|
if (_isMounted)
|
|
{
|
|
UnmountDrive();
|
|
_lastConfig = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!_isMounted)
|
|
{
|
|
EnsureDevConfigFromConfig(config);
|
|
MountDrive();
|
|
SyncSymlinksFromConfig(config);
|
|
_lastConfig = config;
|
|
|
|
if (config.StartupApps.Count > 0)
|
|
{
|
|
var driveName = config.DriveName ?? "Dev Drive";
|
|
Task.Run(() => StartupDialog.ShowIfNeeded(config.StartupApps, driveName));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void MountDrive()
|
|
{
|
|
if (_dokan == null || _isMounted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var config = ReadConfig(_configFilePath);
|
|
var volumeName = config?.DriveName ?? "Dev Drive";
|
|
var totalBytes = (long)((config?.DriveTotalMB ?? 1.44) * 1024 * 1024);
|
|
var freeBytes = (long)((config?.DriveFreeMB ?? 0.72) * 1024 * 1024);
|
|
|
|
try
|
|
{
|
|
_mirrorFs = new MirrorFileSystem(_devDrivePath, volumeName, totalBytes, freeBytes);
|
|
|
|
var dokanBuilder = new DokanInstanceBuilder(_dokan)
|
|
.ConfigureOptions(options =>
|
|
{
|
|
options.Options = DokanOptions.FixedDrive | DokanOptions.CurrentSession;
|
|
options.MountPoint = _mountPoint;
|
|
options.SingleThread = false;
|
|
});
|
|
|
|
_dokanInstance = dokanBuilder.Build(_mirrorFs);
|
|
_isMounted = true;
|
|
|
|
Console.WriteLine($"{LogPrefix} Mounted {_driveLetter} (Dokan)");
|
|
Console.WriteLine($"{LogPrefix} Volume: {volumeName}");
|
|
Console.WriteLine($"{LogPrefix} Capacity: {config?.DriveTotalMB ?? 1.44} MB total, {config?.DriveFreeMB ?? 0.72} MB free");
|
|
}
|
|
catch (DokanException ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Mount error: {ex.Message}");
|
|
_isMounted = false;
|
|
}
|
|
}
|
|
|
|
private static void UnmountDrive()
|
|
{
|
|
if (!_isMounted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
_dokan?.RemoveMountPoint(_mountPoint);
|
|
_dokanInstance?.Dispose();
|
|
_dokanInstance = null;
|
|
_mirrorFs = null;
|
|
_isMounted = false;
|
|
Console.WriteLine($"{LogPrefix} Unmounted {_driveLetter}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Unmount error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static void SetupDevConfigWatcher()
|
|
{
|
|
if (_devConfigWatcher != null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!Directory.Exists(_devDrivePath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_devConfigWatcher = new FileSystemWatcher(_devDrivePath)
|
|
{
|
|
Filter = "devDriveConfig.json",
|
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.FileName,
|
|
EnableRaisingEvents = true
|
|
};
|
|
|
|
_devConfigWatcher.Changed += OnDevConfigChanged;
|
|
_devConfigWatcher.Created += OnDevConfigChanged;
|
|
|
|
Console.WriteLine($"{LogPrefix} Watching devDriveConfig.json for changes...");
|
|
}
|
|
|
|
private static void OnDevConfigChanged(object sender, FileSystemEventArgs e)
|
|
{
|
|
lock (SyncLock)
|
|
{
|
|
if (_isUpdatingDevConfig)
|
|
{
|
|
// Change originated from real config sync, ignore.
|
|
_isUpdatingDevConfig = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
Thread.Sleep(100);
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(_devConfigPath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var json = File.ReadAllText(_devConfigPath);
|
|
var config = JsonSerializer.Deserialize<DevDriveConfig>(json);
|
|
if (config == null)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} devDriveConfig.json change could not be deserialized.");
|
|
return;
|
|
}
|
|
|
|
lock (SyncLock)
|
|
{
|
|
File.WriteAllText(_configFilePath, json);
|
|
|
|
// Check if drive metadata changed (name, capacity)
|
|
var metadataChanged = _lastConfig == null ||
|
|
_lastConfig.DriveName != config.DriveName ||
|
|
_lastConfig.DriveTotalMB != config.DriveTotalMB ||
|
|
_lastConfig.DriveFreeMB != config.DriveFreeMB;
|
|
|
|
if (metadataChanged && _mirrorFs != null)
|
|
{
|
|
var volumeName = config.DriveName ?? "Dev Drive";
|
|
var totalBytes = (long)(config.DriveTotalMB * 1024 * 1024);
|
|
var freeBytes = (long)(config.DriveFreeMB * 1024 * 1024);
|
|
_mirrorFs.UpdateMetadata(volumeName, totalBytes, freeBytes);
|
|
Console.WriteLine($"{LogPrefix} Updated drive metadata: {volumeName}, {config.DriveTotalMB} MB total, {config.DriveFreeMB} MB free");
|
|
}
|
|
|
|
SyncSymlinksFromConfig(config);
|
|
_lastConfig = config;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Error processing devDriveConfig.json change: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static DevDriveConfig? ReadConfig(string path)
|
|
{
|
|
try
|
|
{
|
|
var json = File.ReadAllText(path);
|
|
return JsonSerializer.Deserialize<DevDriveConfig>(json);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Error reading config: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static void SyncSymlinksFromConfig(DevDriveConfig config)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine($"{LogPrefix} Syncing symlinks (mklink /d):");
|
|
|
|
var existingEntries = Directory.GetFileSystemEntries(_devDrivePath)
|
|
.Where(p => !Path.GetFileName(p).Equals("desktop.ini", StringComparison.OrdinalIgnoreCase))
|
|
.Where(p => !Path.GetFileName(p).Equals("devDriveConfig.json", StringComparison.OrdinalIgnoreCase))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var configPaths = config.Paths
|
|
.Select(p => Path.Combine(_devDrivePath, p.Name))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var entry in existingEntries)
|
|
{
|
|
if (!configPaths.Contains(entry))
|
|
{
|
|
RemoveSymlink(entry);
|
|
}
|
|
}
|
|
|
|
foreach (var pathEntry in config.Paths)
|
|
{
|
|
var symlinkPath = Path.Combine(_devDrivePath, pathEntry.Name);
|
|
CreateSymlink(symlinkPath, pathEntry.Path);
|
|
}
|
|
|
|
Console.WriteLine();
|
|
}
|
|
|
|
private static void EnsureDevConfigFromConfig(DevDriveConfig config)
|
|
{
|
|
try
|
|
{
|
|
_isUpdatingDevConfig = true;
|
|
var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
|
|
File.WriteAllText(_devConfigPath, json);
|
|
Console.WriteLine($"{LogPrefix} Created devDriveConfig.json from index.json");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Error creating devDriveConfig.json: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static void CreateSymlink(string symlinkPath, string targetPath)
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(symlinkPath) || File.Exists(symlinkPath))
|
|
{
|
|
var attr = File.GetAttributes(symlinkPath);
|
|
if (attr.HasFlag(FileAttributes.ReparsePoint))
|
|
{
|
|
var existingTarget = new FileInfo(symlinkPath).LinkTarget;
|
|
if (existingTarget?.Equals(targetPath, StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} {Path.GetFileName(symlinkPath)} -> {targetPath} (unchanged)");
|
|
return;
|
|
}
|
|
RemoveSymlink(symlinkPath);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Skipping {Path.GetFileName(symlinkPath)} - not a symlink");
|
|
return;
|
|
}
|
|
}
|
|
|
|
var isDirectory = Directory.Exists(targetPath);
|
|
var args = isDirectory
|
|
? $"/c mklink /d \"{symlinkPath}\" \"{targetPath}\""
|
|
: $"/c mklink \"{symlinkPath}\" \"{targetPath}\"";
|
|
|
|
var result = RunCommand("cmd.exe", args);
|
|
|
|
if (result.ExitCode == 0)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} {Path.GetFileName(symlinkPath)} -> {targetPath}");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Failed: {Path.GetFileName(symlinkPath)} - {result.Error}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Error: {Path.GetFileName(symlinkPath)} - {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static void RemoveSymlink(string path)
|
|
{
|
|
try
|
|
{
|
|
var attr = File.GetAttributes(path);
|
|
if (attr.HasFlag(FileAttributes.Directory))
|
|
{
|
|
Directory.Delete(path, false);
|
|
}
|
|
else
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
Console.WriteLine($"{LogPrefix} Removed: {Path.GetFileName(path)}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"{LogPrefix} Error removing {Path.GetFileName(path)}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static void CleanupSymlinks()
|
|
{
|
|
if (!Directory.Exists(_devDrivePath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine($"{LogPrefix} Cleaning up symlinks...");
|
|
|
|
var entries = Directory.GetFileSystemEntries(_devDrivePath)
|
|
.Where(p => !Path.GetFileName(p).Equals("desktop.ini", StringComparison.OrdinalIgnoreCase))
|
|
.Where(p => !Path.GetFileName(p).Equals("devDriveConfig.json", StringComparison.OrdinalIgnoreCase));
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
try
|
|
{
|
|
var attr = File.GetAttributes(entry);
|
|
if (attr.HasFlag(FileAttributes.ReparsePoint))
|
|
{
|
|
if (attr.HasFlag(FileAttributes.Directory))
|
|
{
|
|
Directory.Delete(entry, false);
|
|
}
|
|
else
|
|
{
|
|
File.Delete(entry);
|
|
}
|
|
|
|
Console.WriteLine($"{LogPrefix} Removed: {Path.GetFileName(entry)}");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
private static (int ExitCode, string Output, string Error) RunCommand(string fileName, string arguments)
|
|
{
|
|
var process = new System.Diagnostics.Process
|
|
{
|
|
StartInfo = new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = fileName,
|
|
Arguments = arguments,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
}
|
|
};
|
|
|
|
process.Start();
|
|
var output = process.StandardOutput.ReadToEnd();
|
|
var error = process.StandardError.ReadToEnd();
|
|
process.WaitForExit();
|
|
|
|
return (process.ExitCode, output.Trim(), error.Trim());
|
|
}
|
|
}
|