Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.0.10-beta
0.0.11-beta
38 changes: 27 additions & 11 deletions apps/CheckOrchestrator.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Threading.Channels;

using apps.Components;

using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -47,20 +49,23 @@ public sealed class CheckOrchestrator(IEnumerable<IScanner> scanners, LiveProgre

int total = 0, errors = 0;
var checkedApps = new List<AppRecord>();
foreach (var (scanner, scannerApps) in appGroups)
{
await foreach (var (app, error) in scanner.CheckAsync(scannerApps, cancellationToken))
{
total++;

if (error)
{
errors++;
}
// Fan every scanner's CheckAsync out concurrently and merge the results through a single
// bounded channel. A slow scanner (e.g. per-cask Homebrew lookups) no longer blocks the
// others, and all mutation of the counters/list happens on the single reader below.
await foreach (var (app, error) in appGroups.WhenAll<(IScanner Scanner, AppRecord[] Apps), (AppRecord App, bool Error)>(
onPublication: RunCheckGroupAsync,
cancellationToken: cancellationToken))
{
total++;

checkedApps.Add(app);
renderer.RenderCheckActive(total);
if (error)
{
errors++;
}

checkedApps.Add(app);
renderer.RenderCheckActive(total);
}

await timerCts.CancelAsync().ConfigureAwait(false);
Expand All @@ -80,4 +85,15 @@ public sealed class CheckOrchestrator(IEnumerable<IScanner> scanners, LiveProgre

return (total, updates, errors);
}

private static async Task RunCheckGroupAsync(
(IScanner Scanner, AppRecord[] Apps) group,
ChannelWriter<(AppRecord App, bool Error)> writer,
CancellationToken cancellationToken)
{
await foreach (var result in group.Scanner.CheckAsync(group.Apps, cancellationToken).ConfigureAwait(false))
{
await writer.WriteAsync(result, cancellationToken).ConfigureAwait(false);
}
}
}
18 changes: 10 additions & 8 deletions apps/Components/Go/GoScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,18 +209,20 @@ private async IAsyncEnumerable<DiscoveredApp> EnumerateTools([EnumeratorCancella
yield break;
}

foreach (var binaryPath in binaries)
// `go version -m` is one subprocess per binary; run them concurrently (bounded by
// ProcessRunner's global cap) instead of serially so a directory of N tools does not
// cost N × spawn latency end to end.
var probes = binaries
.Where(p => !Path.GetFileName(p).StartsWith('.'))
.Select(binaryPath => (binaryPath, task: runner.RunAsync(_executablePath!, $"version -m \"{binaryPath}\"", cancellationToken)))
.ToArray();

foreach (var (binaryPath, task) in probes)
{
cancellationToken.ThrowIfCancellationRequested();

// Skip non-executable files and hidden files
var name = Path.GetFileName(binaryPath);
if (name.StartsWith('.'))
{
continue;
}

var result = await runner.RunAsync(_executablePath!, $"version -m \"{binaryPath}\"", cancellationToken);
var result = await task;

if (!result.Success
|| result.StandardOutput.Contains("not an executable", StringComparison.OrdinalIgnoreCase)
Expand Down
20 changes: 14 additions & 6 deletions apps/Components/MacOs/MacApplicationsScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ public async IAsyncEnumerable<DiscoveredApp> ScanAsync([EnumeratorCancellation]

public async IAsyncEnumerable<(AppRecord App, bool Error)> CheckAsync(AppRecord[] apps, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var resolvedApps = new List<AppRecord>();
var resolvedApps = new HashSet<AppRecord>();
var resolvedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var erroredApps = new HashSet<AppRecord>();
await foreach (var (app, resolved, error) in apps.WhenAll<AppRecord, (AppRecord App, bool Resolved, bool Error)>(onPublication: CheckAppAsync, cancellationToken: cancellationToken))
{
Expand All @@ -99,10 +100,11 @@ public async IAsyncEnumerable<DiscoveredApp> ScanAsync([EnumeratorCancellation]
}

resolvedApps.Add(app);
resolvedNames.Add(app.App.Name);
yield return (app, error);
}

var unresolvedApps = apps.Where(a => !resolvedApps.Any(r => r.App.Name.Equals(a.App.Name, StringComparison.OrdinalIgnoreCase))).ToList();
var unresolvedApps = apps.Where(a => !resolvedNames.Contains(a.App.Name)).ToList();
await foreach (var (app, resolved, error) in unresolvedApps.WhenAll<AppRecord, (AppRecord App, bool Resolved, bool Error)>(onPublication: CheckHomebrewAsync, cancellationToken: cancellationToken))
{
if (error)
Expand All @@ -120,8 +122,8 @@ public async IAsyncEnumerable<DiscoveredApp> ScanAsync([EnumeratorCancellation]
yield return (app, error);
}

unresolvedApps = apps.Except(resolvedApps).ToList();
foreach (var record in unresolvedApps)
var stillUnresolved = apps.Where(a => !resolvedApps.Contains(a)).ToList();
foreach (var record in stillUnresolved)
{
var errored = erroredApps.Contains(record);
logger.LogDebug("Failed to resolve update information for {AppName}, skipping (errored: {Errored})", record.App.Name, errored);
Expand Down Expand Up @@ -458,10 +460,16 @@ private async IAsyncEnumerable<DiscoveredApp> EnumerateHomebrew([EnumeratorCance
}

/// <summary>
/// Discovers pending macOS software updates via <c>softwareupdate --list --all</c>.
/// Discovers pending macOS software updates via <c>softwareupdate --list --no-scan</c>.
/// Each item is emitted with <see cref="DiscoveredApp.LatestVersion"/> pre-filled so that
/// <c>CheckAppAsync</c> treats it as already resolved (no further remote check needed).
/// </summary>
/// <remarks>
/// <c>--no-scan</c> reads the result of macOS's own periodic background scan instead of
/// contacting Apple's servers synchronously. A live <c>--list</c> can block 10–50s on Apple's
/// latency and gate the entire scan phase; the cached result is effectively instant and stays
/// fresh because macOS scans on its own schedule.
/// </remarks>
private async IAsyncEnumerable<DiscoveredApp> EnumerateSoftwareUpdates([EnumeratorCancellation] CancellationToken cancellationToken)
{
const string swuPath = "/usr/sbin/softwareupdate";
Expand All @@ -470,7 +478,7 @@ private async IAsyncEnumerable<DiscoveredApp> EnumerateSoftwareUpdates([Enumerat
yield break;
}

var result = await runner.RunAsync(swuPath, "--list --all", cancellationToken);
var result = await runner.RunAsync(swuPath, "--list --no-scan", cancellationToken);
var output = result.StandardOutput + result.StandardError;

foreach (var (label, version) in ParseSoftwareUpdates(output))
Expand Down
2 changes: 1 addition & 1 deletion apps/Components/MacOs/MacOsRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public static class MacOsRegistration
/// <summary>Adds the macOS Applications scanner with integrated update checks.</summary>
public static IServiceCollection AddMacOs(this IServiceCollection services)
{
services.AddCheckerClient("homebrew-api", "https://formulae.brew.sh", 4);
services.AddCheckerClient("homebrew-api", "https://formulae.brew.sh", 12);
services.AddCheckerClient("generic", "https://example.com", 4);
services.AddCheckerClient("github", "https://github.com", 10);
services.AddCheckerClient("github-api", "https://api.github.com", 10);
Expand Down
11 changes: 11 additions & 0 deletions apps/Components/ScannerHelper.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Concurrent;

namespace apps.Components;

/// <summary>
Expand All @@ -7,6 +9,10 @@ internal static class ScannerHelper
{
private static readonly Dictionary<string, string[]> WindowsFallbacks = [];

// Executable locations are stable for the life of the process; memoize so repeated
// IsAvailable()/lookup calls don't re-walk PATH and probe the filesystem each time.
private static readonly ConcurrentDictionary<string, string?> ExecutableCache = new();

static ScannerHelper()
{
if (OperatingSystem.IsWindows())
Expand Down Expand Up @@ -37,6 +43,11 @@ static ScannerHelper()
/// /usr/bin, /bin). Returns the full path on success, or <c>null</c> if not found.
/// </summary>
public static string? FindExecutable(string name)
{
return ExecutableCache.GetOrAdd(name, FindExecutableCore);
}

private static string? FindExecutableCore(string name)
{
// Walk the actual PATH so user-overridden versions are preferred.
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
Expand Down
13 changes: 9 additions & 4 deletions apps/PlistReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,6 @@ private static PlistInfo ParseXmlPlist(string appBundlePath, string xml)
doc.LoadXml(xml);

// Apple XML plist schema: <plist><dict><key>…</key><string>…</string>…</dict></plist>
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(xml));

var dict = doc.SelectSingleNode("/plist") ?? throw new FormatException("No top-level <dict> in plist");
var plist = Plist.Parse(dict);

Expand Down Expand Up @@ -237,8 +235,15 @@ private static PlistInfo ParseXmlPlist(string appBundlePath, string xml)

private static bool HasAppStoreReceipt(string appBundlePath)
{
// _MASReceipt sits in Contents/ for native Mac apps and elsewhere
// for iOS-on-Mac bundles, so don't assume a fixed path.
// Native Mac apps — the overwhelming majority — keep the receipt at the fixed
// Contents/_MASReceipt/receipt path; check it directly to avoid walking the whole bundle.
if (File.Exists(Path.Join(appBundlePath, "Contents", "_MASReceipt", "receipt")))
{
return true;
}

// iOS-on-Mac bundles nest the receipt under Wrapper/*.app; fall back to a recursive
// walk only for those (rare) layouts.
return Directory.EnumerateDirectories(appBundlePath, "_MASReceipt", SearchOption.AllDirectories)
.Any(dir => File.Exists(Path.Combine(dir, "receipt")));
}
Expand Down
9 changes: 6 additions & 3 deletions apps/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
namespace apps;

/// <summary>
/// Runs subprocesses with a global concurrency cap of 6 to avoid overloading the system.
/// Runs subprocesses with a global concurrency cap (one per logical core, floor of 6) to avoid overloading the system.
/// stdout and stderr are read concurrently with WaitForExitAsync to prevent deadlocks
/// when a child fills its pipe buffer.
/// Each subprocess is killed after <see cref="DefaultTimeout"/> if it has not exited.
/// </summary>
public sealed class ProcessRunner(ILogger<ProcessRunner> logger) : IProcessRunner
{
// Global cap: never run more than 6 subprocesses simultaneously
private static readonly SemaphoreSlim Cap = new(6, 6);
// Global cap on concurrent subprocesses. Child work (brew, npm, go, …) is I/O-bound —
// mostly waiting on disk and network — so allow one per logical core (floor of 6) rather
// than a fixed 6, which under-utilises modern multi-core Macs.
private static readonly SemaphoreSlim Cap =
new(Math.Max(6, Environment.ProcessorCount), Math.Max(6, Environment.ProcessorCount));

private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60);

Expand Down
57 changes: 36 additions & 21 deletions apps/ProjectManifestFinder.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;

using Microsoft.Extensions.Logging;
Expand All @@ -11,6 +12,11 @@
/// </summary>
public sealed class ProjectManifestFinder(ILogger<ProjectManifestFinder> logger)
{
// Parsed .gitignore directory-name patterns, keyed by the .gitignore file path.
// A directory's .gitignore is otherwise read once per subdirectory; cache it so it is read once.
// A null value marks "no .gitignore here" so the miss is not re-probed.
private readonly ConcurrentDictionary<string, HashSet<string>?> _gitignoreCache = new();

// Well-known directories to always skip regardless of .gitignore
private static readonly HashSet<string> AlwaysSkip = new(StringComparer.OrdinalIgnoreCase)
{
Expand Down Expand Up @@ -100,7 +106,7 @@
}
}

private static async Task<bool> IsGitIgnoredAsync(string dirPath, CancellationToken cancellationToken)
private async Task<bool> IsGitIgnoredAsync(string dirPath, CancellationToken cancellationToken)
{
var dirName = Path.GetFileName(dirPath);
var parentDir = Path.GetDirectoryName(dirPath);
Expand All @@ -110,35 +116,44 @@
}

var gitignorePath = Path.Combine(parentDir, ".gitignore");
if (!File.Exists(gitignorePath))
var patterns = await GetGitignorePatternsAsync(gitignorePath, cancellationToken);
return patterns is not null && (patterns.Contains(dirName) || patterns.Contains($"/{dirName}"));
}

private async Task<HashSet<string>?> GetGitignorePatternsAsync(string gitignorePath, CancellationToken cancellationToken)
{
if (_gitignoreCache.TryGetValue(gitignorePath, out var cached))
{
return false;
return cached;
}

try
HashSet<string>? patterns = null;
if (File.Exists(gitignorePath))
{
var lines = await File.ReadAllLinesAsync(gitignorePath, cancellationToken);
foreach (var rawLine in lines)
try
{
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line) || line.StartsWith('#'))
{
continue;
}

// Simple exact-match and trailing-slash patterns
var pattern = line.TrimEnd('/');
if (pattern == dirName || pattern == $"/{dirName}")
var lines = await File.ReadAllLinesAsync(gitignorePath, cancellationToken);
patterns = new HashSet<string>(StringComparer.Ordinal);
foreach (var rawLine in lines)
{
return true;
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line) || line.StartsWith('#'))
{
continue;
}

// Simple exact-match and trailing-slash patterns
patterns.Add(line.TrimEnd('/'));
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Select Note

This foreach loop immediately
maps its iteration variable to another variable
- consider mapping the sequence explicitly using '.Select(...)'.
}
}
catch
{
// Ignore unreadable .gitignore
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// Ignore unreadable .gitignore
patterns = null;
}
}

return false;
_gitignoreCache[gitignorePath] = patterns;
return patterns;
}
}