Editor helper that creates and manages a short-lived temporary workspace under the project Imported/.iup-temp for importing Unity packages. It creates a unique temp directory with an exclusive lease file, sweeps and removes abandoned runs, rejects symlinked content, and cleans up empty parent folders.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
namespace ImportUnityPackage;
/// <summary>Short-lived work under project-root Imported/.iup-temp, outside Assets. Leases protect active runs during cleanup.</summary>
public sealed class ImportWorkspace : IDisposable
{
public string DirectoryPath { get; }
public List<string> Warnings { get; } = new();
readonly string root;
FileStream lease;
public ImportWorkspace( string assetsDirectory )
{
root = GetRoot( assetsDirectory );
UnityImport.CheckDirectory( root );
using var gate = Enter( root );
Sweep( root, Warnings );
Directory.CreateDirectory( root );
DirectoryPath = Path.Combine( root, Guid.NewGuid().ToString( "N" ) );
Directory.CreateDirectory( DirectoryPath );
try { lease = new FileStream( Path.Combine( DirectoryPath, ".active" ), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None ); }
catch { RemoveTree( DirectoryPath ); RemoveEmptyRoot( root, Warnings ); throw; }
}
public static string[] CleanupAbandoned( string assetsDirectory )
{
var root = GetRoot( assetsDirectory );
UnityImport.CheckDirectory( root );
var warnings = new List<string>();
using var gate = Enter( root );
Sweep( root, warnings );
RemoveEmptyRoot( root, warnings );
return warnings.ToArray();
}
// Always use the project's storage root, independent of the final import destination.
static string GetRoot( string assetsDirectory ) => Path.Combine( ImportMergePlan.GetDestination( assetsDirectory, false ), ".iup-temp" );
static void Sweep( string root, List<string> warnings )
{
if ( !Directory.Exists( root ) ) return;
foreach ( var run in Directory.EnumerateDirectories( root ) )
{
if ( !Guid.TryParseExact( Path.GetFileName( run ), "N", out _ ) ) continue;
try
{
UnityImport.CheckDirectory( Path.Combine( run, ".active" ) );
// The exclusive lease stays open throughout an active run, including other editor processes.
using ( new FileStream( Path.Combine( run, ".active" ), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None ) ) { }
}
catch ( IOException ex ) when ( (ex.HResult & 0xffff) is 32 or 33 ) { continue; }
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
{
warnings.Add( $"Temporary folder could not be checked for cleanup: {run}. {ex.Message}" );
continue;
}
try { RemoveTree( run ); }
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException ) { warnings.Add( $"Temporary folder could not be removed: {run}. {ex.Message}" ); }
}
}
// All recursive deletion is constrained to importer work and rejects linked directories/files.
internal static void RemoveTree( string path )
{
if ( !Directory.Exists( path ) ) return;
path = Path.GetFullPath( path );
var run = new DirectoryInfo( path );
while ( run != null && run.Parent?.Name != ".iup-temp" ) run = run.Parent;
if ( run == null || !Guid.TryParseExact( run.Name, "N", out _ ) || !string.Equals( run.Parent.Parent?.Name, "Imported", StringComparison.OrdinalIgnoreCase ) )
throw new IOException( "Refusing to clean outside Imported/.iup-temp run folders." );
UnityImport.CheckDirectory( path );
void Check( string directory )
{
foreach ( var item in Directory.EnumerateFileSystemEntries( directory ) )
{
var flags = File.GetAttributes( item );
if ( flags.HasFlag( FileAttributes.ReparsePoint ) ) throw new IOException( "Refusing to clean linked temporary content: " + item );
if ( flags.HasFlag( FileAttributes.Directory ) ) Check( item );
}
}
Check( path );
ImportStorage.Retry( () => Directory.Delete( path, true ), CancellationToken.None );
}
static void RemoveEmptyRoot( string root, List<string> warnings )
{
// Only prune .iup-temp and its project-level Imported parent, never their contents.
foreach ( var directory in new[] { root, Path.GetDirectoryName( root ) } )
{
try
{
UnityImport.CheckDirectory( directory );
ImportStorage.Retry( () =>
{
if ( Directory.Exists( directory ) && !Directory.EnumerateFileSystemEntries( directory ).Any() )
Directory.Delete( directory, false );
}, CancellationToken.None );
}
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
{
warnings.Add( $"Empty import folder could not be removed: {directory}. Cleanup will be retried next time the importer opens. {ex.Message}" );
}
}
}
public void Dispose()
{
if ( lease == null ) return;
using var gate = Enter( root );
lease.Dispose(); lease = null;
try { RemoveTree( DirectoryPath ); RemoveEmptyRoot( root, Warnings ); }
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
{
Warnings.Add( $"Temporary folder could not be removed: {DirectoryPath}. Cleanup will be retried next time the importer opens. {ex.Message}" );
}
}
static IDisposable Enter( string root ) => new Gate( "iup-temp-" + Convert.ToHexString( SHA256.HashData( Encoding.UTF8.GetBytes( root.ToLowerInvariant() ) ) ) );
sealed class Gate : IDisposable
{
readonly Mutex mutex;
public Gate( string name ) { mutex = new Mutex( false, name ); try { mutex.WaitOne(); } catch ( AbandonedMutexException ) { } }
public void Dispose() { mutex.ReleaseMutex(); mutex.Dispose(); }
}
}