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
9 changes: 8 additions & 1 deletion install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""

Expand Down
2 changes: 2 additions & 0 deletions src/Envoy.Assets/Pdf/ResumePdfGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/Envoy.Core/Configuration/EnvoySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/Envoy.Core/Data/DatabaseInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnvoyDbContext>();
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<EnvoyDbContext>>();
using var context = factory.CreateDbContext();

try
{
Expand Down
30 changes: 4 additions & 26 deletions src/Envoy.Core/Services/ApplicationOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,38 +115,16 @@ public async Task<PreparedApplication> 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<ApplicationLog> SubmitApplicationAsync(
Guid tailoredProfileId,
ExecutionMode mode,
Expand Down
17 changes: 15 additions & 2 deletions src/Envoy.Core/Services/CdpBrowserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public class CdpBrowserService : ICdpCommandExecutor, IPageInteractor, IBrowserL
private readonly EnvoySettings _settings;
private readonly ILogger<CdpBrowserService> _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;
Expand Down Expand Up @@ -344,7 +346,15 @@ public async Task<JsonElement> SendCommandAsync(string method, object? parameter

var json = JsonSerializer.Serialize(messageDict);
var bytes = Encoding.UTF8.GetBytes(json);
await _webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, ct);
await _sendLock.WaitAsync(ct);
try
{
await _webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, ct);
}
finally
{
_sendLock.Release();
}

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
Expand Down Expand Up @@ -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
Expand All @@ -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)
{
Expand Down
26 changes: 20 additions & 6 deletions src/Envoy.Core/Services/CloudLLMProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,14 @@ public async Task<bool> 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")
Expand All @@ -146,7 +146,7 @@ public async Task<bool> 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;
}
Expand All @@ -170,7 +170,7 @@ public async Task<List<LLMModelInfo>> 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);
Expand Down Expand Up @@ -200,7 +200,7 @@ public async Task<List<LLMModelInfo>> 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);
Expand All @@ -212,7 +212,21 @@ public async Task<List<LLMModelInfo>> 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
{
Expand Down
10 changes: 5 additions & 5 deletions src/Envoy.Core/Services/DomScorer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 10 additions & 6 deletions src/Envoy.Core/Services/HardwareProfiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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
{
Expand Down Expand Up @@ -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
{
Expand Down
14 changes: 9 additions & 5 deletions src/Envoy.Core/Services/OllamaProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,22 @@ public class OllamaProvider : ILLMProvider
private readonly string _endpoint;
private readonly string _defaultModel;
private readonly ILogger<OllamaProvider> _log;
private readonly OllamaApiClient _apiClient;

public OllamaProvider(string endpoint, string defaultModel, ILogger<OllamaProvider> log)
{
_endpoint = endpoint;
_defaultModel = defaultModel;
_log = log;
_apiClient = new OllamaApiClient(new Uri(endpoint));
}

public async Task<bool> 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
Expand All @@ -46,7 +48,7 @@ public async Task<List<LLMModelInfo>> 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);

Expand Down Expand Up @@ -94,13 +96,15 @@ public async Task<List<LLMModelInfo>> ListModelsAsync()
public async Task<string> 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))
Expand Down
Loading