diff --git a/install.ps1 b/install.ps1 index ccc97f7..ed02bb7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -5,11 +5,18 @@ param( [string]$InstallPath = "$env:LOCALAPPDATA\Envoy", [switch]$CreateDesktopShortcut = $true, [switch]$CreateStartMenuShortcut = $true, - [string]$Version = "1.0.1" + [string]$Version = "" ) $ErrorActionPreference = "Stop" +# Single-source the version from Directory.Build.props (like publish/build-release). +if (-not $Version) { + $propsPath = Join-Path $PSScriptRoot "Directory.Build.props" + if (Test-Path $propsPath) { $Version = ([xml](Get-Content $propsPath -Raw)).Project.PropertyGroup.Version } + if (-not $Version) { $Version = "1.0.0" } +} + Write-Host "Envoy Installer v$Version" -ForegroundColor Cyan Write-Host "" diff --git a/src/Envoy.Assets/Pdf/ResumePdfGenerator.cs b/src/Envoy.Assets/Pdf/ResumePdfGenerator.cs index 8441ddc..f621513 100644 --- a/src/Envoy.Assets/Pdf/ResumePdfGenerator.cs +++ b/src/Envoy.Assets/Pdf/ResumePdfGenerator.cs @@ -54,6 +54,8 @@ private void BuildContent(IContainer container, MasterProfile profile) { container.Column(column => { + BuildHeader(column.Item(), profile); + if (!string.IsNullOrWhiteSpace(profile.Summary)) { column.Item().Text("SUMMARY").Bold().FontSize(11); diff --git a/src/Envoy.Core/Configuration/EnvoySettings.cs b/src/Envoy.Core/Configuration/EnvoySettings.cs index 871f8ab..c6ff02e 100644 --- a/src/Envoy.Core/Configuration/EnvoySettings.cs +++ b/src/Envoy.Core/Configuration/EnvoySettings.cs @@ -244,8 +244,11 @@ private static bool TryReadPlaintext(JsonElement root, string property, out stri } catch (Exception ex) { + // Surface the failure instead of silently dropping the key. The settings + // save flow catches this and tells the user the save failed, rather than + // reporting success while the key is quietly lost. TryLog($"API key encryption (DPAPI Protect) failed: {ex.Message}"); - return null; + throw; } } diff --git a/src/Envoy.Core/Data/DatabaseInitializer.cs b/src/Envoy.Core/Data/DatabaseInitializer.cs index 590757f..d230390 100644 --- a/src/Envoy.Core/Data/DatabaseInitializer.cs +++ b/src/Envoy.Core/Data/DatabaseInitializer.cs @@ -8,7 +8,8 @@ public static class DatabaseInitializer public static async Task InitializeAsync(IServiceProvider serviceProvider) { using var scope = serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService>(); + using var context = factory.CreateDbContext(); try { diff --git a/src/Envoy.Core/Services/ApplicationOrchestrator.cs b/src/Envoy.Core/Services/ApplicationOrchestrator.cs index 3d29d4b..3054dcd 100644 --- a/src/Envoy.Core/Services/ApplicationOrchestrator.cs +++ b/src/Envoy.Core/Services/ApplicationOrchestrator.cs @@ -115,38 +115,16 @@ public async Task PrepareApplicationAsync(Guid masterProfil if (tailored.SafetyResult.Passed) { var pdfBytes = _pdfGenerator.Generate(tailored); - var envoyDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Envoy"); - // Company/JobTitle come from an untrusted job posting — sanitize each into a - // single filename component so they cannot inject path separators or ".." and - // steer the write outside the Envoy folder. - var fileName = SanitizeFileComponent(tailored.TailoredData.Name) - + "_" + SanitizeFileComponent(tailored.Company) - + "_" + SanitizeFileComponent(tailored.JobTitle) + ".pdf"; - var pdfPath = Path.Combine(envoyDir, fileName); - Directory.CreateDirectory(envoyDir); + // Shared with TemplateEngine (the upload reader) so the paths always match; + // the untrusted Company/JobTitle are sanitized inside ResumeFilePath. + var pdfPath = ResumeFilePath.For(tailored.TailoredData.Name, tailored.Company, tailored.JobTitle); + Directory.CreateDirectory(Path.GetDirectoryName(pdfPath)!); await File.WriteAllBytesAsync(pdfPath, pdfBytes, ct); } return new PreparedApplication(tailored, jobDescription); } - // Reduce an untrusted display string to a single safe filename component: replace - // any invalid file-name character (which on Windows includes '/' and '\\') and - // spaces with '_', trim leading/trailing dots and underscores (blocks '..'), and - // bound the length so a job posting cannot steer the file write elsewhere. - private static string SanitizeFileComponent(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return "unknown"; - var invalid = Path.GetInvalidFileNameChars(); - var sb = new System.Text.StringBuilder(value.Length); - foreach (var c in value) - sb.Append(c == ' ' ? '_' : (Array.IndexOf(invalid, c) >= 0 ? '_' : c)); - var cleaned = sb.ToString().Trim('.', '_'); - if (cleaned.Length == 0) return "unknown"; - return cleaned.Length > 60 ? cleaned[..60] : cleaned; - } - public async Task SubmitApplicationAsync( Guid tailoredProfileId, ExecutionMode mode, diff --git a/src/Envoy.Core/Services/CdpBrowserService.cs b/src/Envoy.Core/Services/CdpBrowserService.cs index c628c20..a0f803f 100644 --- a/src/Envoy.Core/Services/CdpBrowserService.cs +++ b/src/Envoy.Core/Services/CdpBrowserService.cs @@ -19,6 +19,8 @@ public class CdpBrowserService : ICdpCommandExecutor, IPageInteractor, IBrowserL private readonly EnvoySettings _settings; private readonly ILogger _log; private readonly object _lock = new(); + // ClientWebSocket.SendAsync is not thread-safe; serialize concurrent sends. + private readonly SemaphoreSlim _sendLock = new(1, 1); private Task? _receiveLoopTask; private readonly CancellationTokenSource _receiveCts = new(); private bool _disposed; @@ -344,7 +346,15 @@ public async Task SendCommandAsync(string method, object? parameter var json = JsonSerializer.Serialize(messageDict); var bytes = Encoding.UTF8.GetBytes(json); - await _webSocket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); + await _sendLock.WaitAsync(ct); + try + { + await _webSocket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); + } + finally + { + _sendLock.Release(); + } using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(30)); @@ -505,6 +515,7 @@ public async ValueTask DisposeAsync() } await CloseAsync(CancellationToken.None); _receiveCts.Dispose(); + _sendLock.Dispose(); } // Synchronous disposal for DI containers that don't honor IAsyncDisposable @@ -516,7 +527,9 @@ public void Dispose() if (_disposed) return; try { - DisposeAsync().AsTask().GetAwaiter().GetResult(); + // Run on the thread pool so DisposeAsync's continuations don't marshal back + // to a blocked UI thread (App.OnExit disposes this on the WPF dispatcher). + Task.Run(() => DisposeAsync().AsTask()).GetAwaiter().GetResult(); } catch (Exception ex) { diff --git a/src/Envoy.Core/Services/CloudLLMProvider.cs b/src/Envoy.Core/Services/CloudLLMProvider.cs index 9577b62..493555f 100644 --- a/src/Envoy.Core/Services/CloudLLMProvider.cs +++ b/src/Envoy.Core/Services/CloudLLMProvider.cs @@ -120,14 +120,14 @@ public async Task IsAvailableAsync() { using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.openai.com/v1/models"); ApplyAuth(req); - var resp = await Http.SendAsync(req, cts.Token); + using var resp = await Http.SendAsync(req, cts.Token); return resp.IsSuccessStatusCode; } if (ProviderId == "gemini") { using var req = new HttpRequestMessage(HttpMethod.Get, "https://generativelanguage.googleapis.com/v1beta/models"); ApplyAuth(req); - var resp = await Http.SendAsync(req, cts.Token); + using var resp = await Http.SendAsync(req, cts.Token); return resp.IsSuccessStatusCode; } if (ProviderId == "anthropic") @@ -146,7 +146,7 @@ public async Task IsAvailableAsync() Content = new StringContent(probeBody, Encoding.UTF8, "application/json") }; ApplyAuth(req); - var resp = await Http.SendAsync(req, cts.Token); + using var resp = await Http.SendAsync(req, cts.Token); return resp.StatusCode != HttpStatusCode.Unauthorized && resp.StatusCode != HttpStatusCode.Forbidden; } @@ -170,7 +170,7 @@ public async Task> ListModelsAsync() { using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.openai.com/v1/models"); ApplyAuth(req); - var resp = await Http.SendAsync(req, cts.Token); + using var resp = await Http.SendAsync(req, cts.Token); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(cts.Token); var doc = JsonDocument.Parse(json); @@ -200,7 +200,7 @@ public async Task> ListModelsAsync() { using var req = new HttpRequestMessage(HttpMethod.Get, "https://generativelanguage.googleapis.com/v1beta/models"); ApplyAuth(req); - var resp = await Http.SendAsync(req, cts.Token); + using var resp = await Http.SendAsync(req, cts.Token); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(cts.Token); var doc = JsonDocument.Parse(json); @@ -212,7 +212,21 @@ public async Task> ListModelsAsync() { var name = m.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "" : ""; var displayName = m.TryGetProperty("displayName", out var dispEl) ? dispEl.GetString() ?? name : name; - if (name.Contains("generateContent", StringComparison.OrdinalIgnoreCase)) + // "generateContent" is an entry in the supportedGenerationMethods + // array, not part of the model name. Filter on that array. + var supportsGenerate = false; + if (m.TryGetProperty("supportedGenerationMethods", out var methodsEl) && methodsEl.ValueKind == JsonValueKind.Array) + { + foreach (var method in methodsEl.EnumerateArray()) + { + if (string.Equals(method.GetString(), "generateContent", StringComparison.OrdinalIgnoreCase)) + { + supportsGenerate = true; + break; + } + } + } + if (supportsGenerate) { models.Add(new LLMModelInfo { diff --git a/src/Envoy.Core/Services/DomScorer.cs b/src/Envoy.Core/Services/DomScorer.cs index f0f8a3f..d827d16 100644 --- a/src/Envoy.Core/Services/DomScorer.cs +++ b/src/Envoy.Core/Services/DomScorer.cs @@ -46,11 +46,11 @@ public static class DomScorer ["class"] = 0.5, }; - public static double WeightAttributes = 0.30; - public static double WeightLabel = 0.30; - public static double WeightAncestor = 0.15; - public static double WeightSibling = 0.15; - public static double WeightPosition = 0.10; + public static readonly double WeightAttributes = 0.30; + public static readonly double WeightLabel = 0.30; + public static readonly double WeightAncestor = 0.15; + public static readonly double WeightSibling = 0.15; + public static readonly double WeightPosition = 0.10; public static CandidateScoreResult? FindBestMatch( Fingerprint fingerprint, diff --git a/src/Envoy.Core/Services/HardwareProfiler.cs b/src/Envoy.Core/Services/HardwareProfiler.cs index 7df2203..e2a093b 100644 --- a/src/Envoy.Core/Services/HardwareProfiler.cs +++ b/src/Envoy.Core/Services/HardwareProfiler.cs @@ -57,11 +57,15 @@ private void DetectWindowsGpu(HardwareProfile profile) try { using var searcher = new System.Management.ManagementObjectSearcher("SELECT * FROM Win32_VideoController"); - foreach (System.Management.ManagementObject obj in searcher.Get()) + using var results = searcher.Get(); + foreach (System.Management.ManagementObject obj in results) { - var adapterRam = Convert.ToUInt64(obj["AdapterRAM"]); - profile.TotalVramMB += (int)(adapterRam / (1024 * 1024)); - profile.GpuName = obj["Name"]?.ToString() ?? "Unknown"; + using (obj) + { + var adapterRam = Convert.ToUInt64(obj["AdapterRAM"]); + profile.TotalVramMB += (int)(adapterRam / (1024 * 1024)); + profile.GpuName = obj["Name"]?.ToString() ?? "Unknown"; + } } profile.HasGpu = profile.TotalVramMB > 0; } @@ -75,7 +79,7 @@ private void DetectMacGpu(HardwareProfile profile) { try { - var process = new System.Diagnostics.Process + using var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo { @@ -117,7 +121,7 @@ private void DetectLinuxGpu(HardwareProfile profile) { try { - var process = new System.Diagnostics.Process + using var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo { diff --git a/src/Envoy.Core/Services/OllamaProvider.cs b/src/Envoy.Core/Services/OllamaProvider.cs index 9d1d827..d8eb03c 100644 --- a/src/Envoy.Core/Services/OllamaProvider.cs +++ b/src/Envoy.Core/Services/OllamaProvider.cs @@ -19,12 +19,14 @@ public class OllamaProvider : ILLMProvider private readonly string _endpoint; private readonly string _defaultModel; private readonly ILogger _log; + private readonly OllamaApiClient _apiClient; public OllamaProvider(string endpoint, string defaultModel, ILogger log) { _endpoint = endpoint; _defaultModel = defaultModel; _log = log; + _apiClient = new OllamaApiClient(new Uri(endpoint)); } public async Task IsAvailableAsync() @@ -32,7 +34,7 @@ public async Task IsAvailableAsync() try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var response = await Http.GetAsync($"{_endpoint}/api/tags", cts.Token); + using var response = await Http.GetAsync($"{_endpoint}/api/tags", cts.Token); return response.IsSuccessStatusCode; } catch @@ -46,7 +48,7 @@ public async Task> ListModelsAsync() try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); - var response = await Http.GetAsync($"{_endpoint}/api/tags", cts.Token); + using var response = await Http.GetAsync($"{_endpoint}/api/tags", cts.Token); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(cts.Token); @@ -94,13 +96,15 @@ public async Task> ListModelsAsync() public async Task CompleteAsync(string prompt, string systemPrompt, string? modelId, CancellationToken ct = default) { var model = modelId ?? _defaultModel; - using var apiClient = new OllamaApiClient(new Uri(_endpoint)) { SelectedModel = model }; + // Reuse one OllamaApiClient (built in the ctor) instead of creating and + // disposing a fresh client, with its own HttpClient, on every call. + _apiClient.SelectedModel = model; try { var chat = string.IsNullOrWhiteSpace(systemPrompt) - ? new Chat(apiClient) - : new Chat(apiClient, systemPrompt); + ? new Chat(_apiClient) + : new Chat(_apiClient, systemPrompt); var sb = new StringBuilder(); await foreach (var token in chat.SendAsync(prompt, ct)) diff --git a/src/Envoy.Core/Services/Repositories.cs b/src/Envoy.Core/Services/Repositories.cs index f7d0923..f2aecbb 100644 --- a/src/Envoy.Core/Services/Repositories.cs +++ b/src/Envoy.Core/Services/Repositories.cs @@ -16,18 +16,19 @@ public interface IProfileRepository public class ProfileRepository : IProfileRepository { - private readonly EnvoyDbContext _db; + private readonly IDbContextFactory _factory; private readonly ILogger _log; - public ProfileRepository(EnvoyDbContext db, ILogger log) + public ProfileRepository(IDbContextFactory factory, ILogger log) { - _db = db; + _factory = factory; _log = log; } public async Task> GetAllAsync(CancellationToken ct = default) { - return await _db.MasterProfiles + using var db = _factory.CreateDbContext(); + return await db.MasterProfiles .AsNoTracking() .Include(p => p.Experience) .Include(p => p.Education) @@ -38,7 +39,8 @@ public async Task> GetAllAsync(CancellationToken ct = defaul public async Task GetByIdAsync(Guid id, CancellationToken ct = default) { - return await _db.MasterProfiles + using var db = _factory.CreateDbContext(); + return await db.MasterProfiles .AsNoTracking() .Include(p => p.Experience) .Include(p => p.Education) @@ -48,31 +50,33 @@ public async Task> GetAllAsync(CancellationToken ct = defaul public async Task AddAsync(MasterProfile profile, CancellationToken ct = default) { - _db.MasterProfiles.Add(profile); - await _db.SaveChangesAsync(ct); + using var db = _factory.CreateDbContext(); + db.MasterProfiles.Add(profile); + await db.SaveChangesAsync(ct); } public async Task UpdateAsync(MasterProfile profile, CancellationToken ct = default) { - var tracked = await _db.MasterProfiles.FindAsync(new object[] { profile.Id }, ct); + using var db = _factory.CreateDbContext(); + var tracked = await db.MasterProfiles.FindAsync(new object[] { profile.Id }, ct); if (tracked == null) return; - _db.Entry(tracked).CurrentValues.SetValues(profile); + db.Entry(tracked).CurrentValues.SetValues(profile); tracked.Skills = profile.Skills; tracked.Anomalies = profile.Anomalies; - await _db.Entry(tracked).Collection(p => p.Experience).LoadAsync(ct); - await _db.Entry(tracked).Collection(p => p.Education).LoadAsync(ct); - await _db.Entry(tracked).Collection(p => p.Projects).LoadAsync(ct); + await db.Entry(tracked).Collection(p => p.Experience).LoadAsync(ct); + await db.Entry(tracked).Collection(p => p.Education).LoadAsync(ct); + await db.Entry(tracked).Collection(p => p.Projects).LoadAsync(ct); - ReplaceOwnedCollection(tracked.Experience, profile.Experience, _db); - ReplaceOwnedCollection(tracked.Education, profile.Education, _db); - ReplaceOwnedCollection(tracked.Projects, profile.Projects, _db); + ReplaceOwnedCollection(tracked.Experience, profile.Experience); + ReplaceOwnedCollection(tracked.Education, profile.Education); + ReplaceOwnedCollection(tracked.Projects, profile.Projects); - await _db.SaveChangesAsync(ct); + await db.SaveChangesAsync(ct); } - private static void ReplaceOwnedCollection(ICollection tracked, ICollection updated, EnvoyDbContext db) + private static void ReplaceOwnedCollection(ICollection tracked, ICollection updated) { tracked.Clear(); foreach (var item in updated) @@ -81,11 +85,12 @@ private static void ReplaceOwnedCollection(ICollection tracked, ICollectio public async Task DeleteAsync(Guid id, CancellationToken ct = default) { - var profile = await _db.MasterProfiles.FindAsync(new object[] { id }, ct); + using var db = _factory.CreateDbContext(); + var profile = await db.MasterProfiles.FindAsync(new object[] { id }, ct); if (profile != null) { - _db.MasterProfiles.Remove(profile); - await _db.SaveChangesAsync(ct); + db.MasterProfiles.Remove(profile); + await db.SaveChangesAsync(ct); } } } @@ -102,25 +107,27 @@ public interface ITailoredProfileRepository public class TailoredProfileRepository : ITailoredProfileRepository { - private readonly EnvoyDbContext _db; + private readonly IDbContextFactory _factory; private readonly ILogger _log; - public TailoredProfileRepository(EnvoyDbContext db, ILogger log) + public TailoredProfileRepository(IDbContextFactory factory, ILogger log) { - _db = db; + _factory = factory; _log = log; } public async Task GetByIdAsync(Guid id, CancellationToken ct = default) { - return await _db.TailoredProfiles + using var db = _factory.CreateDbContext(); + return await db.TailoredProfiles .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == id, ct); } public async Task> GetByMasterProfileIdAsync(Guid masterProfileId, CancellationToken ct = default) { - return await _db.TailoredProfiles + using var db = _factory.CreateDbContext(); + return await db.TailoredProfiles .AsNoTracking() .Where(p => p.MasterProfileId == masterProfileId) .OrderByDescending(p => p.CreatedAt) @@ -129,7 +136,8 @@ public async Task> GetByMasterProfileIdAsync(Guid masterPr public async Task> GetAllAsync(CancellationToken ct = default) { - return await _db.TailoredProfiles + using var db = _factory.CreateDbContext(); + return await db.TailoredProfiles .AsNoTracking() .OrderByDescending(p => p.CreatedAt) .ToListAsync(ct); @@ -137,24 +145,27 @@ public async Task> GetAllAsync(CancellationToken ct = defa public async Task AddAsync(TailoredProfile profile, CancellationToken ct = default) { - _db.TailoredProfiles.Add(profile); - await _db.SaveChangesAsync(ct); + using var db = _factory.CreateDbContext(); + db.TailoredProfiles.Add(profile); + await db.SaveChangesAsync(ct); } public async Task UpdateAsync(TailoredProfile profile, CancellationToken ct = default) { + using var db = _factory.CreateDbContext(); profile.UpdatedAt = DateTime.UtcNow; - _db.TailoredProfiles.Update(profile); - await _db.SaveChangesAsync(ct); + db.TailoredProfiles.Update(profile); + await db.SaveChangesAsync(ct); } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { - var profile = await _db.TailoredProfiles.FindAsync(new object[] { id }, ct); + using var db = _factory.CreateDbContext(); + var profile = await db.TailoredProfiles.FindAsync(new object[] { id }, ct); if (profile != null) { - _db.TailoredProfiles.Remove(profile); - await _db.SaveChangesAsync(ct); + db.TailoredProfiles.Remove(profile); + await db.SaveChangesAsync(ct); } } } @@ -170,18 +181,19 @@ public interface IApplicationLogRepository public class ApplicationLogRepository : IApplicationLogRepository { - private readonly EnvoyDbContext _db; + private readonly IDbContextFactory _factory; private readonly ILogger _log; - public ApplicationLogRepository(EnvoyDbContext db, ILogger log) + public ApplicationLogRepository(IDbContextFactory factory, ILogger log) { - _db = db; + _factory = factory; _log = log; } public async Task> GetAllAsync(CancellationToken ct = default) { - return await _db.ApplicationLogs + using var db = _factory.CreateDbContext(); + return await db.ApplicationLogs .AsNoTracking() .OrderByDescending(l => l.StartedAt) .ToListAsync(ct); @@ -189,26 +201,30 @@ public async Task> GetAllAsync(CancellationToken ct = defau public async Task GetByIdAsync(Guid id, CancellationToken ct = default) { - return await _db.ApplicationLogs + using var db = _factory.CreateDbContext(); + return await db.ApplicationLogs .AsNoTracking() .FirstOrDefaultAsync(l => l.Id == id, ct); } public async Task AddAsync(ApplicationLog log, CancellationToken ct = default) { - _db.ApplicationLogs.Add(log); - await _db.SaveChangesAsync(ct); + using var db = _factory.CreateDbContext(); + db.ApplicationLogs.Add(log); + await db.SaveChangesAsync(ct); } public async Task UpdateAsync(ApplicationLog log, CancellationToken ct = default) { - _db.ApplicationLogs.Update(log); - await _db.SaveChangesAsync(ct); + using var db = _factory.CreateDbContext(); + db.ApplicationLogs.Update(log); + await db.SaveChangesAsync(ct); } public async Task GetByTailoredProfileIdAsync(Guid tailoredProfileId, CancellationToken ct = default) { - return await _db.ApplicationLogs + using var db = _factory.CreateDbContext(); + return await db.ApplicationLogs .AsNoTracking() .FirstOrDefaultAsync(l => l.TailoredProfileId == tailoredProfileId, ct); } diff --git a/src/Envoy.Core/Services/ResumeFilePath.cs b/src/Envoy.Core/Services/ResumeFilePath.cs new file mode 100644 index 0000000..b44bb77 --- /dev/null +++ b/src/Envoy.Core/Services/ResumeFilePath.cs @@ -0,0 +1,35 @@ +namespace Envoy.Core.Services; + +/// +/// Builds the tailored-resume PDF path from untrusted posting fields. The writer +/// (ApplicationOrchestrator) and the reader (TemplateEngine, for the form upload) +/// both call this so the paths always agree, and the components are sanitized so a +/// job posting can't steer the file in or out of the Envoy data folder. +/// +public static class ResumeFilePath +{ + public static string For(string? name, string? company, string? jobTitle) + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Envoy"); + var fileName = SanitizeComponent(name) + + "_" + SanitizeComponent(company) + + "_" + SanitizeComponent(jobTitle) + ".pdf"; + return Path.Combine(dir, fileName); + } + + // Reduce an untrusted display string to a single safe filename component: replace + // invalid file-name characters (which include the path separators) and spaces with + // '_', trim leading/trailing dots and underscores, and bound the length. + public static string SanitizeComponent(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return "unknown"; + var invalid = Path.GetInvalidFileNameChars(); + var sb = new System.Text.StringBuilder(value.Length); + foreach (var c in value) + sb.Append(c == ' ' ? '_' : (Array.IndexOf(invalid, c) >= 0 ? '_' : c)); + var cleaned = sb.ToString().Trim('.', '_'); + if (cleaned.Length == 0) return "unknown"; + return cleaned.Length > 60 ? cleaned[..60] : cleaned; + } +} diff --git a/src/Envoy.Core/Services/SafetyService.cs b/src/Envoy.Core/Services/SafetyService.cs index 6fc6ae7..61a6972 100644 --- a/src/Envoy.Core/Services/SafetyService.cs +++ b/src/Envoy.Core/Services/SafetyService.cs @@ -39,6 +39,11 @@ public SafetyService(ILogger log) public SafetyResult ValidateTailoredProfile(MasterProfile original, MasterProfile tailored, string jobDescription) { + // The tailored profile comes from LLM JSON, which can emit explicit nulls that + // overwrite the `= new()` collection initializers. Normalize so nothing below NREs. + Normalize(original); + Normalize(tailored); + var result = new SafetyResult { Passed = true }; CheckFactualIntegrity(original, tailored, result); @@ -70,6 +75,16 @@ public SafetyResult ValidateTailoredProfile(MasterProfile original, MasterProfil return result; } + private static void Normalize(MasterProfile p) + { + p.Skills ??= new(); + p.Experience ??= new(); + p.Education ??= new(); + p.Projects ??= new(); + foreach (var e in p.Experience) + e.Bullets ??= new(); + } + private void CheckFactualIntegrity(MasterProfile original, MasterProfile tailored, SafetyResult result) { var originalSkills = new HashSet(original.Skills, StringComparer.OrdinalIgnoreCase); @@ -102,7 +117,7 @@ private void CheckFactualIntegrity(MasterProfile original, MasterProfile tailore if (!isDerivedFromOriginal) { - var longestOriginal = match.Bullets.Max(b => b.Length); + var longestOriginal = match.Bullets.Count > 0 ? match.Bullets.Max(b => b.Length) : 0; if (bullet.Length > longestOriginal * 1.5) { result.Violations.Add(new SafetyViolation diff --git a/src/Envoy.Core/Services/ServiceRegistration.cs b/src/Envoy.Core/Services/ServiceRegistration.cs index d891461..fe94465 100644 --- a/src/Envoy.Core/Services/ServiceRegistration.cs +++ b/src/Envoy.Core/Services/ServiceRegistration.cs @@ -11,7 +11,11 @@ public static class ServiceRegistration { public static IServiceCollection AddEnvoyCore(this IServiceCollection services) { - services.AddDbContext(); + // A factory (not a scoped context) so the singleton views that hold repositories + // never capture a long-lived DbContext. Each repository creates a short-lived + // context per operation, so there's no shared change tracker and no concurrent- + // access crash. + services.AddDbContextFactory(); services.AddScoped(); services.AddScoped(); diff --git a/src/Envoy.Core/Services/TailoringEngine.cs b/src/Envoy.Core/Services/TailoringEngine.cs index e9b9e42..cc2fb39 100644 --- a/src/Envoy.Core/Services/TailoringEngine.cs +++ b/src/Envoy.Core/Services/TailoringEngine.cs @@ -9,20 +9,21 @@ public class TailoringEngine { private readonly OllamaService _ollama; private readonly SafetyService _safety; - private readonly EnvoyDbContext _db; + private readonly IDbContextFactory _factory; private readonly ILogger _log; - public TailoringEngine(OllamaService ollama, SafetyService safety, EnvoyDbContext db, ILogger log) + public TailoringEngine(OllamaService ollama, SafetyService safety, IDbContextFactory dbFactory, ILogger log) { _ollama = ollama; _safety = safety; - _db = db; + _factory = dbFactory; _log = log; } public async Task TailorAsync(Guid masterProfileId, string jobUrl, string jobDescription, CancellationToken ct = default) { - var master = await _db.MasterProfiles + using var db = _factory.CreateDbContext(); + var master = await db.MasterProfiles .Include(p => p.Experience) .Include(p => p.Education) .Include(p => p.Projects) @@ -56,8 +57,8 @@ public async Task TailorAsync(Guid masterProfileId, string jobU MatchScore = 0, ChangesMade = new List { "Original profile returned (LLM tailoring failed)" } }; - _db.TailoredProfiles.Add(failedResult); - await _db.SaveChangesAsync(ct); + db.TailoredProfiles.Add(failedResult); + await db.SaveChangesAsync(ct); return failedResult; } @@ -84,8 +85,8 @@ public async Task TailorAsync(Guid masterProfileId, string jobU _log.LogInformation("Tailoring complete: Safety={Passed}, Match={Score:F1}%, Changes={ChangeCount}", safetyResult.Passed ? "PASSED" : "FAILED", result.MatchScore, result.ChangesMade.Count); - _db.TailoredProfiles.Add(result); - await _db.SaveChangesAsync(ct); + db.TailoredProfiles.Add(result); + await db.SaveChangesAsync(ct); return result; } diff --git a/src/Envoy.Core/Services/TemplateEngine.cs b/src/Envoy.Core/Services/TemplateEngine.cs index 292c524..11c7001 100644 --- a/src/Envoy.Core/Services/TemplateEngine.cs +++ b/src/Envoy.Core/Services/TemplateEngine.cs @@ -204,10 +204,9 @@ private static string GetValueFromProfile(TailoredProfile profile, string field) case "company": return data.Experience.FirstOrDefault()?.Company ?? ""; case "resume_file": case "generated_pdf_path": - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Envoy", - $"{name.Replace(" ", "_")}_{profile.Company}_{profile.JobTitle}.pdf"); + // Same sanitized path the PDF was written to, so the upload always finds + // it and untrusted Company/JobTitle can't inject a traversal. + return ResumeFilePath.For(name, profile.Company, profile.JobTitle); } // Unknown field — log so template authors can debug typos rather than diff --git a/src/Envoy.Discovery/JobDiscoveryService.cs b/src/Envoy.Discovery/JobDiscoveryService.cs index a928036..cd03a2d 100644 --- a/src/Envoy.Discovery/JobDiscoveryService.cs +++ b/src/Envoy.Discovery/JobDiscoveryService.cs @@ -55,6 +55,10 @@ public async Task SearchBoardsAsync(IEnumerable bo var jobs = await source.FetchBoardAsync(b.Token, b.CompanyName, ct); lock (all) all.AddRange(jobs); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; // honor cancellation instead of recording it as a board failure + } catch (Exception ex) { _log.LogWarning(ex, "Discovery board {Ats}/{Token} failed", b.Ats, b.Token); @@ -99,6 +103,10 @@ public async Task WebSearchAsync(string apiKey, string query, D TotalBeforeFilter = results.Count }; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; // honor cancellation instead of reporting it as a search failure + } catch (Exception ex) { _log.LogWarning(ex, "Brave web search failed"); diff --git a/src/Envoy.Discovery/Sources/GreenhouseSource.cs b/src/Envoy.Discovery/Sources/GreenhouseSource.cs index c062002..11d61db 100644 --- a/src/Envoy.Discovery/Sources/GreenhouseSource.cs +++ b/src/Envoy.Discovery/Sources/GreenhouseSource.cs @@ -32,6 +32,9 @@ public async Task> FetchBoardAsync(string token, strin var location = Json.TryObj(j, "location", out var loc) ? Json.Str(loc, "name") : ""; var updated = Json.Str(j, "updated_at"); + // Greenhouse exposes first_published for the original post date; use it + // for PostedAtUtc so posting-age and repost signals see the real age. + var firstPublished = Json.Str(j, "first_published"); jobs.Add(new JobPosting { @@ -41,7 +44,7 @@ public async Task> FetchBoardAsync(string token, strin Location = location, DescriptionText = HtmlText.Strip(Json.Str(j, "content")), Url = Json.Str(j, "absolute_url"), - PostedAtUtc = DateParsing.Iso(updated), + PostedAtUtc = DateParsing.Iso(string.IsNullOrWhiteSpace(firstPublished) ? updated : firstPublished), LastUpdatedUtc = DateParsing.Iso(updated), RawSourceId = j.TryGetProperty("id", out var id) ? id.ToString() : null }); diff --git a/src/Envoy.GhostDetection/ServiceRegistration.cs b/src/Envoy.GhostDetection/ServiceRegistration.cs index 71a243b..972b545 100644 --- a/src/Envoy.GhostDetection/ServiceRegistration.cs +++ b/src/Envoy.GhostDetection/ServiceRegistration.cs @@ -34,6 +34,7 @@ public static IServiceCollection AddEnvoyGhostDetection(this IServiceCollection .Where(m => m.Name == "AddHttpClient" && m.IsGenericMethodDefinition) .Select(m => new { Method = m, Params = m.GetParameters() }) .Where(x => x.Params.Length == 2 + && x.Method.GetGenericArguments().Length == 1 && x.Params[0].ParameterType == typeof(IServiceCollection) && x.Params[1].ParameterType == typeof(Action)) .Select(x => x.Method) @@ -43,7 +44,12 @@ public static IServiceCollection AddEnvoyGhostDetection(this IServiceCollection throw new InvalidOperationException("Could not locate AddHttpClient(IServiceCollection, Action) extension method."); var generic = method.MakeGenericMethod(type); - generic.Invoke(null, new object[] { services, new Action(c => c.Timeout = TimeSpan.FromSeconds(8)) }); + generic.Invoke(null, new object[] { services, new Action(c => + { + c.Timeout = TimeSpan.FromSeconds(8); + c.DefaultRequestHeaders.UserAgent.ParseAdd("Envoy/1.0 (+https://github.com/LXBStudioLLC/envoy)"); + c.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + }) }); // Resolve IGhostSignal THROUGH the typed-client registration so the configured // 8s HttpClient is injected. Bare AddSingleton(typeof(IGhostSignal), type) diff --git a/src/Envoy.GhostDetection/Signals/AtsCrossCheckSignal.cs b/src/Envoy.GhostDetection/Signals/AtsCrossCheckSignal.cs index ab73ae7..884654f 100644 --- a/src/Envoy.GhostDetection/Signals/AtsCrossCheckSignal.cs +++ b/src/Envoy.GhostDetection/Signals/AtsCrossCheckSignal.cs @@ -67,6 +67,10 @@ public AtsCrossCheckSignal(HttpClient http) Tier = Tier }; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; // caller cancelled: honor it instead of reporting "ATS unreachable" + } catch (HttpRequestException) { return null; // ATS unreachable — no opinion diff --git a/src/Envoy.Templates/linkedin-easy-apply.json b/src/Envoy.Templates/linkedin-easy-apply.json index 097c38e..aa94d9d 100644 --- a/src/Envoy.Templates/linkedin-easy-apply.json +++ b/src/Envoy.Templates/linkedin-easy-apply.json @@ -1,7 +1,7 @@ { "id": "linkedin-easy-apply", "name": "LinkedIn Easy Apply", - "urlMatch": "linkedin.com/jobs/view/*", + "url_match": "linkedin.com/jobs/view/*", "version": "1.0.0", "steps": [ { diff --git a/src/Envoy.UI/ApplyView.xaml.cs b/src/Envoy.UI/ApplyView.xaml.cs index 5479b98..fa5eb13 100644 --- a/src/Envoy.UI/ApplyView.xaml.cs +++ b/src/Envoy.UI/ApplyView.xaml.cs @@ -62,6 +62,13 @@ private async void BtnInitiate_Click(object sender, RoutedEventArgs e) return; } + if (_profileId == Guid.Empty) + { + StatusText.Text = "⚠ SELECT A PROFILE ON THE DASHBOARD FIRST"; + StatusText.Foreground = Yellow; + return; + } + if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { diff --git a/src/Envoy.UI/DashboardView.xaml.cs b/src/Envoy.UI/DashboardView.xaml.cs index 9c8187d..5fc5ab9 100644 --- a/src/Envoy.UI/DashboardView.xaml.cs +++ b/src/Envoy.UI/DashboardView.xaml.cs @@ -139,6 +139,7 @@ private async Task RefreshBrowserStatusAsync() ChromeDetailLabel.Text = "Click below to retry launching."; ChromeDetailLabel.Foreground = Gray; BtnLaunchChrome.Content = "◈ LAUNCH BROWSER"; + BtnLaunchChrome.IsEnabled = true; } } diff --git a/src/Envoy.UI/FindJobsView.xaml.cs b/src/Envoy.UI/FindJobsView.xaml.cs index 20d0b87..2d3b73e 100644 --- a/src/Envoy.UI/FindJobsView.xaml.cs +++ b/src/Envoy.UI/FindJobsView.xaml.cs @@ -102,7 +102,8 @@ private bool SaveKeyIfChanged(string key) { if (string.IsNullOrEmpty(key) || key == _settings.BraveSearchApiKey) return true; - _settings.BraveSearchApiKey = key; + try { _settings.BraveSearchApiKey = key; } + catch { return false; } // DPAPI encrypt failure surfaces as a failed save return _settings.Save(); } diff --git a/src/Envoy.UI/LLMSettingsView.xaml.cs b/src/Envoy.UI/LLMSettingsView.xaml.cs index 43f1153..f967aa9 100644 --- a/src/Envoy.UI/LLMSettingsView.xaml.cs +++ b/src/Envoy.UI/LLMSettingsView.xaml.cs @@ -201,11 +201,19 @@ private void ActivateProvider_Click(object sender, RoutedEventArgs e) ProviderList.ItemsSource = null; ProviderList.ItemsSource = _cards; - _settings.Save(); + var saved = _settings.Save(); SwitchActiveProvider(); - ConnectionStatusLabel.Text = $"Switched to {card?.ProviderName ?? providerId}"; - ConnectionStatusLabel.Foreground = Green; + if (saved) + { + ConnectionStatusLabel.Text = $"Switched to {card?.ProviderName ?? providerId}"; + ConnectionStatusLabel.Foreground = Green; + } + else + { + ConnectionStatusLabel.Text = $"Switched to {card?.ProviderName ?? providerId} for now, but could not save (settings.json may be locked)."; + ConnectionStatusLabel.Foreground = Yellow; + } } } @@ -286,7 +294,11 @@ private void Model_Click(object sender, MouseButtonEventArgs e) ProviderList.ItemsSource = null; ProviderList.ItemsSource = _cards; - _settings.Save(); + if (!_settings.Save()) + { + ConnectionStatusLabel.Text = "Model selected for now, but could not save (settings.json may be locked)."; + ConnectionStatusLabel.Foreground = Yellow; + } SwitchActiveProvider(); } } diff --git a/src/Envoy.UI/Logging/FileLogger.cs b/src/Envoy.UI/Logging/FileLogger.cs index dd13737..603c3a0 100644 --- a/src/Envoy.UI/Logging/FileLogger.cs +++ b/src/Envoy.UI/Logging/FileLogger.cs @@ -85,15 +85,21 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except { if (!IsEnabled(logLevel)) return; - var message = formatter(state, exception); - if (string.IsNullOrEmpty(message) && exception is null) return; - - var sb = new StringBuilder(); - sb.Append('[').Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append("] "); - sb.Append(ShortLevel(logLevel)).Append(' '); - sb.Append(_category).Append(" - ").Append(message).Append('\n'); - if (exception is not null) sb.Append(exception).Append('\n'); - _write(sb.ToString()); + // Logging must never throw into the app: a bad formatter or a failing writer + // is swallowed (the class contract is that every path is guarded). + try + { + var message = formatter(state, exception); + if (string.IsNullOrEmpty(message) && exception is null) return; + + var sb = new StringBuilder(); + sb.Append('[').Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append("] "); + sb.Append(ShortLevel(logLevel)).Append(' '); + sb.Append(_category).Append(" - ").Append(message).Append('\n'); + if (exception is not null) sb.Append(exception).Append('\n'); + _write(sb.ToString()); + } + catch { /* never let logging crash the caller */ } } private static string ShortLevel(LogLevel level) => level switch diff --git a/src/Envoy.UI/MainWindow.xaml.cs b/src/Envoy.UI/MainWindow.xaml.cs index 54bc610..1ba7353 100644 --- a/src/Envoy.UI/MainWindow.xaml.cs +++ b/src/Envoy.UI/MainWindow.xaml.cs @@ -198,11 +198,18 @@ public async void UpdateBrowserStatus() catch { } } + private bool _isTransitioning; + public void NavigateTo(UserControl view) { + // Drop rapid nav clicks during the fade-out so overlapping animations can't + // leave a view half-faded. + if (_isTransitioning) return; + var oldContent = ContentArea.Children.OfType().FirstOrDefault(); if (oldContent != null) { + _isTransitioning = true; var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(120)); fadeOut.Completed += (_, _) => { @@ -210,6 +217,7 @@ public void NavigateTo(UserControl view) view.Opacity = 0; view.RenderTransform = new TranslateTransform(20, 0); ContentArea.Children.Add(view); + _isTransitioning = false; var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)); var slideIn = new DoubleAnimation(20, 0, TimeSpan.FromMilliseconds(200));