From f0848daf43a39fd61f25eecee700aeaf7a6174e2 Mon Sep 17 00:00:00 2001 From: "NathanNeurotic (Ripto)" <109461996+NathanNeurotic@users.noreply.github.com> Date: Thu, 28 May 2026 23:57:51 -0700 Subject: [PATCH] v0.15.3: ARCH-1 step 3 - extract DependencyProbe Third collaborator extracted from TrayContext. No user-visible changes. - New src/DependencyProbe.cs owns cached RcloneAvailable / WinFspInstalled state, TTL stamp, and the slow synchronous probes (File.Exists, spawn rclone version, registry lookups). Constructor takes Func rclone path provider + log callback. - TrayContext.Setup.cs delegates RcloneAvailable, WinFspInstalled, ProbeRcloneAvailableSync, ProbeWinFspInstalledSync. The async refresh worker stays in TrayContext (composes results into the setup-status line) and calls Deps.PublishProbeResults at the end. 53/53 tests green, FileVersion 0.15.3.0. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 9 +++++ src/DependencyProbe.cs | 79 ++++++++++++++++++++++++++++++++++++++++ src/Pixelpipe.Setup.cs | 83 ++++++++++++++---------------------------- 3 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 src/DependencyProbe.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e35796c..741623a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.15.3 + +ARCH-1 step 3. Third collaborator extracted from `TrayContext`. No user-visible changes. + +Changed: + +- **New `DependencyProbe` class** (`src/DependencyProbe.cs`) owns the cached `RcloneAvailable` / `WinFspInstalled` booleans plus their TTL stamp and the synchronous probes themselves (the slow `File.Exists` + `spawn rclone version` + registry lookups). Constructor takes a `Func` rclone path provider and a log callback. +- **`TrayContext.Setup.cs` delegates** the cached-state and probe methods. The async refresh worker (`RefreshDependencyStatusAsync`) stays in `TrayContext` because it composes results into the setup-status line and marshals back via `BeginUi`; it just hands the actual probing off to `DependencyProbe.PublishProbeResults`. + ## 0.15.2 ARCH-1 step 2. Second collaborator extracted from `TrayContext`. No user-visible changes. diff --git a/src/DependencyProbe.cs b/src/DependencyProbe.cs new file mode 100644 index 0000000..e809df6 --- /dev/null +++ b/src/DependencyProbe.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using Microsoft.Win32; + +namespace Pixelpipe +{ + // ARCH-1 step 3 (v0.15.3, audit): third collaborator extracted from + // the TrayContext partial. Owns the cached rclone / WinFsp availability + // state plus the synchronous probes themselves. + // + // The TTL + UI-thread-friendly cached accessors stay where they were + // semantically — UI callers ask "is rclone available?" and get back + // whatever the last refresh wrote, never touching disk on the UI thread. + // The async refresh worker (RefreshDependencyStatusAsync) stays in + // TrayContext for now because it composes results into the setup-status + // line; it just hands off the actual probe calls to this class. + internal sealed class DependencyProbe + { + public const int CacheTtlSeconds = 30; + + private readonly Func _rclonePathProvider; + private readonly Action _logIssue; + private volatile bool _cachedRcloneAvailable; + private volatile bool _cachedWinfspInstalled; + private DateTime _cachedStampUtc = DateTime.MinValue; + + public DependencyProbe(Func rclonePathProvider, Action logIssue) + { + _rclonePathProvider = rclonePathProvider; + _logIssue = logIssue; + } + + public bool RcloneAvailable { get { return _cachedRcloneAvailable; } } + public bool WinFspInstalled { get { return _cachedWinfspInstalled; } } + public DateTime LastProbeUtc { get { return _cachedStampUtc; } } + public bool IsStale { get { return (DateTime.UtcNow - _cachedStampUtc).TotalSeconds >= CacheTtlSeconds; } } + + // Called by the async refresh worker after the slow probes complete. + public void PublishProbeResults(bool rcloneAvailable, bool winfspInstalled) + { + _cachedRcloneAvailable = rcloneAvailable; + _cachedWinfspInstalled = winfspInstalled; + _cachedStampUtc = DateTime.UtcNow; + } + + // Synchronous rclone probe. Tries the resolved path first; if that + // file isn't present, falls back to spawning `rclone.exe version` + // (PATH resolution). Returns true if either path responds. + public bool ProbeRcloneSync(Func runProcessCapture) + { + try + { + string resolved = _rclonePathProvider == null ? null : _rclonePathProvider(); + if (!String.IsNullOrEmpty(resolved) && File.Exists(resolved)) return true; + if (runProcessCapture == null) return false; + string version = runProcessCapture("rclone.exe", 3000); + return !String.IsNullOrEmpty(version) && version.IndexOf("rclone", StringComparison.OrdinalIgnoreCase) >= 0; + } + catch (Exception ex) { if (_logIssue != null) _logIssue("dep probe rclone", ex); return false; } + } + + // Synchronous WinFsp probe. Two on-disk dll locations + two registry + // keys; any positive signal is enough. + public bool ProbeWinFspSync() + { + try + { + string pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + string pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (File.Exists(Path.Combine(pf86, "WinFsp", "bin", "winfsp-x64.dll"))) return true; + if (File.Exists(Path.Combine(pf, "WinFsp", "bin", "winfsp-x64.dll"))) return true; + using (RegistryKey k1 = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WinFsp")) { if (k1 != null) return true; } + using (RegistryKey k2 = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\WinFsp")) { if (k2 != null) return true; } + } + catch (Exception ex) { if (_logIssue != null) _logIssue("dep probe winfsp", ex); } + return false; + } + } +} diff --git a/src/Pixelpipe.Setup.cs b/src/Pixelpipe.Setup.cs index a999ff4..9139112 100644 --- a/src/Pixelpipe.Setup.cs +++ b/src/Pixelpipe.Setup.cs @@ -43,70 +43,45 @@ private void RunFirstLaunchSetup(bool manual) } } - // PERF-1 (v0.13.1): both RcloneAvailable and WinFspInstalled used - // to be called repeatedly from UI-thread paths (ApplyLiveState, - // UpdateMenuLiveState) every refresh tick. RcloneAvailable on a - // cold path could spawn `rclone.exe version` with a 3 s wait — - // multiple times per ~7 s refresh = visible stutter. We now cache - // the booleans with a TTL; RefreshDependencyStatusAsync (which - // already runs off-thread) is the one place that does the real - // probes and writes the cached fields. UI callers read the cached - // values via the public methods below, which never touch disk. - private const int DependencyCacheTtlSeconds = 30; - private volatile bool cachedRcloneAvailable; - private volatile bool cachedWinfspInstalled; - private DateTime cachedDependencyStampUtc = DateTime.MinValue; - - private bool RcloneAvailable() + // ARCH-1 step 3 (v0.15.3): cached dependency state + sync probes + // moved into DependencyProbe. The async refresh worker stays here + // because it composes results into the setup-status line and posts + // back via BeginUi. + private DependencyProbe _depProbe; + private DependencyProbe Deps { - EnsureDependencyCacheFresh(); - return cachedRcloneAvailable; + get + { + if (_depProbe == null) + { + _depProbe = new DependencyProbe( + delegate { rclonePath = FindRclonePath(); return rclonePath; }, + LogUiIssue); + } + return _depProbe; + } } - private bool WinFspInstalled() + private bool RcloneAvailable() { - EnsureDependencyCacheFresh(); - return cachedWinfspInstalled; + if (Deps.IsStale) RefreshDependencyStatusAsync(false); + return Deps.RcloneAvailable; } - // Lazy first-call probe: if the cache is empty / stale and we're on - // a UI thread, kick the async refresh and return whatever we have - // (false on cold start). UI updates within one refresh cycle. - private void EnsureDependencyCacheFresh() + private bool WinFspInstalled() { - if ((DateTime.UtcNow - cachedDependencyStampUtc).TotalSeconds < DependencyCacheTtlSeconds) return; - RefreshDependencyStatusAsync(false); + if (Deps.IsStale) RefreshDependencyStatusAsync(false); + return Deps.WinFspInstalled; } - // Synchronous probes — the real File.Exists / spawn-rclone / registry - // work — called only from the async refresh worker. Anything UI-bound - // must go through RcloneAvailable() / WinFspInstalled() above. + // Compatibility shims used by the refresh worker; both just forward + // to DependencyProbe. private bool ProbeRcloneAvailableSync() { - try - { - rclonePath = FindRclonePath(); - if (File.Exists(rclonePath)) return true; - string version = RunProcessCapture("rclone.exe", "version", 3000); - return version.IndexOf("rclone", StringComparison.OrdinalIgnoreCase) >= 0; - } - catch { return false; } + return Deps.ProbeRcloneSync(delegate(string exe, int timeoutMs) { return RunProcessCapture(exe, "version", timeoutMs); }); } - private bool ProbeWinFspInstalledSync() - { - try - { - string pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); - string pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - if (File.Exists(Path.Combine(pf86, "WinFsp", "bin", "winfsp-x64.dll"))) return true; - if (File.Exists(Path.Combine(pf, "WinFsp", "bin", "winfsp-x64.dll"))) return true; - using (RegistryKey k1 = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WinFsp")) { if (k1 != null) return true; } - using (RegistryKey k2 = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\WinFsp")) { if (k2 != null) return true; } - } - catch { } - return false; - } + private bool ProbeWinFspInstalledSync() { return Deps.ProbeWinFspSync(); } private bool AnyRemoteConfigured() { @@ -140,7 +115,7 @@ private string[] GetCachedRcloneRemotes(bool force) // Cheap cached lookup — never triggers a fresh disk probe; if // the dependency cache hasn't been seeded yet this returns false // and we keep the existing remotes cache rather than spinning. - if (!cachedRcloneAvailable) return cachedRcloneRemotes; + if (!Deps.RcloneAvailable) return cachedRcloneRemotes; string output; try { output = RunRcloneCapture("listremotes", 6000); } catch (Exception ex) { LogUiIssue("listremotes", ex); return cachedRcloneRemotes; } @@ -183,9 +158,7 @@ private void RefreshDependencyStatusAsync(bool force) catch (Exception ex) { LogUiIssue("dependency status", ex); text = setupStatusText; } BeginUi(delegate { - cachedRcloneAvailable = rcloneProbe; - cachedWinfspInstalled = winfspProbe; - cachedDependencyStampUtc = DateTime.UtcNow; + Deps.PublishProbeResults(rcloneProbe, winfspProbe); setupStatusText = text; lastDependencyRefreshUtc = DateTime.UtcNow; Interlocked.Exchange(ref dependencyRefreshingFlag, 0);