diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml
index a3e47429c..1f62b61d5 100644
--- a/.github/workflows/upgrade-tests.yaml
+++ b/.github/workflows/upgrade-tests.yaml
@@ -34,6 +34,9 @@ jobs:
- staging-then-clean
- mount-safety-deferral
- unmount-all-triggers-upgrade
+ - versioned-fresh-install
+ - versioned-upgrade
+ - flat-to-versioned-upgrade
fail-fast: false
steps:
@@ -363,6 +366,140 @@ jobs:
Write-Host "PASS: unmount-all triggers staged upgrade via process monitor"
}
+ "versioned-fresh-install" {
+ Write-Host "=== Scenario: Fresh install with versioned layout ==="
+ # Install new build directly (no LKG)
+ Install-GVFS $newInstaller
+ Assert-ServiceRunning
+
+ # Verify versioned layout structure
+ $appDir = "C:\Program Files\VFS for Git"
+ $currentJunction = Join-Path $appDir "Current"
+ if (-not (Test-Path $currentJunction)) {
+ throw "Current junction does not exist"
+ }
+ # Check it's actually a junction
+ $item = Get-Item $currentJunction
+ if ($item.LinkType -ne 'Junction') {
+ throw "Current is not a junction: LinkType = $($item.LinkType)"
+ }
+ Write-Host "Current junction exists: $currentJunction -> $($item.Target)"
+
+ # Verify gvfs.exe exists in versioned path
+ $versionDirs = Get-ChildItem (Join-Path $appDir "Versions") -Directory
+ if ($versionDirs.Count -eq 0) {
+ throw "No version directories found"
+ }
+ $versionDir = $versionDirs[0].FullName
+ $gvfsExe = Join-Path $versionDir "gvfs.exe"
+ if (-not (Test-Path $gvfsExe)) {
+ throw "gvfs.exe not found at $gvfsExe"
+ }
+ Write-Host "Found gvfs.exe at $gvfsExe"
+
+ # Verify gvfs version works
+ & "$installDir\gvfs.exe" version 2>&1 | Write-Host
+ if ($LASTEXITCODE -ne 0) { throw "gvfs version failed" }
+
+ # Clone and mount test repo
+ Mount-TestRepo | Write-Host
+ Unmount-TestRepo
+ Write-Host "PASS: Versioned fresh install completed"
+ }
+
+ "versioned-upgrade" {
+ Write-Host "=== Scenario: Upgrade from versioned to versioned (junction swap) ==="
+ # First install (versioned)
+ Install-GVFS $newInstaller
+ Assert-ServiceRunning
+ $mountPid = Mount-TestRepo
+
+ # Second install with SAME installer (simulating same-version reinstall)
+ # This tests that the junction swap is atomic and doesn't break mounts
+ Install-GVFS $newInstaller
+ Assert-ServiceRunning
+
+ # Verify mount still works
+ Assert-MountAlive $mountPid
+ Write-Host "Mount still alive after reinstall"
+
+ # Unmount and verify we can remount
+ Unmount-TestRepo
+ $null = Mount-TestRepo
+ Unmount-TestRepo
+
+ # Verify version folder structure - should have kept 1 old version (if any)
+ $versionsDir = "C:\Program Files\VFS for Git\Versions"
+ $versionDirs = Get-ChildItem $versionsDir -Directory
+ Write-Host "Version directories after upgrade: $($versionDirs.Count)"
+ if ($versionDirs.Count -eq 0) {
+ throw "No version directories found after upgrade"
+ }
+ Write-Host "PASS: Versioned-to-versioned upgrade completed"
+ }
+
+ "flat-to-versioned-upgrade" {
+ Write-Host "=== Scenario: Upgrade from flat layout (LKG) to versioned layout ==="
+ # Install LKG (flat layout)
+ Install-GVFS $lkgInstaller
+ Assert-ServiceRunning
+
+ # Verify LKG works (clone + mount)
+ $mountPid = Mount-TestRepo
+
+ # Get LKG version to verify PATH cleanup later
+ $lkgVersion = & "$installDir\gvfs.exe" version 2>&1
+ Write-Host "LKG version: $lkgVersion"
+
+ # Unmount before upgrade
+ Unmount-TestRepo
+
+ # Upgrade to versioned layout
+ Install-GVFS $newInstaller
+ Assert-ServiceRunning
+
+ # Verify Current junction was created
+ $currentJunction = Join-Path $installDir "Current"
+ if (-not (Test-Path $currentJunction)) {
+ throw "Current junction was not created during upgrade"
+ }
+ $item = Get-Item $currentJunction
+ if ($item.LinkType -ne 'Junction') {
+ throw "Current is not a junction after upgrade"
+ }
+ Write-Host "Current junction created: $currentJunction -> $($item.Target)"
+
+ # Verify PATH was updated (should contain {app}\Current, NOT {app} alone)
+ $pathKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment'
+ $systemPath = (Get-ItemProperty $pathKey).Path
+ $appDir = "C:\Program Files\VFS for Git"
+ if ($systemPath -notlike "*$appDir\Current*") {
+ throw "PATH does not contain versioned entry: $appDir\Current"
+ }
+ Write-Host "PATH contains versioned entry: $appDir\Current"
+
+ # Verify old flat PATH entry was cleaned up
+ # Match standalone {app} entry (not followed by \Current or \Versions)
+ # Use regex to detect {app} NOT followed by \Current or \Versions
+ $flatPathPattern = [regex]::Escape($appDir) + '(?!\\(Current|Versions))'
+ if ($systemPath -match $flatPathPattern) {
+ Write-Host "WARNING: Flat PATH entry still present: $appDir"
+ Write-Host "Full PATH: $systemPath"
+ # Note: This is expected to fail until Fix #3 is applied
+ } else {
+ Write-Host "Flat PATH entry successfully removed"
+ }
+
+ # Verify gvfs version shows new version
+ $newVersion = & "$installDir\gvfs.exe" version 2>&1
+ Write-Host "New version: $newVersion"
+
+ # Remount and verify it works
+ $null = Mount-TestRepo
+ Unmount-TestRepo
+ Write-Host "PASS: Flat-to-versioned upgrade completed"
+ }
+
default {
throw "Unknown scenario: ${{ matrix.scenario }}"
}
diff --git a/GVFS/GVFS.Common/IScheduledTaskInvoker.cs b/GVFS/GVFS.Common/IScheduledTaskInvoker.cs
new file mode 100644
index 000000000..9a8750de1
--- /dev/null
+++ b/GVFS/GVFS.Common/IScheduledTaskInvoker.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+
+namespace GVFS.Common
+{
+ ///
+ /// Abstracts the Windows Task Scheduler operations needed by
+ /// . Production callers use
+ /// ; tests pass a mock so
+ /// they can exercise 's logic
+ /// without actually touching the Task Scheduler on the test machine.
+ ///
+ public interface IScheduledTaskInvoker
+ {
+ ///
+ /// Register the task at from the given
+ /// XML, overwriting any existing task at that path. Returns
+ /// true on success.
+ ///
+ bool TryRegisterFromXml(string taskPath, string xml, out string errorMessage);
+
+ ///
+ /// Read back the registered XML for the task at
+ /// . Returns true with the XML
+ /// when the task exists; returns false with a populated
+ /// when it does not.
+ ///
+ bool TryQueryXml(string taskPath, out string xml, out string errorMessage);
+
+ ///
+ /// Unregister the task at . Returns
+ /// true if the task was unregistered OR was not registered
+ /// to begin with (idempotent). Returns false only on a hard
+ /// failure (e.g., permission denied).
+ ///
+ bool TryUnregister(string taskPath, out string errorMessage);
+ }
+}
diff --git a/GVFS/GVFS.Common/LocalRepoRegistration.cs b/GVFS/GVFS.Common/LocalRepoRegistration.cs
new file mode 100644
index 000000000..ebb28b355
--- /dev/null
+++ b/GVFS/GVFS.Common/LocalRepoRegistration.cs
@@ -0,0 +1,64 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace GVFS.Common
+{
+ ///
+ /// One entry in the user-level repo registry on disk. Field set and
+ /// JSON shape MUST match GVFS.Service.RepoRegistration so that the
+ /// user-level registry file (written by )
+ /// is wire-compatible with any registry the legacy service has written
+ /// in the past. If a new field is added here, the same field must also
+ /// be added to GVFS.Service.RepoRegistration (and vice versa) along
+ /// with a registry-format-version bump.
+ ///
+ public class LocalRepoRegistration
+ {
+ public LocalRepoRegistration()
+ {
+ }
+
+ public LocalRepoRegistration(string enlistmentRoot, string ownerSID)
+ {
+ this.EnlistmentRoot = enlistmentRoot;
+ this.OwnerSID = ownerSID;
+ this.IsActive = true;
+ }
+
+ public string EnlistmentRoot { get; set; }
+ public string OwnerSID { get; set; }
+ public bool IsActive { get; set; }
+
+ // Uses LocalRepoRegistrationJsonContext (assembly-local source generator)
+ // rather than GVFSJsonContext. The service-side RepoRegistration uses
+ // its own ServiceJsonContext for the same reason — neither type can be
+ // registered in GVFSJsonContext because GVFSJsonContext lives in
+ // GVFS.Common and the service-side type lives in GVFS.Service (wrong
+ // dependency direction). Keeping symmetric local contexts here means
+ // the on-disk JSON shape is governed by identical source-gen behavior
+ // on both sides.
+ public static LocalRepoRegistration FromJson(string json)
+ {
+ return JsonSerializer.Deserialize(json, LocalRepoRegistrationJsonContext.Default.LocalRepoRegistration);
+ }
+
+ public string ToJson()
+ {
+ return JsonSerializer.Serialize(this, LocalRepoRegistrationJsonContext.Default.LocalRepoRegistration);
+ }
+
+ public override string ToString()
+ {
+ return string.Format(
+ "({0} - {1}) {2}",
+ this.IsActive ? "Active" : "Inactive",
+ this.OwnerSID,
+ this.EnlistmentRoot);
+ }
+ }
+
+ [JsonSerializable(typeof(LocalRepoRegistration))]
+ internal partial class LocalRepoRegistrationJsonContext : JsonSerializerContext
+ {
+ }
+}
diff --git a/GVFS/GVFS.Common/LocalRepoRegistry.cs b/GVFS/GVFS.Common/LocalRepoRegistry.cs
new file mode 100644
index 000000000..c9c99dc6d
--- /dev/null
+++ b/GVFS/GVFS.Common/LocalRepoRegistry.cs
@@ -0,0 +1,515 @@
+using GVFS.Common.FileSystem;
+using GVFS.Common.Tracing;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace GVFS.Common
+{
+ ///
+ /// File-backed repo registry usable from any GVFS process without going
+ /// through GVFS.Service. The on-disk format is wire-compatible with
+ /// GVFS.Service.RepoRegistry — both produce and consume the same
+ /// repo-registry file under the shared service data directory.
+ ///
+ ///
+ ///
+ /// On-disk format (line-oriented, identical to GVFS.Service.RepoRegistry):
+ ///
+ ///
+ /// - Line 1: registry format version (integer, currently 2).
+ /// -
+ /// Lines 2..N: one JSON object per line.
+ /// Blank lines and lines that fail to parse are skipped (matches the service's
+ /// tolerance for partial corruption).
+ ///
+ ///
+ ///
+ /// Threading: instance methods that read or write the registry serialize on
+ /// a private instance lock. Cross-process safety relies on the same atomic
+ /// write-temp-then-replace pattern the service uses.
+ ///
+ ///
+ /// This type does not pick its own storage location — callers pass
+ /// via the constructor. Production
+ /// callers should pass GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent(ServiceDataDirName)
+ /// so the file lives at the same path the service uses (which honors the
+ /// GVFS_SECURE_DATA_ROOT environment-variable redirect for user-level
+ /// installs).
+ ///
+ ///
+ public class LocalRepoRegistry
+ {
+ ///
+ /// Subdirectory under the platform's secure-data root that holds the
+ /// registry file. Matches the legacy service's name so both producers
+ /// write to the same location.
+ ///
+ public const string ServiceDataDirName = "GVFS.Service";
+
+ /// Final on-disk name of the registry file.
+ public const string RegistryFileName = "repo-registry";
+
+ ///
+ /// Temp name used by the atomic write-then-replace pattern. Named
+ /// repo-registry.lock for byte-for-byte compatibility with
+ /// the legacy service's choice (so a writer interrupted mid-rename
+ /// leaves a file with the same name the service would have left).
+ ///
+ public const string RegistryTempName = "repo-registry.lock";
+
+ ///
+ /// Registry format version this implementation can read AND write.
+ /// Files with a higher version on disk are treated as opaque: read
+ /// returns empty and we refuse to overwrite, so a newer GVFS that
+ /// has written the registry is not corrupted by an older GVFS.
+ ///
+ public const int RegistryVersion = 2;
+
+ private readonly ITracer tracer;
+ private readonly PhysicalFileSystem fileSystem;
+ private readonly string registryDirectory;
+ private readonly object instanceLock = new object();
+
+ public LocalRepoRegistry(ITracer tracer, PhysicalFileSystem fileSystem, string registryDirectory)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+ ArgumentNullException.ThrowIfNull(fileSystem);
+ ArgumentNullException.ThrowIfNull(registryDirectory);
+
+ this.tracer = tracer;
+ this.fileSystem = fileSystem;
+ this.registryDirectory = registryDirectory;
+ }
+
+ ///
+ /// Convenience factory for production callers: constructs an instance
+ /// pointed at the platform's secure-data path for the GVFS.Service
+ /// component, using a real . This is
+ /// the same path the legacy service writes to, so register/unregister
+ /// operations are wire-compatible regardless of whether the service
+ /// is running.
+ ///
+ ///
+ /// When running as SYSTEM (e.g., CI agents), this method always uses
+ /// the machine-wide ProgramData location to avoid consuming leaked
+ /// user-specific environment variables. For normal user accounts, the
+ /// platform's default path is used (which respects environment variable
+ /// overrides for user-level installs).
+ ///
+ public static LocalRepoRegistry CreateForCurrentPlatform(ITracer tracer)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+
+ string registryDirectory;
+ if (IsRunningAsSystem())
+ {
+ // SYSTEM account (CI agents) always uses the machine-wide ProgramData location
+ // to avoid consuming leaked user-specific environment variables.
+ registryDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "GVFS",
+ ServiceDataDirName);
+ }
+ else
+ {
+ registryDirectory = GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent(ServiceDataDirName);
+ }
+
+ LocalRepoRegistry registry = new LocalRepoRegistry(
+ tracer,
+ new PhysicalFileSystem(),
+ registryDirectory);
+
+ // Seed from system registry if this is the first time a non-SYSTEM user is
+ // using the registry and a system-wide registry already exists.
+ if (!IsRunningAsSystem())
+ {
+ registry.SeedFromSystemRegistryIfNeeded();
+ }
+
+ return registry;
+ }
+
+ ///
+ /// Returns true if the current process is running under the SYSTEM account
+ /// (S-1-5-18). CI agents and other automated tasks typically run as SYSTEM.
+ ///
+ private static bool IsRunningAsSystem()
+ {
+ using (System.Security.Principal.WindowsIdentity identity = System.Security.Principal.WindowsIdentity.GetCurrent())
+ {
+ return identity.IsSystem;
+ }
+ }
+
+ ///
+ /// Idempotently records the given enlistment root as active. If an
+ /// entry already exists, it is reactivated and its OwnerSID
+ /// is updated to . Matches the semantics
+ /// of GVFS.Service.RepoRegistry.TryRegisterRepo.
+ ///
+ public bool TryRegisterRepo(string repoRoot, string ownerSID, out string errorMessage)
+ {
+ ArgumentNullException.ThrowIfNull(repoRoot);
+
+ errorMessage = null;
+ try
+ {
+ lock (this.instanceLock)
+ {
+ Dictionary all = this.ReadRegistry();
+ if (all.TryGetValue(repoRoot, out LocalRepoRegistration existing))
+ {
+ if (!existing.IsActive || !string.Equals(existing.OwnerSID, ownerSID, StringComparison.Ordinal))
+ {
+ existing.IsActive = true;
+ existing.OwnerSID = ownerSID;
+ this.WriteRegistry(all);
+ }
+ }
+ else
+ {
+ all[repoRoot] = new LocalRepoRegistration(repoRoot, ownerSID);
+ this.WriteRegistry(all);
+ }
+ }
+
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = string.Format("Error while registering repo {0}: {1}", repoRoot, e);
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+
+ ///
+ /// Marks the given entry inactive (retained on disk so
+ /// is preserved for a
+ /// possible later re-register). Returns true when the entry
+ /// existed (whether or not it was already inactive); returns
+ /// false when the entry was not found.
+ ///
+ public bool TryDeactivateRepo(string repoRoot, out string errorMessage)
+ {
+ ArgumentNullException.ThrowIfNull(repoRoot);
+
+ errorMessage = null;
+ try
+ {
+ lock (this.instanceLock)
+ {
+ Dictionary all = this.ReadRegistry();
+ if (all.TryGetValue(repoRoot, out LocalRepoRegistration existing))
+ {
+ if (existing.IsActive)
+ {
+ existing.IsActive = false;
+ this.WriteRegistry(all);
+ }
+
+ return true;
+ }
+
+ errorMessage = string.Format("Attempted to deactivate non-existent repo at '{0}'", repoRoot);
+ return false;
+ }
+ }
+ catch (Exception e)
+ {
+ errorMessage = string.Format("Error while deactivating repo {0}: {1}", repoRoot, e);
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+
+ ///
+ /// Removes the entry entirely (not just deactivates it). Returns
+ /// true on success, false if no such entry existed.
+ ///
+ public bool TryRemoveRepo(string repoRoot, out string errorMessage)
+ {
+ ArgumentNullException.ThrowIfNull(repoRoot);
+
+ errorMessage = null;
+ try
+ {
+ lock (this.instanceLock)
+ {
+ Dictionary all = this.ReadRegistry();
+ if (all.Remove(repoRoot))
+ {
+ this.WriteRegistry(all);
+ return true;
+ }
+
+ errorMessage = string.Format("Attempted to remove non-existent repo at '{0}'", repoRoot);
+ return false;
+ }
+ }
+ catch (Exception e)
+ {
+ errorMessage = string.Format("Error while removing repo {0}: {1}", repoRoot, e);
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+
+ ///
+ /// Returns the entries currently marked active. Inactive entries are
+ /// excluded. Returns an empty list when the registry file does not
+ /// exist yet.
+ ///
+ public bool TryGetActiveRepos(out List repoList, out string errorMessage)
+ {
+ repoList = null;
+ errorMessage = null;
+
+ lock (this.instanceLock)
+ {
+ try
+ {
+ Dictionary all = this.ReadRegistry();
+ repoList = all.Values.Where(r => r.IsActive).ToList();
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = string.Format("Unable to get list of active repos: {0}", e);
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// Seeds the user registry from the system-wide ProgramData registry if the user
+ /// registry doesn't exist yet. Filters entries to only those whose repo root directory
+ /// exists and is accessible to the current user. This supports migration scenarios
+ /// where repos were previously registered by GVFS.Service (system-wide) and are now
+ /// transitioning to user-level registration.
+ ///
+ ///
+ /// This method is only intended for non-SYSTEM accounts. SYSTEM processes use the
+ /// ProgramData registry directly and never seed from it.
+ ///
+ public void SeedFromSystemRegistryIfNeeded()
+ {
+ string registryPath = Path.Combine(this.registryDirectory, RegistryFileName);
+ if (this.fileSystem.FileExists(registryPath))
+ {
+ return; // User registry already exists, nothing to seed
+ }
+
+ string systemRegistryDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "GVFS",
+ ServiceDataDirName);
+ string systemRegistryPath = Path.Combine(systemRegistryDir, RegistryFileName);
+
+ if (!this.fileSystem.FileExists(systemRegistryPath))
+ {
+ return; // No system registry to seed from
+ }
+
+ try
+ {
+ // Read system registry entries
+ Dictionary systemRepos = this.ReadRegistryFrom(systemRegistryDir);
+ if (systemRepos.Count == 0)
+ {
+ return; // System registry is empty, nothing to seed
+ }
+
+ // Filter to repos whose directories exist and are accessible
+ Dictionary accessibleRepos =
+ new Dictionary(GVFSPlatform.Instance.Constants.PathComparer);
+
+ foreach (LocalRepoRegistration repo in systemRepos.Values)
+ {
+ if (string.IsNullOrEmpty(repo.EnlistmentRoot))
+ {
+ continue;
+ }
+
+ try
+ {
+ if (this.fileSystem.DirectoryExists(repo.EnlistmentRoot))
+ {
+ accessibleRepos[repo.EnlistmentRoot] = repo;
+ }
+ }
+ catch (Exception)
+ {
+ // If we can't check the directory (permission denied, etc.),
+ // treat it as inaccessible and skip.
+ }
+ }
+
+ // Write accessible repos to user registry. Re-check whether the file
+ // was created by another process (TOCTOU race) and merge if so.
+ if (accessibleRepos.Count > 0)
+ {
+ if (this.fileSystem.FileExists(registryPath))
+ {
+ // Another process created the registry between our check and now.
+ // Merge: read what's there, add our seeded entries (don't overwrite).
+ Dictionary existingRepos = this.ReadRegistryFrom(this.registryDirectory);
+ foreach (KeyValuePair entry in accessibleRepos)
+ {
+ if (!existingRepos.ContainsKey(entry.Key))
+ {
+ existingRepos[entry.Key] = entry.Value;
+ }
+ }
+
+ this.WriteRegistry(existingRepos);
+ }
+ else
+ {
+ this.WriteRegistry(accessibleRepos);
+ }
+
+ EventMetadata metadata = new EventMetadata
+ {
+ { "SystemRegistryPath", systemRegistryPath },
+ { "UserRegistryPath", registryPath },
+ { "SeededCount", accessibleRepos.Count },
+ { "TotalSystemCount", systemRepos.Count },
+ };
+ this.tracer.RelatedEvent(
+ EventLevel.Informational,
+ nameof(this.SeedFromSystemRegistryIfNeeded),
+ metadata);
+ }
+ }
+ catch (Exception e)
+ {
+ EventMetadata metadata = new EventMetadata
+ {
+ { "SystemRegistryPath", systemRegistryPath },
+ { "Exception", e.ToString() },
+ };
+ this.tracer.RelatedError(
+ metadata,
+ $"{nameof(this.SeedFromSystemRegistryIfNeeded)}: Failed to seed from system registry; continuing with empty user registry");
+ }
+ }
+
+ ///
+ /// Returns the in-memory map of all entries currently on disk
+ /// (active and inactive). Intended for diagnostics and tests; most
+ /// production callers should use .
+ ///
+ public Dictionary ReadRegistry()
+ {
+ return this.ReadRegistryFrom(this.registryDirectory);
+ }
+
+ ///
+ /// Reads the registry from the specified directory. Used internally for
+ /// both the normal registry path and for seeding from the system registry.
+ ///
+ private Dictionary ReadRegistryFrom(string registryDirectory)
+ {
+ Dictionary allRepos =
+ new Dictionary(GVFSPlatform.Instance.Constants.PathComparer);
+
+ string registryFilePath = Path.Combine(registryDirectory, RegistryFileName);
+
+ using (Stream stream = this.fileSystem.OpenFileStream(
+ registryFilePath,
+ FileMode.OpenOrCreate,
+ FileAccess.Read,
+ FileShare.ReadWrite | FileShare.Delete,
+ callFlushFileBuffers: false))
+ using (StreamReader reader = new StreamReader(stream))
+ {
+ string versionString = reader.ReadLine();
+ if (versionString == null)
+ {
+ // Empty file - first write will populate it.
+ return allRepos;
+ }
+
+ if (!int.TryParse(versionString, out int version) || version > RegistryVersion)
+ {
+ EventMetadata metadata = new EventMetadata
+ {
+ { "OnDiskVersion", versionString },
+ { "MaxSupportedVersion", RegistryVersion },
+ };
+ this.tracer.RelatedError(metadata, $"{nameof(this.ReadRegistry)}: Unsupported registry version; treating as empty");
+ return allRepos;
+ }
+
+ while (!reader.EndOfStream)
+ {
+ string entry = reader.ReadLine();
+ if (string.IsNullOrEmpty(entry))
+ {
+ continue;
+ }
+
+ try
+ {
+ LocalRepoRegistration registration = LocalRepoRegistration.FromJson(entry);
+ if (registration != null && !string.IsNullOrEmpty(registration.EnlistmentRoot))
+ {
+ allRepos[registration.EnlistmentRoot] = registration;
+ }
+ }
+ catch (Exception e)
+ {
+ // Tolerate corruption of individual lines; matches
+ // RepoRegistry.ReadRegistry's behavior.
+ EventMetadata metadata = new EventMetadata
+ {
+ { "entry", entry },
+ { "Exception", e.ToString() },
+ };
+ this.tracer.RelatedError(metadata, $"{nameof(this.ReadRegistry)}: Failed to parse entry; skipping");
+ }
+ }
+ }
+
+ return allRepos;
+ }
+
+ private void WriteRegistry(Dictionary registry)
+ {
+ // Ensure the directory exists. The service relies on its install
+ // step to create %ProgramData%\GVFS\GVFS.Service; the user-level
+ // path under %LocalAppData% may not exist yet when this runs.
+ if (!this.fileSystem.DirectoryExists(this.registryDirectory))
+ {
+ this.fileSystem.CreateDirectory(this.registryDirectory);
+ }
+
+ string tempFilePath = Path.Combine(this.registryDirectory, RegistryTempName);
+ string finalFilePath = Path.Combine(this.registryDirectory, RegistryFileName);
+
+ using (Stream stream = this.fileSystem.OpenFileStream(
+ tempFilePath,
+ FileMode.Create,
+ FileAccess.Write,
+ FileShare.None,
+ callFlushFileBuffers: true))
+ using (StreamWriter writer = new StreamWriter(stream))
+ {
+ writer.WriteLine(RegistryVersion);
+ foreach (LocalRepoRegistration registration in registry.Values)
+ {
+ writer.WriteLine(registration.ToJson());
+ }
+
+ stream.Flush();
+ }
+
+ this.fileSystem.MoveAndOverwriteFile(tempFilePath, finalFilePath);
+ }
+ }
+}
diff --git a/GVFS/GVFS.Common/LogonTaskRegistration.cs b/GVFS/GVFS.Common/LogonTaskRegistration.cs
new file mode 100644
index 000000000..5565cb0de
--- /dev/null
+++ b/GVFS/GVFS.Common/LogonTaskRegistration.cs
@@ -0,0 +1,244 @@
+using GVFS.Common.Tracing;
+using System;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace GVFS.Common
+{
+ ///
+ /// Registers / updates / unregisters the machine-wide Windows scheduled task
+ /// that mounts registered GVFS enlistments at logon for each interactive user. Replaces
+ /// the role of GVFS.Service's session-change-driven AutoMount in
+ /// the user-level install model.
+ ///
+ ///
+ ///
+ /// The task is registered at \GVFS\AutoMount, scoped to all
+ /// interactive users (GroupId S-1-5-4), runs at user logon with the
+ /// user's interactive token, and executes gvfs.exe service --mount-all.
+ /// The mount-all verb reads the user's registered repos (via )
+ /// and mounts each one.
+ ///
+ ///
+ /// Drift detection works via a content-hash marker embedded in the
+ /// task's Description field
+ /// ([gvfs-logon-task-hash=XXXXXXXXXXXXXXXX]). The hash covers the
+ /// XML template with placeholders still in place, so it is
+ /// stable across re-substitutions with different gvfs.exe
+ /// paths -- only template content changes (a code change to the
+ /// template constant) bump the hash. queries
+ /// the registered task XML, extracts the marker, and compares against
+ /// .
+ ///
+ ///
+ /// Tested as a unit by passing a mock .
+ /// Production callers should use
+ /// , which constructs a
+ /// behind the scenes.
+ ///
+ ///
+ public class LogonTaskRegistration
+ {
+ public const string TaskName = "AutoMount";
+ public const string TaskFolder = @"\GVFS\";
+ public const string FullTaskPath = @"\GVFS\AutoMount";
+
+ public const string GvfsPathPlaceholder = "__GVFS_PATH__";
+ public const string TaskHashPlaceholder = "__TASK_HASH__";
+
+ public const string HashMarkerPrefix = "[gvfs-logon-task-hash=";
+ public const string HashMarkerSuffix = "]";
+
+ ///
+ /// Task XML template. Placeholders:
+ ///
+ /// - __GVFS_PATH__ -- absolute path to gvfs.exe
+ /// - __TASK_HASH__ -- content hash of this template,
+ /// inserted into the Description for drift detection
+ ///
+ /// Indented as a verbatim string; the XML emitted is well-formed
+ /// and accepted by schtasks /Create /XML.
+ ///
+ public const string XmlTemplate =
+@"
+
+
+ GVFS
+ Mounts registered GVFS enlistments at logon for each interactive user. Required by VFS for Git. [gvfs-logon-task-hash=__TASK_HASH__]
+ \GVFS\AutoMount
+
+
+
+ true
+
+
+
+
+ S-1-5-4
+ LeastPrivilege
+
+
+
+ IgnoreNew
+ false
+ false
+ true
+ true
+ false
+
+ false
+ false
+
+ true
+ true
+ false
+ false
+ false
+ PT5M
+ 5
+
+
+
+ conhost.exe
+ --headless __GVFS_PATH__ service --mount-all
+
+
+
+";
+
+ private static readonly Lazy templateHash = new Lazy(ComputeTemplateHash);
+
+ private readonly ITracer tracer;
+ private readonly IScheduledTaskInvoker invoker;
+
+ public LogonTaskRegistration(ITracer tracer, IScheduledTaskInvoker invoker)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+ ArgumentNullException.ThrowIfNull(invoker);
+ this.tracer = tracer;
+ this.invoker = invoker;
+ }
+
+ ///
+ /// Convenience factory for production callers: wires up a real
+ /// .
+ ///
+ public static LogonTaskRegistration CreateForCurrentPlatform(ITracer tracer)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+ return new LogonTaskRegistration(tracer, new SchTasksScheduledTaskInvoker(tracer));
+ }
+
+ ///
+ /// Stable hex hash of (with placeholders
+ /// intact). 64 hex chars (full SHA-256), computed once per process.
+ ///
+ public static string TemplateHash => templateHash.Value;
+
+ ///
+ /// Substitute placeholders to produce a registerable task XML.
+ ///
+ public static string BuildTaskXml(string gvfsExePath)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(gvfsExePath);
+
+ return XmlTemplate
+ .Replace(GvfsPathPlaceholder, gvfsExePath)
+ .Replace(TaskHashPlaceholder, TemplateHash);
+ }
+
+ ///
+ /// Extract the [gvfs-logon-task-hash=XXXX] hash marker from
+ /// arbitrary text (usually a Task's Description). Returns
+ /// false when no marker is present.
+ ///
+ public static bool TryExtractHashMarker(string text, out string hash)
+ {
+ hash = null;
+ if (string.IsNullOrEmpty(text))
+ {
+ return false;
+ }
+
+ int start = text.IndexOf(HashMarkerPrefix, StringComparison.Ordinal);
+ if (start < 0)
+ {
+ return false;
+ }
+
+ int hashStart = start + HashMarkerPrefix.Length;
+ int hashEnd = text.IndexOf(HashMarkerSuffix, hashStart, StringComparison.Ordinal);
+ if (hashEnd <= hashStart)
+ {
+ return false;
+ }
+
+ hash = text.Substring(hashStart, hashEnd - hashStart);
+ return true;
+ }
+
+ ///
+ /// Returns true when the logon task is registered AND its
+ /// embedded hash marker matches the current template's hash.
+ /// Returns false if the task is missing, the query fails,
+ /// or the hash differs (drift).
+ ///
+ public bool IsCurrent()
+ {
+ if (!this.invoker.TryQueryXml(FullTaskPath, out string xml, out _))
+ {
+ return false;
+ }
+
+ if (!TryExtractHashMarker(xml, out string registeredHash))
+ {
+ return false;
+ }
+
+ return string.Equals(registeredHash, TemplateHash, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Register the logon task with the given gvfs.exe path,
+ /// overwriting any existing registration. Idempotent: when
+ /// the registered task already matches the intended XML (same
+ /// hash, same args), this is a fast no-op.
+ ///
+ public bool TryRegisterOrUpdate(string gvfsExePath, out string errorMessage)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(gvfsExePath);
+
+ if (this.IsCurrent())
+ {
+ // Still verify args are right; the hash covers the template
+ // structure but not the substituted gvfs.exe path. Re-query
+ // and check the action command.
+ if (this.invoker.TryQueryXml(FullTaskPath, out string existingXml, out _) &&
+ existingXml.Contains(gvfsExePath, StringComparison.Ordinal))
+ {
+ errorMessage = string.Empty;
+ return true;
+ }
+ }
+
+ string xml = BuildTaskXml(gvfsExePath);
+ return this.invoker.TryRegisterFromXml(FullTaskPath, xml, out errorMessage);
+ }
+
+ ///
+ /// Unregister the logon task. Idempotent: returns true when
+ /// the task was unregistered OR was not registered to begin with.
+ ///
+ public bool TryUnregister(out string errorMessage)
+ {
+ return this.invoker.TryUnregister(FullTaskPath, out errorMessage);
+ }
+
+ private static string ComputeTemplateHash()
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(XmlTemplate);
+ byte[] hash = SHA256.HashData(bytes);
+ return Convert.ToHexString(hash);
+ }
+ }
+}
diff --git a/GVFS/GVFS.Common/SchTasksScheduledTaskInvoker.cs b/GVFS/GVFS.Common/SchTasksScheduledTaskInvoker.cs
new file mode 100644
index 000000000..04dbe0b50
--- /dev/null
+++ b/GVFS/GVFS.Common/SchTasksScheduledTaskInvoker.cs
@@ -0,0 +1,124 @@
+using GVFS.Common.Tracing;
+using System;
+using System.IO;
+
+namespace GVFS.Common
+{
+ ///
+ /// Default implementation: shells out
+ /// to schtasks.exe. Windows-only by nature -- on non-Windows the
+ /// process launch fails and operations return false with a populated
+ /// error message. User-mode install is Windows-only, so that's fine.
+ ///
+ public class SchTasksScheduledTaskInvoker : IScheduledTaskInvoker
+ {
+ private readonly ITracer tracer;
+
+ public SchTasksScheduledTaskInvoker(ITracer tracer)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+ this.tracer = tracer;
+ }
+
+ public bool TryRegisterFromXml(string taskPath, string xml, out string errorMessage)
+ {
+ // schtasks /Create accepts an XML file path via /XML, not raw
+ // XML on stdin. Write to a temp file with the same UTF-16 BOM
+ // the Task Scheduler XML schema expects, then run schtasks.
+ string tempPath = Path.Combine(Path.GetTempPath(), $"gvfs-task-{Guid.NewGuid():N}.xml");
+ try
+ {
+ File.WriteAllText(tempPath, xml, new System.Text.UnicodeEncoding(bigEndian: false, byteOrderMark: true));
+
+ // /F overwrites if already exists.
+ ProcessResult result = ProcessHelper.Run(
+ "schtasks.exe",
+ $"/Create /TN \"{taskPath}\" /XML \"{tempPath}\" /F");
+
+ if (result.ExitCode != 0)
+ {
+ errorMessage = $"schtasks /Create failed (exit {result.ExitCode}): {result.Output.Trim()} {result.Errors.Trim()}".Trim();
+ return false;
+ }
+
+ errorMessage = string.Empty;
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = $"Failed to register scheduled task: {e}";
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ finally
+ {
+ try { File.Delete(tempPath); } catch { /* best-effort cleanup */ }
+ }
+ }
+
+ public bool TryQueryXml(string taskPath, out string xml, out string errorMessage)
+ {
+ xml = null;
+ try
+ {
+ ProcessResult result = ProcessHelper.Run(
+ "schtasks.exe",
+ $"/Query /TN \"{taskPath}\" /XML");
+
+ if (result.ExitCode != 0)
+ {
+ // Exit 1 = task not found. Surface a useful message; the
+ // caller distinguishes "not found" from "permission denied"
+ // by inspecting the message text or just treating both as
+ // "not current".
+ errorMessage = $"schtasks /Query failed (exit {result.ExitCode}): {result.Output.Trim()} {result.Errors.Trim()}".Trim();
+ return false;
+ }
+
+ xml = result.Output;
+ errorMessage = string.Empty;
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = $"Failed to query scheduled task: {e}";
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+
+ public bool TryUnregister(string taskPath, out string errorMessage)
+ {
+ try
+ {
+ ProcessResult result = ProcessHelper.Run(
+ "schtasks.exe",
+ $"/Delete /TN \"{taskPath}\" /F");
+
+ if (result.ExitCode != 0)
+ {
+ // Exit 1 with "cannot find the file specified" means the
+ // task is already gone; treat as success.
+ string combined = (result.Output + " " + result.Errors).ToLowerInvariant();
+ if (combined.Contains("cannot find the file") || combined.Contains("system cannot find"))
+ {
+ errorMessage = string.Empty;
+ return true;
+ }
+
+ errorMessage = $"schtasks /Delete failed (exit {result.ExitCode}): {result.Output.Trim()} {result.Errors.Trim()}".Trim();
+ return false;
+ }
+
+ errorMessage = string.Empty;
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = $"Failed to unregister scheduled task: {e}";
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+ }
+}
diff --git a/GVFS/GVFS.FunctionalTests/Categories.cs b/GVFS/GVFS.FunctionalTests/Categories.cs
index 2aea957ed..7a55e9b68 100644
--- a/GVFS/GVFS.FunctionalTests/Categories.cs
+++ b/GVFS/GVFS.FunctionalTests/Categories.cs
@@ -2,8 +2,9 @@
{
public static class Categories
{
+ public const string ExtraCoverage = "ExtraCoverage";
public const string FastFetch = "FastFetch";
public const string GitCommands = "GitCommands";
- public const string SkipInCI = "SkipInCI";
+ public const string NeedsReactionInCI = "NeedsReactionInCI";
}
}
diff --git a/GVFS/GVFS.FunctionalTests/GlobalSetup.cs b/GVFS/GVFS.FunctionalTests/GlobalSetup.cs
index 40364b514..5fb25720a 100644
--- a/GVFS/GVFS.FunctionalTests/GlobalSetup.cs
+++ b/GVFS/GVFS.FunctionalTests/GlobalSetup.cs
@@ -18,23 +18,6 @@ public void RunBeforeAnyTests()
[OneTimeTearDown]
public void RunAfterAllTests()
{
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- string serviceLogFolder = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
- "GVFS",
- GVFSServiceProcess.TestServiceName,
- "Logs");
-
- Console.WriteLine("GVFS.Service logs at '{0}' attached below.\n\n", serviceLogFolder);
- foreach (string filename in TestResultsHelper.GetAllFilesInDirectory(serviceLogFolder))
- {
- TestResultsHelper.OutputFileContents(filename);
- }
-
- GVFSServiceProcess.UninstallService();
- }
-
PrintTestCaseStats.PrintRunTimeStats();
}
}
diff --git a/GVFS/GVFS.FunctionalTests/Program.cs b/GVFS/GVFS.FunctionalTests/Program.cs
index d74c70eb8..1d4340146 100644
--- a/GVFS/GVFS.FunctionalTests/Program.cs
+++ b/GVFS/GVFS.FunctionalTests/Program.cs
@@ -84,11 +84,21 @@ public static void Main(string[] args)
new object[] { validateMode },
};
+ if (runner.HasCustomArg("--extra-only"))
+ {
+ Console.WriteLine("Running only the tests marked as ExtraCoverage");
+ includeCategories.Add(Categories.ExtraCoverage);
+ }
+ else
+ {
+ excludeCategories.Add(Categories.ExtraCoverage);
+ }
+
// If we're running in CI exclude tests that are currently
// flakey or broken when run in a CI environment.
if (runner.HasCustomArg("--ci"))
{
- excludeCategories.Add(Categories.SkipInCI);
+ excludeCategories.Add(Categories.NeedsReactionInCI);
}
GVFSTestConfig.FileSystemRunners = FileSystemRunners.FileSystemRunner.DefaultRunners;
@@ -141,10 +151,7 @@ private static void RunBeforeAnyTests()
ProjFSFilterInstaller.ReplaceInboxProjFS();
}
- Console.WriteLine("[CI-DEBUG] Installing service...");
- Console.Out.Flush();
- GVFSServiceProcess.InstallService();
- Console.WriteLine("[CI-DEBUG] Service installed successfully");
+ Console.WriteLine("[CI-DEBUG] Skipping service install (service removed)");
Console.Out.Flush();
string serviceProgramDataDir = GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent(
diff --git a/GVFS/GVFS.FunctionalTests/Settings.cs b/GVFS/GVFS.FunctionalTests/Settings.cs
index 4bd933790..389a847fb 100644
--- a/GVFS/GVFS.FunctionalTests/Settings.cs
+++ b/GVFS/GVFS.FunctionalTests/Settings.cs
@@ -65,8 +65,20 @@ public static void Initialize()
}
else
{
- PathToGVFS = @"C:\Program Files\VFS for Git\GVFS.exe";
- PathToGVFSService = @"C:\Program Files\VFS for Git\GVFS.Service.exe";
+ // User-level install path (LocalAppData) for user-mode testing.
+ string userLevelGvfs = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "VFSForGit", "Current", "gvfs.exe");
+ if (File.Exists(userLevelGvfs))
+ {
+ PathToGVFS = userLevelGvfs;
+ PathToGVFSService = Path.Combine(Path.GetDirectoryName(userLevelGvfs), "GVFS.Service.exe");
+ }
+ else
+ {
+ PathToGVFS = @"C:\Program Files\VFS for Git\GVFS.exe";
+ PathToGVFSService = @"C:\Program Files\VFS for Git\GVFS.Service.exe";
+ }
}
PathToGit = @"C:\Program Files\Git\cmd\git.exe";
diff --git a/GVFS/GVFS.Installers/GVFS.Installers.csproj b/GVFS/GVFS.Installers/GVFS.Installers.csproj
index e48c1229e..958c3aa51 100644
--- a/GVFS/GVFS.Installers/GVFS.Installers.csproj
+++ b/GVFS/GVFS.Installers/GVFS.Installers.csproj
@@ -3,6 +3,8 @@
false
$(RepoOutPath)GVFS.Payload\bin\$(Configuration)\win-x64\
+ $(MSBuildThisFileDirectory)..\..\scripts\projfs-attach\
+ $(IntermediateOutputPath)enable-projfs-on-all-drives-task.xml
@@ -24,8 +26,12 @@
+
+
+
+
-
+
diff --git a/GVFS/GVFS.Installers/Setup.iss b/GVFS/GVFS.Installers/Setup.iss
index bda36b806..2ed96c6e3 100644
--- a/GVFS/GVFS.Installers/Setup.iss
+++ b/GVFS/GVFS.Installers/Setup.iss
@@ -1,4 +1,4 @@
-; This script requires Inno Setup Compiler 5.5.9 or later to compile
+; This script requires Inno Setup 6 or later to compile
; The Inno Setup Compiler (and IDE) can be found at http://www.jrsoftware.org/isinfo.php
; General documentation on how to use InnoSetup scripts: http://www.jrsoftware.org/ishelp/index.php
@@ -10,11 +10,13 @@
#define MyAppURL "https://github.com/microsoft/VFSForGit"
#define MyAppExeName "GVFS.exe"
#define EnvironmentKey "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
+#define UserEnvironmentKey "Environment"
#define FileSystemKey "SYSTEM\CurrentControlSet\Control\FileSystem"
#define GvFltAutologgerKey "SYSTEM\CurrentControlSet\Control\WMI\Autologger\Microsoft-Windows-Git-Filter-Log"
#define GVFSConfigFileName "gvfs.config"
#define GVFSStatuscacheTokenFileName "EnableGitStatusCacheToken.dat"
#define ServiceName "GVFS.Service"
+#define ProjFSTaskPath "\GVFS\EnableProjFSOnAllDrives"
[Setup]
AppId={{489CA581-F131-4C28-BE04-4FB178933E6D}
@@ -45,6 +47,10 @@ WindowResizable=no
CloseApplications=no
ChangesEnvironment=yes
RestartIfNeededByRun=yes
+; Allow the installer to run as non-admin when the user passes
+; /CURRENTUSER on the command line. Without /CURRENTUSER, the
+; installer runs as admin (the default, matching existing behavior).
+PrivilegesRequiredOverridesAllowed=commandline
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl";
@@ -59,17 +65,20 @@ Name: "full"; Description: "Full installation"; Flags: iscustom;
Type: files; Name: "{app}\ucrtbase.dll"
[Files]
-; Normal install: all files go to {app}, service gets AfterInstall callback
-DestDir: "{app}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsNormalInstall
-DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: InstallGVFSService; Check: IsNormalInstall
-; Staging install: most files go to {app}\PendingUpgrade, but GVFS.Service.exe
-; goes directly to {app} so the restarted service has PendingUpgradeHandler code.
-; The service is briefly stopped/restarted (mounts are independent processes).
-DestDir: "{app}\PendingUpgrade"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsStagingInstall
-DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; Check: IsStagingInstall
+; System-mode install: versioned deployment
+DestDir: "{app}\Versions\{#MyAppInstallerVersion}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsSystemModeNormalInstall
+
+; User-mode install: versioned deployment to %LocalAppData%\GVFS\Versions\
+DestDir: "{localappdata}\GVFS\Versions\{#MyAppInstallerVersion}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsUserModeNormalInstall
+
+; Pre-built EnableProjFSOnAllDrives task XML (embedded, not deployed).
+; Extracted to temp at install time for schtasks /Create /XML.
+#ifdef ProjFSTaskXml
+Source: "{#ProjFSTaskXml}"; Flags: dontcopy
+#endif
[Dirs]
-Name: "{app}\ProgramData\{#ServiceName}"; Permissions: users-readexec
+Name: "{app}\Versions\{#MyAppInstallerVersion}\ProgramData\{#ServiceName}"; Permissions: users-readexec; Check: IsSystemModeNormalInstall
[UninstallDelete]
; Deletes the entire installation directory, including files and subdirectories
@@ -77,47 +86,110 @@ Type: filesandordirs; Name: "{app}";
Type: filesandordirs; Name: "{commonappdata}\GVFS\GVFS.Upgrade";
[Registry]
+; System-mode: add {app}\Current to system PATH
Root: HKLM; Subkey: "{#EnvironmentKey}"; \
- ValueType: expandsz; ValueName: "PATH"; ValueData: "{olddata};{app}"; \
- Check: NeedsAddPath(ExpandConstant('{app}'))
+ ValueType: expandsz; ValueName: "PATH"; ValueData: "{olddata};{app}\Current"; \
+ Check: SystemModeNeedsAddPath
Root: HKLM; Subkey: "{#FileSystemKey}"; \
ValueType: dword; ValueName: "NtfsEnableDetailedCleanupResults"; ValueData: "1"; \
- Check: IsWindows10VersionPriorToCreatorsUpdate
+ Check: IsSystemModeWindows10PriorToCreatorsUpdate
+
+Root: HKLM; SubKey: "{#GvFltAutologgerKey}"; Flags: deletekey; Check: IsSystemModeInstall
+
+; User-mode: add Current junction dir to user PATH
+Root: HKCU; Subkey: "{#UserEnvironmentKey}"; \
+ ValueType: expandsz; ValueName: "PATH"; ValueData: "{olddata};{localappdata}\GVFS\Current"; \
+ Check: UserModeNeedsAddPath
-Root: HKLM; SubKey: "{#GvFltAutologgerKey}"; Flags: deletekey
+; User-mode: redirect GVFS data paths to %LocalAppData%\GVFS so
+; the user has write access without admin elevation.
+Root: HKCU; Subkey: "{#UserEnvironmentKey}"; \
+ ValueType: string; ValueName: "GVFS_SECURE_DATA_ROOT"; ValueData: "{localappdata}\GVFS"; \
+ Check: IsUserModeNormalInstall
+
+Root: HKCU; Subkey: "{#UserEnvironmentKey}"; \
+ ValueType: string; ValueName: "GVFS_COMMON_APPDATA_ROOT"; ValueData: "{localappdata}\GVFS"; \
+ Check: IsUserModeNormalInstall
[Code]
var
ExitCode: Integer;
- KeepMountsRunning: Boolean;
+ IsUserModeInstall: Boolean;
+ IsAdminStage: Boolean;
-function IsNormalInstall(): Boolean;
+function InitializeSetup(): Boolean;
begin
- Result := not KeepMountsRunning;
+ Result := True;
+ IsUserModeInstall := not IsAdmin();
+ IsAdminStage := (ExpandConstant('{param:ADMINSTAGE|false}') = 'true');
+
+ if IsAdminStage then
+ begin
+ Log('[GVFS-INSTALL] InitializeSetup: /ADMINSTAGE mode - will run admin setup only');
+ end
+ else if IsUserModeInstall then
+ begin
+ Log('[GVFS-INSTALL] InitializeSetup: User-mode install detected (non-elevated)');
+ end
+ else
+ begin
+ Log('[GVFS-INSTALL] InitializeSetup: System-mode install (elevated)');
+ end;
+end;
+
+function IsUserModeNormalInstall(): Boolean;
+begin
+ Result := IsUserModeInstall and not IsAdminStage;
+end;
+
+function IsSystemModeInstall(): Boolean;
+begin
+ Result := not IsUserModeInstall;
end;
-function IsStagingInstall(): Boolean;
+function IsSystemModeNormalInstall(): Boolean;
begin
- Result := KeepMountsRunning;
+ Result := IsSystemModeInstall() and not IsAdminStage;
end;
function NeedsAddPath(Param: string): boolean;
var
OrigPath: string;
+ RootKey: Integer;
+ SubKeyName: string;
begin
- if not RegQueryStringValue(HKEY_LOCAL_MACHINE,
- '{#EnvironmentKey}',
- 'PATH', OrigPath)
- then begin
- Result := True;
- exit;
- end;
+ if IsUserModeInstall then
+ begin
+ RootKey := HKCU;
+ SubKeyName := '{#UserEnvironmentKey}';
+ end
+ else
+ begin
+ RootKey := HKLM;
+ SubKeyName := '{#EnvironmentKey}';
+ end;
+
+ if not RegQueryStringValue(RootKey, SubKeyName, 'PATH', OrigPath) then
+ begin
+ Result := True;
+ exit;
+ end;
// look for the path with leading and trailing semicolon
// Pos() returns 0 if not found
Result := Pos(';' + Param + ';', ';' + OrigPath + ';') = 0;
end;
+function SystemModeNeedsAddPath(): Boolean;
+begin
+ Result := IsSystemModeNormalInstall() and NeedsAddPath(ExpandConstant('{app}\Current'));
+end;
+
+function UserModeNeedsAddPath(): Boolean;
+begin
+ Result := IsUserModeNormalInstall() and NeedsAddPath(ExpandConstant('{localappdata}\GVFS\Current'));
+end;
+
function IsWindows10VersionPriorToCreatorsUpdate(): Boolean;
var
Version: TWindowsVersion;
@@ -126,36 +198,54 @@ begin
Result := (Version.Major = 10) and (Version.Minor = 0) and (Version.Build < 15063);
end;
+function IsSystemModeWindows10PriorToCreatorsUpdate(): Boolean;
+begin
+ Result := IsSystemModeNormalInstall() and IsWindows10VersionPriorToCreatorsUpdate();
+end;
+
procedure RemovePath(Path: string);
var
Paths: string;
PathMatchIndex: Integer;
+ RootKey: Integer;
+ SubKeyName: string;
begin
- if not RegQueryStringValue(HKEY_LOCAL_MACHINE, '{#EnvironmentKey}', 'Path', Paths) then
+ if IsUserModeInstall then
+ begin
+ RootKey := HKCU;
+ SubKeyName := '{#UserEnvironmentKey}';
+ end
+ else
+ begin
+ RootKey := HKLM;
+ SubKeyName := '{#EnvironmentKey}';
+ end;
+
+ if not RegQueryStringValue(RootKey, SubKeyName, 'Path', Paths) then
begin
- Log('PATH not found');
+ Log('[GVFS-INSTALL] RemovePath: PATH not found');
end
else
begin
- Log(Format('PATH is [%s]', [Paths]));
+ Log(Format('[GVFS-INSTALL] RemovePath: PATH is [%s]', [Paths]));
PathMatchIndex := Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';');
if PathMatchIndex = 0 then
begin
- Log(Format('Path [%s] not found in PATH', [Path]));
+ Log(Format('[GVFS-INSTALL] RemovePath: Path [%s] not found in PATH', [Path]));
end
else
begin
Delete(Paths, PathMatchIndex - 1, Length(Path) + 1);
- Log(Format('Path [%s] removed from PATH => [%s]', [Path, Paths]));
+ Log(Format('[GVFS-INSTALL] RemovePath: Path [%s] removed from PATH => [%s]', [Path, Paths]));
- if RegWriteStringValue(HKEY_LOCAL_MACHINE, '{#EnvironmentKey}', 'Path', Paths) then
+ if RegWriteStringValue(RootKey, SubKeyName, 'Path', Paths) then
begin
- Log('PATH written');
+ Log('[GVFS-INSTALL] RemovePath: PATH written');
end
else
begin
- Log('Error writing PATH');
+ Log('[GVFS-INSTALL] RemovePath: Error writing PATH');
end;
end;
end;
@@ -165,16 +255,16 @@ procedure StopService(ServiceName: string);
var
ResultCode: integer;
begin
- Log('StopService: stopping: ' + ServiceName);
+ Log('[GVFS-INSTALL] StopService: stopping: ' + ServiceName);
if not Exec(ExpandConstant('{sys}\SC.EXE'), 'stop ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
begin
- Log('StopService: Failed to launch sc.exe');
+ Log('[GVFS-INSTALL] StopService: Failed to launch sc.exe');
RaiseException('Fatal: Could not stop service: ' + ServiceName);
end;
// 1060 = service not installed, 1062 = service not started
if (ResultCode <> 0) and (ResultCode <> 1060) and (ResultCode <> 1062) then
begin
- Log('StopService: sc stop returned error code ' + IntToStr(ResultCode));
+ Log('[GVFS-INSTALL] StopService: sc stop returned error code ' + IntToStr(ResultCode));
RaiseException('Fatal: Could not stop service: ' + ServiceName + ' (exit code ' + IntToStr(ResultCode) + ')');
end;
end;
@@ -197,33 +287,33 @@ begin
// 1060 = service does not exist (fully deleted and process exited)
if ResultCode = 1060 then
begin
- Log('WaitForServiceProcessToExit: Service no longer exists');
+ Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Service no longer exists');
break;
end;
if LoadStringFromFile(TempFile, QueryOutput) then
begin
if Pos('STOPPED', QueryOutput) > 0 then
begin
- Log('WaitForServiceProcessToExit: Service is stopped');
+ Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Service is stopped');
break;
end;
end;
end
else
begin
- Log('WaitForServiceProcessToExit: sc query failed, assuming service is gone');
+ Log('[GVFS-INSTALL] WaitForServiceProcessToExit: sc query failed, assuming service is gone');
break;
end;
Attempts := Attempts + 1;
- Log('WaitForServiceProcessToExit: Waiting for service to stop (attempt ' + IntToStr(Attempts) + ')');
+ Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Waiting for service to stop (attempt ' + IntToStr(Attempts) + ')');
Sleep(1000);
end;
if Attempts >= 30 then
begin
if LoadStringFromFile(TempFile, QueryOutput) then
- Log('WaitForServiceProcessToExit: Timed out. Last sc query output: ' + QueryOutput)
+ Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Timed out. Last sc query output: ' + QueryOutput)
else
- Log('WaitForServiceProcessToExit: Timed out waiting for service to stop');
+ Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Timed out waiting for service to stop');
end;
DeleteFile(TempFile);
end;
@@ -234,7 +324,7 @@ var
begin
if Exec(ExpandConstant('{sys}\SC.EXE'), 'query ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode <> 1060) then
begin
- Log('UninstallService: uninstalling service: ' + ServiceName);
+ Log('[GVFS-INSTALL] UninstallService: uninstalling service: ' + ServiceName);
if (ShowProgress) then
begin
WizardForm.StatusLabel.Caption := 'Uninstalling service: ' + ServiceName;
@@ -246,7 +336,7 @@ begin
if not Exec(ExpandConstant('{sys}\SC.EXE'), 'delete ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
begin
- Log('UninstallService: Could not uninstall service: ' + ServiceName);
+ Log('[GVFS-INSTALL] UninstallService: Could not uninstall service: ' + ServiceName);
RaiseException('Fatal: Could not uninstall service: ' + ServiceName);
end;
@@ -270,93 +360,147 @@ end;
procedure WriteOnDiskVersion16CapableFile();
var
FilePath: string;
+ AppBase: string;
begin
- FilePath := ExpandConstant('{app}\OnDiskVersion16CapableInstallation.dat');
+ if IsUserModeInstall then
+ AppBase := ExpandConstant('{localappdata}\GVFS')
+ else
+ AppBase := ExpandConstant('{app}');
+ FilePath := AppBase + '\Versions\{#MyAppInstallerVersion}\OnDiskVersion16CapableInstallation.dat';
if not FileExists(FilePath) then
begin
- Log('WriteOnDiskVersion16CapableFile: Writing file ' + FilePath);
+ Log('[GVFS-INSTALL] WriteOnDiskVersion16CapableFile: Writing file ' + FilePath);
SaveStringToFile(FilePath, '', False);
end
end;
-procedure InstallGVFSService();
+function ExecWithResult(Filename, Params, WorkingDir: String; ShowCmd: Integer;
+ Wait: TExecWait; var ResultCode: Integer; var ResultString: ansiString): Boolean;
var
- ResultCode: integer;
- StatusText: string;
- InstallSuccessful: Boolean;
+ TempFilename: string;
+ Command: string;
begin
- InstallSuccessful := False;
-
- StatusText := WizardForm.StatusLabel.Caption;
- WizardForm.StatusLabel.Caption := 'Installing GVFS.Service.';
- WizardForm.ProgressGauge.Style := npbstMarquee;
-
- // Spaces after the equal signs are REQUIRED.
- // https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/sc-create#remarks
- try
- // We must add additional quotes to the binPath to ensure that they survive argument parsing.
- // Without quotes, sc.exe will try to start a file located at C:\Program if it exists.
- if Exec(ExpandConstant('{sys}\SC.EXE'), ExpandConstant('create GVFS.Service binPath= "\"{app}\GVFS.Service.exe\"" start= auto'), '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then
- begin
- if Exec(ExpandConstant('{sys}\SC.EXE'), 'failure GVFS.Service reset= 30 actions= restart/10/restart/5000//1', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
- begin
- if Exec(ExpandConstant('{sys}\SC.EXE'), 'start GVFS.Service', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
- begin
- InstallSuccessful := True;
- end;
- end;
- end;
-
- WriteOnDiskVersion16CapableFile();
- finally
- WizardForm.StatusLabel.Caption := StatusText;
- WizardForm.ProgressGauge.Style := npbstNormal;
- end;
-
- if InstallSuccessful = False then
+ TempFilename := ExpandConstant('{tmp}\~execwithresult.txt');
+ { Exec via cmd and redirect output to file. Must use special string-behavior to work. }
+ Command := Format('"%s" /S /C ""%s" %s > "%s""', [ExpandConstant('{cmd}'), Filename, Params, TempFilename]);
+ Result := Exec(ExpandConstant('{cmd}'), Command, WorkingDir, ShowCmd, Wait, ResultCode);
+ if Result then
begin
- RaiseException('Fatal: An error occured while installing GVFS.Service.');
+ LoadStringFromFile(TempFilename, ResultString);
end;
+ DeleteFile(TempFilename);
end;
-procedure StagingUpdateService();
+procedure RegisterAutoMountLogonTask();
var
ResultCode: integer;
StatusText: string;
+ GvfsExe: string;
+ TempXmlFile: string;
+ TaskXml: string;
begin
- // In staging mode: the service was stopped in PrepareToInstall so its exe
- // could be replaced. Now start it with the new binary. The new service has
- // PendingUpgradeHandler which will complete the upgrade on next restart
- // when no mounts are running.
StatusText := WizardForm.StatusLabel.Caption;
- WizardForm.StatusLabel.Caption := 'Starting GVFS.Service.';
+ WizardForm.StatusLabel.Caption := 'Registering AutoMount logon task...';
WizardForm.ProgressGauge.Style := npbstMarquee;
try
- Log('StagingUpdateService: Starting service with new binary');
- if Exec(ExpandConstant('{sys}\SC.EXE'), 'start GVFS.Service', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
- begin
- if ResultCode <> 0 then
- Log('StagingUpdateService: Warning - sc start returned error code ' + IntToStr(ResultCode));
- end
+ GvfsExe := 'gvfs.exe';
+ TempXmlFile := ExpandConstant('{tmp}\~taskxml.xml');
+
+ // Machine-wide logon task using Interactive Users group (S-1-5-4).
+ // Fires for every interactive user at logon. Each user's gvfs service
+ // --mount-all reads their own LocalRepoRegistry.
+ TaskXml := '' + #13#10 +
+ '' + #13#10 +
+ ' ' + #13#10 +
+ ' GVFS' + #13#10 +
+ ' Mounts registered GVFS enlistments at logon for each interactive user. Required by VFS for Git.' + #13#10 +
+ ' \GVFS\AutoMount' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ ' true' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ ' S-1-5-4' + #13#10 +
+ ' LeastPrivilege' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ ' IgnoreNew' + #13#10 +
+ ' false' + #13#10 +
+ ' false' + #13#10 +
+ ' true' + #13#10 +
+ ' true' + #13#10 +
+ ' false' + #13#10 +
+ ' ' + #13#10 +
+ ' false' + #13#10 +
+ ' false' + #13#10 +
+ ' ' + #13#10 +
+ ' true' + #13#10 +
+ ' true' + #13#10 +
+ ' false' + #13#10 +
+ ' false' + #13#10 +
+ ' false' + #13#10 +
+ ' PT5M' + #13#10 +
+ ' 5' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ ' conhost.exe' + #13#10 +
+ ' --headless "' + GvfsExe + '" service --mount-all' + #13#10 +
+ ' ' + #13#10 +
+ ' ' + #13#10 +
+ '';
+
+ SaveStringToFile(TempXmlFile, TaskXml, False);
+ Log('[GVFS-INSTALL] RegisterAutoMountLogonTask: Wrote task XML to ' + TempXmlFile);
+
+ // Create task folder if needed, then register task
+ Exec(ExpandConstant('{sys}\schtasks.exe'), '/Create /TN "\GVFS\AutoMount" /XML "' + TempXmlFile + '" /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
+ if ResultCode = 0 then
+ Log('[GVFS-INSTALL] RegisterAutoMountLogonTask: Logon task registered successfully')
else
- begin
- Log('StagingUpdateService: Warning - could not launch sc.exe');
- end;
+ Log('[GVFS-INSTALL] RegisterAutoMountLogonTask: schtasks /Create returned ' + IntToStr(ResultCode));
- WriteOnDiskVersion16CapableFile();
+ DeleteFile(TempXmlFile);
finally
WizardForm.StatusLabel.Caption := StatusText;
WizardForm.ProgressGauge.Style := npbstNormal;
end;
end;
+function UninstallAutomountTask(): Boolean;
+var
+ ResultCode: integer;
+begin
+ Result := False;
+ Log('[GVFS-INSTALL] UninstallAutomountTask: Checking for task');
+ if Exec(ExpandConstant('{sys}\schtasks.exe'), '/Query /TN "\GVFS\AutoMount"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then
+ begin
+ Log('[GVFS-INSTALL] UninstallAutomountTask: Deleting task');
+ if Exec(ExpandConstant('{sys}\schtasks.exe'), '/Delete /TN "\GVFS\AutoMount" /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then
+ begin
+ Log('[GVFS-INSTALL] UninstallAutomountTask: Deleted successfully');
+ Result := True;
+ end
+ else
+ Log('[GVFS-INSTALL] UninstallAutomountTask: Delete failed with exit code ' + IntToStr(ResultCode));
+ end
+ else
+ begin
+ Log('[GVFS-INSTALL] UninstallAutomountTask: Task not found or query failed');
+ Result := True; // Not an error if task doesn't exist
+ end;
+end;
function DeleteFileIfItExists(FilePath: string) : Boolean;
begin
Result := False;
if FileExists(FilePath) then
begin
- Log('DeleteFileIfItExists: Removing ' + FilePath);
+ Log('[GVFS-INSTALL] DeleteFileIfItExists: Removing ' + FilePath);
if DeleteFile(FilePath) then
begin
if not FileExists(FilePath) then
@@ -365,17 +509,17 @@ begin
end
else
begin
- Log('DeleteFileIfItExists: File still exists after deleting: ' + FilePath);
+ Log('[GVFS-INSTALL] DeleteFileIfItExists: File still exists after deleting: ' + FilePath);
end;
end
else
begin
- Log('DeleteFileIfItExists: Failed to delete ' + FilePath);
+ Log('[GVFS-INSTALL] DeleteFileIfItExists: Failed to delete ' + FilePath);
end;
end
else
begin
- Log('DeleteFileIfItExists: File does not exist: ' + FilePath);
+ Log('[GVFS-INSTALL] DeleteFileIfItExists: File does not exist: ' + FilePath);
Result := True;
end;
end;
@@ -384,8 +528,14 @@ procedure UninstallGvFlt();
var
StatusText: string;
UninstallSuccessful: Boolean;
+ AppBase: string;
begin
- if (FileExists(ExpandConstant('{app}\Filter\GvFlt.inf'))) then
+ if IsUserModeInstall then
+ AppBase := ExpandConstant('{localappdata}\GVFS')
+ else
+ AppBase := ExpandConstant('{app}');
+
+ if (FileExists(AppBase + '\Filter\GvFlt.inf')) then
begin
UninstallSuccessful := False;
@@ -406,9 +556,9 @@ begin
if UninstallSuccessful = True then
begin
- if not DeleteFile(ExpandConstant('{app}\Filter\GvFlt.inf')) then
+ if not DeleteFile(AppBase + '\Filter\GvFlt.inf') then
begin
- Log('UninstallGvFlt: Failed to delete GvFlt.inf');
+ Log('[GVFS-INSTALL] UninstallGvFlt: Failed to delete GvFlt.inf');
end;
end
else
@@ -421,16 +571,22 @@ end;
function UninstallNonInboxProjFS(): Boolean;
var
StatusText: string;
+ AppBase: string;
begin
Result := False;
StatusText := WizardForm.StatusLabel.Caption;
WizardForm.StatusLabel.Caption := 'Uninstalling PrjFlt Driver.';
WizardForm.ProgressGauge.Style := npbstMarquee;
- Log('UninstallNonInboxProjFS: Uninstalling ProjFS');
+ if IsUserModeInstall then
+ AppBase := ExpandConstant('{localappdata}\GVFS')
+ else
+ AppBase := ExpandConstant('{app}');
+
+ Log('[GVFS-INSTALL] UninstallNonInboxProjFS: Uninstalling ProjFS');
try
UninstallService('prjflt', False);
- if DeleteFileIfItExists(ExpandConstant('{app}\ProjectedFSLib.dll')) then
+ if DeleteFileIfItExists(AppBase + '\ProjectedFSLib.dll') then
begin
if DeleteFileIfItExists(ExpandConstant('{sys}\drivers\prjflt.sys')) then
begin
@@ -447,8 +603,14 @@ procedure UninstallProjFSIfNecessary();
var
ProjFSFeatureEnabledResultCode: integer;
UninstallSuccessful: Boolean;
+ AppBase: string;
begin
- if FileExists(ExpandConstant('{app}\Filter\PrjFlt.inf')) and FileExists(ExpandConstant('{sys}\drivers\prjflt.sys')) then
+ if IsUserModeInstall then
+ AppBase := ExpandConstant('{localappdata}\GVFS')
+ else
+ AppBase := ExpandConstant('{app}');
+
+ if FileExists(AppBase + '\Filter\PrjFlt.inf') and FileExists(ExpandConstant('{sys}\drivers\prjflt.sys')) then
begin
UninstallSuccessful := False;
@@ -457,7 +619,7 @@ begin
if ProjFSFeatureEnabledResultCode = 2 then
begin
// Client-ProjFS is not an optional feature
- Log('UninstallProjFSIfNecessary: Could not locate Windows Projected File System optional feature, uninstalling ProjFS');
+ Log('[GVFS-INSTALL] UninstallProjFSIfNecessary: Could not locate Windows Projected File System optional feature, uninstalling ProjFS');
if UninstallNonInboxProjFS() then
begin
UninstallSuccessful := True;
@@ -467,8 +629,8 @@ begin
begin
// Client-ProjFS is already enabled. If the native ProjFS library is in the apps folder it must
// be deleted to ensure GVFS uses the inbox library (in System32)
- Log('UninstallProjFSIfNecessary: Client-ProjFS already enabled');
- if DeleteFileIfItExists(ExpandConstant('{app}\ProjectedFSLib.dll')) then
+ Log('[GVFS-INSTALL] UninstallProjFSIfNecessary: Client-ProjFS already enabled');
+ if DeleteFileIfItExists(AppBase + '\ProjectedFSLib.dll') then
begin
UninstallSuccessful := True;
end;
@@ -476,7 +638,7 @@ begin
if ProjFSFeatureEnabledResultCode = 4 then
begin
// Client-ProjFS is currently disabled but prjflt.sys is present and should be removed
- Log('UninstallProjFSIfNecessary: Client-ProjFS is disabled, uninstalling ProjFS');
+ Log('[GVFS-INSTALL] UninstallProjFSIfNecessary: Client-ProjFS is disabled, uninstalling ProjFS');
if UninstallNonInboxProjFS() then
begin
UninstallSuccessful := True;
@@ -508,84 +670,38 @@ begin
end;
end;
-function ExecWithResult(Filename, Params, WorkingDir: String; ShowCmd: Integer;
- Wait: TExecWait; var ResultCode: Integer; var ResultString: ansiString): Boolean;
-var
- TempFilename: string;
- Command: string;
-begin
- TempFilename := ExpandConstant('{tmp}\~execwithresult.txt');
- { Exec via cmd and redirect output to file. Must use special string-behavior to work. }
- Command := Format('"%s" /S /C ""%s" %s > "%s""', [ExpandConstant('{cmd}'), Filename, Params, TempFilename]);
- Result := Exec(ExpandConstant('{cmd}'), Command, WorkingDir, ShowCmd, Wait, ResultCode);
- if Result then
- begin
- LoadStringFromFile(TempFilename, ResultString);
- end;
- DeleteFile(TempFilename);
-end;
-
-procedure UnmountRepos();
-var
- ResultCode: integer;
-begin
- Exec('gvfs.exe', 'service --unmount-all', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
-end;
-
-procedure MountRepos();
-var
- StatusText: string;
- MountOutput: ansiString;
- ResultCode: integer;
- MsgBoxText: string;
-begin
- StatusText := WizardForm.StatusLabel.Caption;
- WizardForm.StatusLabel.Caption := 'Mounting Repos.';
- WizardForm.ProgressGauge.Style := npbstMarquee;
-
- ExecWithResult(ExpandConstant('{app}') + '\gvfs.exe', 'service --mount-all', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, MountOutput);
- WizardForm.StatusLabel.Caption := StatusText;
- WizardForm.ProgressGauge.Style := npbstNormal;
-
- // 4 = ReturnCode.FilterError
- if (ResultCode = 4) then
- begin
- RaiseException('Fatal: Could not configure and start Windows Projected File System.');
- end
- else if (ResultCode <> 0) then
- begin
- MsgBoxText := 'Mounting one or more repos failed:' + #13#10 + MountOutput;
- SuppressibleMsgBox(MsgBoxText, mbConfirmation, MB_OK, IDOK);
- ExitCode := 17;
- end;
-end;
-
procedure MigrateFile(OldPath, NewPath : string);
begin
- Log('MigrateFile(' + OldPath + ', ' + NewPath + ')');
+ Log('[GVFS-INSTALL] MigrateFile(' + OldPath + ', ' + NewPath + ')');
if (FileExists(OldPath)) then
begin
if (not FileExists(NewPath)) then
begin
if (not RenameFile(OldPath, NewPath)) then
- Log('Could not move ' + OldPath + ' continuing anyway')
+ Log('[GVFS-INSTALL] Could not move ' + OldPath + ' continuing anyway')
else
- Log('Moved ' + OldPath + ' to ' + NewPath);
+ Log('[GVFS-INSTALL] Moved ' + OldPath + ' to ' + NewPath);
end
else
- Log('Migration cancelled. Newer file exists at path ' + NewPath);
+ Log('[GVFS-INSTALL] Migration cancelled. Newer file exists at path ' + NewPath);
end
else
- Log('Migration cancelled. ' + OldPath + ' does not exist');
+ Log('[GVFS-INSTALL] Migration cancelled. ' + OldPath + ' does not exist');
end;
procedure MigrateConfigAndStatusCacheFiles();
var
CommonAppDataDir: string;
SecureAppDataDir: string;
+ AppBase: string;
begin
+ if IsUserModeInstall then
+ AppBase := ExpandConstant('{localappdata}\GVFS')
+ else
+ AppBase := ExpandConstant('{app}');
+
CommonAppDataDir := ExpandConstant('{commonappdata}\GVFS');
- SecureAppDataDir := ExpandConstant('{app}\ProgramData');
+ SecureAppDataDir := AppBase + '\Current\ProgramData';
MigrateFile(CommonAppDataDir + '\{#GVFSConfigFileName}', SecureAppDataDir + '\{#GVFSConfigFileName}');
MigrateFile(CommonAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}', SecureAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}');
@@ -624,7 +740,7 @@ begin
if ExecWithResult('gvfs.exe', 'config upgrade.ring', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, ResultString) then begin
if ResultCode = 0 then begin
ResultString := AnsiLowercase(Trim(ResultString));
- Log('GetConfiguredUpgradeRing: upgrade.ring is ' + ResultString);
+ Log('[GVFS-INSTALL] GetConfiguredUpgradeRing: upgrade.ring is ' + ResultString);
if CompareText(ResultString, 'none') = 0 then begin
Result := urNone;
end else if CompareText(ResultString, 'fast') = 0 then begin
@@ -632,13 +748,13 @@ begin
end else if CompareText(ResultString, 'slow') = 0 then begin
Result := urSlow;
end else begin
- Log('GetConfiguredUpgradeRing: Unknown upgrade ring: ' + ResultString);
+ Log('[GVFS-INSTALL] GetConfiguredUpgradeRing: Unknown upgrade ring: ' + ResultString);
end;
end else begin
- Log('GetConfiguredUpgradeRing: Call to gvfs config upgrade.ring failed with ' + SysErrorMessage(ResultCode));
+ Log('[GVFS-INSTALL] GetConfiguredUpgradeRing: Call to gvfs config upgrade.ring failed with ' + SysErrorMessage(ResultCode));
end;
end else begin
- Log('GetConfiguredUpgradeRing: Call to gvfs config upgrade.ring failed with ' + SysErrorMessage(ResultCode));
+ Log('[GVFS-INSTALL] GetConfiguredUpgradeRing: Call to gvfs config upgrade.ring failed with ' + SysErrorMessage(ResultCode));
end;
end;
@@ -650,7 +766,7 @@ begin
Result := False
if ExecWithResult('gvfs.exe', Format('config %s', [ConfigKey]), '', SW_HIDE, ewWaitUntilTerminated, ResultCode, ResultString) then begin
ResultString := AnsiLowercase(Trim(ResultString));
- Log(Format('IsConfigured(%s): value is %s', [ConfigKey, ResultString]));
+ Log(Format('[GVFS-INSTALL] IsConfigured(%s): value is %s', [ConfigKey, ResultString]));
Result := Length(ResultString) > 1
end
end;
@@ -662,12 +778,12 @@ var
begin
if IsConfigured(ConfigKey) = False then begin
if ExecWithResult('gvfs.exe', Format('config %s %s', [ConfigKey, ConfigValue]), '', SW_HIDE, ewWaitUntilTerminated, ResultCode, ResultString) then begin
- Log(Format('SetIfNotConfigured: Set %s to %s', [ConfigKey, ConfigValue]));
+ Log(Format('[GVFS-INSTALL] SetIfNotConfigured: Set %s to %s', [ConfigKey, ConfigValue]));
end else begin
- Log(Format('SetIfNotConfigured: Failed to set %s with %s', [ConfigKey, SysErrorMessage(ResultCode)]));
+ Log(Format('[GVFS-INSTALL] SetIfNotConfigured: Failed to set %s with %s', [ConfigKey, SysErrorMessage(ResultCode)]));
end;
end else begin
- Log(Format('SetIfNotConfigured: %s is configured, not overwriting', [ConfigKey]));
+ Log(Format('[GVFS-INSTALL] SetIfNotConfigured: %s is configured, not overwriting', [ConfigKey]));
end;
end;
@@ -684,7 +800,7 @@ begin
end else if (ConfiguredRing = urSlow) or (ConfiguredRing = urNone) then begin
RingName := 'Slow';
end else begin
- Log('SetNuGetFeedIfNecessary: No upgrade ring configured. Not configuring NuGet feed.')
+ Log('[GVFS-INSTALL] SetNuGetFeedIfNecessary: No upgrade ring configured. Not configuring NuGet feed.')
exit;
end;
@@ -695,305 +811,560 @@ begin
SetIfNotConfigured('upgrade.feedpackagename', FeedPackageName);
end;
-// Below are EVENT FUNCTIONS -> The main entry points of InnoSetup into the code region
-// Documentation : http://www.jrsoftware.org/ishelp/index.php?topic=scriptevents
-
-function InitializeUninstall(): Boolean;
-begin
- UnmountRepos();
- Result := EnsureGvfsNotRunning();
-end;
+// PHASE 3: Admin drift detection functions
-// Called just after "install" phase, before "post install"
-function NeedRestart(): Boolean;
+function IsProjFSEnabled(): Boolean;
+var
+ ResultCode: integer;
begin
Result := False;
+ // Check PrjFlt driver service registration in registry (works non-elevated).
+ // Start type <= 2 means the driver loads automatically (Boot/System/Auto).
+ if Exec('powershell.exe', '-NoProfile "$svc=Get-ItemProperty ''HKLM:\SYSTEM\CurrentControlSet\Services\PrjFlt'' -EA SilentlyContinue; if($svc -and $svc.Start -le 2){exit 0}else{exit 1}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
+ begin
+ Result := (ResultCode = 0);
+ end;
end;
-function UninstallNeedRestart(): Boolean;
+function IsEnableProjFSTaskCurrent(): Boolean;
+var
+ ResultCode: integer;
+ TaskXml: ansiString;
+ HashPos: Integer;
+ ExpectedHash: string;
begin
Result := False;
+ // Query the registered task XML via schtasks
+ if not ExecWithResult(ExpandConstant('{sys}\schtasks.exe'), '/Query /TN "{#ProjFSTaskPath}" /XML', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, TaskXml) then
+ exit;
+ if ResultCode <> 0 then
+ exit;
+ // Look for the hash marker in the task description
+ HashPos := Pos('[gvfs-task-hash=', TaskXml);
+ if HashPos = 0 then
+ exit;
+ // The expected hash is passed as a preprocessor define at build time
+ // (set by the build step that runs build-task-xml.ps1). For now, if
+ // any valid hash marker is present, we consider the task registered.
+ // B4 will add the exact hash comparison when it wires up the build step.
+ Result := True;
+ Log('[GVFS-INSTALL] IsEnableProjFSTaskCurrent: Task found with hash marker');
end;
-procedure CurStepChanged(CurStep: TSetupStep);
+procedure EnableProjFSFeature();
+var
+ ResultCode: integer;
begin
- case CurStep of
- ssInstall:
- begin
- if not KeepMountsRunning then
- UninstallService('GVFS.Service', True);
- end;
- ssPostInstall:
- begin
- if KeepMountsRunning then
- begin
- // All staged files have been written to PendingUpgrade.
- // Write .ready marker so the service knows the staging is
- // complete and safe to apply.
- SaveStringToFile(ExpandConstant('{app}\PendingUpgrade\.ready'), '', False);
- Log('CurStepChanged: Wrote PendingUpgrade .ready marker');
-
- // Start the service AFTER .ready is written. Previously this
- // was an AfterInstall hook on GVFS.Service.exe, but that races:
- // the service's debounce timer could fire before .ready exists.
- StagingUpdateService();
- end;
- MigrateConfigAndStatusCacheFiles();
- if (not KeepMountsRunning) and (ExpandConstant('{param:REMOUNTREPOS|true}') = 'true') then
- begin
- MountRepos();
- end
- end;
+ if IsProjFSEnabled() then
+ begin
+ Log('[GVFS-INSTALL] EnableProjFSFeature: Already enabled, skipping');
+ exit;
+ end;
+ Log('[GVFS-INSTALL] EnableProjFSFeature: Enabling Client-ProjFS optional feature');
+ if Exec('powershell.exe', '-NoProfile "Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -NoRestart"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
+ begin
+ if ResultCode <> 0 then
+ Log('[GVFS-INSTALL] EnableProjFSFeature: PowerShell returned ' + IntToStr(ResultCode) + ' (may require reboot)')
+ else
+ Log('[GVFS-INSTALL] EnableProjFSFeature: Enabled successfully');
+ end
+ else
+ begin
+ Log('[GVFS-INSTALL] EnableProjFSFeature: Failed to launch PowerShell');
+ RaiseException('Fatal: Could not enable ProjFS.');
end;
end;
-function GetCustomSetupExitCode: Integer;
+procedure RegisterEnableProjFSTask();
+var
+ ResultCode: integer;
+ TaskXmlPath: string;
begin
- Result := ExitCode;
+ if IsEnableProjFSTaskCurrent() then
+ begin
+ Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Task is already current, skipping');
+ exit;
+ end;
+
+ Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Registering EnableProjFSOnAllDrives task');
+ // Extract the pre-built task XML from the installer's embedded files
+ ExtractTemporaryFile('enable-projfs-on-all-drives-task.xml');
+ TaskXmlPath := ExpandConstant('{tmp}\enable-projfs-on-all-drives-task.xml');
+
+ if not Exec(ExpandConstant('{sys}\schtasks.exe'), '/Create /TN "{#ProjFSTaskPath}" /XML "' + TaskXmlPath + '" /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
+ begin
+ Log('[GVFS-INSTALL] RegisterEnableProjFSTask: schtasks /Create failed with exit code ' + IntToStr(ResultCode));
+ RaiseException('Fatal: Could not register EnableProjFSOnAllDrives scheduled task.');
+ end;
+ Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Task registered successfully');
+
+ // Run the task immediately so PrjFlt is attached before we return
+ Exec(ExpandConstant('{sys}\schtasks.exe'), '/Run /TN "{#ProjFSTaskPath}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
+ if ResultCode <> 0 then
+ Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Warning - immediate task run returned ' + IntToStr(ResultCode))
+ else
+ Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Task triggered for immediate execution');
end;
-procedure CurUninstallStepChanged(CurStep: TUninstallStep);
+function NeedsAdminSetup(): Boolean;
begin
- case CurStep of
- usUninstall:
- begin
- UninstallService('GVFS.Service', False);
- RemovePath(ExpandConstant('{app}'));
- end;
+ if not IsProjFSEnabled() then
+ begin
+ Log('[GVFS-INSTALL] NeedsAdminSetup: ProjFS not enabled');
+ Result := True;
+ exit;
+ end;
+ if not IsEnableProjFSTaskCurrent() then
+ begin
+ Log('[GVFS-INSTALL] NeedsAdminSetup: EnableProjFSOnAllDrives task not current');
+ Result := True;
+ exit;
end;
+ Log('[GVFS-INSTALL] NeedsAdminSetup: Admin setup is current, no elevation needed');
+ Result := False;
end;
-// Shows a modal dialog letting the user choose how to handle mounted repos.
-// Returns True if the user clicked Continue, False if Cancel. On Continue,
-// KeepMounted is set to True if the user chose to stage the upgrade and
-// leave repos mounted, or False to unmount and remount immediately.
-function ShowMountChoiceDialog(Repos: String; var KeepMounted: Boolean): Boolean;
-var
- Form: TForm;
- HeaderLbl, ReposLbl, RemountDescLbl, KeepDescLbl: TNewStaticText;
- RemountRadio, KeepRadio: TNewRadioButton;
- BtnContinue, BtnCancel: TNewButton;
- ButtonWidth, ButtonHeight, ContentWidth, Margin, IndentMargin: Integer;
- ModalResult, Y: Integer;
+// Below are EVENT FUNCTIONS -> The main entry points of InnoSetup into the code region
+// Documentation : http://www.jrsoftware.org/ishelp/index.php?topic=scriptevents
+
+function InitializeUninstall(): Boolean;
begin
- Margin := ScaleX(15);
- IndentMargin := ScaleX(34);
- ButtonWidth := ScaleX(85);
- ButtonHeight := ScaleY(25);
+ IsUserModeInstall := not IsAdmin();
+ Result := EnsureGvfsNotRunning();
+end;
- Form := TForm.Create(nil);
- try
- Form.Caption := 'Setup';
- Form.BorderStyle := bsDialog;
- Form.Position := poOwnerFormCenter;
- Form.ClientWidth := ScaleX(520);
- ContentWidth := Form.ClientWidth - (2 * Margin);
-
- Y := ScaleY(15);
-
- HeaderLbl := TNewStaticText.Create(Form);
- HeaderLbl.Parent := Form;
- HeaderLbl.Left := Margin;
- HeaderLbl.Top := Y;
- HeaderLbl.Caption := 'The following repos are currently mounted:';
- HeaderLbl.AutoSize := True;
- Y := HeaderLbl.Top + HeaderLbl.Height + ScaleY(4);
-
- ReposLbl := TNewStaticText.Create(Form);
- ReposLbl.Parent := Form;
- ReposLbl.Left := IndentMargin;
- ReposLbl.Top := Y;
- ReposLbl.Width := Form.ClientWidth - IndentMargin - Margin;
- ReposLbl.WordWrap := True;
- ReposLbl.AutoSize := True;
- ReposLbl.Caption := Trim(Repos);
- Y := ReposLbl.Top + ReposLbl.Height + ScaleY(16);
-
- RemountRadio := TNewRadioButton.Create(Form);
- RemountRadio.Parent := Form;
- RemountRadio.Left := Margin;
- RemountRadio.Top := Y;
- RemountRadio.Width := ContentWidth;
- RemountRadio.Caption := 'Remount repos as part of the installation';
- RemountRadio.Checked := True;
- Y := RemountRadio.Top + RemountRadio.Height + ScaleY(2);
-
- RemountDescLbl := TNewStaticText.Create(Form);
- RemountDescLbl.Parent := Form;
- RemountDescLbl.Left := IndentMargin;
- RemountDescLbl.Top := Y;
- RemountDescLbl.Width := Form.ClientWidth - IndentMargin - Margin;
- RemountDescLbl.WordWrap := True;
- RemountDescLbl.AutoSize := True;
- RemountDescLbl.Caption := 'They will be temporarily unavailable.';
- Y := RemountDescLbl.Top + RemountDescLbl.Height + ScaleY(14);
-
- KeepRadio := TNewRadioButton.Create(Form);
- KeepRadio.Parent := Form;
- KeepRadio.Left := Margin;
- KeepRadio.Top := Y;
- KeepRadio.Width := ContentWidth;
- KeepRadio.Caption := 'Keep repos mounted';
- Y := KeepRadio.Top + KeepRadio.Height + ScaleY(2);
-
- KeepDescLbl := TNewStaticText.Create(Form);
- KeepDescLbl.Parent := Form;
- KeepDescLbl.Left := IndentMargin;
- KeepDescLbl.Top := Y;
- KeepDescLbl.Width := Form.ClientWidth - IndentMargin - Margin;
- KeepDescLbl.WordWrap := True;
- KeepDescLbl.AutoSize := True;
- KeepDescLbl.Caption := 'The upgrade will complete automatically when all repos are unmounted, or at next reboot.';
- Y := KeepDescLbl.Top + KeepDescLbl.Height + ScaleY(20);
-
- BtnContinue := TNewButton.Create(Form);
- BtnContinue.Parent := Form;
- BtnContinue.Width := ButtonWidth;
- BtnContinue.Height := ButtonHeight;
- BtnContinue.Top := Y;
- BtnContinue.Left := Form.ClientWidth - Margin - ButtonWidth - ScaleX(10) - ButtonWidth;
- BtnContinue.Caption := '&Continue';
- BtnContinue.Default := True;
- BtnContinue.ModalResult := mrOk;
-
- BtnCancel := TNewButton.Create(Form);
- BtnCancel.Parent := Form;
- BtnCancel.Width := ButtonWidth;
- BtnCancel.Height := ButtonHeight;
- BtnCancel.Top := Y;
- BtnCancel.Left := Form.ClientWidth - Margin - ButtonWidth;
- BtnCancel.Caption := '&Cancel';
- BtnCancel.Cancel := True;
- BtnCancel.ModalResult := mrCancel;
-
- Form.ClientHeight := Y + ButtonHeight + ScaleY(15);
- Form.ActiveControl := BtnContinue;
-
- ModalResult := Form.ShowModal();
- if ModalResult = mrOk then
- begin
- KeepMounted := KeepRadio.Checked;
- Result := True;
- end
- else
- begin
- Result := False;
- end;
- finally
- Form.Free();
- end;
+// Called just after "install" phase, before "post install"
+function NeedRestart(): Boolean;
+begin
+ Result := False;
end;
-function PrepareToInstall(var NeedsRestart: Boolean): String;
+procedure CreateOrUpdateCurrentJunction();
var
- Repos: ansiString;
+ AppDir: string;
+ JunctionPath: string;
+ JunctionNew: string;
+ VersionDir: string;
ResultCode: integer;
- HasMounts: Boolean;
begin
- NeedsRestart := False;
- KeepMountsRunning := False;
- Result := '';
- SetNuGetFeedIfNecessary();
+ if IsUserModeInstall then
+ AppDir := ExpandConstant('{localappdata}\GVFS')
+ else
+ AppDir := ExpandConstant('{app}');
+
+ JunctionPath := AppDir + '\Current';
+ JunctionNew := AppDir + '\Current.new';
+ VersionDir := AppDir + '\Versions\{#MyAppInstallerVersion}';
+
+ Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Target version = {#MyAppInstallerVersion}');
- // Check for mounted repos by querying the service, and also check for
- // running GVFS processes (a mount can be running without being registered
- // in the service's repo-registry, e.g., after a reinstall).
- HasMounts := False;
- if ExecWithResult('gvfs.exe', 'service --list-mounted', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Repos) then
+ // Fix #4: Atomic junction swap using .new temporary
+ // Create new junction at Current.new
+ Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Creating junction Current.new -> ' + VersionDir);
+ if not Exec(ExpandConstant('{cmd}'), '/C mklink /J "' + JunctionNew + '" "' + VersionDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
begin
- if (ResultCode = 0) and (Repos <> '') then
- HasMounts := True;
+ Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: mklink /J failed with exit code ' + IntToStr(ResultCode));
+ RaiseException('Fatal: Could not create Current.new junction at ' + JunctionNew);
end;
- if (not HasMounts) and IsGVFSRunning() then
+
+ // Remove existing Current junction if present
+ if DirExists(JunctionPath) then
begin
- HasMounts := True;
- Repos := '(GVFS processes detected)';
- Log('PrepareToInstall: No registered mounts but GVFS processes are running');
+ Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Removing existing Current junction');
+ if not Exec(ExpandConstant('{cmd}'), '/C rmdir "' + JunctionPath + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
+ begin
+ Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: WARNING - rmdir failed with exit code ' + IntToStr(ResultCode));
+ // Continue anyway - rename might still work
+ end;
end;
- if HasMounts then
+ // Rename Current.new -> Current
+ Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Renaming Current.new -> Current');
+ if not Exec(ExpandConstant('{cmd}'), '/C ren "' + JunctionNew + '" Current', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
begin
- if WizardSilent() then
+ Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: ren failed with exit code ' + IntToStr(ResultCode));
+ // Fallback: if Current.new exists, at least installer can reference it
+ if DirExists(JunctionNew) then
begin
- // Silent mode: STAGEIFMOUNTED=true stages files instead of unmounting.
- // Default: false (clean upgrade, matching pre-existing behavior).
- KeepMountsRunning := ExpandConstant('{param:STAGEIFMOUNTED|false}') = 'true';
- if KeepMountsRunning then
- Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=True')
- else
- Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=False');
+ Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: WARNING - Using Current.new as fallback');
end
else
begin
- // Interactive mode: show a radio-button modal so the user can pick
- // between remounting (immediate but brief unavailability) and
- // staging the upgrade (deferred until repos are unmounted).
- if not ShowMountChoiceDialog(Repos, KeepMountsRunning) then
- begin
- Result := 'Installation cancelled.';
- exit;
- end;
+ RaiseException('Fatal: Could not rename Current.new to Current');
end;
+ end
+ else
+ begin
+ Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Junction created successfully');
+ end;
+end;
+
+function GetFileVersion(FilePath: string): string;
+var
+ VersionMS: Cardinal;
+ VersionLS: Cardinal;
+begin
+ Result := '';
+ if GetVersionNumbers(FilePath, VersionMS, VersionLS) then
+ begin
+ Result := Format('%d.%d.%d.%d', [
+ VersionMS shr 16,
+ VersionMS and $FFFF,
+ VersionLS shr 16,
+ VersionLS and $FFFF
+ ]);
+ end;
+end;
+
+function IsProcessRunningFromPath(PathPrefix: string): Boolean;
+var
+ ResultCode: integer;
+ PowerShellCmd: string;
+begin
+ // PowerShell: check if any gvfs.mount process has a path starting with PathPrefix
+ PowerShellCmd := Format('-NoProfile "$procs = Get-Process gvfs.mount -ErrorAction SilentlyContinue; ' +
+ 'if ($procs) { foreach ($p in $procs) { ' +
+ 'try { if ($p.Path -like ''%s*'') { exit 10 } } catch {} } }; exit 0"', [PathPrefix]);
+
+ if Exec('powershell.exe', PowerShellCmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
+ begin
+ Result := (ResultCode = 10);
+ end
+ else
+ begin
+ Log('[GVFS-INSTALL] IsProcessRunningFromPath: PowerShell query failed');
+ Result := False;
end;
+end;
+
+procedure GarbageCollectOldVersions();
+var
+ AppDir: string;
+ VersionsDir: string;
+ CurrentVersion: string;
+ FlatGvfsExe: string;
+ FlatVersion: string;
+ FindRec: TFindRec;
+ VersionDirs: array of string;
+ VersionTimes: array of Int64;
+ Count: integer;
+ I, J: integer;
+ TempStr: string;
+ TempTime: Int64;
+ VersionPath: string;
+ CanDelete: Boolean;
+begin
+ if IsUserModeInstall then
+ AppDir := ExpandConstant('{localappdata}\GVFS')
+ else
+ AppDir := ExpandConstant('{app}');
+
+ VersionsDir := AppDir + '\Versions';
+ CurrentVersion := '{#MyAppInstallerVersion}';
+
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Current version = ' + CurrentVersion);
- if KeepMountsRunning then
+ // First, check for flat-layout binaries at {app}\GVFS.exe
+ FlatGvfsExe := AppDir + '\GVFS.exe';
+ if FileExists(FlatGvfsExe) then
begin
- // Staging mode: most files go to {app}\PendingUpgrade\ via [Files] entries
- // with Check: IsStagingInstall. GVFS.Service.exe goes directly to {app}.
- // Clean up any leftover staging dirs from a prior attempt first,
- // so we don't mix files from different upgrade versions.
- if DirExists(ExpandConstant('{app}\PendingUpgrade')) then
+ FlatVersion := GetFileVersion(FlatGvfsExe);
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Detected flat layout with version ' + FlatVersion);
+
+ // Check if any mounts are running from the flat install
+ if IsProcessRunningFromPath(AppDir + '\') then
begin
- Log('PrepareToInstall: Removing stale PendingUpgrade from prior staging attempt');
- DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True);
- end;
- if DirExists(ExpandConstant('{app}\PreviousVersion')) then
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Mounts running from flat layout - leaving in place');
+ end
+ else
begin
- Log('PrepareToInstall: Removing stale PreviousVersion from prior staging attempt');
- DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True);
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: No mounts running from flat layout - would migrate to Versions\' + FlatVersion);
+ // For now, just log. Full migration logic can move files to Versions\.
+ // Defer to avoid complexity in first PR.
end;
- // Stop the service now so its exe is unlocked for replacement.
- // Mounts are independent processes and unaffected.
- Log('PrepareToInstall: Staging mode. Stopping service for exe replacement.');
- StopService('GVFS.Service');
- WaitForServiceProcessToExit('GVFS.Service');
- end
- else
+ end;
+
+ // Enumerate version directories
+ Count := 0;
+ SetArrayLength(VersionDirs, 0);
+ SetArrayLength(VersionTimes, 0);
+
+ if not DirExists(VersionsDir) then
+ begin
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Versions directory does not exist');
+ exit;
+ end;
+
+ if FindFirst(VersionsDir + '\*', FindRec) then
begin
- // Clean upgrade: unmount, stop everything, replace files directly.
- // Remove any leftover PendingUpgrade or PreviousVersion from a
- // previous staging install so stale files don't interfere with
- // the fresh install.
- if DirExists(ExpandConstant('{app}\PendingUpgrade')) then
+ try
+ repeat
+ if (FindRec.Name <> '.') and (FindRec.Name <> '..') and (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) then
+ begin
+ // Skip the current version
+ if FindRec.Name <> CurrentVersion then
+ begin
+ SetArrayLength(VersionDirs, Count + 1);
+ SetArrayLength(VersionTimes, Count + 1);
+ VersionDirs[Count] := FindRec.Name;
+ VersionTimes[Count] := FindRec.Time;
+ Count := Count + 1;
+ end;
+ end;
+ until not FindNext(FindRec);
+ finally
+ FindClose(FindRec);
+ end;
+ end;
+
+ if Count = 0 then
+ begin
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: No old versions to clean up');
+ exit;
+ end;
+
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Found ' + IntToStr(Count) + ' old version(s)');
+
+ // Sort by time (bubble sort, oldest first)
+ for I := 0 to Count - 2 do
+ begin
+ for J := I + 1 to Count - 1 do
begin
- Log('PrepareToInstall: Removing leftover PendingUpgrade directory');
- DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True);
+ if VersionTimes[I] > VersionTimes[J] then
+ begin
+ TempTime := VersionTimes[I];
+ VersionTimes[I] := VersionTimes[J];
+ VersionTimes[J] := TempTime;
+ TempStr := VersionDirs[I];
+ VersionDirs[I] := VersionDirs[J];
+ VersionDirs[J] := TempStr;
+ end;
end;
- if DirExists(ExpandConstant('{app}\PreviousVersion')) then
+ end;
+
+ // Keep the 1 most recent old version (index Count-1), delete the rest
+ for I := 0 to Count - 2 do
+ begin
+ VersionPath := VersionsDir + '\' + VersionDirs[I];
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Checking version ' + VersionDirs[I]);
+
+ // Check if any mounts are running from this version
+ CanDelete := not IsProcessRunningFromPath(VersionPath + '\');
+
+ if CanDelete then
begin
- Log('PrepareToInstall: Removing leftover PreviousVersion directory');
- DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True);
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Deleting old version ' + VersionDirs[I]);
+ if DelTree(VersionPath, True, True, True) then
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Deleted ' + VersionPath)
+ else
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Failed to delete ' + VersionPath);
+ end
+ else
+ begin
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Version ' + VersionDirs[I] + ' has running mounts - skipping');
end;
- if HasMounts then
+ end;
+
+ // Log the most recent old version that we're keeping
+ if Count > 0 then
+ Log('[GVFS-INSTALL] GarbageCollectOldVersions: Keeping most recent old version ' + VersionDirs[Count - 1]);
+end;
+
+function PrepareToInstall(var NeedsRestart: Boolean): String;
+var
+ ResultCode: integer;
+ HasGVFSRunning: Boolean;
+ KillCmd: string;
+begin
+ NeedsRestart := False;
+ Result := '';
+
+ if IsAdminStage then
+ begin
+ // Elevated re-launch from the user-mode installer. Do only
+ // the per-machine admin setup, then exit cleanly. The user-mode
+ // installer is waiting for our exit code (0 = success).
+ //
+ // We let Inno Setup proceed through the install phase, but all
+ // [Files] entries are gated behind Check functions that return
+ // false in admin-stage mode, so nothing is deployed. This gives
+ // us a clean exit code without fighting the Inno Setup lifecycle.
+ Log('[GVFS-INSTALL] PrepareToInstall: /ADMINSTAGE - running admin setup');
+ EnableProjFSFeature();
+ RegisterEnableProjFSTask();
+ Log('[GVFS-INSTALL] PrepareToInstall: /ADMINSTAGE - admin setup complete');
+ exit;
+ end;
+
+ SetNuGetFeedIfNecessary();
+
+ if IsUserModeInstall then
+ begin
+ // User-mode install: check whether per-machine admin setup
+ // (ProjFS feature + EnableProjFSOnAllDrives task) is current.
+ // If not, re-launch ourselves elevated to do the admin portion.
+ if NeedsAdminSetup() then
+ begin
+ Log('[GVFS-INSTALL] PrepareToInstall: Admin setup needed, re-launching elevated');
+ if not ShellExec('runas', ExpandConstant('{srcexe}'), '/VERYSILENT /ADMINSTAGE=true', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
+ begin
+ Result := 'Failed to launch elevated admin setup (user may have declined UAC).';
+ exit;
+ end;
+ if ResultCode <> 0 then
+ begin
+ Result := 'Admin setup failed (exit code ' + IntToStr(ResultCode) + ').';
+ exit;
+ end;
+ Log('[GVFS-INSTALL] PrepareToInstall: Admin setup completed successfully');
+ end
+ else
begin
- UnmountRepos();
+ Log('[GVFS-INSTALL] PrepareToInstall: Admin setup is current, skipping elevation');
end;
- // With CloseApplications=no, Restart Manager won't kill GVFS
- // processes. If unmount-all didn't clean up everything (e.g.
- // registry was empty), force-kill remaining processes since
- // the user already consented to a full upgrade.
+
+ // Check for running GVFS processes
if IsGVFSRunning() then
begin
- Log('PrepareToInstall: GVFS processes still running after unmount, force-killing');
- Exec('powershell.exe', '-NoProfile "Get-Process gvfs,gvfs.mount -ErrorAction SilentlyContinue | Stop-Process -Force"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
- Sleep(2000);
+ if WizardSilent() then
+ begin
+ Log('[GVFS-INSTALL] PrepareToInstall: Silent mode - killing GVFS processes');
+ KillCmd := '-NoProfile "Get-Process gvfs,gvfs.mount -ErrorAction SilentlyContinue | % { $pid = $_.Id; Stop-Process -Id $pid -Force }"';
+ Exec('powershell.exe', KillCmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
+ Sleep(2000);
+ end
+ else
+ begin
+ if not EnsureGvfsNotRunning() then
+ begin
+ Result := 'Installation cancelled.';
+ exit;
+ end;
+ end;
end;
- if not EnsureGvfsNotRunning() then
+ end
+ else
+ begin
+ // System-mode install: versioned layout with GVFS processes check
+ HasGVFSRunning := IsGVFSRunning();
+
+ if HasGVFSRunning then
begin
- Abort();
+ if WizardSilent() then
+ begin
+ Log('[GVFS-INSTALL] PrepareToInstall: GVFS processes running in silent mode, proceeding anyway');
+ end
+ else
+ begin
+ // Interactive mode: warn user but allow them to continue
+ Log('[GVFS-INSTALL] PrepareToInstall: GVFS processes detected in interactive mode');
+ end;
+ end;
end;
+ end;
+
+ // Clean up old PendingUpgrade/PreviousVersion dirs from pre-versioned installs
+ if DirExists(ExpandConstant('{app}\PendingUpgrade')) then
+ begin
+ Log('[GVFS-INSTALL] PrepareToInstall: Removing legacy PendingUpgrade directory');
+ DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True);
+ end;
+ if DirExists(ExpandConstant('{app}\PreviousVersion')) then
+ begin
+ Log('[GVFS-INSTALL] PrepareToInstall: Removing legacy PreviousVersion directory');
+ DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True);
+ end;
+
+ // Stop and delete the old service if it exists (migration from service-based install).
+ // Only for system-mode installs — user-mode can't stop services (ACCESS_DENIED).
+ // The /ADMINSTAGE path handles ProjFS setup for user-mode.
+ if not IsUserModeInstall then
+ begin
+ Log('[GVFS-INSTALL] PrepareToInstall: Stopping and deleting GVFS.Service if present');
StopService('GVFS.Service');
+ WaitForServiceProcessToExit('GVFS.Service');
+
UninstallGvFlt();
UninstallProjFSIfNecessary();
end;
end;
+
+function UninstallNeedRestart(): Boolean;
+begin
+ Result := False;
+end;
+
+procedure CurStepChanged(CurStep: TSetupStep);
+var
+ AppBase: string;
+begin
+ case CurStep of
+ ssInstall:
+ begin
+ // Stop and delete service if present (upgrade from service-based install)
+ Log('[GVFS-INSTALL] CurStepChanged ssInstall: Stopping and deleting GVFS.Service if present');
+ UninstallService('GVFS.Service', True);
+
+ // Create/update the Current junction BEFORE files are extracted
+ if not IsAdminStage then
+ CreateOrUpdateCurrentJunction();
+ end;
+ ssPostInstall:
+ begin
+ if IsAdminStage then
+ begin
+ Log('[GVFS-INSTALL] CurStepChanged ssPostInstall: /ADMINSTAGE - skipping post-install tasks');
+ exit;
+ end;
+
+ if IsUserModeInstall then
+ AppBase := ExpandConstant('{localappdata}\GVFS')
+ else
+ AppBase := ExpandConstant('{app}');
+
+ // Remove legacy flat PATH entry on upgrade from flat layout
+ Log('[GVFS-INSTALL] CurStepChanged ssPostInstall: Removing legacy flat PATH entry');
+ RemovePath(AppBase);
+
+ // GC runs after junction is already in place (from ssInstall above)
+ GarbageCollectOldVersions();
+
+ // Migrate config and status cache files
+ MigrateConfigAndStatusCacheFiles();
+
+ // Write OnDiskVersion16Capable marker
+ WriteOnDiskVersion16CapableFile();
+
+ // Register AutoMount logon task (replaces service startup)
+ Log('[GVFS-INSTALL] CurStepChanged ssPostInstall: Registering AutoMount logon task');
+ RegisterAutoMountLogonTask();
+ end;
+ end;
+end;
+
+function GetCustomSetupExitCode: Integer;
+begin
+ Result := ExitCode;
+end;
+
+procedure CurUninstallStepChanged(CurStep: TUninstallStep);
+var
+ AppBase: string;
+begin
+ case CurStep of
+ usUninstall:
+ begin
+ UninstallService('GVFS.Service', False);
+
+ if IsUserModeInstall then
+ AppBase := ExpandConstant('{localappdata}\GVFS')
+ else
+ AppBase := ExpandConstant('{app}');
+
+ RemovePath(AppBase + '\Current');
+
+ // Unregister the AutoMount logon task
+ UninstallAutomountTask();
+ end;
+ end;
+end;
diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs
index 1896d21f6..fa2d495d7 100644
--- a/GVFS/GVFS.Mount/InProcessMount.cs
+++ b/GVFS/GVFS.Mount/InProcessMount.cs
@@ -56,8 +56,7 @@ public class InProcessMount
private GVFSContext context;
private GVFSGitObjects gitObjects;
- private volatile MountState currentState;
- private volatile string mountProgressMessage;
+ private MountState currentState;
private HeartbeatThread heartbeat;
private ManualResetEvent unmountEvent;
@@ -196,71 +195,65 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords)
this.enlistment.InitializeCachePaths(localCacheRoot, gitObjectsRoot, blobSizesRoot);
- // Start the pipe server early so MountVerb can connect and poll progress
- // during the parallel validation phase. Only GetStatus requests are
- // handled while currentState == Mounting (see HandleRequest guard).
- this.mountProgressMessage = "Authenticating and validating";
- using (NamedPipeServer pipeServer = this.StartNamedPipe())
+ // Local validations and git config run while we wait for the network
+ var localTask = Task.Run(() =>
{
- this.tracer.RelatedEvent(
- EventLevel.Informational,
- $"{nameof(this.Mount)}_StartedNamedPipe",
- new EventMetadata { { "NamedPipeName", this.enlistment.NamedPipeName } });
+ Stopwatch sw = Stopwatch.StartNew();
- // Local validations and git config run while we wait for the network
- Task localTask = Task.Run(() =>
- {
- Stopwatch sw = Stopwatch.StartNew();
+ this.ValidateGitVersion();
+ this.tracer.RelatedInfo("ParallelMount: ValidateGitVersion completed in {0}ms", sw.ElapsedMilliseconds);
- this.ValidateGitVersion();
- this.tracer.RelatedInfo("ParallelMount: ValidateGitVersion completed in {0}ms", sw.ElapsedMilliseconds);
+ this.ValidateHooksVersion();
+ this.ValidateFileSystemSupportsRequiredFeatures();
- this.ValidateHooksVersion();
- this.ValidateFileSystemSupportsRequiredFeatures();
+ GitProcess git = new GitProcess(this.enlistment);
+ if (!git.IsValidRepo())
+ {
+ this.FailMountAndExit("The .git folder is missing or has invalid contents");
+ }
- GitProcess git = new GitProcess(this.enlistment);
- if (!git.IsValidRepo())
- {
- this.FailMountAndExit("The .git folder is missing or has invalid contents");
- }
+ if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.WorkingDirectoryRoot, out string fsError))
+ {
+ this.FailMountAndExit("FileSystem unsupported: " + fsError);
+ }
- if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.WorkingDirectoryRoot, out string fsError))
- {
- this.FailMountAndExit("FileSystem unsupported: " + fsError);
- }
+ this.tracer.RelatedInfo("ParallelMount: Local validations completed in {0}ms", sw.ElapsedMilliseconds);
- this.tracer.RelatedInfo("ParallelMount: Local validations completed in {0}ms", sw.ElapsedMilliseconds);
+ if (!this.TrySetRequiredGitConfigSettings())
+ {
+ this.FailMountAndExit("Unable to configure git repo");
+ }
- if (!this.TrySetRequiredGitConfigSettings())
- {
- this.FailMountAndExit("Unable to configure git repo");
- }
+ this.LogEnlistmentInfoAndSetConfigValues();
+ this.tracer.RelatedInfo("ParallelMount: Local validations + git config completed in {0}ms", sw.ElapsedMilliseconds);
+ });
- this.LogEnlistmentInfoAndSetConfigValues();
- this.tracer.RelatedInfo("ParallelMount: Local validations + git config completed in {0}ms", sw.ElapsedMilliseconds);
- });
+ try
+ {
+ Task.WaitAll(networkTask, localTask);
+ }
+ catch (AggregateException ae)
+ {
+ this.FailMountAndExit(ae.Flatten().InnerExceptions[0].Message);
+ }
- try
- {
- Task.WaitAll(networkTask, localTask);
- }
- catch (AggregateException ae)
- {
- this.FailMountAndExit(ae.Flatten().InnerExceptions[0].Message);
- }
+ parallelTimer.Stop();
+ this.tracer.RelatedInfo("ParallelMount: All parallel tasks completed in {0}ms", parallelTimer.ElapsedMilliseconds);
- parallelTimer.Stop();
- this.tracer.RelatedInfo("ParallelMount: All parallel tasks completed in {0}ms", parallelTimer.ElapsedMilliseconds);
+ ServerGVFSConfig serverGVFSConfig = networkTask.Result;
- ServerGVFSConfig serverGVFSConfig = networkTask.Result;
+ CacheServerResolver cacheServerResolver = new CacheServerResolver(this.tracer, this.enlistment);
+ this.cacheServer = cacheServerResolver.ResolveNameFromRemote(this.cacheServer.Url, serverGVFSConfig);
- this.mountProgressMessage = "Resolving cache server";
- CacheServerResolver cacheServerResolver = new CacheServerResolver(this.tracer, this.enlistment);
- this.cacheServer = cacheServerResolver.ResolveNameFromRemote(this.cacheServer.Url, serverGVFSConfig);
+ this.EnsureLocalCacheIsHealthy(serverGVFSConfig);
- this.EnsureLocalCacheIsHealthy(serverGVFSConfig);
+ using (NamedPipeServer pipeServer = this.StartNamedPipe())
+ {
+ this.tracer.RelatedEvent(
+ EventLevel.Informational,
+ $"{nameof(this.Mount)}_StartedNamedPipe",
+ new EventMetadata { { "NamedPipeName", this.enlistment.NamedPipeName } });
- this.mountProgressMessage = "Preparing mount";
this.context = this.CreateContext();
if (this.context.Unattended)
@@ -281,7 +274,6 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords)
GVFSPlatform.Instance.ConfigureVisualStudio(this.enlistment.GitBinPath, this.tracer);
- this.mountProgressMessage = "Starting virtualization";
this.MountAndStartWorkingDirectoryCallbacks(this.cacheServer);
try
@@ -304,7 +296,6 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords)
},
Keywords.Telemetry);
- this.mountProgressMessage = null;
this.currentState = MountState.Ready;
this.unmountEvent.WaitOne();
@@ -484,17 +475,6 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne
{
NamedPipeMessages.Message message = NamedPipeMessages.Message.FromString(request);
- // While mounting, only GetStatus requests are safe — other handlers depend
- // on context, fileSystemCallbacks, etc. that aren't initialized yet.
- // MountFailed is NOT guarded: HandleUnmountRequest needs to reach the
- // "unmount even if mount failed" path so users aren't forced to kill the process.
- if (message.Header != NamedPipeMessages.GetStatus.Request &&
- this.currentState == MountState.Mounting)
- {
- connection.TrySendResponse(NamedPipeMessages.MountNotReadyResult);
- return;
- }
-
try
{
switch (message.Header)
@@ -568,6 +548,8 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne
metadata.Add("Header", message.Header);
metadata.Add("Exception", e.ToString());
this.tracer.RelatedError(metadata, "HandleRequest: Unhandled exception in request handler");
+
+ connection.TrySendResponse(NamedPipeMessages.UnknownRequest);
}
}
@@ -912,76 +894,56 @@ private void HandleDownloadObjectRequest(NamedPipeMessages.Message message, Name
}
else
{
- try
+ Stopwatch downloadTime = Stopwatch.StartNew();
+
+ /* If this is the root tree for a commit that was was just downloaded, assume that more
+ * trees will be needed soon and download them as well by using the download commit API.
+ *
+ * Otherwise, or as a fallback if the commit download fails, download the object directly.
+ */
+ if (this.ShouldDownloadCommitPack(objectSha, out string commitSha)
+ && this.gitObjects.TryDownloadCommit(commitSha))
{
- response = this.DownloadObject(objectSha);
+ this.DownloadedCommitPack(commitSha);
+ response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.SuccessResult);
+ // FUTURE: Should the stats be updated to reflect all the trees in the pack?
+ // FUTURE: Should we try to clean up duplicate trees or increase depth of the commit download?
}
- catch (Exception e) when (e is not OutOfMemoryException)
+ else if (this.gitObjects.TryDownloadAndSaveObject(objectSha, GVFSGitObjects.RequestSource.NamedPipeMessage) == GitObjects.DownloadAndSaveObjectResult.Success)
+ {
+ this.UpdateTreesForDownloadedCommits(objectSha);
+ response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.SuccessResult);
+ }
+ else
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add("Area", "Mount");
- metadata.Add("objectSha", objectSha);
- metadata.Add("Exception", e.ToString());
- this.tracer.RelatedWarning(metadata, nameof(this.HandleDownloadObjectRequest) + ": Exception downloading object");
-
response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.DownloadFailed);
}
- }
- }
-
- connection.TrySendResponse(response.CreateMessage());
- }
- private NamedPipeMessages.DownloadObject.Response DownloadObject(string objectSha)
- {
- NamedPipeMessages.DownloadObject.Response response;
- Stopwatch downloadTime = Stopwatch.StartNew();
-
- /* If this is the root tree for a commit that was was just downloaded, assume that more
- * trees will be needed soon and download them as well by using the download commit API.
- *
- * Otherwise, or as a fallback if the commit download fails, download the object directly.
- */
- if (this.ShouldDownloadCommitPack(objectSha, out string commitSha)
- && this.gitObjects.TryDownloadCommit(commitSha))
- {
- this.DownloadedCommitPack(commitSha);
- response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.SuccessResult);
- // FUTURE: Should the stats be updated to reflect all the trees in the pack?
- // FUTURE: Should we try to clean up duplicate trees or increase depth of the commit download?
- }
- else if (this.gitObjects.TryDownloadAndSaveObject(objectSha, GVFSGitObjects.RequestSource.NamedPipeMessage) == GitObjects.DownloadAndSaveObjectResult.Success)
- {
- this.UpdateTreesForDownloadedCommits(objectSha);
- response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.SuccessResult);
- }
- else
- {
- response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.DownloadFailed);
- }
- Native.ObjectTypes? objectType;
- this.context.Repository.TryGetObjectType(objectSha, out objectType);
- this.context.Repository.GVFSLock.Stats.RecordObjectDownload(objectType == Native.ObjectTypes.Blob, downloadTime.ElapsedMilliseconds);
+ Native.ObjectTypes? objectType;
+ this.context.Repository.TryGetObjectType(objectSha, out objectType);
+ this.context.Repository.GVFSLock.Stats.RecordObjectDownload(objectType == Native.ObjectTypes.Blob, downloadTime.ElapsedMilliseconds);
- if (objectType == Native.ObjectTypes.Commit
- && !this.context.Repository.CommitAndRootTreeExists(objectSha, out var treeSha)
- && !string.IsNullOrEmpty(treeSha))
- {
- /* If a commit is downloaded, it wasn't prefetched.
- * The trees for the commit may be needed soon depending on the context.
- * e.g. git log (without a pathspec) doesn't need trees, but git checkout does.
- *
- * If any prefetch has been done there is probably a similar commit/tree in the graph,
- * but in case there isn't (such as if the cache server repack maintenance job is failing)
- * we should still try to avoid downloading an excessive number of loose trees for a commit.
- *
- * Save the tree/commit so if more trees are requested we can download all the trees for the commit in a batch.
- */
- this.missingTreeTracker.AddMissingRootTree(treeSha: treeSha, commitSha: objectSha);
+ if (objectType == Native.ObjectTypes.Commit
+ && !this.context.Repository.CommitAndRootTreeExists(objectSha, out var treeSha)
+ && !string.IsNullOrEmpty(treeSha))
+ {
+ /* If a commit is downloaded, it wasn't prefetched.
+ * The trees for the commit may be needed soon depending on the context.
+ * e.g. git log (without a pathspec) doesn't need trees, but git checkout does.
+ *
+ * If any prefetch has been done there is probably a similar commit/tree in the graph,
+ * but in case there isn't (such as if the cache server repack maintenance job is failing)
+ * we should still try to avoid downloading an excessive number of loose trees for a commit.
+ *
+ * Save the tree/commit so if more trees are requested we can download all the trees for the commit in a batch.
+ */
+ this.missingTreeTracker.AddMissingRootTree(treeSha: treeSha, commitSha: objectSha);
+ }
+ }
}
- return response;
+ connection.TrySendResponse(response.CreateMessage());
}
private bool ShouldDownloadCommitPack(string objectSha, out string commitSha)
@@ -1230,15 +1192,14 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection)
response.EnlistmentRoot = this.enlistment.WorkingDirectoryRoot;
response.LocalCacheRoot = !string.IsNullOrWhiteSpace(this.enlistment.LocalCacheRoot) ? this.enlistment.LocalCacheRoot : this.enlistment.GitObjectsRoot;
response.RepoUrl = this.enlistment.RepoUrl;
- response.CacheServer = this.cacheServer?.ToString() ?? string.Empty;
- response.LockStatus = this.context?.Repository?.GVFSLock != null ? this.context.Repository.GVFSLock.GetStatus() : "Unavailable";
+ response.CacheServer = this.cacheServer.ToString();
+ response.LockStatus = this.context?.Repository.GVFSLock != null ? this.context.Repository.GVFSLock.GetStatus() : "Unavailable";
response.DiskLayoutVersion = $"{GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion}.{GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMinorVersion}";
switch (this.currentState)
{
case MountState.Mounting:
response.MountStatus = NamedPipeMessages.GetStatus.Mounting;
- response.MountProgress = this.mountProgressMessage;
break;
case MountState.Ready:
diff --git a/GVFS/GVFS.Payload/layout.bat b/GVFS/GVFS.Payload/layout.bat
index fbaf9ea7c..14e9e1168 100644
--- a/GVFS/GVFS.Payload/layout.bat
+++ b/GVFS/GVFS.Payload/layout.bat
@@ -45,7 +45,7 @@ xcopy /Y %VCRUNTIME%\lib\x64\vcruntime140.dll %OUTPUT%
xcopy /Y /S %BUILD_OUT%\GVFS\%MANAGED_OUT_FRAGMENT%\* %OUTPUT%
xcopy /Y /S %BUILD_OUT%\GVFS.Hooks\%MANAGED_OUT_FRAGMENT%\* %OUTPUT%
xcopy /Y /S %BUILD_OUT%\GVFS.Mount\%MANAGED_OUT_FRAGMENT%\* %OUTPUT%
-xcopy /Y /S %BUILD_OUT%\GVFS.Service\%MANAGED_OUT_FRAGMENT%\* %OUTPUT%
+REM GVFS.Service removed -- no longer part of the user-level install model
xcopy /Y /S %BUILD_OUT%\GitHooksLoader\%NATIVE_OUT_FRAGMENT%\* %OUTPUT%
xcopy /Y /S %BUILD_OUT%\GVFS.PostIndexChangedHook\%NATIVE_OUT_FRAGMENT%\* %OUTPUT%
xcopy /Y /S %BUILD_OUT%\GVFS.ReadObjectHook\%NATIVE_OUT_FRAGMENT%\* %OUTPUT%
diff --git a/GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs b/GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs
new file mode 100644
index 000000000..9f789fddc
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs
@@ -0,0 +1,420 @@
+using GVFS.Common;
+using GVFS.Tests.Should;
+using GVFS.UnitTests.Mock.Common;
+using GVFS.UnitTests.Mock.FileSystem;
+using NUnit.Framework;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace GVFS.UnitTests.Common
+{
+ [TestFixture]
+ public class LocalRepoRegistryTests
+ {
+ private const string DataLocation = @"mock:\registryDataFolder";
+ private const string Repo1 = @"mock:\code\repo1";
+ private const string Repo2 = @"mock:\code\repo2";
+ private const string Repo3 = @"mock:\code\repo3";
+
+ [TestCase]
+ public void TryRegisterRepo_EmptyRegistry_RoundTripsThroughDisk()
+ {
+ (LocalRepoRegistry registry, MockFileSystem _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out string error).ShouldBeTrue(error);
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(1);
+ VerifyEntry(all[Repo1], expectedOwnerSID: ownerSID, expectedIsActive: true);
+ }
+
+ [TestCase]
+ public void TryRegisterRepo_DuplicateActiveSameOwner_DoesNotRewrite()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+
+ string contentBefore = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName));
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ string contentAfter = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName));
+
+ // No semantic change → no rewrite. Important for caller patterns
+ // that re-register on every mount; we don't want a writer storm.
+ contentAfter.ShouldEqual(contentBefore);
+ }
+
+ [TestCase]
+ public void TryRegisterRepo_ReactivatesAfterDeactivate()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ registry.TryDeactivateRepo(Repo1, out _).ShouldBeTrue();
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+
+ Dictionary all = registry.ReadRegistry();
+ VerifyEntry(all[Repo1], expectedOwnerSID: ownerSID, expectedIsActive: true);
+ }
+
+ [TestCase]
+ public void TryRegisterRepo_NewOwnerSidIsPersisted()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerA = Guid.NewGuid().ToString();
+ string ownerB = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerA, out _).ShouldBeTrue();
+ registry.TryRegisterRepo(Repo1, ownerB, out _).ShouldBeTrue();
+
+ Dictionary all = registry.ReadRegistry();
+ VerifyEntry(all[Repo1], expectedOwnerSID: ownerB, expectedIsActive: true);
+ }
+
+ [TestCase]
+ public void TryDeactivateRepo_NonExistent_ReturnsFalseWithError()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+
+ registry.TryDeactivateRepo(Repo1, out string error).ShouldBeFalse();
+ string.IsNullOrEmpty(error).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryDeactivateRepo_AlreadyInactive_StillReturnsTrue()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ registry.TryDeactivateRepo(Repo1, out _).ShouldBeTrue();
+ // Second deactivate on an already-inactive entry is a no-op success
+ registry.TryDeactivateRepo(Repo1, out _).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void TryRemoveRepo_RemovesEntryEntirely()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ registry.TryRemoveRepo(Repo1, out _).ShouldBeTrue();
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void TryRemoveRepo_NonExistent_ReturnsFalse()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ registry.TryRemoveRepo(Repo1, out string error).ShouldBeFalse();
+ string.IsNullOrEmpty(error).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryGetActiveRepos_FiltersInactive()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ registry.TryRegisterRepo(Repo2, ownerSID, out _).ShouldBeTrue();
+ registry.TryRegisterRepo(Repo3, ownerSID, out _).ShouldBeTrue();
+ registry.TryDeactivateRepo(Repo2, out _).ShouldBeTrue();
+
+ registry.TryGetActiveRepos(out List active, out _).ShouldBeTrue();
+ active.Count.ShouldEqual(2);
+ active.Any(r => r.EnlistmentRoot.Equals(Repo1)).ShouldBeTrue();
+ active.Any(r => r.EnlistmentRoot.Equals(Repo3)).ShouldBeTrue();
+ active.Any(r => r.EnlistmentRoot.Equals(Repo2)).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryGetActiveRepos_EmptyRegistry_ReturnsEmptyList()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+
+ registry.TryGetActiveRepos(out List active, out string error).ShouldBeTrue(error);
+ active.Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void ReadRegistry_NoRegistryFile_ReturnsEmpty()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ registry.ReadRegistry().Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void ReadRegistry_HigherVersionOnDisk_ReturnsEmptyAndDoesNotOverwrite()
+ {
+ // Simulate a newer GVFS having written the registry.
+ // We must read as empty AND must NOT overwrite when a subsequent
+ // write happens, so the newer GVFS's data is preserved.
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string registryPath = Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName);
+ string futureContent = "99\n{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"future\",\"IsActive\":true}\n";
+ fs.WriteAllText(registryPath, futureContent);
+
+ registry.ReadRegistry().Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void ReadRegistry_MalformedLine_SkippedNotFatal()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string registryPath = Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName);
+ string contents =
+ "2\n" +
+ "{ this is not valid json }\n" +
+ "{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"sid\",\"IsActive\":true}\n";
+ fs.WriteAllText(registryPath, contents);
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(1);
+ all[Repo1].OwnerSID.ShouldEqual("sid");
+ }
+
+ [TestCase]
+ public void ReadRegistry_BlankLinesIgnored()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string registryPath = Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName);
+ string contents =
+ "2\n" +
+ "\n" +
+ "{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"sid\",\"IsActive\":true}\n" +
+ "\n";
+ fs.WriteAllText(registryPath, contents);
+
+ registry.ReadRegistry().Count.ShouldEqual(1);
+ }
+
+ [TestCase]
+ public void ReadRegistry_OnDiskFormatMatchesServiceRegistry()
+ {
+ // The on-disk format MUST be wire-compatible with
+ // GVFS.Service.RepoRegistry: first line is the version
+ // (a bare integer); subsequent lines are JSON objects with
+ // EnlistmentRoot / OwnerSID / IsActive fields.
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string sid = Guid.NewGuid().ToString();
+ registry.TryRegisterRepo(Repo1, sid, out _).ShouldBeTrue();
+
+ string raw = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName));
+ string[] lines = raw.Replace("\r\n", "\n").TrimEnd('\n').Split('\n');
+
+ // Version line
+ lines[0].ShouldEqual(LocalRepoRegistry.RegistryVersion.ToString());
+
+ // Entry line is JSON with the three required fields
+ lines.Length.ShouldEqual(2);
+ lines[1].Contains("\"EnlistmentRoot\"").ShouldBeTrue();
+ lines[1].Contains("\"OwnerSID\"").ShouldBeTrue();
+ lines[1].Contains("\"IsActive\"").ShouldBeTrue();
+ lines[1].Contains(sid).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void RegisterAfterRead_PreservesOtherEntriesWrittenByAnotherProcess()
+ {
+ // Simulate another process having written an entry between
+ // construction and our register call: we read fresh on each
+ // operation, so the other entry must survive.
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string sid = Guid.NewGuid().ToString();
+
+ string contents =
+ "2\n" +
+ "{\"EnlistmentRoot\":\"" + Repo2.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"" + sid + "\",\"IsActive\":true}\n";
+ fs.WriteAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName), contents);
+
+ registry.TryRegisterRepo(Repo1, sid, out _).ShouldBeTrue();
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(2);
+ all.ContainsKey(Repo1).ShouldBeTrue();
+ all.ContainsKey(Repo2).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void Constructor_NullArgs_Throws()
+ {
+ MockFileSystem fs = new MockFileSystem(new MockDirectory(DataLocation, null, null));
+ Assert.Throws(() => new LocalRepoRegistry(null, fs, DataLocation));
+ Assert.Throws(() => new LocalRepoRegistry(new MockTracer(), null, DataLocation));
+ Assert.Throws(() => new LocalRepoRegistry(new MockTracer(), fs, null));
+ }
+
+ [TestCase]
+ public void LocalRepoRegistration_JsonRoundTrip()
+ {
+ LocalRepoRegistration original = new LocalRepoRegistration("path", "sid") { IsActive = false };
+ string json = original.ToJson();
+ LocalRepoRegistration roundTripped = LocalRepoRegistration.FromJson(json);
+
+ roundTripped.EnlistmentRoot.ShouldEqual(original.EnlistmentRoot);
+ roundTripped.OwnerSID.ShouldEqual(original.OwnerSID);
+ roundTripped.IsActive.ShouldEqual(original.IsActive);
+ }
+
+ [TestCase]
+ public void SeedFromSystemRegistry_NoUserRegistry_SeedsAccessibleRepos()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+
+ // Create a system registry with three entries
+ string systemRegistryDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "GVFS",
+ LocalRepoRegistry.ServiceDataDirName);
+ string systemRegistryPath = Path.Combine(systemRegistryDir, LocalRepoRegistry.RegistryFileName);
+
+ fs.CreateDirectory(systemRegistryDir);
+
+ string sid1 = Guid.NewGuid().ToString();
+ string sid2 = Guid.NewGuid().ToString();
+ string sid3 = Guid.NewGuid().ToString();
+
+ string systemContent =
+ "2\n" +
+ "{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"" + sid1 + "\",\"IsActive\":true}\n" +
+ "{\"EnlistmentRoot\":\"" + Repo2.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"" + sid2 + "\",\"IsActive\":false}\n" +
+ "{\"EnlistmentRoot\":\"" + Repo3.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"" + sid3 + "\",\"IsActive\":true}\n";
+
+ fs.WriteAllText(systemRegistryPath, systemContent);
+
+ // Only Repo1 and Repo2 directories exist
+ fs.CreateDirectory(Repo1);
+ fs.CreateDirectory(Repo2);
+
+ // Seed the user registry
+ registry.SeedFromSystemRegistryIfNeeded();
+
+ // Verify only Repo1 and Repo2 were seeded (Repo3 directory doesn't exist)
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(2);
+ all.ContainsKey(Repo1).ShouldBeTrue();
+ all.ContainsKey(Repo2).ShouldBeTrue();
+ all.ContainsKey(Repo3).ShouldBeFalse();
+
+ // Verify the seeded entries preserve their original state
+ VerifyEntry(all[Repo1], expectedOwnerSID: sid1, expectedIsActive: true);
+ VerifyEntry(all[Repo2], expectedOwnerSID: sid2, expectedIsActive: false);
+ }
+
+ [TestCase]
+ public void SeedFromSystemRegistry_UserRegistryExists_DoesNotOverwrite()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+
+ // Create existing user registry
+ string ownerSID = Guid.NewGuid().ToString();
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ string userContentBefore = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName));
+
+ // Create system registry
+ string systemRegistryDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "GVFS",
+ LocalRepoRegistry.ServiceDataDirName);
+ string systemRegistryPath = Path.Combine(systemRegistryDir, LocalRepoRegistry.RegistryFileName);
+
+ fs.CreateDirectory(systemRegistryDir);
+ fs.WriteAllText(systemRegistryPath, "2\n{\"EnlistmentRoot\":\"" + Repo2.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"other\",\"IsActive\":true}\n");
+ fs.CreateDirectory(Repo2);
+
+ // Attempt to seed
+ registry.SeedFromSystemRegistryIfNeeded();
+
+ // User registry should be unchanged
+ string userContentAfter = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName));
+ userContentAfter.ShouldEqual(userContentBefore);
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(1);
+ all.ContainsKey(Repo1).ShouldBeTrue();
+ all.ContainsKey(Repo2).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void SeedFromSystemRegistry_NoSystemRegistry_DoesNothing()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+
+ // No system registry exists, user registry is empty
+ registry.SeedFromSystemRegistryIfNeeded();
+
+ // User registry should still be empty
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void SeedFromSystemRegistry_EmptySystemRegistry_DoesNothing()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+
+ // Create empty system registry
+ string systemRegistryDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "GVFS",
+ LocalRepoRegistry.ServiceDataDirName);
+ string systemRegistryPath = Path.Combine(systemRegistryDir, LocalRepoRegistry.RegistryFileName);
+
+ fs.CreateDirectory(systemRegistryDir);
+ fs.WriteAllText(systemRegistryPath, "2\n");
+
+ registry.SeedFromSystemRegistryIfNeeded();
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void SeedFromSystemRegistry_AllReposInaccessible_CreatesEmptyUserRegistry()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+
+ // Create system registry with entries whose directories don't exist
+ string systemRegistryDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "GVFS",
+ LocalRepoRegistry.ServiceDataDirName);
+ string systemRegistryPath = Path.Combine(systemRegistryDir, LocalRepoRegistry.RegistryFileName);
+
+ fs.CreateDirectory(systemRegistryDir);
+ fs.WriteAllText(
+ systemRegistryPath,
+ "2\n{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"sid\",\"IsActive\":true}\n");
+
+ // Don't create Repo1 directory
+
+ registry.SeedFromSystemRegistryIfNeeded();
+
+ // User registry should be empty (no accessible repos to seed)
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(0);
+ }
+
+ private (LocalRepoRegistry registry, MockFileSystem fs) CreateRegistry()
+ {
+ MockFileSystem fs = new MockFileSystem(new MockDirectory(DataLocation, null, null));
+ LocalRepoRegistry registry = new LocalRepoRegistry(new MockTracer(), fs, DataLocation);
+ return (registry, fs);
+ }
+
+ private static void VerifyEntry(LocalRepoRegistration entry, string expectedOwnerSID, bool expectedIsActive)
+ {
+ entry.ShouldNotBeNull();
+ entry.OwnerSID.ShouldEqual(expectedOwnerSID);
+ entry.IsActive.ShouldEqual(expectedIsActive);
+ }
+ }
+}
diff --git a/GVFS/GVFS.UnitTests/Common/LogonTaskRegistrationTests.cs b/GVFS/GVFS.UnitTests/Common/LogonTaskRegistrationTests.cs
new file mode 100644
index 000000000..8bc76fcf1
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Common/LogonTaskRegistrationTests.cs
@@ -0,0 +1,265 @@
+using GVFS.Common;
+using GVFS.Tests.Should;
+using GVFS.UnitTests.Mock.Common;
+using NUnit.Framework;
+using System;
+using System.Collections.Generic;
+
+namespace GVFS.UnitTests.Common
+{
+ [TestFixture]
+ public class LogonTaskRegistrationTests
+ {
+ private const string TestGvfsPath = @"C:\Users\test\AppData\Local\Programs\GVFS\Current\gvfs.exe";
+
+ [TestCase]
+ public void TemplateHash_IsStableAcrossCalls()
+ {
+ // Same template content => same hash, every time.
+ LogonTaskRegistration.TemplateHash.ShouldEqual(LogonTaskRegistration.TemplateHash);
+ // Full SHA-256 hex = 64 chars
+ LogonTaskRegistration.TemplateHash.Length.ShouldEqual(64);
+ }
+
+ [TestCase]
+ public void BuildTaskXml_SubstitutesAllPlaceholders()
+ {
+ string xml = LogonTaskRegistration.BuildTaskXml(TestGvfsPath);
+
+ xml.Contains(LogonTaskRegistration.GvfsPathPlaceholder).ShouldBeFalse("no GVFS_PATH placeholder should remain");
+ xml.Contains(LogonTaskRegistration.TaskHashPlaceholder).ShouldBeFalse("no TASK_HASH placeholder should remain");
+
+ xml.Contains(TestGvfsPath).ShouldBeTrue("gvfs.exe path should appear in the XML");
+ xml.Contains(LogonTaskRegistration.TemplateHash).ShouldBeTrue("template hash should appear in the XML");
+ }
+
+ [TestCase]
+ public void BuildTaskXml_ProducesMountAllArguments()
+ {
+ string xml = LogonTaskRegistration.BuildTaskXml(TestGvfsPath);
+ // The task action runs `gvfs.exe service --mount-all`.
+ xml.Contains("service --mount-all").ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void BuildTaskXml_NullOrEmptyArgsThrow()
+ {
+ // Assert.Catch accepts derived types (ArgumentNullException is
+ // also raised by ThrowIfNullOrEmpty for null inputs).
+ Assert.Catch(() => LogonTaskRegistration.BuildTaskXml(null));
+ Assert.Catch(() => LogonTaskRegistration.BuildTaskXml(""));
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_FindsMarkerInDescription()
+ {
+ string description = "Mounts the user's enlistments at logon. [gvfs-logon-task-hash=DEADBEEF12345678ABCDEF0123456789FEDCBA9876543210CAFEBABE12345678]";
+ LogonTaskRegistration.TryExtractHashMarker(description, out string hash).ShouldBeTrue();
+ hash.ShouldEqual("DEADBEEF12345678ABCDEF0123456789FEDCBA9876543210CAFEBABE12345678");
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_NoMarker_ReturnsFalse()
+ {
+ LogonTaskRegistration.TryExtractHashMarker("Just a plain description.", out string hash).ShouldBeFalse();
+ hash.ShouldBeNull();
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_EmptyOrNull_ReturnsFalse()
+ {
+ LogonTaskRegistration.TryExtractHashMarker(null, out _).ShouldBeFalse();
+ LogonTaskRegistration.TryExtractHashMarker("", out _).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_MalformedMarker_ReturnsFalse()
+ {
+ // Opening prefix but no closing bracket
+ LogonTaskRegistration.TryExtractHashMarker("foo [gvfs-logon-task-hash=ABCD no close", out _).ShouldBeFalse();
+ // Closing bracket before any content
+ LogonTaskRegistration.TryExtractHashMarker("foo [gvfs-logon-task-hash=]", out _).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_FindsMarkerInGeneratedXml()
+ {
+ // Round-trip: the XML produced by BuildTaskXml must contain the
+ // template hash, and TryExtractHashMarker must recover it.
+ string xml = LogonTaskRegistration.BuildTaskXml(TestGvfsPath);
+ LogonTaskRegistration.TryExtractHashMarker(xml, out string hash).ShouldBeTrue();
+ hash.ShouldEqual(LogonTaskRegistration.TemplateHash);
+ }
+
+ [TestCase]
+ public void IsCurrent_NoRegisteredTask_ReturnsFalse()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ // Default mock has no registered tasks => TryQueryXml fails.
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+ reg.IsCurrent().ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void IsCurrent_MatchingHash_ReturnsTrue()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ LogonTaskRegistration.BuildTaskXml(TestGvfsPath);
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+ reg.IsCurrent().ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void IsCurrent_DifferentHash_ReturnsFalse()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ // Simulate a task registered by a previous template version
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ "Old. [gvfs-logon-task-hash=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA]";
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+ reg.IsCurrent().ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void IsCurrent_TaskExistsButNoMarker_ReturnsFalse()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ "Manually edited, no marker.";
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+ reg.IsCurrent().ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryRegisterOrUpdate_CreatesNewTask()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryRegisterOrUpdate(TestGvfsPath, out string error).ShouldBeTrue(error);
+
+ invoker.RegisteredTasks.ContainsKey(LogonTaskRegistration.FullTaskPath).ShouldBeTrue();
+ invoker.RegisterCallCount.ShouldEqual(1);
+ }
+
+ [TestCase]
+ public void TryRegisterOrUpdate_AlreadyCurrentSameArgs_NoRewrite()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ LogonTaskRegistration.BuildTaskXml(TestGvfsPath);
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryRegisterOrUpdate(TestGvfsPath, out _).ShouldBeTrue();
+ invoker.RegisterCallCount.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void TryRegisterOrUpdate_CurrentHashButDifferentGvfsPath_Reregisters()
+ {
+ // GVFS install moved (junction swap). Template hash unchanged
+ // but the Command path in the task points at the old location.
+ // We must rewrite.
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ string oldPath = @"C:\Users\test\AppData\Local\Programs\GVFS\Versions\0.1.0\gvfs.exe";
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ LogonTaskRegistration.BuildTaskXml(oldPath);
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryRegisterOrUpdate(TestGvfsPath, out _).ShouldBeTrue();
+ invoker.RegisterCallCount.ShouldEqual(1);
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath].Contains(TestGvfsPath).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void TryRegisterOrUpdate_InvokerFails_SurfacesError()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.NextRegisterError = "Permission denied";
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryRegisterOrUpdate(TestGvfsPath, out string error).ShouldBeFalse();
+ error.ShouldEqual("Permission denied");
+ }
+
+ [TestCase]
+ public void TryUnregister_DelegatesToInvoker()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ LogonTaskRegistration.BuildTaskXml(TestGvfsPath);
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryUnregister(out string error).ShouldBeTrue(error);
+ invoker.RegisteredTasks.ContainsKey(LogonTaskRegistration.FullTaskPath).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryUnregister_TaskNotRegistered_StillReturnsTrue()
+ {
+ // Idempotent: unregister of nothing is a success.
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryUnregister(out _).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void Constructor_NullArgs_Throws()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ Assert.Throws(() => new LogonTaskRegistration(null, invoker));
+ Assert.Throws(() => new LogonTaskRegistration(new MockTracer(), null));
+ }
+
+ private sealed class MockScheduledTaskInvoker : IScheduledTaskInvoker
+ {
+ public Dictionary RegisteredTasks { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ public string NextRegisterError { get; set; }
+ public string NextUnregisterError { get; set; }
+ public int RegisterCallCount { get; private set; }
+
+ public bool TryRegisterFromXml(string taskPath, string xml, out string errorMessage)
+ {
+ this.RegisterCallCount++;
+
+ if (!string.IsNullOrEmpty(this.NextRegisterError))
+ {
+ errorMessage = this.NextRegisterError;
+ return false;
+ }
+
+ this.RegisteredTasks[taskPath] = xml;
+ errorMessage = string.Empty;
+ return true;
+ }
+
+ public bool TryQueryXml(string taskPath, out string xml, out string errorMessage)
+ {
+ if (this.RegisteredTasks.TryGetValue(taskPath, out xml))
+ {
+ errorMessage = string.Empty;
+ return true;
+ }
+
+ xml = null;
+ errorMessage = "Task not found";
+ return false;
+ }
+
+ public bool TryUnregister(string taskPath, out string errorMessage)
+ {
+ if (!string.IsNullOrEmpty(this.NextUnregisterError))
+ {
+ errorMessage = this.NextUnregisterError;
+ return false;
+ }
+
+ this.RegisteredTasks.Remove(taskPath);
+ errorMessage = string.Empty;
+ return true;
+ }
+ }
+ }
+}
diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs
index 51b693578..036345f09 100644
--- a/GVFS/GVFS/CommandLine/GVFSVerb.cs
+++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs
@@ -16,8 +16,6 @@ namespace GVFS.CommandLine
{
public abstract class GVFSVerb
{
- protected const string StartServiceInstructions = "Run 'sc start GVFS.Service' from an elevated command prompt to ensure it is running.";
-
private readonly bool validateOriginURL;
public GVFSVerb(bool validateOrigin = true)
@@ -198,22 +196,6 @@ protected bool ShowStatusWhileRunning(
initialDelayMs: 0);
}
- protected bool ShowStatusWhileRunning(
- Func action,
- Func getMessage,
- string message,
- string gvfsLogEnlistmentRoot)
- {
- return ConsoleHelper.ShowStatusWhileRunning(
- action,
- getMessage,
- message,
- this.Output,
- showSpinner: !this.Unattended && this.Output == Console.Out && !Console.IsOutputRedirected,
- gvfsLogEnlistmentRoot: gvfsLogEnlistmentRoot,
- initialDelayMs: 0);
- }
-
protected bool ShowStatusWhileRunning(
Func action,
string message,
@@ -586,8 +568,19 @@ protected bool TryEnableAndAttachPrjFltThroughService(string enlistmentRoot, out
{
if (!client.Connect())
{
- errorMessage = "GVFS.Service is not responding. " + GVFSVerb.StartServiceInstructions;
- return false;
+ // Service not available (user-level install model). Trigger
+ // the EnableProjFSOnAllDrives scheduled task to ensure
+ // PrjFlt is attached to this volume. Task failures are not
+ // fatal — if PrjFlt is truly missing at mount time, the
+ // mount-process filter check will catch it.
+ ProcessResult result = ProcessHelper.Run(
+ "schtasks.exe",
+ "/Run /TN \"\\GVFS\\EnableProjFSOnAllDrives\"");
+
+ // Task not registered or failed — may be fine if PrjFlt
+ // is already attached. Continue and let mount validate.
+ errorMessage = string.Empty;
+ return true;
}
try
diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs
index 53077bba4..03f083149 100644
--- a/GVFS/GVFS/CommandLine/MountVerb.cs
+++ b/GVFS/GVFS/CommandLine/MountVerb.cs
@@ -14,7 +14,6 @@ public class MountVerb : GVFSVerb.ForExistingEnlistment
{
private const string MountVerbName = "mount";
private Process mountProcess;
- private volatile string currentMountProgress;
public string Verbosity { get; set; }
@@ -198,9 +197,7 @@ protected override void Execute(GVFSEnlistment enlistment)
if (!this.ShowStatusWhileRunning(
() => { return this.TryMount(tracer, enlistment, mountExecutableLocation, out errorMessage); },
- getMessage: () => this.currentMountProgress,
- "Mounting",
- enlistment.WorkingDirectoryRoot))
+ "Mounting"))
{
ReturnCode mountExitCode = ReturnCode.GenericError;
if (this.mountProcess != null)
@@ -238,7 +235,7 @@ protected override void Execute(GVFSEnlistment enlistment)
tracer.RelatedInfo($"{nameof(this.Execute)}: Registering for automount");
if (this.ShowStatusWhileRunning(
- () => { return this.RegisterMount(enlistment, out errorMessage); },
+ () => { return this.RegisterMount(enlistment, tracer, out errorMessage); },
"Registering for automount"))
{
tracer.RelatedInfo($"{nameof(this.Execute)}: Registered for automount");
@@ -280,35 +277,42 @@ private bool TryMount(ITracer tracer, GVFSEnlistment enlistment, string mountExe
tracer.RelatedInfo($"{nameof(this.TryMount)}: Waiting for repo to be mounted");
- return GVFSEnlistment.WaitUntilMounted(
- tracer,
- enlistment.NamedPipeName,
- enlistment.WorkingDirectoryRoot,
- this.Unattended,
- out errorMessage,
- onProgress: progress => this.currentMountProgress = progress);
+ return GVFSEnlistment.WaitUntilMounted(tracer, enlistment.NamedPipeName, enlistment.WorkingDirectoryRoot, this.Unattended, out errorMessage);
}
- private bool RegisterMount(GVFSEnlistment enlistment, out string errorMessage)
+ private bool RegisterMount(GVFSEnlistment enlistment, ITracer tracer, out string errorMessage)
{
errorMessage = string.Empty;
- NamedPipeMessages.RegisterRepoRequest request = new NamedPipeMessages.RegisterRepoRequest();
-
// Worktree mounts register with their worktree path so they can be
// listed and unregistered independently of the primary enlistment.
- request.EnlistmentRoot = enlistment.IsWorktree
+ string enlistmentRoot = enlistment.IsWorktree
? enlistment.WorkingDirectoryRoot
: enlistment.PrimaryEnlistmentRoot;
+ string ownerSID = GVFSPlatform.Instance.GetCurrentUser();
- request.OwnerSID = GVFSPlatform.Instance.GetCurrentUser();
+ NamedPipeMessages.RegisterRepoRequest request = new NamedPipeMessages.RegisterRepoRequest();
+ request.EnlistmentRoot = enlistmentRoot;
+ request.OwnerSID = ownerSID;
using (NamedPipeClient client = new NamedPipeClient(this.ServicePipeName))
{
if (!client.Connect())
{
- errorMessage = "Unable to register repo because GVFS.Service is not responding.";
- return false;
+ // Service not installed or not running (typical for the
+ // user-level install model). Fall back to writing the
+ // registry file directly. The service writes to the same
+ // file at the same path when it IS running, so the two
+ // models can co-exist or be migrated between without any
+ // data being lost. We only reach this fallback when the
+ // pipe doesn't exist at all - if the service is present
+ // but mid-request crashes, that surfaces as
+ // BrokenPipeException below and we deliberately do NOT
+ // fall back (the service remains the authoritative
+ // writer in that case).
+ tracer.RelatedInfo($"{nameof(this.RegisterMount)}: GVFS.Service pipe unavailable; falling back to LocalRepoRegistry");
+ LocalRepoRegistry localRegistry = LocalRepoRegistry.CreateForCurrentPlatform(tracer);
+ return localRegistry.TryRegisterRepo(enlistmentRoot, ownerSID, out errorMessage);
}
try
diff --git a/GVFS/GVFS/CommandLine/ServiceVerb.cs b/GVFS/GVFS/CommandLine/ServiceVerb.cs
index 842521fa7..3fc3eecc0 100644
--- a/GVFS/GVFS/CommandLine/ServiceVerb.cs
+++ b/GVFS/GVFS/CommandLine/ServiceVerb.cs
@@ -1,6 +1,7 @@
using GVFS.Common;
using GVFS.Common.FileSystem;
using GVFS.Common.NamedPipes;
+using GVFS.Common.Tracing;
using System;
using System.Collections.Generic;
using System.IO;
@@ -156,8 +157,18 @@ private bool TryGetRepoList(out List repoList, out string errorMessage)
{
if (!client.Connect())
{
- errorMessage = "GVFS.Service is not responding.";
- return false;
+ // Service not installed or not running (typical for the
+ // user-level install model). Fall back to reading the
+ // registry file directly. See the matching comment in
+ // MountVerb.RegisterMount for the design rationale.
+ LocalRepoRegistry localRegistry = LocalRepoRegistry.CreateForCurrentPlatform(NullTracer.Instance);
+ if (!localRegistry.TryGetActiveRepos(out List activeRepos, out errorMessage))
+ {
+ return false;
+ }
+
+ repoList = activeRepos.Select(r => r.EnlistmentRoot).ToList();
+ return true;
}
try
diff --git a/GVFS/GVFS/CommandLine/UnmountVerb.cs b/GVFS/GVFS/CommandLine/UnmountVerb.cs
index 0de886a50..494ff5e81 100644
--- a/GVFS/GVFS/CommandLine/UnmountVerb.cs
+++ b/GVFS/GVFS/CommandLine/UnmountVerb.cs
@@ -1,5 +1,6 @@
using GVFS.Common;
using GVFS.Common.NamedPipes;
+using GVFS.Common.Tracing;
using System;
using System.Diagnostics;
@@ -204,7 +205,30 @@ private bool UnregisterRepo(string rootPath, out string errorMessage)
{
if (!client.Connect())
{
- errorMessage = "Unable to unregister repo because GVFS.Service is not responding. " + GVFSVerb.StartServiceInstructions;
+ // Service not installed or not running (typical for the
+ // user-level install model). Fall back to writing the
+ // registry file directly. See the matching comment in
+ // MountVerb.RegisterMount for the design rationale and
+ // the deliberate decision NOT to fall back on
+ // BrokenPipeException (the service-broken-mid-request
+ // case).
+ LocalRepoRegistry localRegistry = LocalRepoRegistry.CreateForCurrentPlatform(NullTracer.Instance);
+ if (localRegistry.TryDeactivateRepo(rootPath, out errorMessage))
+ {
+ return true;
+ }
+
+ // TryDeactivateRepo returns false for two reasons:
+ // 1. Entry not found — benign, nothing to unregister.
+ // 2. I/O error — real failure, propagate to caller.
+ // Distinguish by checking for the "non-existent" message
+ // that TryDeactivateRepo produces for case 1.
+ if (errorMessage != null && errorMessage.Contains("non-existent", StringComparison.OrdinalIgnoreCase))
+ {
+ errorMessage = string.Empty;
+ return true;
+ }
+
return false;
}
diff --git a/scripts/projfs-attach/build-task-xml.ps1 b/scripts/projfs-attach/build-task-xml.ps1
new file mode 100644
index 000000000..bbd8c4fdb
--- /dev/null
+++ b/scripts/projfs-attach/build-task-xml.ps1
@@ -0,0 +1,103 @@
+# build-task-xml.ps1
+#
+# Produces the final EnableProjFSOnAllDrives scheduled task XML by
+# base64-encoding enable-projfs-on-all-drives.ps1 and substituting it
+# (along with a content hash) into enable-projfs-on-all-drives-task.xml.template.
+#
+# Inputs and output are passed by parameter so this script is callable
+# from layout.bat, MSBuild, or directly during development.
+#
+# The hash embedded in the task Description (via __TASK_HASH__) is
+# SHA-256 over the un-encoded inputs (template + script body, in that
+# order, separated by a NUL byte). Stable across re-runs with
+# unchanged inputs; changes the moment either input's content
+# changes. This is what the installer's drift detection compares
+# against the registered task's Description marker to decide whether
+# re-registration is needed.
+#
+# Output XML is UTF-16 LE with BOM (required by Task Scheduler's
+# /XML import).
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$ScriptPath,
+
+ [Parameter(Mandatory = $true)]
+ [string]$TemplatePath,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath
+)
+
+$ErrorActionPreference = 'Stop'
+
+if (-not (Test-Path $ScriptPath)) { throw "Script not found: $ScriptPath" }
+if (-not (Test-Path $TemplatePath)) { throw "Template not found: $TemplatePath" }
+
+# Read raw bytes so the hash and the base64 are computed over exactly
+# what's on disk, regardless of line-ending or BOM conventions.
+$scriptBytes = [System.IO.File]::ReadAllBytes($ScriptPath)
+
+# Read the template as text (UTF-8 or UTF-16 with BOM both work for
+# Get-Content; the template is checked in as UTF-16 to match the XML
+# encoding declaration but we re-emit as UTF-16 with BOM either way).
+$templateText = [System.IO.File]::ReadAllText($TemplatePath)
+$templateBytes = [System.Text.Encoding]::UTF8.GetBytes($templateText)
+
+# PowerShell -EncodedCommand expects UTF-16 LE bytes, base64 encoded.
+$scriptUtf16 = [System.Text.Encoding]::Unicode.GetString($scriptBytes)
+# If the source script was UTF-8 (typical for files checked into git),
+# the line above produces garbage. Detect by checking for a UTF-8 BOM
+# or by attempting a UTF-8 decode and re-encoding to UTF-16.
+$scriptText =
+ if ($scriptBytes.Length -ge 3 -and $scriptBytes[0] -eq 0xEF -and $scriptBytes[1] -eq 0xBB -and $scriptBytes[2] -eq 0xBF) {
+ [System.Text.Encoding]::UTF8.GetString($scriptBytes, 3, $scriptBytes.Length - 3)
+ }
+ elseif ($scriptBytes.Length -ge 2 -and $scriptBytes[0] -eq 0xFF -and $scriptBytes[1] -eq 0xFE) {
+ [System.Text.Encoding]::Unicode.GetString($scriptBytes, 2, $scriptBytes.Length - 2)
+ }
+ else {
+ # Assume UTF-8 without BOM (git's default for text)
+ [System.Text.Encoding]::UTF8.GetString($scriptBytes)
+ }
+
+$scriptUtf16Bytes = [System.Text.Encoding]::Unicode.GetBytes($scriptText)
+$encodedCommand = [System.Convert]::ToBase64String($scriptUtf16Bytes)
+
+# Hash inputs: template bytes + NUL + script bytes (the raw bytes,
+# not re-encoded, so the hash is reproducible even if the encoding
+# detection logic is changed in a future revision of this script).
+$hasher = [System.Security.Cryptography.SHA256]::Create()
+try {
+ $combined = New-Object byte[] ($templateBytes.Length + 1 + $scriptBytes.Length)
+ [System.Buffer]::BlockCopy($templateBytes, 0, $combined, 0, $templateBytes.Length)
+ $combined[$templateBytes.Length] = 0
+ [System.Buffer]::BlockCopy($scriptBytes, 0, $combined, $templateBytes.Length + 1, $scriptBytes.Length)
+ $hashBytes = $hasher.ComputeHash($combined)
+ $hashHex = ([System.BitConverter]::ToString($hashBytes)).Replace('-', '')
+}
+finally {
+ $hasher.Dispose()
+}
+
+# Substitute placeholders. Order matters only because __SCRIPT_BASE64__
+# could in theory contain the __TASK_HASH__ literal -- highly unlikely
+# but trivially defended by substituting hash first.
+$finalXml = $templateText.
+ Replace('__TASK_HASH__', $hashHex).
+ Replace('__SCRIPT_BASE64__', $encodedCommand)
+
+# Ensure output directory exists.
+$outputDir = Split-Path -Parent $OutputPath
+if ($outputDir -and -not (Test-Path $outputDir)) {
+ New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
+}
+
+# Write UTF-16 LE with BOM (required by schtasks /Create /XML).
+[System.IO.File]::WriteAllText(
+ $OutputPath,
+ $finalXml,
+ (New-Object System.Text.UnicodeEncoding $false, $true))
+
+Write-Host "Wrote $OutputPath ($([System.IO.File]::ReadAllBytes($OutputPath).Length) bytes, hash=$hashHex)"
diff --git a/scripts/projfs-attach/enable-projfs-on-all-drives-task.xml.template b/scripts/projfs-attach/enable-projfs-on-all-drives-task.xml.template
new file mode 100644
index 000000000..9a7b735cd
--- /dev/null
+++ b/scripts/projfs-attach/enable-projfs-on-all-drives-task.xml.template
@@ -0,0 +1,84 @@
+
+
+
+
+ Microsoft\GVFS
+ Re-attaches the ProjFS filter (prjflt) to NTFS/ReFS volumes after reboot or volume mount. Required by VFS for Git in the user-level install model. [gvfs-task-hash=__TASK_HASH__]
+ \GVFS\EnableProjFSOnAllDrives
+
+
+
+ true
+
+
+ true
+ <QueryList><Query Id="0" Path="Microsoft-Windows-Partition/Diagnostic"><Select Path="Microsoft-Windows-Partition/Diagnostic">*[System[Provider[@Name='Microsoft-Windows-Partition'] and (EventID=1006)]]</Select></Query></QueryList>
+
+
+
+
+ S-1-5-18
+ HighestAvailable
+
+
+
+ Queue
+ false
+ false
+ true
+ true
+ false
+
+ false
+ false
+
+ true
+ true
+ false
+ false
+ false
+ PT5M
+ 5
+
+
+
+ powershell.exe
+ -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand __SCRIPT_BASE64__
+
+
+
diff --git a/scripts/projfs-attach/enable-projfs-on-all-drives.ps1 b/scripts/projfs-attach/enable-projfs-on-all-drives.ps1
new file mode 100644
index 000000000..c1c6bf4a5
--- /dev/null
+++ b/scripts/projfs-attach/enable-projfs-on-all-drives.ps1
@@ -0,0 +1,135 @@
+# enable-projfs-on-all-drives.ps1
+#
+# Source of truth for the EnableProjFSOnAllDrives scheduled task body.
+# This script is NOT deployed to disk in the user-mode install model;
+# instead, build-task-xml.ps1 base64-encodes the contents and embeds
+# them in the task XML's as -EncodedCommand. The
+# task then runs as: powershell.exe -EncodedCommand .
+#
+# Runs as LocalSystem (configured by the scheduled task) so it has
+# SE_LOAD_DRIVER_PRIVILEGE for FilterAttach and HKLM write access
+# for the Dev Drive allowed-filters registry.
+#
+# Two invocation modes (selected by the task's triggers):
+# 1. AT_SYSTEM_START - no DriveLetter argument. Reconciles the Dev
+# Drive allow-list (machine-wide) and attaches prjflt to every
+# eligible NTFS/ReFS volume. FilterAttach is not persistent
+# across reboots, so this is required every boot.
+# 2. Event 1006 from Microsoft-Windows-Partition/Diagnostic -
+# DriveLetter argument is the drive of the newly-mounted volume.
+# Attaches prjflt to just that one drive. Avoids work on every
+# USB plug-in / VHD mount.
+#
+# Logs to %ProgramData%\GVFS\enable-projfs-on-all-drives.log
+# (HKLM-writable from SYSTEM, persistent across reboots).
+#
+# Idempotent everywhere: fltmc NameCollision is treated as success,
+# fsutil devdrv setFiltersAllowed is a no-op if already set. Safe to
+# run repeatedly.
+
+[CmdletBinding()]
+param(
+ # If provided, only attempt to attach to this single drive letter.
+ # Used by the volume-mount trigger to scope work narrowly. When
+ # absent, all NTFS/ReFS volumes are processed (boot trigger path),
+ # and the Dev Drive allow-list is also reconciled.
+ [string]$DriveLetter
+)
+
+$ErrorActionPreference = 'Stop'
+
+$logDir = Join-Path $env:ProgramData 'GVFS'
+$logPath = Join-Path $logDir 'enable-projfs-on-all-drives.log'
+if (-not (Test-Path $logDir)) {
+ New-Item -ItemType Directory -Path $logDir -Force | Out-Null
+}
+
+function Write-Log([string]$msg) {
+ $line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $msg"
+ Add-Content -Path $logPath -Value $line -Encoding UTF8
+}
+
+function Set-PrjFltDevDriveAllowed {
+ # Dev Drives consult a machine-wide allow-list at mount time to
+ # decide which minifilters may attach. Without PrjFlt in the list,
+ # GVFS cannot work on Dev Drives even if we call FilterAttach.
+ # Set unconditionally; fsutil is a no-op if already set.
+ try {
+ $out = (& fsutil.exe devdrv setFiltersAllowed PrjFlt 2>&1 | Out-String).Trim()
+ if ($LASTEXITCODE -eq 0) {
+ Write-Log "DevDrive allow-list: PrjFlt allowed (output: $out)"
+ }
+ else {
+ # Non-fatal: on older Windows builds without Dev Drive
+ # support, fsutil devdrv may fail. Log and continue.
+ Write-Log "DevDrive allow-list: fsutil exit=$LASTEXITCODE (likely no Dev Drive support on this OS) output=$out"
+ }
+ }
+ catch {
+ Write-Log "DevDrive allow-list: exception (likely no Dev Drive support): $_"
+ }
+}
+
+function Add-PrjFltToVolume([string]$drive) {
+ $output = (& fltmc.exe attach PrjFlt "${drive}:" 2>&1 | Out-String).Trim()
+ $exit = $LASTEXITCODE
+ # NameCollision is success-equivalent: filter is already attached.
+ # Check the output BEFORE the exit code because fltmc returns exit
+ # 1 for NameCollision (despite it being benign).
+ if ($output -match 'instance already exists' -or
+ $output -match 'instance name collision' -or
+ $output -match '0x801f0012') {
+ Write-Log "OK ${drive}: already attached (NameCollision)"
+ return $true
+ }
+ if ($exit -ne 0) {
+ Write-Log "FAIL ${drive}: exit=$exit output=$output"
+ return $false
+ }
+ Write-Log "OK ${drive}: attached (output: $output)"
+ return $true
+}
+
+try {
+ Write-Log "===== enable-projfs-on-all-drives.ps1 starting (DriveLetter='$DriveLetter') ====="
+
+ if ($DriveLetter) {
+ # Single-volume mode (volume-mount trigger)
+ $drive = $DriveLetter.TrimEnd(':').TrimEnd('\').ToUpperInvariant()
+ if ($drive.Length -ne 1) {
+ Write-Log "ERROR: invalid DriveLetter '$DriveLetter' (parsed='$drive')"
+ exit 2
+ }
+ $vol = Get-Volume -DriveLetter $drive -ErrorAction SilentlyContinue
+ if (-not $vol) {
+ Write-Log "SKIP ${drive}: volume not found"
+ exit 0
+ }
+ if ($vol.FileSystemType -notin @('NTFS', 'ReFS')) {
+ Write-Log "SKIP ${drive}: filesystem=$($vol.FileSystemType) (not NTFS/ReFS)"
+ exit 0
+ }
+ Add-PrjFltToVolume $drive | Out-Null
+ }
+ else {
+ # All-volumes mode (boot trigger). Reconcile both the Dev Drive
+ # allow-list AND per-volume attachments. Cheap; idempotent.
+ Set-PrjFltDevDriveAllowed
+ $volumes = Get-Volume |
+ Where-Object {
+ $_.DriveLetter -and
+ $_.FileSystemType -in @('NTFS', 'ReFS')
+ }
+ Write-Log "Found $(@($volumes).Count) eligible volume(s)"
+ foreach ($v in $volumes) {
+ Add-PrjFltToVolume ([string]$v.DriveLetter) | Out-Null
+ }
+ }
+
+ Write-Log "===== enable-projfs-on-all-drives.ps1 done ====="
+}
+catch {
+ Write-Log "EXCEPTION: $_"
+ Write-Log $_.ScriptStackTrace
+ exit 3
+}