Compare commits

...
68 Commits
Author SHA1 Message Date
amacocian fe4720c5e1 Update GWCA - fixes Character Select widget (#1626) 2026-09-09 22:58:09 +02:00
github-actions[bot]andamacocian b57d2ad5d3 Bump version to 0.9.10.21 2026-09-09 22:58:09 +02:00
amacocian 5faeae64d3 Download service robustness (Closes #1074) (#1624) 2026-09-04 19:34:25 +02:00
amacocian fdcf238d68 Fix wine password param expansion (#1623) 2026-09-04 19:34:25 +02:00
amacocian d9f7d05672 Add widgets for empty views (Closes #1093) (#1622) 2026-09-04 19:34:25 +02:00
amacocian c61ee2ecd7 Fix toolbox assertion error in wine due to long computer name (Closes #1619) (#1621) 2026-09-04 19:34:25 +02:00
amacocian 82512f9461 Improve documentation (#1620) 2026-09-04 19:34:25 +02:00
github-actions[bot]andamacocian 2dc13b7184 Bump version to 0.9.10.20 2026-09-04 19:34:25 +02:00
amacocian da691e3310 Improvements to skill updater (#1617) 2026-08-31 23:07:24 +02:00
amacocian 5e020f51d9 Update skills (#1616) 2026-08-31 23:07:24 +02:00
github-actions[bot]andamacocian 1f63e59b8a Bump version to 0.9.10.19 2026-08-31 23:07:24 +02:00
amacocian dcb9c29505 Update deps (#1612)
* Update dependencies

* Fix GWCA issues
2026-08-30 23:27:31 +02:00
amacocian db83e5a954 Setup stable computer name in Wine prefix (Closes #1609) (#1611) 2026-08-30 23:27:31 +02:00
amacocian c7c5506ee0 Update GWCA (#1610) 2026-08-30 23:27:31 +02:00
github-actions[bot]andamacocian 2988b6b38d Bump version to 0.9.10.18 2026-08-30 23:27:31 +02:00
amacocian debef9a36f Update documentation with Steam support (#1603) 2026-08-02 15:01:35 +02:00
amacocian 839bc57b48 Improve interaction with system shell (#1601) 2026-08-02 15:01:35 +02:00
amacocian 175c16bd00 Notifications show an animation for their expiration (#1600) 2026-08-02 15:01:35 +02:00
amacocian ad356f431e Steam login support (Windows) (Closes #1597) (#1599) 2026-08-02 15:01:35 +02:00
github-actions[bot]andamacocian ebe368e61e Bump version to 0.9.10.17 2026-08-02 15:01:35 +02:00
amacocian d800a630b5 Sanitize secrets (#1595) 2026-07-28 14:55:57 +02:00
github-actions[bot]andamacocian 239f91a65f Bump version to 0.9.10.16 2026-07-28 14:55:57 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aff7c24b6a Bump actions/setup-dotnet from 5 to 6 (#1588)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 13:54:14 +02:00
amacocian 5bf0af61ba Fix commandline arguments with space (#1593) 2026-07-28 13:54:14 +02:00
amacocian 03ed31e85b Fix telemetry (Closes #1591) (#1592) 2026-07-28 13:54:14 +02:00
amacocian b21a4482bd Redact sensitive logs (#1590) 2026-07-28 13:54:14 +02:00
github-actions[bot]andamacocian 2ad8ae79ce Bump version to 0.9.10.15 2026-07-28 13:54:14 +02:00
amacocianandAlexandru Macocian a4be747dd5 Fix more notifications expiration times (#1587)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
amacocianandAlexandru Macocian a6cd5d6f37 Update dependencies (#1585)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
amacocianandAlexandru Macocian 0e80fb7d86 Update GWCA (#1584)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-07-18 07:00:00 +02:00
github-actions[bot]andamacocian 0793f181bb Bump version to 0.9.10.14 2026-07-18 07:00:00 +02:00
amacocian a9ee0424f7 FocusView browser maximize (Closes #1577) (#1578) 2026-07-03 18:45:45 +02:00
amacocian 49f45b6b76 Fix notifications disappearing too soon (Closes #1573) (#1576) 2026-07-03 18:45:45 +02:00
github-actions[bot]andamacocian 4216a03438 Bump version to 0.9.10.13 2026-07-03 18:45:45 +02:00
amacocianandAlexandru Macocian a9a8346a5b Fix character select (Closes #1561) (#1571)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 70a95b5e08 Executable list eviction limit only on invalid exes (#1570)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 1e8c2e44e2 Fix Kill command on Linux (Closes #1564) (#1569)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian d553aadead Update skills after patch update (Closes #1565) (#1568)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian fabb98f74e Improve exe matching on Linux (#1567)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 0a0ec5cefc Fix multibox support on Linux (#1566)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
amacocianandAlexandru Macocian 0d31587855 Revert GWCA static linking (Closes #1562) (#1563)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-30 17:09:16 +02:00
github-actions[bot]andamacocian f4a08e36dd Bump version to 0.9.10.12 2026-06-30 17:09:16 +02:00
amacocian c71dd6ed9c Update docs (#1559) 2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian a61cff8dfd Focus View Grid layout and resizable components (#1558) (Closes #1552)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian 9d7f145c25 Sign windows binaries (#1557)
* Sign windows binaries

* Fix temp test

* Remove temporary test

---------

Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian dc340189d6 Improve x11 support in Linux with GDK X11 fallback and scale support (#1556) (Closes #1551)
* Fix GDK X11 support

* Better scaling support for Linux

---------

Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian 5edb5ff780 Update dependencies + move to StbImageSharp (#1555)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian 890bb26c01 Make Daybreak.API compile statically against GWCA (#1554)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
amacocianandAlexandru Macocian 6c2574c722 Debug scripts improvements (#1553)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
2026-06-29 22:36:44 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8760f743ab Bump Microsoft.Web.WebView2 from 1.0.3967.48 to 1.0.4022.49 (#1548)
---
updated-dependencies:
- dependency-name: Microsoft.Web.WebView2
  dependency-version: 1.0.4022.49
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 22:36:44 +02:00
dependabot[bot]andamacocian 97da183bd3 Bump actions/checkout from 6 to 7 (#1549) 2026-06-29 22:36:44 +02:00
github-actions[bot]andamacocian 65439f25dc Bump version to 0.9.10.11 2026-06-29 22:36:44 +02:00
amacocian a755319fb1 Add value to inventory service response (Closes #1545) (#1546) 2026-06-03 22:09:58 +02:00
amacocian 7e72cb3d3f Add more testing coverage (#1543) 2026-06-03 22:09:58 +02:00
amacocian ca154827b2 Setup linux api build and some tests (#1542) 2026-06-03 22:09:58 +02:00
amacocian 089a10a056 Reorganize skills (#1541)
* Reorganize skills

* Minor fixes
2026-06-03 22:09:58 +02:00
amacocian 65767d6ab5 Fix build paths for linux (Closes #1539) (#1540) 2026-06-03 22:09:58 +02:00
github-actions[bot]andamacocian 5828263717 Bump version to 0.9.10.10 2026-06-03 22:09:58 +02:00
amacocian 05f73cb306 GWCA fixes (#1538) 2026-05-13 14:37:06 +02:00
amacocian 0271e6b08c Fix gwca generator (#1537) 2026-05-13 14:37:06 +02:00
amacocian 640f28584e Implement logsview filter (#1535) 2026-05-13 14:37:06 +02:00
amacocian dd89e3316e Improve toolbox integration (Closes #1532) (#1534) 2026-05-13 14:37:06 +02:00
dependabot[bot]amacociandependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
10fb70787b Bump Microsoft.Web.WebView2 from 1.0.3912.50 to 1.0.3967.48 (#1533)
---
updated-dependencies:
- dependency-name: Microsoft.Web.WebView2
  dependency-version: 1.0.3967.48
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 14:37:06 +02:00
github-actions[bot]andamacocian 1393223826 Bump version to 0.9.10.9 2026-05-13 14:37:06 +02:00
amacocian b23907025d Update dependencies (#1530) 2026-04-29 15:48:06 +02:00
amacocian 09aed18d9d Fix executable update (Closes #1526) (#1529) 2026-04-29 15:48:06 +02:00
amacocian b7d58f5e4c Update GWCA (Closes #1527) (#1528) 2026-04-29 15:48:06 +02:00
github-actions[bot]andamacocian 9888bc741f Bump version to 0.9.10.8 2026-04-29 15:48:06 +02:00
237 changed files with 21790 additions and 8423 deletions
+71
View File
@@ -0,0 +1,71 @@
# Copilot instructions for Daybreak
Daybreak is a cross-platform Guild Wars launcher built on .NET 10 and
Photino.Blazor. Read the
[Architecture Overview](../README.md#architecture-overview) for the project
layout and [CONTRIBUTING.md](../CONTRIBUTING.md) for the branching and release
process.
## Workflow
- Branch off the current `release/[version]` branch, never off `master`.
- One feature or fix per branch and per PR.
- Never push to `master` and never edit the version in `Directory.Build.props`.
Both are owned by the release workflows.
- Title PRs `Short description (Closes #123)` so the linked issue gets labelled.
## Build and test
CI does not run on PRs targeting a release branch, so validate locally. Run the
smallest command that covers the change:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj --filter "FullyQualifiedName~YourTests"
```
Keep the build warning-free.
## Where code goes
| Change | Project |
| -------------------------------------------------- | ------------------------------------ |
| Models, utilities, interfaces shared by everything | `Daybreak.Shared` |
| Blazor views and services | `Daybreak.Core` |
| Platform-specific implementations | `Daybreak.Windows`, `Daybreak.Linux` |
`Daybreak.Tests` only references `Daybreak.Core`. When adding platform code, put
the logic worth testing in `Daybreak.Shared` and leave the platform project as a
thin caller.
Add NuGet packages through `Directory.Packages.props`.
## C# conventions
`.editorconfig` is the source of truth. The projects are set up to treat
warnings as errors.
## Verify before claiming
Prefer evidence over reasoning about behaviour, especially for the Wine and
injection paths. Wine is scriptable, so reproduce the actual failure and confirm
the fix against it rather than inferring what a Win32 call does.
## Writing style
Applies to docs, comments, PR descriptions and answers.
- Be short. Cut filler, recap and process narration.
- No AI-isms and no inflated language.
- Do not paste code into documentation. Link to the file so there is only one
copy of it.
- Explain the reason for a change, not a summary of the diff.
## Markdown
- No inline HTML. Use `![alt][ref]` with a reference definition instead of an
`img` tag.
- Start a mermaid fence with the diagram type, for example `flowchart LR`. YAML
frontmatter inside the fence is rejected by some renderers.
- Long URLs belong in reference-style link definitions at the bottom of the
file.
+28
View File
@@ -0,0 +1,28 @@
# Pre-configure SimplySign Desktop for unattended use:
# - auto-show the login dialog on launch (so the TOTP keystrokes have a target)
# - cache the smart-card PIN in the CSP, so signtool signs without a per-call
# PIN prompt, and forget it again when the session disconnects.
# Mirrors the settings used by the blinkdisk / docuscope CI signing setups.
$ErrorActionPreference = "Stop"
$RegistryPath = "HKCU:\Software\Certum\SimplySign"
$settings = [ordered]@{
ShowLoginDialogOnStart = 1
ShowLoginDialogOnAppRequest = 1
RememberLastUserName = 0
Autostart = 0
UnregisterCertificatesOnDisconnect = 0
RememberPINinCSP = 1
ForgetPINinCSPonDisconnect = 1
LangID = 9
}
Write-Host "=== Configuring SimplySign Desktop registry ==="
New-Item -Path $RegistryPath -Force | Out-Null
foreach ($name in $settings.Keys) {
Set-ItemProperty -Path $RegistryPath -Name $name -Value $settings[$name] -Type DWord
Write-Host " $name = $($settings[$name])"
}
Write-Host "Done."
+245
View File
@@ -0,0 +1,245 @@
# Authenticate SimplySign Desktop non-interactively.
#
# Certum's SimplySign cloud has no headless login: the certificate only reaches
# the Windows store after the GUI client authenticates. So we generate the
# current TOTP from the otpauth:// secret (CERTUM_OTP_URI) and paste the
# credentials into the login dialog. This needs an interactive desktop session,
# which GitHub-hosted Windows runners provide.
#
# Based on https://www.devas.life/how-to-automate-signing-your-windows-app-with-certum/
# and the refinements in blinkdisk's connect-simplysign.ps1.
param(
[string]$OtpUri = $env:CERTUM_OTP_URI,
[string]$UserId = $env:CERTUM_USERID,
[string]$ExePath = $env:CERTUM_EXE_PATH
)
if (-not $OtpUri) { Write-Host "ERROR: CERTUM_OTP_URI not provided"; exit 1 }
if (-not $UserId) { Write-Host "ERROR: CERTUM_USERID not provided"; exit 1 }
if (-not $ExePath) {
$ExePath = "C:\Program Files\Certum\SimplySign Desktop\SimplySignDesktop.exe"
}
Write-Host "=== SimplySign Desktop TOTP authentication ==="
if (-not (Test-Path $ExePath)) {
Write-Host "ERROR: SimplySign Desktop not found at $ExePath"
exit 1
}
# --- Parse the otpauth:// URI (works on PowerShell 5.1 and 7+) ---------------
$uri = [Uri]$OtpUri
try {
$q = [System.Web.HttpUtility]::ParseQueryString($uri.Query)
} catch {
$q = @{}
foreach ($part in $uri.Query.TrimStart('?') -split '&') {
$kv = $part -split '=', 2
if ($kv.Count -eq 2) { $q[$kv[0]] = [Uri]::UnescapeDataString($kv[1]) }
}
}
$Base32 = $q['secret']
$Digits = if ($q['digits']) { [int]$q['digits'] } else { 6 }
$Period = if ($q['period']) { [int]$q['period'] } else { 30 }
$Algorithm = if ($q['algorithm']) { $q['algorithm'].ToUpper() } else { 'SHA256' }
if (-not $Base32) { Write-Host "ERROR: otpauth URI has no 'secret'"; exit 1 }
if ($Algorithm -notin @('SHA1', 'SHA256', 'SHA512')) {
Write-Host "ERROR: unsupported TOTP algorithm: $Algorithm"
exit 1
}
# --- TOTP generator (RFC 6238), inline C# so there are no dependencies --------
Add-Type -Language CSharp @"
using System;
using System.Security.Cryptography;
public static class Totp
{
private const string B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private static byte[] Base32Decode(string s)
{
s = s.TrimEnd('=').ToUpperInvariant();
byte[] bytes = new byte[s.Length * 5 / 8];
int bitBuffer = 0, bitsLeft = 0, idx = 0;
foreach (char c in s)
{
int val = B32.IndexOf(c);
if (val < 0) throw new ArgumentException("Invalid Base32 char: " + c);
bitBuffer = (bitBuffer << 5) | val;
bitsLeft += 5;
if (bitsLeft >= 8) { bytes[idx++] = (byte)(bitBuffer >> (bitsLeft - 8)); bitsLeft -= 8; }
}
return bytes;
}
private static HMAC Hmac(string algorithm, byte[] key)
{
switch (algorithm.ToUpper())
{
case "SHA1": return new HMACSHA1(key);
case "SHA256": return new HMACSHA256(key);
case "SHA512": return new HMACSHA512(key);
default: throw new ArgumentException("Unsupported algorithm: " + algorithm);
}
}
public static string Now(string secret, int digits, int period, string algorithm)
{
byte[] key = Base32Decode(secret);
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
byte[] cnt = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(cnt);
byte[] hash;
using (var h = Hmac(algorithm, key)) { hash = h.ComputeHash(cnt); }
int offset = hash[hash.Length - 1] & 0x0F;
int binary =
((hash[offset] & 0x7F) << 24) |
((hash[offset + 1] & 0xFF) << 16) |
((hash[offset + 2] & 0xFF) << 8) |
(hash[offset + 3] & 0xFF);
int otp = binary % (int)Math.Pow(10, digits);
return otp.ToString(new string('0', digits));
}
}
"@
function Find-UiByName($el, $walker, $name) {
$c = $walker.GetFirstChild($el)
while ($c) {
if ($c.Current.Name -eq $name) { return $c }
$r = Find-UiByName $c $walker $name
if ($r) { return $r }
$c = $walker.GetNextSibling($c)
}
return $null
}
# An outdated SimplySign build pops a modal "New version found - download?" box
# over the login form, which swallows the credential keystrokes. Decline it by
# clicking "No" (Invoke / legacy default action / click the element's point).
function Dismiss-UpdatePrompt {
try { Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes -ErrorAction Stop } catch { return $false }
if (-not ('Win32Mouse' -as [type])) {
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class Win32Mouse {
[DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] static extern void mouse_event(uint f, uint x, uint y, uint d, int e);
public static void Click(int x, int y){ SetCursorPos(x,y); mouse_event(0x02,0,0,0,0); mouse_event(0x04,0,0,0,0); }
}
"@
}
$procIds = @((Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue).Id)
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$top = $walker.GetFirstChild([System.Windows.Automation.AutomationElement]::RootElement)
while ($top) {
if ($procIds -contains $top.Current.ProcessId) {
$no = Find-UiByName $top $walker "No"
if ($no) {
Write-Host "Update prompt detected; declining (clicking 'No')."
try { ($no.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)).Invoke(); return $true } catch {}
try { ($no.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePattern]::Pattern)).DoDefaultAction(); return $true } catch {}
try { $pt = $no.GetClickablePoint(); [Win32Mouse]::Click([int]$pt.X, [int]$pt.Y); return $true } catch { Write-Host "Click 'No' failed: $($_.Exception.Message)" }
}
}
$top = $walker.GetNextSibling($top)
}
return $false
}
# Start from a clean slate so a stale window/session can't swallow the keystrokes.
if ($existing = Get-Process -Name "SimplySignDesktop" -ErrorAction Ignore) {
Write-Host "Killing existing SimplySignDesktop process..."
$existing | Stop-Process -Force
Start-Sleep -Seconds 1
}
Write-Host "Launching SimplySign Desktop..."
$proc = Start-Process -FilePath $ExePath -PassThru
Start-Sleep -Seconds 3
$wshell = New-Object -ComObject WScript.Shell
Write-Host "Focusing the login window..."
$focused = $wshell.AppActivate($proc.Id)
if (-not $focused) { $focused = $wshell.AppActivate('SimplySign Desktop') }
for ($i = 0; (-not $focused) -and ($i -lt 10); $i++) {
Start-Sleep -Milliseconds 500
$focused = $wshell.AppActivate($proc.Id) -or $wshell.AppActivate('SimplySign Desktop')
}
if (-not $focused) {
Write-Host "ERROR: could not bring SimplySign Desktop to the foreground"
exit 1
}
# Decline the "new version available" modal if it's covering the login form.
if (Dismiss-UpdatePrompt) { Start-Sleep -Milliseconds 800 }
# Re-assert focus: dismissing the dialog can move the foreground window, and
# keystrokes sent to the wrong window are silently dropped.
$wshell.AppActivate($proc.Id) | Out-Null
$wshell.AppActivate('SimplySign Desktop') | Out-Null
Start-Sleep -Milliseconds 400
# Paste rather than type: SendKeys mangles characters like + ^ % ( ) and can
# drop characters; pasting delivers the exact string. Falls back to typing if
# the clipboard is unavailable.
function Set-Field([string]$text) {
try {
Set-Clipboard -Value $text -ErrorAction Stop
Start-Sleep -Milliseconds 150
$wshell.SendKeys("^v")
} catch {
Write-Host "Clipboard unavailable ($($_.Exception.Message)); typing instead."
$wshell.SendKeys($text)
}
Start-Sleep -Milliseconds 250
}
Write-Host "Injecting credentials..."
Set-Field $UserId
$wshell.SendKeys("{TAB}")
Start-Sleep -Milliseconds 200
# Generate the code right before sending it so it can't expire while we focus.
$otp = [Totp]::Now($Base32, $Digits, $Period, $Algorithm)
Set-Field $otp
Start-Sleep -Milliseconds 200
$wshell.SendKeys("{ENTER}")
# Don't leave the OTP / username sitting on the clipboard afterwards.
try { Set-Clipboard -Value " " -ErrorAction Stop } catch {}
Write-Host "Waiting for authentication to settle..."
Start-Sleep -Seconds 5
if (-not (Get-Process -Id $proc.Id -ErrorAction SilentlyContinue)) {
Write-Host "ERROR: SimplySign Desktop exited - authentication failed."
exit 1
}
# Confirm the login actually mounted the signing certificate into the store.
$thumb = if ($env:CERTUM_CERT_SHA1) { ($env:CERTUM_CERT_SHA1 -replace '\s', '').ToUpperInvariant() } else { $null }
Write-Host "Verifying the signing certificate reached the store..."
$deadline = (Get-Date).AddSeconds(60)
$found = $false
do {
$certs = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue
if ($thumb) { $found = [bool]($certs | Where-Object { $_.Thumbprint -eq $thumb }) }
else { $found = [bool]($certs | Where-Object { $_.Subject -like '*Certum*' -or $_.Issuer -like '*Certum*' }) }
if ($found) { break }
Start-Sleep -Seconds 3
} while ((Get-Date) -lt $deadline)
if ($found) {
Write-Host "=== Authentication complete: signing certificate is available. ==="
} else {
Write-Host "ERROR: signing certificate did not appear after authentication."
exit 1
}
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Install Certum SimplySign Desktop on the (ephemeral) Windows runner.
# SimplySign Desktop is what mounts the cloud signing certificate as a virtual
# smart card into the Windows certificate store; signtool then selects it by
# thumbprint. The version + checksum are pinned for reproducibility.
set -euo pipefail
VERSION="9.3.4.72"
URL="https://files.certum.eu/software/SimplySignDesktop/Windows/${VERSION}/SimplySignDesktop-${VERSION}-64-bit-en.msi"
EXPECTED_SHA256="bd51ebbaaac20fc7d59ab7103b5ed532b7800586df4e31f6999d03a394f9c515"
INSTALLER="SimplySignDesktop.msi"
INSTALL_DIR="/c/Program Files/Certum/SimplySign Desktop"
echo "=== Installing Certum SimplySign Desktop ${VERSION} ==="
# Idempotent: a cached/pre-provisioned image may already have it.
if [ -d "$INSTALL_DIR" ]; then
echo "Already installed at: $INSTALL_DIR"
exit 0
fi
echo "Downloading installer..."
curl -L "$URL" -o "$INSTALLER" --fail --max-time 600
ACTUAL_SHA256=$(sha256sum "$INSTALLER" | awk '{print $1}')
if [ "$EXPECTED_SHA256" != "$ACTUAL_SHA256" ]; then
echo "ERROR: checksum mismatch"
echo " expected: $EXPECTED_SHA256"
echo " actual: $ACTUAL_SHA256"
exit 1
fi
echo "Checksum verified."
echo "Running msiexec (silent)..."
# Start-Process inherits this CWD, so the relative installer path resolves here.
powershell -Command "Start-Process msiexec.exe -ArgumentList '/i','${INSTALLER}','/quiet','/norestart','/l*v','install.log','ALLUSERS=1','REBOOT=ReallySuppress' -Wait -NoNewWindow"
if [ ! -d "$INSTALL_DIR" ]; then
echo "ERROR: installation directory not found after install"
echo "Last 20 lines of install.log:"
tail -20 install.log 2>/dev/null || echo "(no install.log)"
exit 1
fi
echo "SimplySign Desktop installed."
+66
View File
@@ -0,0 +1,66 @@
# Sign the given files with the Certum cloud certificate (selected by SHA1
# thumbprint from the store) and verify each signature. Assumes SimplySign
# Desktop has already authenticated (connect-simplysign.ps1).
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Files,
[string]$CertSha1 = $env:CERTUM_CERT_SHA1,
[string]$TimestampUrl = "http://time.certum.pl"
)
$ErrorActionPreference = "Stop"
if (-not $CertSha1) { throw "CERTUM_CERT_SHA1 not provided." }
$thumb = ($CertSha1 -replace '\s', '').ToUpperInvariant()
# Resolve the newest signtool.exe from the installed Windows SDK(s).
$kitsBin = "C:\Program Files (x86)\Windows Kits\10\bin"
$signtool = Get-ChildItem -Path $kitsBin -Recurse -Filter signtool.exe -File -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like '*\x64\*' } |
Sort-Object { [version]$_.Directory.Parent.Name } |
Select-Object -Last 1 -ExpandProperty FullName
if (-not $signtool) { throw "signtool.exe not found under $kitsBin" }
Write-Host "Using signtool: $signtool"
# The SimplySign virtual smart card registers in the store a moment after the
# desktop client authenticates; poll for our certificate before signing.
Write-Host "Waiting for certificate $thumb to appear in the store..."
$deadline = (Get-Date).AddSeconds(120)
$cert = $null
$elapsed = 0
do {
$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Where-Object { $_.Thumbprint -eq $thumb } | Select-Object -First 1
if ($cert) { break }
Start-Sleep -Seconds 3
$elapsed += 3
Write-Host " ...still waiting (${elapsed}s)"
} while ((Get-Date) -lt $deadline)
if (-not $cert) {
Write-Host "Certificate not found. CurrentUser\My + LocalMachine\My contents:"
Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Select-Object Thumbprint, Subject, HasPrivateKey | Format-Table -AutoSize | Out-String | Write-Host
Write-Host "SimplySign processes:"
Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue |
Select-Object Name, Id, Responding, MainWindowTitle | Format-Table -AutoSize | Out-String | Write-Host
throw "Certificate $thumb not found in the store - SimplySign authentication likely failed (see dump above)."
}
Write-Host "Found signing certificate: $($cert.Subject)"
$failed = @()
foreach ($file in $Files) {
if (-not (Test-Path $file)) { throw "File to sign not found: $file" }
Write-Host "=== Signing $file ==="
& $signtool sign /sha1 $thumb /fd sha256 /td sha256 /tr $TimestampUrl /v $file
if ($LASTEXITCODE -ne 0) { $failed += $file; continue }
& $signtool verify /pa /v $file
if ($LASTEXITCODE -ne 0) { $failed += $file }
}
if ($failed.Count -gt 0) {
throw "Signing/verification failed for: $($failed -join ', ')"
}
Write-Host "All binaries signed and verified."
+4
View File
@@ -13,10 +13,14 @@ on:
- "Daybreak.Shared/**"
- "Daybreak.Windows/**"
- "Daybreak.Linux/**"
- ".github/scripts/**"
- ".github/workflows/cd-template.yml"
- ".github/workflows/cd-dry-run.yml"
jobs:
cd:
uses: ./.github/workflows/cd-template.yml
with:
publishRelease: false
signBinaries: false
secrets: inherit
+66 -9
View File
@@ -9,6 +9,11 @@ on:
required: false
type: boolean
default: false
signBinaries:
description: "Whether to code-sign Windows binaries"
required: false
type: boolean
default: false
jobs:
# ───────────────────────────────────────────────────────
@@ -16,7 +21,7 @@ jobs:
# (Injector + API) that both platforms need.
# ───────────────────────────────────────────────────────
build-x86:
runs-on: windows-latest
runs-on: windows-2025
outputs:
version: ${{ steps.get_version.outputs.version }}
@@ -24,10 +29,13 @@ jobs:
env:
Configuration: Release
Actions_Allow_Unsecure_Commands: true
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_USERID: ${{ secrets.CERTUM_USERID }}
CERTUM_CERT_SHA1: ${{ secrets.CERTUM_CERT_SHA1 }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
@@ -40,7 +48,7 @@ jobs:
echo "version=$version" >> $env:GITHUB_OUTPUT
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -48,6 +56,30 @@ jobs:
run: .\Scripts\PublishWindowsX86.ps1
shell: pwsh
# Certum SimplySign code signing. Binaries are signed only when explicitly
# requested and the Certum secrets are provisioned.
- name: Set up Certum SimplySign
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
shell: bash
run: |
chmod +x ./.github/scripts/install-simplysign.sh
./.github/scripts/install-simplysign.sh
powershell -ExecutionPolicy Bypass -File "./.github/scripts/configure-simplysign.ps1"
- name: Authenticate Certum SimplySign
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
shell: bash
run: powershell -ExecutionPolicy Bypass -File "./.github/scripts/connect-simplysign.ps1"
- name: Sign x86 binaries
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
run: |
./.github/scripts/sign-certum.ps1 -Files @(
"Publish/Injector/Daybreak.Injector.exe",
"Publish/Api/Daybreak.API.dll"
)
shell: pwsh
- name: Upload x86 artifacts
uses: actions/upload-artifact@v7
with:
@@ -62,21 +94,24 @@ jobs:
# ───────────────────────────────────────────────────────
build-windows:
needs: build-x86
runs-on: windows-latest
runs-on: windows-2025
env:
Configuration: Release
Actions_Allow_Unsecure_Commands: true
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_USERID: ${{ secrets.CERTUM_USERID }}
CERTUM_CERT_SHA1: ${{ secrets.CERTUM_CERT_SHA1 }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -98,6 +133,28 @@ jobs:
run: .\Scripts\PublishWindows.ps1
shell: pwsh
- name: Set up Certum SimplySign
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
shell: bash
run: |
chmod +x ./.github/scripts/install-simplysign.sh
./.github/scripts/install-simplysign.sh
powershell -ExecutionPolicy Bypass -File "./.github/scripts/configure-simplysign.ps1"
- name: Authenticate Certum SimplySign
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
shell: bash
run: powershell -ExecutionPolicy Bypass -File "./.github/scripts/connect-simplysign.ps1"
- name: Sign Windows build
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
run: |
./.github/scripts/sign-certum.ps1 -Files @(
"Publish/Daybreak.exe",
"Publish/Installer/Daybreak.Installer.exe"
)
shell: pwsh
- name: Upload Windows build
uses: actions/upload-artifact@v7
with:
@@ -114,13 +171,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -228,7 +285,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
+4
View File
@@ -15,6 +15,9 @@ on:
- "Daybreak.Shared/**"
- "Daybreak.Windows/**"
- "Daybreak.Linux/**"
- ".github/scripts/**"
- ".github/workflows/cd-template.yml"
- ".github/workflows/cd.yaml"
workflow_dispatch:
jobs:
@@ -22,4 +25,5 @@ jobs:
uses: ./.github/workflows/cd-template.yml
with:
publishRelease: true
signBinaries: true
secrets: inherit
+4 -4
View File
@@ -16,13 +16,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
@@ -49,13 +49,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -63,7 +63,7 @@ jobs:
# If this step fails, then you should remove it and run the build manually (see below)
- name: Install .NET Core
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
+2 -2
View File
@@ -27,12 +27,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install .NET Core
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.x"
+2 -2
View File
@@ -181,8 +181,8 @@ DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Click-Once / local publish directories
[Pp]ublish/
# Publish Web Output
*.[Pp]ublish.xml
+108
View File
@@ -0,0 +1,108 @@
-- Per-project Neovim config for the Daybreak repo.
-- Auto-sourced when nvim is started inside the repo, provided the user has
-- `vim.o.exrc = true` in their config (or runs `:set exrc`). Neovim 0.9+
-- prompts to trust this file on first load.
--
-- Commands added:
-- :DaybreakStageX86 [Config] build x86 components via wine and stage
-- them into Daybreak.Linux/bin/<Config>/...
-- /{Injector,Api}/ (default Config: Debug)
-- :DaybreakRunLinux run Daybreak.Linux via `dotnet run` in a
-- terminal split (honors launchSettings.json,
-- which easy-dot-net's launcher does not)
--
-- Note: a Linux/wine attach-debugger workflow was attempted (winedbg --gdb
-- stub *and* native Linux gdb -p attach), but both freeze GW reliably because
-- of ptrace/wineserver interactions that wine doesn't tolerate. For now,
-- prefer logging-based debugging via the API's console + log file in the
-- game directory (Daybreak.API<YYYYMMDD>.log next to Gw.exe).
local repo = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h')
local scripts = repo .. '/Scripts'
-- ───── Progress notification with spinner ──────────────────────────────────
-- Uses nvim-notify / snacks.nvim's `id`/`replace` semantics to update a
-- single notification in place. Falls back to plain notify if the backend
-- doesn't support replace (the spinner just stops updating).
local SPINNER = { '', '', '', '', '', '', '', '' }
local function start_progress(title, message)
-- stable id so snacks.nvim/nvim-notify replace the same notification
local id = 'daybreak-' .. title:gsub('%s+', '-') .. '-' .. tostring(vim.uv.hrtime())
local state = { title = title, msg = message, frame = 1, done = false, id = id }
local function render()
local icon = state.done and '' or (SPINNER[state.frame] .. ' ')
local opts = {
title = state.title,
id = state.id,
timeout = state.done and 4000 or false,
hide_from_history = not state.done,
icon = state.done and '' or nil,
}
if state.notif then opts.replace = state.notif end
state.notif = vim.notify(icon .. state.msg, vim.log.levels.INFO, opts)
end
render()
state.timer = vim.uv.new_timer()
state.timer:start(80, 80, vim.schedule_wrap(function()
if state.done then return end
state.frame = (state.frame % #SPINNER) + 1
render()
end))
return {
update = function(msg) state.msg = msg; render() end,
finish = function(ok, msg)
state.done = true
if state.timer then state.timer:stop(); state.timer:close(); state.timer = nil end
state.msg = msg or state.msg
local final_opts = {
title = state.title,
id = state.id,
timeout = 4000,
icon = ok and '' or '',
}
if state.notif then final_opts.replace = state.notif end
vim.notify((ok and '' or '') .. state.msg,
ok and vim.log.levels.INFO or vim.log.levels.ERROR, final_opts)
end,
}
end
-- ───── :DaybreakStageX86 [Config] ─────────────────────────────────────────
vim.api.nvim_create_user_command('DaybreakStageX86', function(opts)
local config = opts.args ~= '' and opts.args or 'Debug'
local progress = start_progress('Daybreak StageX86', 'Building x86 components (' .. config .. ')…')
vim.fn.jobstart({ scripts .. '/StageX86ForDebug.sh', config, '' }, {
cwd = repo,
on_exit = function(_, code)
vim.schedule(function()
if code == 0 then
progress.finish(true, 'Staged x86 (' .. config .. ')')
else
progress.finish(false, 'Stage failed (exit ' .. code ..
'). See /tmp/wine-injector-publish.log, /tmp/wine-api-publish.log')
end
end)
end,
stdout_buffered = false,
stderr_buffered = false,
})
end, {
nargs = '?',
complete = function() return { 'Debug', 'Release' } end,
desc = 'Build x86 components in wine and stage into Daybreak.Linux output',
})
-- ───── :DaybreakRunLinux ───────────────────────────────────────────────────
-- easy-dot-net launches the produced binary directly, bypassing launchSettings.json.
-- This runs Daybreak.Linux through `dotnet run`, which honors the environment
-- variables (GDK_BACKEND, WEBKIT_DISABLE_DMABUF_RENDERER, etc.) declared there.
-- Opens in the current window (no split) so it integrates with normal buffer
-- navigation; Ctrl-\ Ctrl-N to leave terminal mode, then your usual buffer
-- bindings work.
vim.api.nvim_create_user_command('DaybreakRunLinux', function()
vim.cmd('enew')
vim.fn.termopen({ 'dotnet', 'run', '--project', 'Daybreak.Linux/Daybreak.Linux.csproj', '-c', 'Debug' },
{ cwd = repo })
vim.cmd('startinsert')
end, { desc = 'Run Daybreak.Linux via dotnet run in the current window' })
+30 -2
View File
@@ -5,13 +5,41 @@
"name": "Debug Daybreak.Linux",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/Daybreak.Linux/Daybreak.Linux.csproj"
"projectPath": "${workspaceFolder}/Daybreak.Linux/Daybreak.Linux.csproj",
"console": "integratedTerminal",
"justMyCode": true,
"stopAtEntry": false,
"suppressJITOptimizations": false,
"symbolOptions": {
"searchMicrosoftSymbolServer": false,
"searchNuGetOrgSymbolServer": false
},
"logging": {
"engineLogging": false,
"moduleLoad": false,
"exceptions": false,
"browserStdOut": false
}
},
{
"name": "Debug Daybreak.Windows",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/Daybreak.Windows/Daybreak.Windows.csproj"
"projectPath": "${workspaceFolder}/Daybreak.Windows/Daybreak.Windows.csproj",
"console": "integratedTerminal",
"justMyCode": true,
"stopAtEntry": false,
"suppressJITOptimizations": false,
"symbolOptions": {
"searchMicrosoftSymbolServer": false,
"searchNuGetOrgSymbolServer": false
},
"logging": {
"engineLogging": false,
"moduleLoad": false,
"exceptions": false,
"browserStdOut": false
}
}
]
}
+113
View File
@@ -0,0 +1,113 @@
# Contributing to Daybreak
## Getting started
Install the prerequisites listed under
[Build Requirements](README.md#build-requirements), then clone with submodules:
```bash
git clone --recurse-submodules https://github.com/AlexMacocian/Daybreak.git
```
Read the [Architecture Overview](README.md#architecture-overview) before making
changes so you put code in the right project.
## Branching model
There is always exactly one active release branch, named `release/[version]`
(for example `release/0.9.10.20`). It is the integration branch: all work
targets it, and `master` only receives finished releases.
```mermaid
flowchart LR
subgraph cycle["Release cycle"]
direction LR
R["release/0.9.10.20<br/><i>active release branch</i>"]
F["feature branch<br/><i>one per change</i>"]
R -->|fork| F
F -->|"PR, squash merge"| R
end
R -->|"PR, rebase merge"| M["master"]
M --> CD["CD pipeline<br/>builds and publishes the release"]
CD -->|"job bumps the revision"| N["release/0.9.10.21<br/><i>next active release branch</i>"]
```
### Feature work
1. Branch off the current release branch.
2. Implement one feature or fix. Keep the branch scoped to a single change.
3. Open a PR against the release branch.
4. Merge with **squash**, so each feature lands as one commit.
### Releasing
1. Open a PR from the release branch onto `master`.
2. Merge with **rebase**, keeping full history. Each feature stays a single
commit on `master`.
3. The [CD pipeline](.github/workflows/cd.yaml) runs on the push to `master`,
builds the Windows and Linux bundles and publishes a GitHub release.
4. On success,
[create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml)
bumps the revision and creates the next `release/[version]` branch
automatically. That becomes the new active release branch.
Do not push directly to `master` or create release branches by hand.
## Versions
The version lives in a single place,
[`Directory.Build.props`](Directory.Build.props), and follows
`major.minor.build.revision`. It is bumped by the release branch creation job,
not manually.
Revision bumps happen automatically after every release. For the other
components, run the matching workflow manually before starting the next cycle:
| Workflow | Bumps |
| ------------------------------------------------------------------------------------------ | --------------------- |
| [create-major-release-branch.yml](.github/workflows/create-major-release-branch.yml) | `^.0.0.0` |
| [create-minor-release-branch.yml](.github/workflows/create-minor-release-branch.yml) | `*.^.0.0` |
| [create-build-release-branch.yml](.github/workflows/create-build-release-branch.yml) | `*.*.^.0` |
| [create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml) | `*.*.*.^` (automatic) |
All four delegate to
[create-release-branch-template.yml](.github/workflows/create-release-branch-template.yml).
## Pull requests
Title format follows the existing history: a short description, plus the issue
reference when it closes one.
```text
Setup stable computer name in Wine prefix (Closes #1609)
```
[label-merged-issues.yaml](.github/workflows/label-merged-issues.yaml) parses
`close/fix/resolve #N` from the title and body of PRs merged into a release
branch and labels the linked issues, so use those keywords.
## Checks
[CI](.github/workflows/ci.yaml) and
[version_check.yaml](.github/workflows/version_check.yaml) only trigger on PRs
targeting `master`, so a feature PR onto a release branch is not covered by
them. Run the equivalent locally before opening one:
```bash
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows on Windows
dotnet test Daybreak.Tests/Daybreak.Tests.csproj
```
The version check fails a PR to `master` if the version in
`Directory.Build.props` is not ahead of the latest tag. This is why the bump
must come from the release branch job.
## Code style
Formatting rules are in [`.editorconfig`](.editorconfig) and are enforced by the
compiler and analyzers; keep the build warning-free.
Tests live in [`Daybreak.Tests`](Daybreak.Tests), use MSTest with
FluentAssertions and NSubstitute, and only reference `Daybreak.Core`. If you
want platform code covered by tests, put the testable part in
[`Daybreak.Shared`](Daybreak.Shared).
+66 -13
View File
@@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Extensions.Core;
using System.Logging;
using System.Net.NetworkInformation;
using System.Reflection;
using System.Runtime.InteropServices;
using Daybreak.API.Configuration;
using Daybreak.API.Extensions;
@@ -22,8 +23,12 @@ namespace Daybreak.API;
public class EntryPoint
{
private const int StartPort = 5080;
private const string GwcaDllName = "gwca.dll";
private static readonly TimeSpan InitializationTimeout = TimeSpan.FromSeconds(5);
private static readonly CancellationTokenSource CancellationTokenSource = new();
private static readonly object GwcaResolverLock = new();
private static IntPtr gwcaHandle;
private static bool gwcaResolverRegistered;
[UnmanagedCallersOnly(EntryPoint = "ThreadInit"), STAThread]
[RequiresUnreferencedCode("The handler uses a static method that gets referenced, so there's no unreferenced code to worry about")]
@@ -182,24 +187,72 @@ public class EntryPoint
private static void PreloadNativeDependencies(ScopedLogger<EntryPoint> logger)
{
// First, check if gwca.dll is already loaded in the process (e.g. by GWToolbox).
// If so, reuse it via a ref-count bump on the existing path rather than loading
// a second copy from the Daybreak.API directory. Loading a second instance of
// gwca.dll would result in duplicate native modules in the process, which can
// lead to inconsistent state across mods that depend on it.
string? daybreakApiDir = default;
foreach (ProcessModule module in Process.GetCurrentProcess().Modules)
{
if (module.ModuleName?.Contains("Daybreak.API", StringComparison.OrdinalIgnoreCase) is true)
if (module.ModuleName?.Equals("gwca.dll", StringComparison.OrdinalIgnoreCase) is true)
{
var moduleDir = Path.GetDirectoryName(module.FileName)!;
var gwcaPath = Path.Combine(moduleDir, "gwca.dll");
if (File.Exists(gwcaPath))
{
NativeLibrary.Load(gwcaPath);
logger.LogDebug($"Preloaded gwca.dll from {gwcaPath}");
}
else
{
logger.LogError($"gwca.dll not found at {gwcaPath}");
}
var handle = NativeLibrary.Load(module.FileName);
RegisterGwcaResolver(handle, module.FileName, logger);
logger.LogDebug($"Reused already-loaded gwca.dll from {module.FileName}");
return;
}
break;
if (daybreakApiDir is null &&
module.ModuleName?.Contains("Daybreak.API", StringComparison.OrdinalIgnoreCase) is true)
{
daybreakApiDir = Path.GetDirectoryName(module.FileName);
}
}
if (daybreakApiDir is null)
{
logger.LogError("Could not locate Daybreak.API module directory to preload gwca.dll");
return;
}
var gwcaPath = Path.Combine(daybreakApiDir, "gwca.dll");
if (File.Exists(gwcaPath))
{
var handle = NativeLibrary.Load(gwcaPath);
RegisterGwcaResolver(handle, gwcaPath, logger);
logger.LogDebug($"Preloaded gwca.dll from {gwcaPath}");
}
else
{
logger.LogError($"gwca.dll not found at {gwcaPath}");
}
}
private static void RegisterGwcaResolver(IntPtr handle, string path, ScopedLogger<EntryPoint> logger)
{
lock (GwcaResolverLock)
{
gwcaHandle = handle;
if (gwcaResolverRegistered)
{
logger.LogDebug($"Updated gwca.dll resolver handle for {path}");
return;
}
NativeLibrary.SetDllImportResolver(typeof(GWCA).Assembly, ResolveNativeLibrary);
gwcaResolverRegistered = true;
logger.LogDebug($"Registered gwca.dll import resolver for {path}");
}
}
private static IntPtr ResolveNativeLibrary(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (string.Equals(libraryName, GwcaDllName, StringComparison.OrdinalIgnoreCase))
{
return gwcaHandle;
}
return IntPtr.Zero;
}
}
+1579 -410
View File
File diff suppressed because it is too large Load Diff
@@ -13,17 +13,44 @@ public readonly unsafe struct GuildWarsArray<T> : IEnumerable<T>
public readonly uint Size;
public readonly uint Param;
/// <summary>
/// Upper bound on a believable element count. Guild Wars' largest arrays hold at most a
/// few thousand entries, so anything beyond this is uninitialised or misresolved memory
/// rather than a real array.
/// </summary>
private const uint MaxPlausibleCapacity = 0x10000;
/// <summary>
/// Mirrors <c>GW::BaseArray::valid()</c> - the buffer must be null or aligned and the size
/// must fit within the capacity - plus a bound on the capacity itself. Guild Wars leaves
/// these fields transiently inconsistent while it (re)allocates an array, and a
/// misresolved GWCA scan can point this struct at arbitrary bytes that still satisfy
/// <c>size &lt;= capacity</c>.
/// </summary>
public bool IsValid =>
(this.Buffer is null || ((nuint)this.Buffer & 0x3) == 0) &&
this.Size <= this.Capacity &&
this.Capacity <= MaxPlausibleCapacity;
/// <summary>
/// Number of elements that can safely be read. Zero whenever the array is not backed by
/// a buffer, matching <c>GW::BaseArray::get()</c>, which returns null instead of
/// dereferencing. Reading past this is an access violation, and because NativeAOT cannot
/// throw <c>AccessViolationException</c> it fail-fasts and takes Guild Wars down with it.
/// </summary>
public uint Count => this.Buffer is not null && this.IsValid ? this.Size : 0;
public T this[int index]
{
get
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Size);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Count);
return this.Buffer[index];
}
}
public Enumerator GetEnumerator() => new(this.Buffer, this.Size);
public Enumerator GetEnumerator() => new(this.Buffer, this.Count);
IEnumerator<T> IEnumerable<T>.GetEnumerator() => this.GetEnumerator();
@@ -7,6 +7,18 @@ public readonly unsafe struct WrappedPointer<T>(T* pointer)
public bool IsNull => this.Pointer is null;
/// <summary>
/// True when the pointer is non-null and plausibly dereferenceable.
/// </summary>
/// <remarks>
/// Guild Wars structures are 4-byte aligned on x86, so a misaligned address is never a
/// real structure. When a GWCA pattern scan fails to resolve, it can hand back an
/// address inside Gw.exe's code section (for example 0x0048CA72); reading through that
/// yields nonsense field values and eventually an access violation. NativeAOT cannot
/// throw <c>AccessViolationException</c>, so it fail-fasts and terminates Guild Wars.
/// </remarks>
public bool IsValid => this.Pointer is not null && ((nuint)this.Pointer & 0x3) == 0;
public static implicit operator WrappedPointer<T>(T* pointer) => new(pointer);
public static implicit operator T*(WrappedPointer<T> wrappedPointer) => wrappedPointer.Pointer;
+83 -36
View File
@@ -1,7 +1,6 @@
using Daybreak.API.Extensions;
using Daybreak.API.Interop;
using Daybreak.API.Interop.GuildWars;
using Daybreak.API.Models;
using Daybreak.API.Services.Interop;
using Daybreak.Shared.Models.Api;
using System.Core.Extensions;
@@ -18,7 +17,6 @@ public sealed class CharacterSelectService(
UIContextService uiContextService,
GameThreadService gameThreadService,
GameContextService gameContextService,
PreferencesService preferencesService,
ILogger<CharacterSelectService> logger)
{
private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
@@ -26,7 +24,6 @@ public sealed class CharacterSelectService(
private readonly UIContextService uiContextService = uiContextService.ThrowIfNull();
private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull();
private readonly GameContextService gameContextService = gameContextService.ThrowIfNull();
private readonly PreferencesService preferencesService = preferencesService.ThrowIfNull();
private readonly ILogger<CharacterSelectService> logger = logger.ThrowIfNull();
public Task<CharacterSelectInformation?> GetCharacterSelectInformation(CancellationToken cancellationToken)
@@ -38,21 +35,28 @@ public sealed class CharacterSelectService(
{
var gameContext = this.gameContextService.GetGameContext();
if (gameContext.IsNull ||
gameContext.Pointer->World is null)
gameContext.Pointer->World is null ||
gameContext.Pointer->Character is null)
{
scopedLogger.LogError("Game context is not initialized");
return default;
}
var availableCharsContext = this.gameContextService.GetAvailableChars();
if (availableCharsContext.IsNull)
if (!availableCharsContext.IsValid ||
!availableCharsContext.Pointer->IsValid)
{
scopedLogger.LogError("Available characters context is not initialized");
scopedLogger.LogError(
"Available characters context is not initialized (array@0x{Array:X8} buffer=0x{Buffer:X8} capacity={Capacity} size={Size})",
(nuint)availableCharsContext.Pointer,
availableCharsContext.IsValid ? (nuint)availableCharsContext.Pointer->Buffer : 0,
availableCharsContext.IsValid ? availableCharsContext.Pointer->Capacity : 0,
availableCharsContext.IsValid ? availableCharsContext.Pointer->Size : 0);
return default;
}
var currentUuid = (*(Uuid*)gameContext.Pointer->Character->PlayerUuid).ToString();
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Size);
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Count);
foreach (var charContext in *availableCharsContext.Pointer)
{
var name = new string(charContext.Name);
@@ -165,6 +169,12 @@ public sealed class CharacterSelectService(
{
unsafe
{
if (!this.IsCharSelectReady())
{
scopedLogger.LogError("Character select is not ready");
return false;
}
var selectorFrame = this.GetCharSelectorFrame();
if (selectorFrame is null)
{
@@ -173,7 +183,8 @@ public sealed class CharacterSelectService(
}
var ctx = this.uiContextService.GetFrameContext<CharSelectorContext>(selectorFrame);
if (ctx.IsNull)
if (ctx.IsNull ||
(nuint)ctx.Pointer <= 0xFFFF)
{
scopedLogger.LogError("Character selector context not found");
return false;
@@ -192,7 +203,9 @@ public sealed class CharacterSelectService(
// Find target character index
uint targetIdx = 0xFFFF;
var charCount = ctx.Pointer->Chars.Size;
// Count (not Size) so a not-yet-allocated roster yields zero iterations
// instead of indexing through a null buffer.
var charCount = ctx.Pointer->Chars.Count;
for (uint i = 0; i < charCount; i++)
{
@@ -268,7 +281,10 @@ public sealed class CharacterSelectService(
}
}
// Navigate to target character by selecting previous/next until we reach it
// Navigate to target character by selecting previous/next until we reach it.
// Empty roster slots are represented by null entries in the character array, so
// step over them instead of bailing out (otherwise navigation gets stuck at the
// slot right before a gap and never reaches characters beyond it).
while (targetIdx < selectedIdx)
{
if (selectedIdx == 0)
@@ -276,13 +292,24 @@ public sealed class CharacterSelectService(
break;
}
if (!SelectChar(selectedIdx - 1))
var prevIdx = selectedIdx - 1;
while (prevIdx > 0 && ctx.Pointer->Chars.Buffer[prevIdx].IsNull)
{
prevIdx--;
}
if (ctx.Pointer->Chars.Buffer[prevIdx].IsNull)
{
break;
}
if (!SelectChar(prevIdx))
{
scopedLogger.LogError("Failed to select previous character");
return false;
}
selectedIdx--;
selectedIdx = prevIdx;
}
while (targetIdx > selectedIdx)
@@ -292,13 +319,24 @@ public sealed class CharacterSelectService(
break;
}
if (!SelectChar(selectedIdx + 1))
var nextIdx = selectedIdx + 1;
while (nextIdx < charCount && ctx.Pointer->Chars.Buffer[nextIdx].IsNull)
{
nextIdx++;
}
if (nextIdx >= charCount)
{
break;
}
if (!SelectChar(nextIdx))
{
scopedLogger.LogError("Failed to select next character");
return false;
}
selectedIdx++;
selectedIdx = nextIdx;
}
var chosen = selectedIdx == targetIdx;
@@ -341,7 +379,7 @@ public sealed class CharacterSelectService(
await Task.Delay(100, cancellationToken);
}
catch(Exception e)
catch (Exception e)
{
scopedLogger.LogError(e, "Error while waiting for character select to be ready");
throw;
@@ -353,25 +391,13 @@ public sealed class CharacterSelectService(
return false;
}
var previousOrderPreference = await this.gameThreadService.QueueOnGameThread(() => this.preferencesService.GetEnumPreference(EnumPreference.CharSortOrder), cancellationToken);
if (previousOrderPreference is null)
{
scopedLogger.LogError("Failed to get previous character sort order preference");
return false;
}
scopedLogger.LogInformation("Previous character sort order preference: {order}. Setting order to alphabetize", (CharSortOrder)previousOrderPreference);
await this.gameThreadService.QueueOnGameThread(() => this.preferencesService.SetEnumPreference(EnumPreference.CharSortOrder, (uint)CharSortOrder.Alphabetize), cancellationToken);
try
{
return await this.SelectCharacterToPlay(characterName, play: true, cancellationToken);
}
finally
{
// TODO: Delay to ensure character select has fully processed the selection before reverting sort order. Should be done after loading into the map.
await Task.Delay(1000, cancellationToken);
await this.gameThreadService.QueueOnGameThread(() => this.preferencesService.SetEnumPreference(EnumPreference.CharSortOrder, previousOrderPreference.Value), cancellationToken);
}
// NOTE: Mirrors GWToolbox's GW::LoginMgr::SelectCharacterToPlay, which navigates the
// character carousel purely by stepping prev/next over the selector context's character
// array. It deliberately does NOT touch the in-game CharSortOrder preference: the
// navigation relies on the selector context order matching the visible carousel order,
// and forcing a re-sort here desyncs the two (the click-by-name steps then land on the
// wrong index, so navigation only appears to work in one direction).
return await this.SelectCharacterToPlay(characterName, play: true, cancellationToken);
}
private async Task<string?> GetCharNameByUuid(string uuid, CancellationToken cancellationToken)
@@ -389,7 +415,8 @@ public sealed class CharacterSelectService(
}
var availableCharsContext = this.gameContextService.GetAvailableChars();
if (availableCharsContext.IsNull)
if (!availableCharsContext.IsValid ||
!availableCharsContext.Pointer->IsValid)
{
scopedLogger.LogError("Available characters context is not initialized");
return default;
@@ -433,7 +460,27 @@ public sealed class CharacterSelectService(
private unsafe bool IsCharSelectReady()
{
return this.GetCharSelectorFrame() is not null;
// GW UI state must report char select (2). kCheckUIState fills the value passed via lParam.
uint uiState = 10;
this.uiContextService.SendMessage(UIMessage.kCheckUIState, 0, (nuint)(&uiState));
var selectorFrame = this.GetCharSelectorFrame();
if (uiState != 2 ||
selectorFrame is null ||
!selectorFrame->IsVisible)
{
return false;
}
// The frame context must be fully populated (a real pointer) and belong to this frame.
var ctx = this.uiContextService.GetFrameContext<CharSelectorContext>(selectorFrame);
if (ctx.IsNull ||
(nuint)ctx.Pointer <= 0xFFFF)
{
return false;
}
return ctx.Pointer->FrameId == selectorFrame->FrameId;
}
private unsafe Frame* GetCharSelectorFrame()
+8 -7
View File
@@ -41,7 +41,7 @@ public sealed class InventoryService(
return default;
}
var itemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint)>)>();
var itemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint, ushort Value)>)>();
for (var i = 0; i < 23; i++)
{
// Use GWCA helper to fetch bag pointers by index — safer than reading an inline array directly.
@@ -51,7 +51,7 @@ public sealed class InventoryService(
continue;
}
var retBag = new List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint)>();
var retBag = new List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint, ushort Value)>();
itemTuples.Add((bag->BagType, retBag));
if (bag->ItemsCount is 0)
{
@@ -75,7 +75,7 @@ public sealed class InventoryService(
modifiers[j] = item->ModStruct[j].Mod;
}
retBag.Add((item->ModelId, completeNameEncoded, singleItemName, nameEncoded, (item->Interaction & 0x08000000) != 0, item->Quantity, modifiers, (ItemType)item->Type, item->Interaction, item->ModelFileId, item->Dye.DyeTint));
retBag.Add((item->ModelId, completeNameEncoded, singleItemName, nameEncoded, (item->Interaction & 0x08000000) != 0, item->Quantity, modifiers, (ItemType)item->Type, item->Interaction, item->ModelFileId, item->Dye.DyeTint, item->Value));
}
}
@@ -90,16 +90,16 @@ public sealed class InventoryService(
// Decode strings sequentially to avoid race conditions with the game's TextParser language field.
// Parallel decoding causes crashes because multiple operations race to set/restore the language.
var decodedItemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint)> Items)>();
var decodedItemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint, ushort Value)> Items)>();
foreach (var tuple in itemTuples)
{
var decodedItems = new List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint)>();
var decodedItems = new List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint, ushort Value)>();
foreach (var item in tuple.Item2)
{
var decodedName = await this.uIService.DecodeString(item.EncodedName, (GWCA.GW.Constants.Language)Language.English, cancellationToken);
var decodedCompleteName = await this.uIService.DecodeString(item.EncodedCompleteName, (GWCA.GW.Constants.Language)Language.English, cancellationToken);
var decodedSingleName = await this.uIService.DecodeString(item.EncodedSingleName, (GWCA.GW.Constants.Language)Language.English, cancellationToken);
decodedItems.Add((item.ModelId, item.EncodedName, decodedName, item.EncodedSingleName, decodedSingleName, item.EncodedCompleteName, decodedCompleteName, item.Inscribable, item.Quantity, item.Modifiers, item.ItemType, item.Interaction, item.ModelFileId, item.DyeTint));
decodedItems.Add((item.ModelId, item.EncodedName, decodedName, item.EncodedSingleName, decodedSingleName, item.EncodedCompleteName, decodedCompleteName, item.Inscribable, item.Quantity, item.Modifiers, item.ItemType, item.Interaction, item.ModelFileId, item.DyeTint, item.Value));
}
decodedItemTuples.Add((tuple.Type, decodedItems));
@@ -125,7 +125,8 @@ public sealed class InventoryService(
ItemType: item.ItemType.ToString(),
Interaction: item.Interaction,
ModelFileId: item.ModelFileId,
DyeTint: item.DyeTint))]))]);
DyeTint: item.DyeTint,
Value: item.Value))]))]);
}
private static string ToBase64(string encoded)
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
using Daybreak.Shared.Attributes;
namespace Daybreak.Configuration.Options;
[OptionsName(Name = "Downloads")]
internal sealed class DownloadOptions
{
[JsonPropertyName(nameof(HeaderTimeout))]
[OptionName(
Name = "Header Timeout",
Description = "Amount of seconds Daybreak will wait for a download to respond before giving up on the attempt"
)]
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
public double HeaderTimeout { get; set; } = 30;
[JsonPropertyName(nameof(ChunkTimeout))]
[OptionName(
Name = "Chunk Timeout",
Description = "Amount of seconds Daybreak will wait for the next chunk of a download before giving up on the attempt"
)]
[OptionRange<double>(MinValue = 5, MaxValue = 300)]
public double ChunkTimeout { get; set; } = 30;
[JsonPropertyName(nameof(Retries))]
[OptionName(
Name = "Retries",
Description = "Amount of times Daybreak will retry a failed download before reporting it as failed"
)]
[OptionRange<int>(MinValue = 0, MaxValue = 10)]
public int Retries { get; set; } = 3;
[JsonPropertyName(nameof(RetryDelay))]
[OptionName(
Name = "Retry Delay",
Description = "Amount of seconds Daybreak will wait before retrying a failed download"
)]
[OptionRange<double>(MinValue = 0, MaxValue = 60)]
public double RetryDelay { get; set; } = 2;
}
@@ -39,4 +39,33 @@ public sealed class FocusViewOptions
[JsonPropertyName(nameof(EnergyDisplay))]
[OptionName(Name = "Energy Display Mode", Description = "Sets how should the energy display show the information")]
public PointsDisplay EnergyDisplay { get; set; }
[JsonPropertyName(nameof(GridColumns))]
[OptionName(Name = "Layout Grid Columns", Description = "Number of columns in the focus view layout grid")]
[OptionIgnore]
public int GridColumns { get; set; } = DefaultGridColumns;
[JsonPropertyName(nameof(GridRows))]
[OptionName(Name = "Layout Grid Rows", Description = "Number of rows in the focus view layout grid")]
[OptionIgnore]
public int GridRows { get; set; } = DefaultGridRows;
[JsonPropertyName(nameof(Layout))]
[OptionName(Name = "Layout", Description = "Tile-based layout of the focus view components")]
public List<FocusViewTile> Layout { get; set; } = DefaultLayout();
public const int DefaultGridColumns = 20;
public const int DefaultGridRows = 16;
public static List<FocusViewTile> DefaultLayout() =>
[
new() { Component = FocusViewComponent.Character, Column = 1, Row = 1, ColumnSpan = 10, RowSpan = 3 },
new() { Component = FocusViewComponent.PlayerResources, Column = 1, Row = 4, ColumnSpan = 10, RowSpan = 3 },
new() { Component = FocusViewComponent.Vanquish, Column = 1, Row = 7, ColumnSpan = 10, RowSpan = 1 },
new() { Component = FocusViewComponent.Title, Column = 1, Row = 8, ColumnSpan = 10, RowSpan = 1 },
new() { Component = FocusViewComponent.CurrentMap, Column = 1, Row = 9, ColumnSpan = 10, RowSpan = 1 },
new() { Component = FocusViewComponent.QuestLog, Column = 1, Row = 10, ColumnSpan = 10, RowSpan = 7 },
new() { Component = FocusViewComponent.Build, Column = 11, Row = 1, ColumnSpan = 10, RowSpan = 5 },
new() { Component = FocusViewComponent.Browser, Column = 11, Row = 6, ColumnSpan = 10, RowSpan = 11 },
];
}
@@ -9,6 +9,7 @@ using Daybreak.Services.ApplicationArguments.ArgumentHandling;
using Daybreak.Services.ApplicationLauncher;
using Daybreak.Services.BuildTemplates;
using Daybreak.Services.Credentials;
using Daybreak.Services.Formatting;
using Daybreak.Services.DirectSong;
using Daybreak.Services.Downloads;
using Daybreak.Services.Events;
@@ -48,12 +49,12 @@ using Daybreak.Services.TradeChat.Models;
using Daybreak.Services.TradeChat.Notifications;
using Daybreak.Services.UMod;
using Daybreak.Services.Updater;
using Daybreak.Services.Wiki;
using Daybreak.Shared.Models.Plugins;
using Daybreak.Shared.Services.Api;
using Daybreak.Shared.Services.ApplicationArguments;
using Daybreak.Shared.Services.ApplicationLauncher;
using Daybreak.Shared.Services.BuildTemplates;
using Daybreak.Shared.Services.Formatting;
using Daybreak.Shared.Services.BuildTemplates.Parsers;
using Daybreak.Shared.Services.Credentials;
using Daybreak.Shared.Services.DirectSong;
@@ -82,7 +83,6 @@ using Daybreak.Shared.Services.Toolbox;
using Daybreak.Shared.Services.TradeChat;
using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Services.Updater;
using Daybreak.Shared.Services.Wiki;
using Daybreak.Services.Graph;
using Daybreak.Themes;
using Daybreak.Views;
@@ -128,7 +128,13 @@ public class ProjectConfiguration : PluginConfigurationBase
var resourceBuilder = ResourceBuilder
.CreateDefault()
.AddService("Daybreak", serviceVersion: CurrentVersion.ToString());
var attributes = new List<KeyValuePair<string, object>>();
var attributes = new List<KeyValuePair<string, object>>
{
// Mirror the fleet-wide `service` stream field (promtail sets it for
// container logs) so Daybreak shows up in Grafana Logs Drilldown's
// `service` picker, alongside the `service_name` set by AddService.
new("service", "Daybreak"),
};
#if DEBUG
attributes.Add(new KeyValuePair<string, object>("deployment.environment", "debug"));
#else
@@ -225,6 +231,7 @@ public class ProjectConfiguration : PluginConfigurationBase
services.AddScoped<IOnboardingService, OnboardingService>();
services.AddScoped<IExperienceCalculator, ExperienceCalculator>();
services.AddScoped<IAttributePointCalculator, AttributePointCalculator>();
services.AddScoped<ISkillNumberFormatter, SkillNumberFormatter>();
services.AddScoped<IDownloadService, DownloadService>();
services.AddScoped<IGuildWarsInstaller, IntegratedGuildwarsInstaller>();
services.AddScoped<IExceptionHandler, ExceptionHandler>();
@@ -248,14 +255,13 @@ public class ProjectConfiguration : PluginConfigurationBase
services.AddScoped<ILaunchConfigurationService, LaunchConfigurationService>();
services.AddScoped<IEventService, EventService>();
services.AddScoped<IApplicationArgumentService, ApplicationArgumentService>();
services.AddScoped<IWikiService, WikiService>();
services.AddScoped<IScreenshotService, ScreenshotService>();
services.AddHostedSingleton<IApplicationUpdater, ApplicationUpdater>();
services.AddHostedSingleton<IThemeManager, BlazorThemeInteropService>();
services.AddHostedSingleton<IConnectivityStatus, ConnectivityStatus>();
services.AddHostedSingleton<ITradeAlertingService, TradeAlertingService>();
services.AddHostedSingleton<IGuildWarsExecutableManager, GuildWarsExecutableManager>();
services.AddSingleton<IGuildWarsExecutableManager, GuildWarsExecutableManager>();
services.AddHostedSingleton<IEventNotifierService, EventNotifierService>();
services.AddHostedSingleton<IApiScanningService, ApiScanningService>();
services.AddHostedSingleton<GameScreenshotsTheme>();
@@ -343,6 +349,7 @@ public class ProjectConfiguration : PluginConfigurationBase
optionsProducer.RegisterOptions<SynchronizationOptions>();
optionsProducer.RegisterOptions<FocusViewOptions>();
optionsProducer.RegisterOptions<DaybreakApiOptions>();
optionsProducer.RegisterOptions<DownloadOptions>();
optionsProducer.RegisterOptions<ToolboxOptions>();
optionsProducer.RegisterOptions<UModOptions>();
@@ -383,8 +390,11 @@ public class ProjectConfiguration : PluginConfigurationBase
public override void RegisterMods(IModsProducer modsManager)
{
modsManager.RegisterMod<IGuildWarsVersionChecker, GuildWarsVersionChecker>();
modsManager.RegisterMod<IDaybreakApiService, DaybreakApiService>();
// Toolbox must be registered before Daybreak API so that Toolbox injects first
// and loads its gwca.dll. Daybreak API will then reuse the already-loaded gwca.dll
// instead of loading a second copy, avoiding duplicate native module loads.
modsManager.RegisterMod<IToolboxService, ToolboxService>();
modsManager.RegisterMod<IDaybreakApiService, DaybreakApiService>();
modsManager.RegisterMod<IUModService, UModService>();
modsManager.RegisterMod<IDirectSongService, DirectSongService>(singleton: true);
}
@@ -565,10 +575,6 @@ public class ProjectConfiguration : PluginConfigurationBase
.WithMessageHandler(SetupLoggingAndMetrics<ScopedApiContext>)
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
.Build()
.RegisterHttpClient<WikiService>()
.WithMessageHandler(SetupLoggingAndMetrics<WikiService>)
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
.Build()
.RegisterHttpClient<ReShadeService>()
.WithMessageHandler(SetupLoggingAndMetrics<ReShadeService>)
.WithDefaultRequestHeadersSetup(SetupDaybreakUserAgent)
+3 -2
View File
@@ -39,14 +39,15 @@
</Target>
<ItemGroup>
<PackageReference Include="Elastic.OpenTelemetry" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" />
<PackageReference Include="Microsoft.AspNetCore.Components" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="OpenTelemetry.Exporter.Console" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="PeNet" />
<PackageReference Include="Plumsy" />
@@ -65,7 +66,7 @@
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.Debug" />
<PackageReference Include="SharpCompress" />
<PackageReference Include="SixLabors.ImageSharp" />
<PackageReference Include="StbImageSharp" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
</ItemGroup>
@@ -4,5 +4,14 @@ namespace Daybreak.Models;
public sealed class NotificationWrapper
{
public required Notification Notification { get; init; }
public DateTime StartTime { get; set; }
public DateTime ExpirationTime { get; set; }
/// <summary>
/// Incremented every time the notification lifetime is prolonged so the UI can
/// restart the expiration progress animation.
/// </summary>
public int LifetimeGeneration { get; set; }
public double LifetimeMilliseconds => Math.Max(0, (this.ExpirationTime - this.StartTime).TotalMilliseconds);
}
@@ -131,7 +131,7 @@ public sealed class ApiScanningService(
}
// Convert the reported PID (Wine PID on Linux) to system PID
var systemPid = this.pidProvider.ResolveSystemPid(reportedPid.Value, GuildWarsExecutable);
var systemPid = this.pidProvider.ResolveSystemPid(reportedPid.Value, GuildWarsExecutable, port);
var uri = new Uri($"http://localhost:{port}");
scopedLogger.LogDebug(
@@ -30,6 +30,7 @@ internal sealed class ApplicationLauncher(
IGuildWarsProcessFinder guildWarsProcessFinder,
IDaybreakRestartingService daybreakRestartingService,
IPrivilegeManager privilegeManager,
ISteamService steamService,
ILogger<ApplicationLauncher> logger
) : IApplicationLauncher
{
@@ -55,6 +56,7 @@ internal sealed class ApplicationLauncher(
private readonly IPrivilegeManager privilegeManager =
privilegeManager.ThrowIfNull();
private readonly ISteamService steamService = steamService.ThrowIfNull();
public async Task<GuildWarsApplicationLaunchContext?> LaunchGuildwars(
LaunchConfigurationWithCredentials launchConfigurationWithCredentials,
@@ -63,6 +65,27 @@ internal sealed class ApplicationLauncher(
{
launchConfigurationWithCredentials.ThrowIfNull();
launchConfigurationWithCredentials.Credentials.ThrowIfNull();
if (launchConfigurationWithCredentials.Credentials?.IsSteamLogin is true)
{
if (!this.steamService.IsSteamLoginSupported)
{
this.notificationService.NotifyError(
title: "Steam login is not supported",
description: "Steam login is not supported on this platform yet. Please select a different set of credentials"
);
return default;
}
if (!this.steamService.IsSteamRunning())
{
this.notificationService.NotifyError(
title: "Steam is not running",
description: "Steam login is selected but the Steam client is not running. Please start Steam and log in, then try again"
);
return default;
}
}
using var timeout = new CancellationTokenSource(LaunchTimeout);
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
@@ -107,8 +130,9 @@ internal sealed class ApplicationLauncher(
)
{
var scopedLogger = this.logger.CreateScopedLogger();
var email = launchConfigurationWithCredentials.Credentials?.Username;
var password = launchConfigurationWithCredentials.Credentials?.Password;
var isSteamLogin = launchConfigurationWithCredentials.Credentials?.IsSteamLogin is true;
var email = isSteamLogin ? null : launchConfigurationWithCredentials.Credentials?.Username;
var password = isSteamLogin ? null : launchConfigurationWithCredentials.Credentials?.Password;
var executable = launchConfigurationWithCredentials.ExecutablePath;
if (executable is not null && File.Exists(executable) is false)
{
@@ -229,7 +253,7 @@ internal sealed class ApplicationLauncher(
};
var steamAppIdFilePath = Path.Combine(workingDirectory, SteamAppIdFile);
if (launchConfigurationWithCredentials.SteamSupport)
if (launchConfigurationWithCredentials.SteamSupport || isSteamLogin)
{
await File.WriteAllTextAsync(steamAppIdFilePath, SteamAppId, cancellationToken);
scopedLogger.LogDebug(
@@ -536,18 +560,7 @@ internal sealed class ApplicationLauncher(
var scopedLogger = this.logger.CreateScopedLogger();
try
{
var process = guildWarsApplicationLaunchContext.GuildWarsProcess;
if (
process.MainModule?.FileName is not null
&& process.MainModule.FileName.Contains(
"Gw.exe",
StringComparison.OrdinalIgnoreCase
)
)
{
process.Kill(true);
return;
}
this.guildWarsProcessFinder.KillProcess(guildWarsApplicationLaunchContext);
}
catch (Exception e)
when (e.Message.Contains(
@@ -26,6 +26,12 @@ internal sealed class CredentialManager(
public bool TryGetCredentialsByIdentifier(string identifier, out LoginCredentials? loginCredentials)
{
loginCredentials = default;
if (string.Equals(identifier, LoginCredentials.SteamLoginIdentifier, StringComparison.Ordinal))
{
loginCredentials = LoginCredentials.SteamLogin;
return true;
}
if (this.GetCredentialList().FirstOrDefault(l => l.Identifier == identifier) is LoginCredentials foundCredentials)
{
loginCredentials = foundCredentials;
@@ -56,6 +62,7 @@ internal sealed class CredentialManager(
this.logger.LogDebug("Storing credentials");
var options = this.liveOptions.CurrentValue;
options.ProtectedLoginCredentials = [.. loginCredentials
.Where(c => !c.IsSteamLogin)
.Select(this.ProtectCredentials)
.OfType<ProtectedLoginCredentials>()];
this.optionsProvider.SaveOption(options);
@@ -433,6 +433,7 @@ internal sealed class DXVKService(
{
Directory.Delete(x32Target, recursive: true);
}
if (Directory.Exists(x64Target))
{
Directory.Delete(x64Target, recursive: true);
@@ -1,20 +1,25 @@
using Daybreak.Shared.Models.Async;
using Daybreak.Configuration.Options;
using Daybreak.Shared.Models.Async;
using Daybreak.Shared.Models.Metrics;
using Daybreak.Shared.Services.Downloads;
using Daybreak.Shared.Services.Metrics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Core.Extensions;
using System.Diagnostics.Metrics;
using System.Extensions.Core;
using System.Logging;
namespace Daybreak.Services.Downloads;
internal sealed class DownloadService(
IMetricsService metricsService,
IHttpClient<DownloadService> httpClient,
IOptionsMonitor<DownloadOptions> options,
ILogger<DownloadService> logger) : IDownloadService
{
private const double StatusUpdateInterval = 50;
private const int BufferSize = 81920;
private const string MetricName = "download.speed";
private const string MetricUnits = "bytes/sec";
private const string MetricDescription = "Average download speed. Specified in bytes per second";
@@ -23,19 +28,79 @@ internal sealed class DownloadService(
private readonly static ProgressUpdate ProgressFailed = new(1, "Download failed");
private readonly static ProgressUpdate ProgressCompleted = new(1, "Download finished");
private static ProgressUpdate ProgressDownload(double progress) => new(progress, "Downloading");
private static ProgressUpdate ProgressRetrying(int attempt, int retries) => new(0, $"Download failed. Retrying ({attempt}/{retries})");
private readonly Histogram<double> averageDownloadSpeed = metricsService.ThrowIfNull().CreateHistogram<double>(MetricName, MetricUnits, MetricDescription, AggregationTypes.NoAggregate);
private readonly IHttpClient<DownloadService> httpClient = httpClient.ThrowIfNull();
private readonly IOptionsMonitor<DownloadOptions> options = options.ThrowIfNull();
private readonly ILogger<DownloadService> logger = logger.ThrowIfNull();
public async Task<bool> DownloadFile(string downloadUri, string destinationPath, IProgress<ProgressUpdate> progress, CancellationToken cancellationToken = default)
{
var scopedLogger = this.logger.CreateScopedLogger();
var currentOptions = this.options.CurrentValue;
var retries = Math.Max(0, currentOptions.Retries);
var attempts = retries + 1;
for (var attempt = 1; attempt <= attempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (await this.TryDownloadFile(downloadUri, destinationPath, progress, currentOptions, cancellationToken))
{
progress.Report(ProgressCompleted);
scopedLogger.LogDebug("Downloaded file");
return true;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The caller gave up. The partial file is still removed, because leaving it behind
// lets a later run mistake it for a complete download.
DeletePartialDownload(destinationPath, scopedLogger);
throw;
}
catch (OperationCanceledException)
{
scopedLogger.LogError("Download timed out on attempt {Attempt} of {Attempts}", attempt, attempts);
}
catch (Exception e)
{
scopedLogger.LogError(e, "Download failed on attempt {Attempt} of {Attempts}", attempt, attempts);
}
DeletePartialDownload(destinationPath, scopedLogger);
if (attempt < attempts)
{
progress.Report(ProgressRetrying(attempt, retries));
if (currentOptions.RetryDelay > 0)
{
await Task.Delay(TimeSpan.FromSeconds(currentOptions.RetryDelay), cancellationToken);
}
}
}
progress.Report(ProgressFailed);
return false;
}
private async Task<bool> TryDownloadFile(
string downloadUri,
string destinationPath,
IProgress<ProgressUpdate> progress,
DownloadOptions currentOptions,
CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
progress.Report(ProgressInitialize);
using var response = await this.httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
// A server that accepts the connection but never answers would otherwise hang the launch.
using var headerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
headerCts.CancelAfter(TimeSpan.FromSeconds(currentOptions.HeaderTimeout));
using var response = await this.httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead, headerCts.Token);
if (response.IsSuccessStatusCode is false)
{
progress.Report(ProgressFailed);
scopedLogger.LogError($"Failed to download installer. Status: {response.StatusCode}. Details: {await response.Content.ReadAsStringAsync(cancellationToken)}");
return false;
}
@@ -44,30 +109,39 @@ internal sealed class DownloadService(
this.logger.LogDebug("Beginning download");
var fileInfo = new FileInfo(destinationPath);
fileInfo.Directory?.Create();
using var fileStream = File.Open(destinationPath, FileMode.Create, FileAccess.Write);
var downloadSize = response.Content?.Headers?.ContentLength ?? double.MaxValue;
var buffer = new byte[1024];
var length = 0;
var downloaded = 0d;
var downloadedPerTimeframe = 0d;
var tickTime = DateTime.Now;
var startTime = DateTime.Now;
while (downloadStream.CanRead && (length = await downloadStream.ReadAsync(buffer, cancellationToken)) > 0)
{
downloaded += length;
downloadedPerTimeframe += length;
await fileStream.WriteAsync(buffer.AsMemory(0, length), cancellationToken);
if ((DateTime.Now - tickTime).TotalMilliseconds > StatusUpdateInterval)
{
tickTime = DateTime.Now;
var downloadedInSecond = downloadedPerTimeframe * 1000d / StatusUpdateInterval;
this.averageDownloadSpeed.Record(downloadedInSecond);
var chunkTimeout = TimeSpan.FromSeconds(currentOptions.ChunkTimeout);
var buffer = new byte[BufferSize];
// var avgSpeed = downloaded / (tickTime - startTime).TotalSeconds;
// var remainingSize = downloadSize - downloaded;
// var secondsRemaining = remainingSize / avgSpeed;
downloadedPerTimeframe = 0d;
progress.Report(ProgressDownload(downloaded / downloadSize));
// Scoped so the handle is released before a failed download is deleted.
using (var fileStream = File.Open(destinationPath, FileMode.Create, FileAccess.Write))
{
while (downloadStream.CanRead)
{
// A stalled connection blocks inside ReadAsync indefinitely, so each chunk gets its own deadline.
using var chunkCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
chunkCts.CancelAfter(chunkTimeout);
var length = await downloadStream.ReadAsync(buffer, chunkCts.Token);
if (length <= 0)
{
break;
}
downloaded += length;
downloadedPerTimeframe += length;
await fileStream.WriteAsync(buffer.AsMemory(0, length), cancellationToken);
if ((DateTime.Now - tickTime).TotalMilliseconds > StatusUpdateInterval)
{
tickTime = DateTime.Now;
var downloadedInSecond = downloadedPerTimeframe * 1000d / StatusUpdateInterval;
this.averageDownloadSpeed.Record(downloadedInSecond);
downloadedPerTimeframe = 0d;
progress.Report(ProgressDownload(downloaded / downloadSize));
}
}
}
@@ -75,14 +149,24 @@ internal sealed class DownloadService(
if (downloadSize != double.MaxValue && downloaded < downloadSize)
{
scopedLogger.LogError("Download incomplete. Expected {ExpectedSize} bytes but received {ActualSize} bytes", (long)downloadSize, (long)downloaded);
progress.Report(ProgressFailed);
fileStream.Close();
File.Delete(destinationPath);
return false;
}
progress.Report(ProgressCompleted);
scopedLogger.LogDebug("Downloaded file");
return true;
}
private static void DeletePartialDownload(string destinationPath, ScopedLogger<DownloadService> scopedLogger)
{
try
{
if (File.Exists(destinationPath))
{
File.Delete(destinationPath);
}
}
catch (Exception e)
{
scopedLogger.LogError(e, "Failed to delete partial download at {DestinationPath}", destinationPath);
}
}
}
@@ -1,6 +1,7 @@
using Daybreak.Configuration.Options;
using Daybreak.Services.Notifications.Handlers;
using Daybreak.Shared.Converters;
using Daybreak.Shared;
using Daybreak.Shared.Models.Guildwars;
using Daybreak.Shared.Services.Events;
using Daybreak.Shared.Services.Notifications;
@@ -32,7 +33,7 @@ internal sealed class EventNotifierService(
await Task.Delay(5000, cancellationToken);
foreach (var e in this.eventService.GetCurrentActiveEvents())
{
this.notificationService.NotifyInformation<NavigateToCalendarViewHandler>(e.Title!, $"{GetRemainingTime(e)}\n{e.Description!}", expirationTime: DateTime.Now + TimeSpan.FromSeconds(15), metaData: e.Title);
this.notificationService.NotifyInformation<NavigateToCalendarViewHandler>(e.Title!, $"{GetRemainingTime(e)}\n{e.Description!}", expirationTime: Global.NotificationShortExpiration, metaData: e.Title);
}
}
@@ -4,7 +4,6 @@ using Daybreak.Shared.Services.ExceptionHandling;
using Daybreak.Shared.Services.Notifications;
using Microsoft.Extensions.Logging;
using System.Core.Extensions;
using System.Diagnostics;
using System.Extensions;
using System.Extensions.Core;
using System.Logging;
@@ -42,7 +41,7 @@ internal sealed class ExceptionHandler(
var exceptionDate = DateTime.UtcNow;
if (this.logger is null)
{
WriteCrashFiles(e, exceptionDate);
this.WriteCrashFiles(e, exceptionDate);
return false;
}
@@ -54,7 +53,7 @@ internal sealed class ExceptionHandler(
case HandleResult.Handled:
return true;
case HandleResult.Fatal:
WriteCrashFiles(e, exceptionDate);
this.WriteCrashFiles(e, exceptionDate);
return false;
case HandleResult.Unhandled:
default:
@@ -1,36 +1,26 @@
using Daybreak.Configuration.Options;
using Daybreak.Shared.Services.ExecutableManagement;
using Daybreak.Shared.Services.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Core.Extensions;
using System.Extensions;
using System.Extensions.Core;
namespace Daybreak.Services.ExecutableManagement;
internal sealed class GuildWarsExecutableManager(
IOptionsProvider optionsProvider,
IOptionsMonitor<GuildwarsExecutableOptions> liveUpdateableOptions,
ILogger<GuildWarsExecutableManager> logger) : IGuildWarsExecutableManager, IHostedService
ILogger<GuildWarsExecutableManager> logger) : IGuildWarsExecutableManager
{
private readonly static TimeSpan ExecutableVerificationLatency = TimeSpan.FromSeconds(5);
internal const int MaxExecutables = 10;
private readonly static SemaphoreSlim ExecutablesSemaphore = new(1, 1);
private readonly IOptionsProvider optionsProvider = optionsProvider.ThrowIfNull();
private readonly IOptionsMonitor<GuildwarsExecutableOptions> liveUpdateableOptions = liveUpdateableOptions.ThrowIfNull();
private readonly ILogger<GuildWarsExecutableManager> logger = logger.ThrowIfNull();
async Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
await this.VerifyExecutables(cancellationToken);
}
Task IHostedService.StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public IEnumerable<string> GetExecutableList()
{
ExecutablesSemaphore.Wait();
@@ -43,7 +33,6 @@ internal sealed class GuildWarsExecutableManager(
public void AddExecutable(string executablePath)
{
var fullPath = Path.GetFullPath(executablePath);
ExecutablesSemaphore.Wait();
var options = this.liveUpdateableOptions.CurrentValue;
@@ -53,6 +42,8 @@ internal sealed class GuildWarsExecutableManager(
list.Insert(0, executablePath);
}
this.TrimInvalidExecutables(list);
options.ExecutablePaths = list;
this.optionsProvider.SaveOption(options);
ExecutablesSemaphore.Release();
@@ -80,38 +71,26 @@ internal sealed class GuildWarsExecutableManager(
return IsValidExecutableInternal(executablePath);
}
private async Task VerifyExecutables(CancellationToken cancellationToken)
/// <summary>
/// Soft-caps the stored executable list at <see cref="MaxExecutables"/>. Only entries that no
/// longer point to an existing file are evicted, oldest first, and only while the list is over
/// capacity. A valid executable is never evicted automatically, even if that keeps the list
/// above the cap. This bounds growth from stale entries without losing executables that live on
/// a temporarily unavailable volume (e.g. an unmounted removable or network drive), which would
/// otherwise be indistinguishable from a deleted file by path alone.
/// </summary>
private void TrimInvalidExecutables(List<string> executables)
{
var scopedLogger = this.logger.CreateScopedLogger(nameof(this.VerifyExecutables), string.Empty);
while (!cancellationToken.IsCancellationRequested)
var scopedLogger = this.logger.CreateScopedLogger();
for (var i = executables.Count - 1; i >= 0 && executables.Count > MaxExecutables; i--)
{
await ExecutablesSemaphore.WaitAsync(cancellationToken);
var executables = this.liveUpdateableOptions.CurrentValue.ExecutablePaths;
var deletedExecutable = false;
for (var i = 0; i < executables.Count; i++)
if (IsValidExecutableInternal(executables[i]))
{
var executable = executables[i];
if (IsValidExecutableInternal(executable))
{
continue;
}
scopedLogger.LogWarning($"Detected deleted executable at {executable}");
deletedExecutable = true;
executables.Remove(executable);
i--;
continue;
}
if (deletedExecutable)
{
var options = this.liveUpdateableOptions.CurrentValue;
options.ExecutablePaths = executables;
this.optionsProvider.SaveOption(options);
}
ExecutablesSemaphore.Release();
await Task.Delay(ExecutableVerificationLatency, cancellationToken);
scopedLogger.LogInformation("Evicting stale executable while over capacity: {executable}", executables[i]);
executables.RemoveAt(i);
}
}
@@ -0,0 +1,54 @@
using System.Globalization;
using Daybreak.Shared.Services.Formatting;
namespace Daybreak.Services.Formatting;
internal sealed class SkillNumberFormatter : ISkillNumberFormatter
{
public string Format(double? value)
{
if (value is not double d)
{
return string.Empty;
}
if (d < 0)
{
return "-" + this.Format(-d);
}
var whole = (long)Math.Floor(d);
var fractional = d - whole;
var fractionGlyph = TryGetFractionGlyph(fractional);
if (fractionGlyph is null)
{
return d == whole
? whole.ToString(CultureInfo.InvariantCulture)
: d.ToString("0.##", CultureInfo.InvariantCulture);
}
return whole == 0
? fractionGlyph
: whole.ToString(CultureInfo.InvariantCulture) + fractionGlyph;
}
public string FormatPercent(double? value)
{
if (value is not double d)
{
return string.Empty;
}
return Math.Round(d * 100).ToString("0", CultureInfo.InvariantCulture) + "%";
}
private static string? TryGetFractionGlyph(double fractional) => fractional switch
{
< 0.001 => null,
< 0.26 and > 0.24 => "¹⁄₄",
< 0.51 and > 0.49 => "¹⁄₂",
< 0.76 and > 0.74 => "³⁄₄",
_ => null,
};
}
@@ -125,7 +125,7 @@ internal sealed class GuildWarsVersionChecker(
this.notificationService.NotifyError<GuildWarsBatchUpdateNotificationHandler>(
title: "Guild Wars needs an update",
description: $"Click here to update the executable located at {guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath}",
expirationTime: DateTime.Now + TimeSpan.FromSeconds(15));
expirationTime: Global.NotificationShortExpiration);
}
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask;
@@ -310,15 +310,19 @@ internal sealed class IntegratedGuildwarsInstaller(
{
var scopedLogger = this.logger.CreateScopedLogger(flowIdentifier: destinationPath);
var exePath = Path.Combine(destinationPath, ExeName);
scopedLogger.LogDebug("Fetching latest Guild Wars executable");
var latestGwPath = await this.FetchLatestGuildwarsInternal(progress, cancellationToken);
if (latestGwPath is null)
{
scopedLogger.LogError("Failed to fetch latest Guild Wars executable");
progress.Report(ProgressFailed);
return false;
}
scopedLogger.LogDebug("Copying latest Guild Wars executable to destination");
var filePath = Path.GetFullPath(exePath);
File.Copy(latestGwPath, filePath, true);
scopedLogger.LogDebug("Copied latest Guild Wars executable to destination");
progress.Report(ProgressStartingExecutable);
await Task.Delay(100, cancellationToken);
this.guildWarsExecutableManager.AddExecutable(filePath);
@@ -2,16 +2,16 @@
using PeNet.Header.Pe;
namespace Daybreak.Services.Guildwars.Utils;
/// <summary>
/// https://github.com/gwdevhub/gwlauncher/blob/master/GW%20Launcher/Guildwars/FileIdFinder.cs
/// </summary>
internal sealed class GuildWarsExecutableParser
{
// Used by older Guild Wars versions
private readonly static byte[] VersionPattern = [0x8B, 0xC8, 0x33, 0xDB, 0x39, 0x8D, 0xC0, 0xFD, 0xFF, 0xFF, 0x0F, 0x95, 0xC3];
// Used by newer Guild Wars versions
private readonly static byte?[] FileIdFunctionPattern = [0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x08, 0xE8, null, null, null, null, 0x83, 0x3D, null, null, null, null, 0x00];
// Used by newer Guild Wars versions. Matches ProcessVersionUpdate prologue:
// push ebp; mov ebp,esp; sub esp,N; call <addr>; cmp dword ptr [global], 0
// Byte 5 (stack size) is wildcarded since it changes across builds.
private readonly static byte?[] FileIdFunctionPattern = [0x55, 0x8B, 0xEC, 0x83, 0xEC, null, 0xE8, null, null, null, null, 0x83, 0x3D, null, null, null, null, 0x00];
private readonly PeFile peFile;
private readonly ImageSectionHeader textSection;
@@ -38,13 +38,39 @@ internal sealed class GuildWarsExecutableParser
return Task.Run(() =>
{
var patternRva = this.FindWithWildcards(FileIdFunctionPattern);
var callRva = patternRva + 0x32;
var fileIdFunctionRva = this.FollowCall(callRva);
var fileIdFunctionRva = this.FindGetFileIdCall(patternRva);
var fileId = (int)this.Read(fileIdFunctionRva + 1);
return fileId;
}, cancellationToken);
}
// GetFileId is a tiny function: mov eax, <imm32>; ret (bytes: B8 xx xx xx xx C3).
// Scan E8 (call) instructions within the matched function and follow each one
// to find the call whose target matches that shape.
private uint FindGetFileIdCall(uint functionRva, int scanLength = 0x80)
{
for (int offset = 0; offset < scanLength; offset++)
{
var rva = functionRva + (uint)offset;
var posInFile = this.RvaToOffset(rva);
if (this.peFile.RawFile.ReadByte(posInFile) != 0xE8)
{
continue;
}
var targetRva = rva + (uint)BitConverter.ToInt32(this.peFile.RawFile.ToArray(), posInFile + 1) + 5;
var targetOffset = this.RvaToOffset(targetRva);
// Check for: B8 xx xx xx xx C3 (mov eax, imm32; ret)
if (this.peFile.RawFile.ReadByte(targetOffset) == 0xB8 &&
this.peFile.RawFile.ReadByte(targetOffset + 5) == 0xC3)
{
return targetRva;
}
}
throw new Exception("Couldn't find GetFileId call within the function");
}
private uint FindWithWildcards(byte?[] pattern, int offset = 0)
{
var buffer = this.peFile.RawFile.AsSpan((int)this.textSection.PointerToRawData, (int)this.textSection.SizeOfRawData);
@@ -1,13 +1,12 @@
using Daybreak.Launch;
using Daybreak.Models;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Display;
using System.Collections.Immutable;
namespace Daybreak.Services.Logging;
public sealed class InMemorySink : ILogEventSink, IDisposable
public sealed class InMemorySink : RedactingLogEventSink, IDisposable
{
private const int MaxLogEvents = 50000;
@@ -30,7 +29,7 @@ public sealed class InMemorySink : ILogEventSink, IDisposable
this.logEvents.Clear();
}
public void Emit(LogEvent logEvent)
protected override void EmitRedacted(LogEvent logEvent)
{
lock (this.snapShotLock)
{
@@ -0,0 +1,155 @@
using Serilog.Events;
using Serilog.Parsing;
using System.Buffers;
using System.Text.RegularExpressions;
namespace Daybreak.Services.Logging;
/// <summary>
/// Rewrites <see cref="LogEvent"/> instances so that sensitive user information (usernames,
/// e-mail addresses and passwords) is masked before it reaches a sink.
/// <para>
/// It relies on two complementary strategies:
/// <list type="bullet">
/// <item>Regex matching against the command-line style tokens (<c>-email &lt;value&gt;</c>,
/// <c>-password &lt;value&gt;</c>) that appear in rendered messages and property values.</item>
/// <item>Property-name matching, which masks any structured property named after a credential
/// field (Username, Password, Email) regardless of its value.</item>
/// </list>
/// </para>
/// </summary>
public static partial class LogEventRedactor
{
public const string RedactedValue = "[REDACTED]";
// Structured property names whose value must always be masked.
private static readonly HashSet<string> SensitivePropertyNames = new(StringComparer.OrdinalIgnoreCase)
{
"Username",
"Password",
"Email",
};
// Cheap, vectorized pre-filter: the (comparatively expensive) look-behind regex patterns are
// skipped unless one of these credential flags is present in the text. SearchValues performs a
// single multi-pattern scan (Teddy/Aho-Corasick internally) that outperforms both individual
// Contains calls and a hand-rolled automaton, and scales as the marker set grows. Every secret
// pattern below must be gated by one of these markers.
private static readonly SearchValues<string> SecretMarkers = SearchValues.Create(
["-email", "-password"],
StringComparison.OrdinalIgnoreCase);
// Patterns whose single match is the secret value to mask. The credential flag itself is kept
// in a look-behind so only the value is replaced.
private static readonly Regex[] SecretPatterns =
[
EmailArgumentRegex(),
PasswordArgumentRegex(),
];
/// <summary>
/// Returns a copy of <paramref name="logEvent"/> with sensitive information masked. The original
/// event is returned unchanged when nothing needs to be redacted.
/// </summary>
public static LogEvent Redact(LogEvent logEvent)
{
ArgumentNullException.ThrowIfNull(logEvent);
var redactedTemplate = RedactTemplate(logEvent.MessageTemplate);
var redactedProperties = logEvent.Properties
.Select(p => new LogEventProperty(p.Key, RedactPropertyValue(p.Key, p.Value)))
.ToList();
return new LogEvent(
logEvent.Timestamp,
logEvent.Level,
logEvent.Exception,
redactedTemplate,
redactedProperties);
}
/// <summary>
/// Masks the credential values embedded in an arbitrary string using the configured regex patterns.
/// </summary>
public static string RedactText(string? text)
{
if (string.IsNullOrEmpty(text))
{
return text ?? string.Empty;
}
// Fast path: avoid running the look-behind regexes over the overwhelming majority of log
// text that cannot contain a credential flag. This keeps redaction global (fail-safe) while
// costing only a vectorized substring scan for events that carry no secrets.
if (!MightContainSecret(text))
{
return text;
}
foreach (var pattern in SecretPatterns)
{
text = pattern.Replace(text, RedactedValue);
}
return text;
}
private static bool MightContainSecret(string text)
{
return text.AsSpan().ContainsAny(SecretMarkers);
}
private static MessageTemplate RedactTemplate(MessageTemplate template)
{
var tokens = template.Tokens
.Select(token => token is TextToken textToken
? new TextToken(RedactText(textToken.Text))
: token)
.ToList();
return new MessageTemplate(tokens);
}
private static LogEventPropertyValue RedactPropertyValue(string propertyName, LogEventPropertyValue value)
{
if (SensitivePropertyNames.Contains(propertyName))
{
return new ScalarValue(RedactedValue);
}
return RedactPropertyValue(value);
}
private static LogEventPropertyValue RedactPropertyValue(LogEventPropertyValue value)
{
switch (value)
{
case ScalarValue { Value: string stringValue }:
return new ScalarValue(RedactText(stringValue));
case SequenceValue sequence:
return new SequenceValue(sequence.Elements.Select(RedactPropertyValue));
case StructureValue structure:
var structureProperties = structure.Properties
.Select(p => new LogEventProperty(p.Name, RedactPropertyValue(p.Name, p.Value)));
return new StructureValue(structureProperties, structure.TypeTag);
case DictionaryValue dictionary:
var elements = dictionary.Elements
.Select(kvp => new KeyValuePair<ScalarValue, LogEventPropertyValue>(
kvp.Key,
RedactPropertyValue(kvp.Value)));
return new DictionaryValue(elements);
default:
return value;
}
}
[GeneratedRegex(@"(?<=-email[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex EmailArgumentRegex();
[GeneratedRegex(@"(?<=-password[=\s])(""[^""]*""|'[^']*'|\S+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex PasswordArgumentRegex();
}
@@ -0,0 +1,27 @@
using Serilog.Core;
using Serilog.Events;
namespace Daybreak.Services.Logging;
/// <summary>
/// Base class for sinks that must never receive sensitive user information. Every emitted
/// <see cref="LogEvent"/> is passed through <see cref="LogEventRedactor"/> before being handed to
/// the concrete sink via <see cref="EmitRedacted"/>.
/// <para>
/// This lets us keep the Console sink (only visible to the local user) unredacted while masking
/// sensitive user information for sinks that persist or forward logs off the machine, such as the
/// in-memory sink used for exporting logs and the telemetry sink used to forward logs to the server.
/// </para>
/// </summary>
public abstract class RedactingLogEventSink : ILogEventSink
{
public void Emit(LogEvent logEvent)
{
this.EmitRedacted(LogEventRedactor.Redact(logEvent));
}
/// <summary>
/// Handles the log event after sensitive information has been masked.
/// </summary>
protected abstract void EmitRedacted(LogEvent logEvent);
}
@@ -153,7 +153,7 @@ internal sealed class NotificationService(
Title = title,
Description = description,
Metadata = metaData ?? string.Empty,
ExpirationTime = expirationTime ?? (DateTime.Now + TimeSpan.FromSeconds(5)),
ExpirationTime = expirationTime ?? (DateTime.UtcNow + TimeSpan.FromSeconds(5)),
Dismissible = dismissible,
Level = logLevel,
};
@@ -10,6 +10,7 @@ using Daybreak.Shared.Services.Injection;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Services.ReShade;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Utils;
using Daybreak.Views.Mods;
using HtmlAgilityPack;
@@ -18,7 +19,6 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Core.Extensions;
using System.Data;
using System.Diagnostics;
using System.Extensions;
using System.Extensions.Core;
using System.IO.Compression;
@@ -34,6 +34,7 @@ internal sealed class ReShadeService(
IHttpClient<ReShadeService> httpClient,
IDownloadService downloadService,
IViewManager viewManager,
IShellExecutor shellExecutor,
ILogger<ReShadeService> logger) : IReShadeService
{
private const string PackagesIniUrl = "https://raw.githubusercontent.com/crosire/reshade-shaders/list/EffectPackages.ini";
@@ -68,6 +69,7 @@ internal sealed class ReShadeService(
private readonly IHttpClient<ReShadeService> httpClient = httpClient.ThrowIfNull();
private readonly IDownloadService downloadService = downloadService.ThrowIfNull();
private readonly IViewManager viewManager = viewManager.ThrowIfNull();
private readonly IShellExecutor shellExecutor = shellExecutor.ThrowIfNull();
private readonly ILogger<ReShadeService> logger = logger.ThrowIfNull();
public string Name => "ReShade";
@@ -220,7 +222,7 @@ internal sealed class ReShadeService(
public void OpenReShadeFolder()
{
Process.Start("explorer.exe", ReShadePath);
this.shellExecutor.OpenPath(ReShadePath);
}
public async Task<IEnumerable<ShaderPackage>> GetStockPackages(CancellationToken cancellationToken)
@@ -1,13 +1,10 @@
using System.Collections.Concurrent;
using System.Extensions.Core;
using System.Numerics;
using System.Runtime.InteropServices;
using Daybreak.Shared.Models;
using Daybreak.Shared.Models.ColorPalette;
using Daybreak.Shared.Services.Screenshots;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using StbImageSharp;
using static Daybreak.Shared.Models.Themes.Theme;
using Color = System.Drawing.Color;
@@ -65,9 +62,9 @@ public sealed class ScreenshotService(
try
{
using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var image = Image.Load<Rgba32>(fileStream);
var image = ImageResult.FromStream(fileStream, ColorComponents.RedGreenBlueAlpha);
var (dominantColor, isDark) = GetDominantColor(image);
var (dominantColor, isDark) = GetDominantColor(image.Data, image.Width, image.Height);
var closestAccent = GetClosestAccentColor(dominantColor);
var entry = new ScreenshotEntry(path, closestAccent, isDark ? LightDarkMode.Dark : LightDarkMode.Light);
EntryCache[path] = entry;
@@ -81,88 +78,25 @@ public sealed class ScreenshotService(
}
}
private static (Color Color, bool IsDark) GetDominantColor(Image<Rgba32> image)
private static (Color Color, bool IsDark) GetDominantColor(ReadOnlySpan<byte> rgba, int width, int height)
{
long totalR = 0, totalG = 0, totalB = 0;
int pixelCount = 0;
int stride = width * 4; // RGBA, 4 bytes per pixel
image.ProcessPixelRows(accessor =>
// Sample every SampleQuality-th pixel of every SampleQuality-th row for performance.
for (int y = 0; y < height; y += SampleQuality)
{
for (int y = 0; y < accessor.Height; y += SampleQuality)
int rowStart = y * stride;
for (int x = 0; x < width; x += SampleQuality)
{
var rowSpan = accessor.GetRowSpan(y);
var byteSpan = MemoryMarshal.AsBytes(rowSpan);
// Process using SIMD where possible
// Each Rgba32 pixel is 4 bytes: R, G, B, A
var vectorSize = Vector<byte>.Count;
var pixelsPerVector = vectorSize / 4; // 4 bytes per pixel
int x = 0;
// SIMD path: process multiple sampled pixels at once
if (Vector.IsHardwareAccelerated && rowSpan.Length >= pixelsPerVector * SampleQuality)
{
var sumR = Vector<long>.Zero;
var sumG = Vector<long>.Zero;
var sumB = Vector<long>.Zero;
int simdPixelCount = 0;
// Gather sampled pixels into arrays for vectorization
Span<int> rValues = stackalloc int[Vector<int>.Count];
Span<int> gValues = stackalloc int[Vector<int>.Count];
Span<int> bValues = stackalloc int[Vector<int>.Count];
// Collect sampled pixels for SIMD processing
while (x + (Vector<int>.Count * SampleQuality) <= rowSpan.Length)
{
for (int i = 0; i < Vector<int>.Count && x < rowSpan.Length; i++, x += SampleQuality)
{
var pixel = rowSpan[x];
rValues[i] = pixel.R;
gValues[i] = pixel.G;
bValues[i] = pixel.B;
simdPixelCount++;
}
var rVec = new Vector<int>(rValues);
var gVec = new Vector<int>(gValues);
var bVec = new Vector<int>(bValues);
// Widen to long and accumulate
Vector.Widen(rVec, out var rLow, out var rHigh);
Vector.Widen(gVec, out var gLow, out var gHigh);
Vector.Widen(bVec, out var bLow, out var bHigh);
sumR += rLow + rHigh;
sumG += gLow + gHigh;
sumB += bLow + bHigh;
}
// Sum up the vector lanes
for (int i = 0; i < Vector<long>.Count; i++)
{
totalR += sumR[i];
totalG += sumG[i];
totalB += sumB[i];
}
pixelCount += simdPixelCount;
}
// Scalar fallback for remaining pixels
for (; x < rowSpan.Length; x += SampleQuality)
{
var pixel = rowSpan[x];
totalR += pixel.R;
totalG += pixel.G;
totalB += pixel.B;
pixelCount++;
}
int i = rowStart + (x * 4);
totalR += rgba[i];
totalG += rgba[i + 1];
totalB += rgba[i + 2];
pixelCount++;
}
});
}
if (pixelCount == 0)
{
@@ -20,9 +20,7 @@ public sealed class VerifyNativeComponentsAction(
private const string ApiFolder = "Api";
private const string InstallerFolder = "Installer";
private static readonly string[] InjectorFiles = OperatingSystem.IsWindows()
? ["Daybreak.Injector.exe", "FASM.DLL"]
: ["Daybreak.Injector.exe", "FASM.DLL"];
private static readonly string[] InjectorFiles = ["Daybreak.Injector.exe"];
private static readonly string[] ApiFiles = ["Daybreak.API.dll"];
private static readonly string[] InstallerFiles = OperatingSystem.IsWindows()
? ["Daybreak.Installer.exe"]
@@ -24,7 +24,7 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
private const string UnknownHost = "Unknown";
private const string MaskedValue = "[REDACTED]";
private static readonly string ApmEndpoint = SecretManager.GetSecret(SecretKeys.ApmUri);
private static readonly string ApmEndpoint = SanitizeUri(SecretManager.GetSecret(SecretKeys.ApmUri));
private static readonly string ApmServiceAccount = SecretManager.GetSecret(SecretKeys.ApmServiceAccount);
private static readonly string ApmServiceKey = SecretManager.GetSecret(SecretKeys.ApmServiceKey);
@@ -159,7 +159,10 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
return;
}
var apmUri = new Uri(ApmEndpoint);
// Endpoints are resolved relative to the vmauth root, so the base must end with '/'
// otherwise Uri resolution would drop the trailing path segment.
var normalizedEndpoint = ApmEndpoint.EndsWith('/') ? ApmEndpoint : ApmEndpoint + "/";
var apmUri = new Uri(normalizedEndpoint);
var creds = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{ApmServiceAccount}:{ApmServiceKey}"));
this.meter = Sdk.CreateMeterProviderBuilder()
@@ -172,11 +175,13 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
.AddMeter("OpenTelemetry.Instrumentation.SqlClient")
.AddOtlpExporter((exporterOptions, readerOptions) =>
{
exporterOptions.Endpoint = new Uri(apmUri, "v1/metrics");
exporterOptions.Endpoint = new Uri(apmUri, "opentelemetry/v1/metrics");
exporterOptions.Headers = $"Authorization=Basic {creds}";
exporterOptions.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf;
readerOptions.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 10_000;
readerOptions.TemporalityPreference = MetricReaderTemporalityPreference.Delta;
// VictoriaMetrics works best with cumulative temporality; delta breaks
// standard rate()/increase() queries used by the Grafana dashboards.
readerOptions.TemporalityPreference = MetricReaderTemporalityPreference.Cumulative;
})
.Build();
@@ -189,8 +194,8 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
logOpts.SetResourceBuilder(this.resourceBuilder);
logOpts.AddOtlpExporter(exp =>
{
exp.Endpoint = new Uri(apmUri, "v1/logs");
exp.Headers = $"Authorization=Basic {creds}";
exp.Endpoint = new Uri(apmUri, "insert/opentelemetry/v1/logs");
exp.Headers = $"Authorization=Basic {creds}";
exp.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf;
});
@@ -201,6 +206,20 @@ internal sealed class TelemetryHost : IDisposable, IHostedService
TelemetryLogSink.Instance.LoggingHandler = this.ForwardLogEvent;
}
// Configured URIs must never contain whitespace. Stray characters such as a non-breaking space
// (U+00A0) from copy-pasted secrets survive SecretManager sanitization (which keeps whitespace to
// preserve JSON formatting) and would otherwise be percent-encoded into a bogus path segment
// (e.g. ".../%C2%A0/opentelemetry/v1/metrics"), producing 400 responses from the collector.
private static string SanitizeUri(string uri)
{
if (string.IsNullOrEmpty(uri))
{
return uri;
}
return new string([.. uri.Where(c => !char.IsWhiteSpace(c) && !char.IsControl(c))]);
}
private void ForwardLogEvent(LogEvent logEvent)
{
if (this.otlpLogger is null)
@@ -1,9 +1,9 @@
using Serilog.Core;
using Daybreak.Services.Logging;
using Serilog.Events;
namespace Daybreak.Services.Telemetry;
public sealed class TelemetryLogSink : ILogEventSink
public sealed class TelemetryLogSink : RedactingLogEventSink
{
public readonly static TelemetryLogSink Instance = new();
@@ -13,7 +13,7 @@ public sealed class TelemetryLogSink : ILogEventSink
{
}
public void Emit(LogEvent logEvent)
protected override void EmitRedacted(LogEvent logEvent)
{
this.LoggingHandler?.Invoke(logEvent);
}
@@ -133,9 +133,9 @@ internal sealed class ToolboxService(
public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWarsStartingDisabledContext, CancellationToken cancellationToken) => Task.CompletedTask;
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask;
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => this.LaunchToolbox(guildWarsCreatedContext.ApplicationLauncherContext.Process, cancellationToken);
public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => this.LaunchToolbox(guildWarsStartedContext.ApplicationLauncherContext.Process, cancellationToken);
public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => Task.CompletedTask;
public IEnumerable<string> GetCustomArguments() => [];
@@ -366,6 +366,10 @@ internal sealed class ToolboxService(
if (await this.processInjector.Inject(process, dll, cancellationToken))
{
scopedLogger.LogDebug("Injected toolbox dll");
// Give Toolbox a moment to load its bundled gwca.dll into the target process
// before any subsequent mod (e.g. Daybreak.API) tries to load its own copy.
// This ensures Daybreak.API's existing-module check sees gwca.dll and reuses it.
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
this.notificationService.NotifyInformation(
title: "GWToolbox started",
description: "GWToolbox has been injected");
@@ -1,6 +1,7 @@
using Daybreak.Configuration.Options;
using Daybreak.Services.TradeChat.Models;
using Daybreak.Services.TradeChat.Notifications;
using Daybreak.Shared;
using Daybreak.Shared.Configuration.Options;
using Daybreak.Shared.Converters;
using Daybreak.Shared.Models.Trade;
@@ -197,7 +198,7 @@ internal sealed class TradeAlertingService : ITradeAlertingService, IHostedServi
this.notificationService.NotifyInformation(
title: "Quote alert!",
description: description,
expirationTime: DateTime.Now + TimeSpan.FromSeconds(15));
expirationTime: Global.NotificationShortExpiration);
}
await Task.Delay(TimeSpan.FromSeconds(this.options.CurrentValue.QuoteAlertsInterval), cancellationToken);
@@ -258,7 +259,7 @@ internal sealed class TradeAlertingService : ITradeAlertingService, IHostedServi
title: $"{source} Trader Alert",
description: $"{alert.Name} has matched on a trader message. Sender: {traderMessageDTO.Sender}. Message: {traderMessageDTO.Message}",
metaData: JsonSerializer.Serialize(traderMessage),
expirationTime: DateTime.Now + TimeSpan.FromDays(1));
expirationTime: DateTime.UtcNow + TimeSpan.FromDays(1));
}
private static bool CheckMatch(string toCheck, string toMatch, bool isRegex)
-299
View File
@@ -1,299 +0,0 @@
using Daybreak.Shared.Models.Guildwars;
using Daybreak.Shared.Services.Wiki;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using Attribute = Daybreak.Shared.Models.Guildwars.Attribute;
namespace Daybreak.Services.Wiki;
public sealed partial class WikiService(
IHttpClient<WikiService> httpClient,
ILogger<WikiService> logger)
: IWikiService
{
private const string WikiQueryTitlePlaceholder = "[TITLE]";
private const string WikiQueryUrl = $"https://wiki.guildwars.com/api.php?action=query&format=json&titles={WikiQueryTitlePlaceholder}&prop=revisions&rvprop=content&rvslots=main";
private static readonly ConcurrentDictionary<int, SkillDescription> DescriptionCache = [];
private readonly IHttpClient<WikiService> httpClient = httpClient;
private readonly ILogger<WikiService> logger = logger;
public async Task<SkillDescription?> GetSkillDescription(Skill skill, CancellationToken cancellationToken)
{
if (DescriptionCache.TryGetValue(skill.Id, out var cachedDescription))
{
return cachedDescription;
}
var wikiText = await this.GetWikiText(skill.Name, cancellationToken);
if (wikiText is null)
{
return default;
}
var description = ParseWikiText(wikiText);
if (description is null)
{
return default;
}
DescriptionCache[skill.Id] = description;
return description;
}
private async ValueTask<string?> GetWikiText(string name, CancellationToken cancellationToken)
{
while (true)
{
var urlEncodedName = UrlEncoder.Default.Encode(name);
var wikiUrl = WikiQueryUrl.Replace(WikiQueryTitlePlaceholder, urlEncodedName);
var response = await this.httpClient.GetAsync(wikiUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return default;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var wikiText = ExtractWikiTextFromJson(content);
if (wikiText is null)
{
return default;
}
var redirectTarget = ExtractRedirectTarget(wikiText);
if (redirectTarget is null)
{
return wikiText;
}
name = redirectTarget;
}
}
private static string? ExtractRedirectTarget(string wikiText)
{
if (string.IsNullOrWhiteSpace(wikiText))
{
return null;
}
var match = RedirectRegex().Match(wikiText.Trim());
return match.Success ? match.Groups[1].Value : null;
}
private static string? ExtractWikiTextFromJson(string jsonContent)
{
try
{
using var json = JsonDocument.Parse(jsonContent);
var root = json.RootElement;
if (!root.TryGetProperty("query", out var query) ||
!query.TryGetProperty("pages", out var pages))
{
return null;
}
// Get the first property (page) from pages object
using var pagesEnumerator = pages.EnumerateObject();
if (!pagesEnumerator.MoveNext())
{
return null;
}
var page = pagesEnumerator.Current.Value;
if (!page.TryGetProperty("revisions", out var revisions) ||
revisions.GetArrayLength() == 0)
{
return null;
}
var firstRevision = revisions[0];
if (firstRevision.TryGetProperty("slots", out var slots) &&
slots.TryGetProperty("main", out var main) &&
main.TryGetProperty("*", out var content))
{
return content.GetString();
}
return null;
}
catch (Exception)
{
return default;
}
}
private static SkillDescription? ParseWikiText(string wikiText)
{
var infoboxContent = ExtractSkillInfobox(CleanWikiText(wikiText));
if (infoboxContent is not null)
{
return new SkillDescription(
Id: ExtractField(infoboxContent, "id") is string idStr ? int.TryParse(idStr, out var id) ? id : -1 : -1,
Campaign: ExtractField(infoboxContent, "campaign") is string campaignStr ? Campaign.TryParse(campaignStr, out var campaign) ? campaign : Campaign.None : Campaign.None,
Name: ExtractField(infoboxContent, "name") ?? string.Empty,
Profession: ExtractField(infoboxContent, "profession") is string professionStr ? Profession.TryParse(professionStr, out var profession) ? profession : Profession.None : Profession.None,
Attribute: ExtractField(infoboxContent, "attribute") is string attrStr ? Attribute.TryParse(attrStr, out var attribute) ? attribute : Attribute.None : Attribute.None,
PveOnly: ExtractField(infoboxContent, "pve-only") is string pveOnlyStr && pveOnlyStr.StartsWith('y'), // Some fields contain yes, others y
Pvp: ExtractField(infoboxContent, "is-pvp") is string pvpStr && pvpStr.StartsWith('y'),
Elite: ExtractField(infoboxContent, "elite") is string eliteStr && eliteStr.StartsWith('y'), // Some fields contain yes, others y
Type: ExtractField(infoboxContent, "type") ?? string.Empty,
Energy: ExtractField(infoboxContent, "energy") ?? string.Empty,
Activation: ExtractField(infoboxContent, "activation") ?? string.Empty,
Recharge: ExtractField(infoboxContent, "recharge") ?? string.Empty,
Overcast: ExtractField(infoboxContent, "overcast") ?? string.Empty,
Adrenaline: ExtractField(infoboxContent, "adrenaline") ?? string.Empty,
Sacrifice: ExtractField(infoboxContent, "sacrifice") ?? string.Empty,
Upkeep: ExtractField(infoboxContent, "upkeep") ?? string.Empty,
Description: ExtractField(infoboxContent, "description") ?? string.Empty,
ConciseDescription: ExtractField(infoboxContent, "concise description") ?? string.Empty);
}
return default;
}
private static string CleanWikiText(string wikiText)
{
if (string.IsNullOrEmpty(wikiText))
{
return wikiText;
}
var cleaned = wikiText;
// Handle common fraction templates
cleaned = HalfFractionRegex().Replace(cleaned, "¹⁄₂");
cleaned = QuarterFractionRegex().Replace(cleaned, "¹⁄₄");
cleaned = ThreeQuarterFractionRegex().Replace(cleaned, "³⁄₄");
cleaned = ThreeOverTwoFractionRegex().Replace(cleaned, "³⁄₂");
// Handle decimal number templates
cleaned = DecimalRegex().Replace(cleaned, "$1");
// Handle {{gr|min|max}} templates (green range values)
cleaned = GreenRangeRegex().Replace(cleaned, "$1…$2");
cleaned = GreenRange2Regex().Replace(cleaned, "$1…$2");
cleaned = GrayRangeRegex().Replace(cleaned, "$1…$2");
// Handle simple links [[Link Text]]
cleaned = SimpleLinkRegex().Replace(cleaned, "$1");
// Handle {{gray|text}} templates
cleaned = GrayTextRegex().Replace(cleaned, "");
cleaned = GreyTextRegex().Replace(cleaned, "");
// Handle {{sic|text}} templates (remove the sic notation but keep the content)
cleaned = SicTextRegex().Replace(cleaned, "");
// Handle links with display text [[Link|Display Text]]
cleaned = LinkWithDisplayTextRegex().Replace(cleaned, "$2");
// Handle skill type formatting
cleaned = SkillTypeLinkRegex().Replace(cleaned, "$1");
// Remove any remaining wiki markup
cleaned = BoldTextRegex().Replace(cleaned, "$1"); // Bold
cleaned = ItalicTextRegex().Replace(cleaned, "$1"); // Italic
cleaned = HtmlTagRegex().Replace(cleaned, ""); // HTML tags
// Clean up extra whitespace
cleaned = WhitespaceRegex().Replace(cleaned, " ").Trim();
return cleaned;
}
private static string? ExtractSkillInfobox(string wikiText)
{
var startPattern = "{{Skill infobox";
var startIndex = wikiText.IndexOf(startPattern, StringComparison.OrdinalIgnoreCase);
if (startIndex == -1)
return null;
// Find the pipe after "{{Skill infobox"
var pipeIndex = wikiText.IndexOf('|', startIndex);
if (pipeIndex == -1)
return null;
// Count braces to find the matching closing }}
var braceCount = 2; // Start with 2 for the opening {{
var index = startIndex + startPattern.Length;
while (index < wikiText.Length && braceCount > 0)
{
if (wikiText[index] == '{' && index + 1 < wikiText.Length && wikiText[index + 1] == '{')
{
braceCount += 2;
index += 2;
}
else if (wikiText[index] == '}' && index + 1 < wikiText.Length && wikiText[index + 1] == '}')
{
braceCount -= 2;
index += 2;
}
else
{
index++;
}
}
if (braceCount == 0)
{
// Extract content between the pipe and the closing }}
return wikiText.Substring(pipeIndex + 1, index - pipeIndex - 3).Trim();
}
return null;
}
private static string? ExtractField(string infoboxContent, string fieldName)
{
var pattern = $@"(?:^|\|\s*){Regex.Escape(fieldName)}\s*=\s*(.*?)(?=\s*\||$)";
var match = Regex.Match(infoboxContent, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline);
return match.Success ? match.Groups[1].Value.Trim() : null;
}
[GeneratedRegex(@"^#REDIRECT\s*\[\[([^\]]+)\]\]", RegexOptions.IgnoreCase)]
private static partial Regex RedirectRegex();
[GeneratedRegex(@"\{\{1/2\}\}", RegexOptions.IgnoreCase)]
private static partial Regex HalfFractionRegex();
[GeneratedRegex(@"\{\{1/4\}\}", RegexOptions.IgnoreCase)]
private static partial Regex QuarterFractionRegex();
[GeneratedRegex(@"\{\{3/4\}\}", RegexOptions.IgnoreCase)]
private static partial Regex ThreeQuarterFractionRegex();
[GeneratedRegex(@"\{\{3/2\}\}", RegexOptions.IgnoreCase)]
private static partial Regex ThreeOverTwoFractionRegex();
[GeneratedRegex(@"\{\{(\d+(?:\.\d+)?)\}\}", RegexOptions.IgnoreCase)]
private static partial Regex DecimalRegex();
[GeneratedRegex(@"\{\{gr\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GreenRangeRegex();
[GeneratedRegex(@"\{\{gr2\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GreenRange2Regex();
[GeneratedRegex(@"\{\{gray\|(\+?-?\d+)\|(\+?-?\d+)\|?-?\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GrayRangeRegex();
[GeneratedRegex(@"\{\{sic(?:\|([^}]*))?\}\}", RegexOptions.IgnoreCase)]
private static partial Regex SicTextRegex();
[GeneratedRegex(@"\[\[([^\]|]+)\]\]", RegexOptions.IgnoreCase)]
private static partial Regex SimpleLinkRegex();
[GeneratedRegex(@"\[\[([^|]+)\|([^\]]+)\]\]", RegexOptions.IgnoreCase)]
private static partial Regex LinkWithDisplayTextRegex();
[GeneratedRegex(@"\[\[([^|]+)\]\]", RegexOptions.IgnoreCase)]
private static partial Regex SkillTypeLinkRegex();
[GeneratedRegex(@"'''([^']+)'''", RegexOptions.IgnoreCase)]
private static partial Regex BoldTextRegex();
[GeneratedRegex(@"''([^']+)''", RegexOptions.IgnoreCase)]
private static partial Regex ItalicTextRegex();
[GeneratedRegex(@"<[^>]*>", RegexOptions.IgnoreCase)]
private static partial Regex HtmlTagRegex();
[GeneratedRegex(@"\s+", RegexOptions.IgnoreCase)]
private static partial Regex WhitespaceRegex();
[GeneratedRegex(@"\{\{gray\|([^}]+)\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GrayTextRegex();
[GeneratedRegex(@"\{\{grey\|([^}]+)\}\}", RegexOptions.IgnoreCase)]
private static partial Regex GreyTextRegex();
}
+80 -70
View File
@@ -8,80 +8,90 @@
<div class="option-view-title">
<h3>Accounts</h3>
</div>
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
@if (this.ViewModel.LoginCredentials.Count is 0)
{
<EmptyStateWidget Message="No accounts configured"
ActionText="Add account"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Person())"
OnAction="@this.ViewModel.CreateCredential" />
}
else
{
<div class="credentials-list">
@foreach (var credential in this.ViewModel.LoginCredentials)
{
<div class="credential-row">
<div class="identifier-row">
<div class="identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="identifier-field">
<FluentTextField Value="@credential.LoginCredentials.Identifier"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
ReadOnly="true"
Placeholder="Identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.RemoveCredential(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
Title="Delete account" />
</FluentTextField>
</div>
</div>
<div class="username-row">
<div class="username-label">
<FluentLabel>Username</FluentLabel>
</div>
<div class="username-field">
<FluentTextField Value="@credential.LoginCredentials.Username"
ValueChanged="@(e => this.ViewModel.UsernameChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="TextFieldType.Email"
Placeholder="Username" />
</div>
</div>
<div class="password-row">
<div class="password-label">
<FluentLabel>Password</FluentLabel>
</div>
<div class="password-field">
<FluentTextField Value="@credential.LoginCredentials.Password"
ValueChanged="@(e => this.ViewModel.PasswordChanged(credential, e))"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Immediate="true"
ImmediateDelay="500"
InputMode="InputMode.Text"
TextFieldType="@(credential.PasswordVisible ? TextFieldType.Text : TextFieldType.Password)"
Placeholder="Password">
@if (credential.PasswordVisible)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Hide password" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.EyeOff())"
@onclick="@(() => this.ViewModel.TogglePasswordVisibility(credential))"
slot="end"
Color="Color.Neutral"
Title="Show password" />
}
</FluentTextField>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
+5 -9
View File
@@ -8,6 +8,7 @@ using Daybreak.Shared.Services.Menu;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Services.Privilege;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.Updater;
using Daybreak.Shared.Services.Window;
@@ -17,7 +18,6 @@ using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using Photino.Blazor;
using System.Core.Extensions;
using System.Diagnostics;
using System.Extensions;
using TrailBlazr.Services;
@@ -39,6 +39,7 @@ public sealed class AppViewModel
private readonly JSConsoleInterop jsConsoleInterop;
private readonly INotificationService notificationService;
private readonly IWindowManipulationService windowManipulationService;
private readonly IShellExecutor shellExecutor;
private readonly ILogger<App> logger;
private SemaphoreSlim themeChangeSemaphore = new(1, 1);
@@ -89,6 +90,7 @@ public sealed class AppViewModel
INotificationProducer notificationProducer,
INotificationService notificationService,
IWindowManipulationService windowManipulationService,
IShellExecutor shellExecutor,
ILogger<App> logger)
{
this.keyboardHookService = keyboardHookService.ThrowIfNull();
@@ -104,6 +106,7 @@ public sealed class AppViewModel
this.NotificationProducer = notificationProducer.ThrowIfNull();
this.notificationService = notificationService.ThrowIfNull();
this.windowManipulationService = windowManipulationService.ThrowIfNull();
this.shellExecutor = shellExecutor.ThrowIfNull();
this.logger = logger.ThrowIfNull();
@@ -215,14 +218,7 @@ public sealed class AppViewModel
public void OpenIssues()
{
try
{
Process.Start(new ProcessStartInfo { FileName = IssueUrl, UseShellExecute = true });
}
catch (Exception ex)
{
this.logger.LogError(ex, "Encountered exception while opening issues page");
}
this.shellExecutor.OpenUrl(IssueUrl);
}
public void OpenSynchronizationView()
@@ -232,7 +232,7 @@ public abstract class BuildTemplateViewModelBase<TViewModel, TView>(
protected void FilterSkillsByProfessionsAndString(string? searchFilter = default)
{
var filteredSkills = Skill.Skills.Where(skill =>
var filteredSkills = Skill.AllSkills.Where(skill =>
{
if (skill.PvP)
{
@@ -1,4 +1,6 @@
<div class="backdrop-panel container skill-snippet" style="--mouse-x: @(this.Context.MousePosition.PosX)px; --mouse-y: @(this.Context.MousePosition.PosY)px;">
@inject ISkillNumberFormatter SkillNumberFormatter
<div class="backdrop-panel container skill-snippet" style="--mouse-x: @(this.Context.MousePosition.PosX)px; --mouse-y: @(this.Context.MousePosition.PosY)px;">
<div class="left-side">
<div class="skill-icon">
<SkillIcon Skill="this.Context.Skill" />
@@ -23,67 +25,67 @@
<div class="skill-concise-description">@this.Context.Skill.ConciseDescription</div>
</div>
<div class="right-side">
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Energy))
@if (this.Context.Skill.Energy.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Energy />
</div>
<div class="skill-resource-text">@this.Context.Skill.Energy</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Energy)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Activation))
@if (this.Context.Skill.Activation.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Activation />
</div>
<div class="skill-resource-text">@this.Context.Skill.Activation</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Activation)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Recharge))
@if (this.Context.Skill.Recharge.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Recharge />
</div>
<div class="skill-resource-text">@this.Context.Skill.Recharge</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Recharge)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Overcast))
@if (this.Context.Skill.Overcast.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Overcast />
</div>
<div class="skill-resource-text">@this.Context.Skill.Overcast</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Overcast)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Adrenaline))
@if (this.Context.Skill.Adrenaline.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Adrenaline />
</div>
<div class="skill-resource-text">@this.Context.Skill.Adrenaline</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Adrenaline)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Sacrifice))
@if (this.Context.Skill.Sacrifice.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Sacrifice />
</div>
<div class="skill-resource-text">@this.Context.Skill.Sacrifice</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.FormatPercent(this.Context.Skill.Sacrifice)</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(this.Context.Skill.Upkeep))
@if (this.Context.Skill.Upkeep.HasValue)
{
<div class="skill-resource">
<div class="skill-resource-img">
<Daybreak.Views.Components.Icons.Misc.Upkeep />
</div>
<div class="skill-resource-text">@this.Context.Skill.Upkeep</div>
<div class="skill-resource-text">@this.SkillNumberFormatter.Format(this.Context.Skill.Upkeep)</div>
</div>
}
</div>
@@ -0,0 +1,28 @@
<div class="empty-state">
<FluentIcon Value="@this.Icon" Color="Color.Neutral" />
<div class="empty-message">@this.Message</div>
<FluentButton Appearance="Appearance.Accent"
Disabled="@this.Disabled"
IconStart="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add())"
Title="@this.ActionText"
OnClick="@this.OnAction">
@this.ActionText
</FluentButton>
</div>
@code {
[Parameter]
public string Message { get; set; } = string.Empty;
[Parameter]
public string ActionText { get; set; } = "Add";
[Parameter]
public Icon Icon { get; set; } = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Search();
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public EventCallback OnAction { get; set; }
}
@@ -0,0 +1,14 @@
.empty-state {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: var(--neutral-foreground-hint);
}
.empty-message {
font-size: var(--font-size-large);
text-align: center;
}
@@ -0,0 +1,37 @@
<div class="browser-container @(this.IsMaximized ? "maximized" : string.Empty)">
<button class="browser-toggle-button"
title="@(this.IsMaximized ? "Restore" : "Expand to fullscreen")"
@onclick="this.ToggleMaximized"
@onclick:stopPropagation="true">
<FluentIcon Value="@(this.IsMaximized ?
(Microsoft.FluentUI.AspNetCore.Components.Icon)new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowMinimize() :
new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowMaximize())" />
</button>
<iframe sandbox="allow-scripts allow-same-origin allow-forms"
class="browser-frame"
src="@this.Source" />
</div>
@code {
[Parameter]
public string? Source { get; init; }
[Parameter]
public Action? OnMaximized { get; init; }
[Parameter]
public Action? OnRestore { get; init; }
private bool IsMaximized { get; set; }
private void ToggleMaximized()
{
this.IsMaximized = !this.IsMaximized;
if (this.IsMaximized)
{
this.OnMaximized?.Invoke();
}
else
{
this.OnRestore?.Invoke();
}
}
}
@@ -0,0 +1,38 @@
.browser-container {
position: relative;
display: flex;
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
}
.browser-frame {
flex: 1;
height: 100%;
width: 100%;
margin: -2px;
border: none;
}
.browser-toggle-button {
position: absolute;
top: 4px;
right: 4px;
z-index: 1002;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 50%;
cursor: pointer;
background: rgba(0, 0, 0, 0.45);
color: #fff;
}
.browser-toggle-button:hover {
background: var(--accent-fill-rest);
}
@@ -1,6 +1,23 @@
@if (this.Context is not null)
{
<div class="container backdrop-panel">
<div class="container backdrop-panel">
@if (this.Context is null)
{
<SpinnerWidget IsLoading="true" />
}
else if (this.Context.Title is null)
{
<div class="title-track-container">
<div class="title-label">
Title Track
</div>
</div>
<div class="title-bar-container">
<div class="title-idle">
No active title
</div>
</div>
}
else
{
<div class="title-track-container">
<div class="title-label">
Title Track
@@ -18,8 +35,8 @@
</div>
</div>
</div>
</div>
}
}
</div>
@code {
[Parameter]
@@ -69,7 +86,7 @@
private string GetTitleDescription()
{
if (this.Context is null)
if (this.Context?.Title is null)
{
return string.Empty;
}
@@ -54,3 +54,15 @@
color: var(--neutral-foreground-rest);
z-index: 1;
}
.title-idle {
width: 100%;
height: var(--font-size-x-large);
display: flex;
align-items: center;
justify-content: center;
background: var(--neutral-fill-rest);
border-radius: 0px 0px 8px 8px;
font-size: var(--font-size-medium);
color: var(--neutral-foreground-hint);
}
@@ -1,21 +1,34 @@
@if (this.Context is not null &&
this.Context.HardMode &&
this.Context.Vanquishing)
{
<div class="container backdrop-panel">
<div class="container backdrop-panel">
@if (this.Context is null)
{
<SpinnerWidget IsLoading="true" />
}
else
{
<div class="title">
Vanquishing
</div>
<div class="vanquish-bar-container">
<div class="vanquish-bar" @onclick="@(() => this.VanquishBarClicked())">
<div class="vanquish-bar-fill" style="@(this.GetVanquishBarWidth())"></div>
<div class="vanquish-bar-text">
@this.GetVanquishBarText()
@if (this.Context.HardMode && this.Context.Vanquishing)
{
<div class="vanquish-bar-container">
<div class="vanquish-bar" @onclick="@(() => this.VanquishBarClicked())">
<div class="vanquish-bar-fill" style="@(this.GetVanquishBarWidth())"></div>
<div class="vanquish-bar-text">
@this.GetVanquishBarText()
</div>
</div>
</div>
</div>
</div>
}
}
else
{
<div class="vanquish-bar-container">
<div class="vanquish-idle">
Not vanquishing
</div>
</div>
}
}
</div>
@code {
[Parameter]
@@ -37,6 +50,11 @@
}
var totalFoes = this.Context.FoesToKill + this.Context.FoesKilled;
if (totalFoes is 0)
{
return "width: 0%;";
}
return $"width: {this.Context.FoesKilled * 100d / totalFoes}%;";
}
@@ -40,3 +40,12 @@
color: var(--neutral-foreground-rest);
z-index: 1;
}
.vanquish-idle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--font-size-medium);
color: var(--neutral-foreground-hint);
}
@@ -25,6 +25,9 @@
{
<p class="notification-description">@notificationTuple.Notification.Description</p>
}
<div class="notification-progress"
@key="@($"{notificationTuple.Notification.Id}-{notificationTuple.LifetimeGeneration}")"
style="animation-duration: @(notificationTuple.LifetimeMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture))ms;"></div>
</div>
}
</div>
@@ -72,7 +75,7 @@
try
{
await this.notificationLock.WaitAsync(cancellationToken);
this.notifications.Add(new NotificationWrapper { Notification = notification, ExpirationTime = notification.ExpirationTime });
this.notifications.Add(new NotificationWrapper { Notification = notification, StartTime = DateTime.UtcNow, ExpirationTime = notification.ExpirationTime });
}
catch (OperationCanceledException)
{
@@ -167,7 +170,9 @@
private void ExtendNotification(NotificationWrapper notification)
{
notification.ExpirationTime = DateTime.Now + NotificationExtension;
notification.StartTime = DateTime.UtcNow;
notification.ExpirationTime = DateTime.UtcNow + NotificationExtension;
notification.LifetimeGeneration++;
}
private async Task OpenNofitication(NotificationWrapper notification)
@@ -99,21 +99,25 @@
/* Notification level styling */
.notification-item.level-information {
border-left: 4px solid var(--accent-fill-rest);
--notification-accent: var(--accent-fill-rest);
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-warning {
border-left: 4px solid #ff9500;
--notification-accent: #ff9500;
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-error,
.notification-item.level-critical {
border-left: 4px solid #d13438;
--notification-accent: #d13438;
border-left: 4px solid var(--notification-accent);
}
.notification-item.level-debug,
.notification-item.level-trace {
border-left: 4px solid var(--neutral-stroke-accessible);
--notification-accent: var(--neutral-stroke-accessible);
border-left: 4px solid var(--notification-accent);
}
.notification-item[class*="level-"]:hover {
@@ -121,6 +125,32 @@
cursor: pointer;
}
/* Expiration progress line animating from start time to expiration */
.notification-progress {
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 100%;
background: var(--notification-accent, var(--accent-fill-rest));
transform: scaleX(0);
transform-origin: left center;
animation-name: notificationProgress;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-iteration-count: 1;
pointer-events: none;
}
@keyframes notificationProgress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes slideInFromRight {
from {
transform: translateX(100%);
@@ -179,4 +209,9 @@
animation: none;
opacity: 0;
}
.notification-progress {
animation: none;
transform: scaleX(1);
}
}
@@ -6,6 +6,7 @@ using System.Core.Extensions;
using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class EventCalendarViewModel(IEventService eventService)
: ViewModelBase<EventCalendarViewModel, EventCalendarView>
{
@@ -92,7 +93,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
{
dates.Add(new DateTime(today.Year, today.Month, day));
}
return dates;
}
@@ -107,6 +108,7 @@ public sealed class EventCalendarViewModel(IEventService eventService)
events.Add(eventItem);
}
}
return events;
}
}
+68 -57
View File
@@ -8,65 +8,76 @@
<div class="option-view-title">
<h3>Executables</h3>
</div>
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
@if (this.ViewModel.Executables.Count is 0)
{
<EmptyStateWidget Message="No executables configured"
ActionText="Add executable"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Apps())"
Disabled="@(!this.ViewModel.AddButtonEnabled)"
OnAction="@this.ViewModel.CreateExecutable" />
}
else
{
<div class="executables-list">
@foreach (var executable in this.ViewModel.Executables)
{
<div class="executable-row">
<div class="executable-label">
<FluentLabel>Path</FluentLabel>
</div>
<div class="executable-field">
<FluentTextField Value="@executable.Path"
ReadOnly="true"
Appearance="FluentInputAppearance.Outline"
AutoComplete="off"
Placeholder="Path">
<div class="executable-field-controls"
slot="end">
@if (executable.Validating)
{
<div class="loading-circle">
<SpinnerWidget IsLoading="true" />
@if (executable.UpdateProgress is string progress)
{
<div class="update-progress-text">@progress</div>
}
</div>
}
else if (executable.Valid)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircle())"
Color="Color.Neutral"
Title="Executable is valid" />
}
else if (executable.NeedsUpdate)
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())"
@onclick="@(() => this.ViewModel.UpdateExecutable(executable))"
Color="Color.Neutral"
Title="Update executable" />
}
else
{
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.CheckmarkCircleWarning())"
Color="Color.Neutral"
Title="Executable is invalid" />
}
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.FolderOpen())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.ModifyPath(executable); })"
Color="Color.Neutral"
Title="Modify path" />
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => { if (executable.Locked) { return; } this.ViewModel.RemoveExecutable(executable); })"
Color="Color.Neutral"
Title="Remove executable" />
</div>
</FluentTextField>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -37,6 +37,7 @@ public class ExecutablesViewModel(
{
this.Executables.Remove(executable);
this.guildWarsExecutableManager.RemoveExecutable(executable.Path);
this.RefreshView();
}
public async Task CreateExecutable()
+210 -42
View File
@@ -1,50 +1,82 @@
@using Daybreak.Views.Components.FocusView
@using Daybreak.Shared.Models.FocusView
@using Daybreak.Views.Components.FocusView
@using Microsoft.JSInterop
@page "/focus"
@inherits ViewBase<FocusView, FocusViewModel>
<div class="focus-dashboard">
<div class="dashboard-half">
<div class="component character-component">
<CharacterComponent Context="@this.ViewModel.CharacterComponentContext"
OnCharacterChanged="@this.ViewModel.OnCharacterChanged"
OnExperienceBarClicked="@this.ViewModel.OnExperienceDisplayClicked" />
</div>
<div class="component resources-component">
<PlayerResourcesComponent Context="@this.ViewModel.PlayerResourcesComponentContext"
OnBalthazarBarClicked="@this.ViewModel.OnBalthazarBarClicked"
OnKurzickBarClicked="@this.ViewModel.OnKurzickBarClicked"
OnImperialBarClicked="@this.ViewModel.OnImperialBarClicked"
OnLuxonBarClicked="@this.ViewModel.OnLuxonBarClicked" />
</div>
<div class="component vanquish-component">
<VanquishComponent Context="@this.ViewModel.VanquishComponentContext"
OnVanquishBarClicked="@this.ViewModel.OnVanquishingDisplayClicked" />
</div>
<div class="component title-component">
<TitleInformationComponent Context="@this.ViewModel.TitleInformationComponentContext"
OnTitleClicked="@this.ViewModel.OnTitleClicked" />
</div>
<div class="component map-component">
<CurrentMapComponent Context="@this.ViewModel.CurrentMapComponentContext"
OnCurrentMapClicked="@this.ViewModel.OnCurrentMapClicked"/>
</div>
<div class="component quest-component">
<QuestLogComponent Context="@this.ViewModel.QuestLogComponentContext"
OnQuestClicked="@this.ViewModel.OnQuestClicked"/>
</div>
<div class="focus-dashboard @(this.ViewModel.IsEditingLayout ? "editing" : string.Empty)">
<div class="title-bar-additional-btns-left">
@if (this.ViewModel.IsEditingLayout)
{
<button class="title-bar-btn" @onclick="@this.SaveLayoutAsync" title="Save layout">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Save())" />
</button>
<button class="title-bar-btn" @onclick="@this.ViewModel.ResetLayout" title="Reset layout to defaults">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUndo())" />
</button>
<div class="add-component-wrapper">
<button class="title-bar-btn"
title="Add component"
disabled="@(!this.ViewModel.HiddenComponents.Any())"
@onclick="@this.ToggleAddDropdown">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add())" />
</button>
@if (this.isAddDropdownOpen && this.ViewModel.HiddenComponents.Any())
{
<div class="add-component-menu">
@foreach (var component in this.ViewModel.HiddenComponents)
{
<div class="add-component-item" @onclick="@(() => this.AddComponentAsync(component))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())" />
<span>@TileName(component)</span>
</div>
}
</div>
}
</div>
}
else
{
<button class="title-bar-btn" @onclick="@this.EnterEditLayout" title="Edit layout">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.SquareMultiple())" />
</button>
}
</div>
<div class="dashboard-half">
<div class="component build-component">
<BuildComponent Context="@this.ViewModel.BuildComponentContext"
OnBuildClicked="@this.ViewModel.OnBuildClicked"
OnLoadPlayerSingleBuildClicked="@this.ViewModel.OnPlayerSingleBuildClicked"
OnLoadPlayerTeamBuildClicked="@this.ViewModel.OnPlayerTeamBuildClicked" />
</div>
<div class="component browser-component">
<iframe sandbox="allow-scripts allow-same-origin allow-forms"
style="height: 100%; width: 100%; margin: -2px;"
src="@this.ViewModel.BrowserSource" />
<div class="focus-content">
<div class="grid-stack @(this.maximizedComponent is not null ? "has-maximized" : string.Empty)" @ref="this.gridElement"
style="--focus-cols: @this.ViewModel.GridColumns; --focus-rows: @this.ViewModel.GridRows;">
@foreach (var tile in this.ViewModel.VisibleTiles)
{
<div class="grid-stack-item @(this.maximizedComponent == tile.Component ? "maximized" : string.Empty)"
@key="tile.Component"
data-component="@tile.Component"
gs-x="@(tile.Column - 1)"
gs-y="@(tile.Row - 1)"
gs-w="@tile.ColumnSpan"
gs-h="@tile.RowSpan">
<div class="grid-stack-item-content component component-@(tile.Component.ToString().ToLowerInvariant())">
@if (this.ViewModel.IsEditingLayout)
{
<div class="focus-tile-placeholder">
<span class="focus-tile-name">@TileName(tile.Component)</span>
<button class="tile-remove-button"
title="Hide @TileName(tile.Component)"
@onclick="@(() => this.ToggleComponentAsync(tile.Component))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Dismiss())" />
</button>
</div>
}
else
{
<div class="focus-tile-body">
@this.RenderTile(tile.Component)
</div>
}
</div>
</div>
}
</div>
</div>
</div>
@@ -54,4 +86,140 @@
public required string ConfigurationId { get; init; }
[Parameter]
public required string ProcessId { get; init; }
[Inject]
public required IJSRuntime JSRuntime { get; init; }
private ElementReference gridElement;
private int lastSyncedVersion = -1;
private bool isAddDropdownOpen;
private FocusViewComponent? maximizedComponent;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (this.ViewModel.Tiles.Count == 0)
{
return;
}
if (this.ViewModel.LayoutVersion != this.lastSyncedVersion)
{
this.lastSyncedVersion = this.ViewModel.LayoutVersion;
this.ViewModel.MarkLayoutSynced();
await this.JSRuntime.InvokeVoidAsync("focusGrid.init", this.gridElement, new
{
columns = this.ViewModel.GridColumns,
rows = this.ViewModel.GridRows,
editing = this.ViewModel.IsEditingLayout
});
}
}
// Pulls the current geometry from Gridstack into the view model. Called only at meaningful
// moments (save / show / hide) so live drag/resize is never interrupted by a round-trip.
private async Task SyncFromGridAsync()
{
if (this.ViewModel.Tiles.Count == 0)
{
return;
}
var changes = await this.JSRuntime.InvokeAsync<FocusTileChange[]>("focusGrid.save");
this.ViewModel.ApplyTileChanges(changes);
}
private async Task SaveLayoutAsync()
{
await this.SyncFromGridAsync();
this.isAddDropdownOpen = false;
this.ViewModel.SaveLayout();
}
private void EnterEditLayout()
{
this.isAddDropdownOpen = false;
this.maximizedComponent = null;
this.ViewModel.ToggleEditLayout();
}
private void ToggleAddDropdown() => this.isAddDropdownOpen = !this.isAddDropdownOpen;
private void SetMaximized(FocusViewComponent? component)
{
this.maximizedComponent = component;
this.StateHasChanged();
}
private async Task ToggleComponentAsync(FocusViewComponent component)
{
await this.SyncFromGridAsync();
if (this.maximizedComponent == component)
{
this.maximizedComponent = null;
}
this.ViewModel.ToggleTileVisibility(component);
}
private async Task AddComponentAsync(FocusViewComponent component)
{
await this.SyncFromGridAsync();
this.ViewModel.ToggleTileVisibility(component);
if (!this.ViewModel.HiddenComponents.Any())
{
this.isAddDropdownOpen = false;
}
}
private RenderFragment RenderTile(FocusViewComponent component) => component switch
{
FocusViewComponent.Character => @<CharacterComponent Context="@this.ViewModel.CharacterComponentContext"
OnCharacterChanged="@this.ViewModel.OnCharacterChanged"
OnExperienceBarClicked="@this.ViewModel.OnExperienceDisplayClicked" />,
FocusViewComponent.PlayerResources => @<PlayerResourcesComponent Context="@this.ViewModel.PlayerResourcesComponentContext"
OnBalthazarBarClicked="@this.ViewModel.OnBalthazarBarClicked"
OnKurzickBarClicked="@this.ViewModel.OnKurzickBarClicked"
OnImperialBarClicked="@this.ViewModel.OnImperialBarClicked"
OnLuxonBarClicked="@this.ViewModel.OnLuxonBarClicked" />,
FocusViewComponent.Vanquish => @<VanquishComponent Context="@this.ViewModel.VanquishComponentContext"
OnVanquishBarClicked="@this.ViewModel.OnVanquishingDisplayClicked" />,
FocusViewComponent.Title => @<TitleInformationComponent Context="@this.ViewModel.TitleInformationComponentContext"
OnTitleClicked="@this.ViewModel.OnTitleClicked" />,
FocusViewComponent.CurrentMap => @<CurrentMapComponent Context="@this.ViewModel.CurrentMapComponentContext"
OnCurrentMapClicked="@this.ViewModel.OnCurrentMapClicked" />,
FocusViewComponent.QuestLog => @<QuestLogComponent Context="@this.ViewModel.QuestLogComponentContext"
OnQuestClicked="@this.ViewModel.OnQuestClicked" />,
FocusViewComponent.Build => @<BuildComponent Context="@this.ViewModel.BuildComponentContext"
OnBuildClicked="@this.ViewModel.OnBuildClicked"
OnLoadPlayerSingleBuildClicked="@this.ViewModel.OnPlayerSingleBuildClicked"
OnLoadPlayerTeamBuildClicked="@this.ViewModel.OnPlayerTeamBuildClicked" />,
FocusViewComponent.Browser => @<BrowserComponent Source="@this.ViewModel.BrowserSource"
OnMaximized="@(() => this.SetMaximized(FocusViewComponent.Browser))"
OnRestore="@(() => this.SetMaximized(null))" />,
_ => @<text></text>
};
private static string TileName(FocusViewComponent component) => component switch
{
FocusViewComponent.Character => "Character",
FocusViewComponent.PlayerResources => "Resources",
FocusViewComponent.Vanquish => "Vanquish",
FocusViewComponent.Title => "Title",
FocusViewComponent.CurrentMap => "Current Map",
FocusViewComponent.QuestLog => "Quest Log",
FocusViewComponent.Build => "Builds",
FocusViewComponent.Browser => "Browser",
_ => component.ToString()
};
protected override void Dispose(bool disposing)
{
if (disposing)
{
_ = this.JSRuntime.InvokeVoidAsync("focusGrid.destroy");
}
base.Dispose(disposing);
}
}
+133 -2
View File
@@ -63,6 +63,115 @@ public sealed class FocusViewModel(
public BuildComponentContext? BuildComponentContext { get; private set; }
public string? BrowserSource { get; private set; } = "https://wiki.guildwars.com/wiki/Main_Page";
private List<FocusViewTile> tiles = [];
public bool IsEditingLayout { get; private set; }
public bool LayoutInitialized { get; private set; }
public int LayoutVersion { get; private set; }
public IReadOnlyList<FocusViewTile> Tiles => this.tiles;
public IEnumerable<FocusViewTile> VisibleTiles => this.tiles.Where(t => t.Visible);
public IEnumerable<FocusViewComponent> HiddenComponents => this.tiles.Where(t => !t.Visible).Select(t => t.Component);
public int GridColumns => FocusViewOptions.DefaultGridColumns;
public int GridRows => FocusViewOptions.DefaultGridRows;
public void ToggleEditLayout()
{
this.IsEditingLayout = !this.IsEditingLayout;
this.LayoutVersion++;
this.RefreshView();
}
public void SaveLayout()
{
this.PersistLayout();
this.IsEditingLayout = false;
this.LayoutVersion++;
this.RefreshView();
}
public bool IsTileVisible(FocusViewComponent component) =>
this.tiles.FirstOrDefault(t => t.Component == component)?.Visible ?? false;
public void ToggleTileVisibility(FocusViewComponent component)
{
var tile = this.tiles.FirstOrDefault(t => t.Component == component);
if (tile is null)
{
return;
}
tile.Visible = !tile.Visible;
this.LayoutVersion++;
this.PersistLayout();
this.RefreshView();
}
public void ApplyTileChanges(IEnumerable<FocusTileChange> changes)
{
var columns = this.GridColumns;
var rows = this.GridRows;
foreach (var change in changes)
{
if (!Enum.TryParse<FocusViewComponent>(change.Component, out var component))
{
continue;
}
var tile = this.tiles.FirstOrDefault(t => t.Component == component);
if (tile is null)
{
continue;
}
tile.ColumnSpan = Math.Clamp(change.ColumnSpan, 1, columns);
tile.RowSpan = Math.Clamp(change.RowSpan, 1, rows);
tile.Column = Math.Clamp(change.Column, 1, columns - tile.ColumnSpan + 1);
tile.Row = Math.Clamp(change.Row, 1, rows - tile.RowSpan + 1);
}
}
public void ResetLayout()
{
this.tiles = FocusViewOptions.DefaultLayout();
this.IsEditingLayout = false;
this.LayoutVersion++;
this.PersistLayout();
this.RefreshView();
}
public void MarkLayoutSynced() => this.LayoutInitialized = true;
private void LoadLayout()
{
var savedLayout = this.options.CurrentValue.Layout ?? [];
var normalized = new List<FocusViewTile>();
var defaults = FocusViewOptions.DefaultLayout();
var columns = this.GridColumns;
var rows = this.GridRows;
foreach (var component in Enum.GetValues<FocusViewComponent>())
{
var tile = savedLayout.FirstOrDefault(t => t.Component == component)
?? defaults.First(t => t.Component == component);
tile.ColumnSpan = Math.Clamp(tile.ColumnSpan, 1, columns);
tile.RowSpan = Math.Clamp(tile.RowSpan, 1, rows);
tile.Column = Math.Clamp(tile.Column, 1, columns - tile.ColumnSpan + 1);
tile.Row = Math.Clamp(tile.Row, 1, rows - tile.RowSpan + 1);
normalized.Add(tile);
}
this.tiles = normalized;
this.PersistLayout();
}
private void PersistLayout()
{
var options = this.options.CurrentValue;
options.GridColumns = this.GridColumns;
options.GridRows = this.GridRows;
options.Layout = this.tiles;
this.optionsProvider.SaveOption(options);
}
public override async ValueTask ParametersSet(FocusView view, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
@@ -291,6 +400,7 @@ public sealed class FocusViewModel(
this.cancellationSource?.Dispose();
this.cancellationSource = new CancellationTokenSource();
var ct = this.cancellationSource.Token;
this.LoadLayout();
this.SetupDefaultValues();
Task.Factory.StartNew(() => this.PeriodicallyFetchGameInformation(ct), ct, TaskCreationOptions.LongRunning, TaskScheduler.Current);
}
@@ -344,7 +454,10 @@ public sealed class FocusViewModel(
else
{
retryCount = 0;
await this.RefreshViewAsync();
if (!this.IsEditingLayout)
{
await this.RefreshViewAsync();
}
}
}
catch (Exception ex)
@@ -602,7 +715,16 @@ public sealed class FocusViewModel(
if (titleInfo is null ||
!Title.TryParse((int)titleInfo.Id, out var title))
{
return default;
return new TitleInformationComponentContext
{
Title = default,
CurrentPoints = 0,
IsPercentage = false,
MaxTierNumber = 0,
TierNumber = 0,
PointsForCurrentRank = 0,
PointsForNextRank = 0
};
}
return new TitleInformationComponentContext
@@ -657,3 +779,12 @@ public sealed class FocusViewModel(
};
}
}
public sealed record FocusTileChange
{
public required string Component { get; init; }
public required int Column { get; init; }
public required int Row { get; init; }
public required int ColumnSpan { get; init; }
public required int RowSpan { get; init; }
}
+184 -27
View File
@@ -1,56 +1,213 @@
.focus-dashboard {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: calc(100% - 55px);
margin-top: 35px;
height: 100%;
overflow: hidden;
}
.dashboard-half {
.focus-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
width: 50%;
height: 100%;
margin-top: 35px;
margin-bottom: 20px;
overflow: hidden;
}
.component {
.grid-stack {
flex: 1;
min-height: 0;
width: 100%;
position: relative;
}
.grid-stack-item-content {
display: flex;
flex-direction: column;
inset: 0;
border-radius: 8px;
background: transparent;
}
/* Gridstack's own rule (.grid-stack>.grid-stack-item>.grid-stack-item-content) forces
overflow-y:auto with high specificity, which makes tiles scroll. Override it at equal
specificity so tiles clip instead. */
.grid-stack > .grid-stack-item > .grid-stack-item-content {
overflow: clip;
}
.character-component {
flex-shrink: 0;
overflow: visible !important;
.focus-tile-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.focus-tile-body > *,
.focus-tile-body ::deep > * {
flex: 1;
min-height: 0;
height: 100%;
}
/* The character select dropdown overflows its tile, so it must not be clipped or scrolled.
Needs to outrank gridstack's own .grid-stack>.grid-stack-item>.grid-stack-item-content rule. */
.grid-stack > .grid-stack-item > .grid-stack-item-content.component-character {
overflow: visible;
}
.component-character .focus-tile-body {
overflow: visible;
}
.component-character {
z-index: 1000;
}
.vanquish-component {
flex-shrink: 0;
/* In edit mode the live Character dropdown is not rendered (only the placeholder), so drop its
raised z-index/overflow which would otherwise let it sit above the resize grabber. */
.focus-dashboard.editing .grid-stack > .grid-stack-item > .grid-stack-item-content.component-character {
overflow: clip;
z-index: auto;
}
.resources-component {
flex-shrink: 0;
/* Editing mode visuals */
.focus-dashboard.editing .grid-stack {
background-image:
linear-gradient(to right, var(--neutral-stroke-rest) 1px, transparent 1px),
linear-gradient(to bottom, var(--neutral-stroke-rest) 1px, transparent 1px);
background-size: calc(100% / var(--focus-cols)) calc(100% / var(--focus-rows));
background-position: 0 0;
}
.title-component {
flex-shrink: 0;
.focus-dashboard.editing .grid-stack-item-content {
outline: 1px dashed var(--accent-stroke-control-rest);
outline-offset: -1px;
cursor: move;
}
.map-component {
flex-shrink: 0;
/* In edit mode each tile is replaced by a full-size name placeholder so the
layout is not distorted by the live components (which require game data). */
.focus-tile-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-fill-rest);
color: var(--foreground-on-accent-rest);
user-select: none;
overflow: hidden;
}
.quest-component {
flex: 1;
min-height: 0;
.focus-tile-name {
font-size: var(--font-size-base);
font-weight: 600;
text-align: center;
padding: 0 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.build-component {
height: 35%;
flex-shrink: 0;
/* Larger, easier-to-grab resize handle in the bottom-right corner. z-index sits above any
component (e.g. Character uses z-index:1000 for its dropdown) so the grabber is always visible. */
.focus-dashboard.editing .grid-stack-item > .ui-resizable-se {
width: 18px;
height: 18px;
right: 2px;
bottom: 2px;
background: transparent;
border-right: 3px solid var(--foreground-on-accent-rest);
border-bottom: 3px solid var(--foreground-on-accent-rest);
border-bottom-right-radius: 4px;
transform: none;
cursor: se-resize;
z-index: 1001;
}
.browser-component {
height: 65%;
flex-shrink: 0;
}
/* A maximized tile (e.g. Browser) expands to cover the whole grid area, hiding its siblings.
GridStack positions tiles with inline transform/width/height, so !important is required to
override it here. */
.grid-stack-item.maximized {
position: absolute !important;
inset: 0 !important;
transform: none !important;
width: 100% !important;
height: 100% !important;
z-index: 1500;
}
.grid-stack.has-maximized > .grid-stack-item:not(.maximized) {
display: none;
}
/* Per-tile remove button (top-right) to hide a component while editing. */
.tile-remove-button {
position: absolute;
top: 4px;
right: 4px;
z-index: 1002;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 50%;
cursor: pointer;
background: rgba(0, 0, 0, 0.45);
color: #fff;
}
.tile-remove-button:hover {
background: var(--error, #c50f1f);
}
/* Add-component dropdown, anchored to the titlebar button (mirrors the character dropdown). */
.add-component-wrapper {
position: relative;
display: inline-flex;
}
.title-bar-btn:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
.add-component-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 180px;
max-height: 320px;
overflow-y: auto;
background: var(--neutral-fill-rest);
border: 1px solid var(--neutral-stroke-rest);
border-radius: 6px;
z-index: 20002;
}
.add-component-item {
display: flex;
align-items: center;
gap: 8px;
padding: 0.5rem 0.75rem;
cursor: pointer;
color: var(--neutral-foreground-rest);
border-bottom: 1px solid var(--neutral-stroke-rest);
}
.add-component-item:last-child {
border-bottom: none;
}
.add-component-item:hover {
background: var(--neutral-fill-hover);
}
+151 -141
View File
@@ -9,151 +9,161 @@
<div class="option-view-title">
<h3>Launch Configurations</h3>
</div>
<div class="launch-configs-list">
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
{
var config = this.ViewModel.LaunchConfigurations[i];
var index = i;
<div class="launch-configs-row" id="launch-config-@config.Identifier">
<div class="launch-config-reorder">
<div class="launch-config-reorder-label">
<FluentLabel>Reorder</FluentLabel>
@if (this.ViewModel.LaunchConfigurations.Count is 0)
{
<EmptyStateWidget Message="No launch configurations yet"
ActionText="Add launch configuration"
Icon="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size48.Rocket())"
OnAction="@this.ViewModel.CreateNewLaunchConfiguration" />
}
else
{
<div class="launch-configs-list">
@for (var i = 0; i < this.ViewModel.LaunchConfigurations.Count; i++)
{
var config = this.ViewModel.LaunchConfigurations[i];
var index = i;
<div class="launch-configs-row" id="launch-config-@config.Identifier">
<div class="launch-config-reorder">
<div class="launch-config-reorder-label">
<FluentLabel>Reorder</FluentLabel>
</div>
<div class="launch-config-reorder-buttons">
@if (index > 0)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move up"
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
</FluentButton>
}
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move down"
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
</FluentButton>
}
</div>
</div>
<div class="launch-config-reorder-buttons">
@if (index > 0)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move up"
OnClick="@(() => this.ViewModel.MoveConfigUp(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUp())" />
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-color">
<div class="launch-config-color-label">
<FluentLabel>Accent Color</FluentLabel>
</div>
<div class="launch-config-color-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.AvailableColors"
SelectedOption="@(config.Color ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
<OptionTemplate>
<div class="color-option">
@if (!string.IsNullOrWhiteSpace(context))
{
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
}
<span>@this.ViewModel.GetColorDisplayName(context)</span>
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Launch arguments" />
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
<div class="launch-config-custom-mods">
<div class="launch-config-custom-mods-label">
<FluentLabel>Custom mod loadout</FluentLabel>
</div>
<div class="launch-config-custom-mods-field">
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
<FluentButton Appearance="Appearance.Neutral"
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
Disabled="@(!config.CustomModLoadoutEnabled)"
Title="Manage custom mods for this launch configuration">
Manage Mods
</FluentButton>
}
@if (index < this.ViewModel.LaunchConfigurations.Count - 1)
{
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
Title="Move down"
OnClick="@(() => this.ViewModel.MoveConfigDown(config))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDown())" />
</FluentButton>
}
</div>
</div>
</div>
<div class="launch-config-identifier">
<div class="launch-config-identifier-label">
<FluentLabel>Identifier</FluentLabel>
</div>
<div class="launch-config-identifier-field">
<FluentTextField Value="@config.Identifier" ReadOnly="true" Spellcheck="false"
Placeholder="Launch config identifier">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Delete())"
@onclick="@(() => this.ViewModel.DeleteLaunchConfiguration(config))" slot="end" Color="Color.Neutral"
Title="Delete launch configuration" />
</FluentTextField>
</div>
</div>
<div class="launch-config-name">
<div class="launch-config-name-label">
<FluentLabel>Name</FluentLabel>
</div>
<div class="launch-config-name-field">
<FluentTextField Value="@config.Name"
ValueChanged="@((newValue) => this.ViewModel.CustomNameChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Custom name" />
</div>
</div>
<div class="launch-config-color">
<div class="launch-config-color-label">
<FluentLabel>Accent Color</FluentLabel>
</div>
<div class="launch-config-color-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.AvailableColors"
SelectedOption="@(config.Color ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.ColorChanged(config, newValue))">
<OptionTemplate>
<div class="color-option">
@if (!string.IsNullOrWhiteSpace(context))
{
<div class="color-swatch" style="background-color: @(Daybreak.Shared.Models.ColorPalette.AccentColor.Accents.FirstOrDefault(a => a.Name.ToString() == context)?.Hex ?? "transparent")"></div>
}
<span>@this.ViewModel.GetColorDisplayName(context)</span>
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-executable">
<div class="launch-config-executable-label">
<FluentLabel>Executable</FluentLabel>
</div>
<div class="launch-config-executable-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Executables" SelectedOption="@config.ExecutablePath"
SelectedOptionChanged="@((newValue) => this.ViewModel.ExecutableChanged(config, newValue))">
<OptionTemplate>
<div title="@(context)">
@(string.IsNullOrWhiteSpace(context) ? "Any Executable" : context)
</div>
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-credentials">
<div class="launch-config-credentials-label">
<FluentLabel>Credentials</FluentLabel>
</div>
<div class="launch-config-credentials-field">
<FluentSelect TOption="string" Multiple="false" Width="100%" Position="SelectPosition.Below"
Items="@this.ViewModel.Credentials.Select(c => c.Identifier ?? string.Empty)"
SelectedOption="@(config.Credentials?.Identifier ?? string.Empty)"
SelectedOptionChanged="@((newValue) => this.ViewModel.CredentialsChanged(config, newValue))">
<OptionTemplate>
@(this.ViewModel.Credentials.FirstOrDefault(c => c.Identifier == context)?.Username ?? "Unknown")
</OptionTemplate>
</FluentSelect>
</div>
</div>
<div class="launch-config-args">
<div class="launch-config-args-label">
<FluentLabel>Custom Arguments</FluentLabel>
</div>
<div class="launch-config-args-field">
<FluentTextField Value="@config.Arguments"
ValueChanged="@((newValue) => this.ViewModel.CustomArgsChanged(config, newValue))" Spellcheck="false"
Immediate="true" ImmediateDelay="500" InputMode="InputMode.Text" AutoComplete="false"
Placeholder="Launch arguments" />
</div>
</div>
<div class="launch-config-steam">
<div class="launch-config-steam-label">
<FluentLabel>Steam support</FluentLabel>
</div>
<div class="launch-config-steam-field">
<FluentCheckbox Value="@config.SteamSupport"
ValueChanged="@((newValue) => this.ViewModel.SteamSupportChanged(config, newValue))" />
</div>
</div>
<div class="launch-config-custom-mods">
<div class="launch-config-custom-mods-label">
<FluentLabel>Custom mod loadout</FluentLabel>
</div>
<div class="launch-config-custom-mods-field">
<FluentCheckbox Value="@config.CustomModLoadoutEnabled"
ValueChanged="@((newValue) => this.ViewModel.CustomModLoadoutChanged(config, newValue))" />
<FluentButton Appearance="Appearance.Neutral"
@onclick="@(() => this.ViewModel.ManageCustomMods(config))"
Disabled="@(!config.CustomModLoadoutEnabled)"
Title="Manage custom mods for this launch configuration">
Manage Mods
</FluentButton>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -30,6 +30,7 @@ public sealed class LaunchConfigurationsViewModel(
public override ValueTask ParametersSet(LaunchConfigurationsView view, CancellationToken cancellationToken)
{
this.Credentials.ClearAnd().AddRange(this.credentialManager.GetCredentialList());
this.Credentials.Add(LoginCredentials.SteamLogin);
this.LaunchConfigurations.ClearAnd().AddRange(this.launchConfigurationService.GetLaunchConfigurations());
this.Executables.ClearAnd().AddRange(this.guildWarsExecutableManager.GetExecutableList()).Add(string.Empty);
return base.ParametersSet(view, cancellationToken);
+4 -1
View File
@@ -507,9 +507,12 @@ public sealed class LaunchViewModel(
cancellationToken
);
// If the API is available but it does not belong to the desired user, return null
// If the API is available but it does not belong to the desired user, return null.
// Steam login configurations use a virtual credential whose username is not the account
// email, so the ownership check is skipped for them.
if (
maybeApiContext is not null
&& launcherViewContext.Configuration.Credentials.IsSteamLogin is false
&& await maybeApiContext.GetLoginInfo(cancellationToken) is LoginInfo loginInfo
&& loginInfo.Email != launcherViewContext.Configuration.Credentials.Username
)
+51 -7
View File
@@ -25,6 +25,12 @@
</div>
<div class="stretch-container backdrop-panel">
<div class="logs-search-container">
<DebouncedTextField Style="width: 100%; font-size: var(--font-size-small);"
Placeholder="Filter logs..."
SearchTerm="@this.ViewModel.SearchTerm"
OnSearch="@this.ViewModel.SetSearchTerm" />
</div>
<div class="console-container" id="logs-console-container" tabindex="0">
<div class="console-output">
@foreach (var log in this.ViewModel.Logs)
@@ -38,22 +44,22 @@
<br />
break;
case LogTokenType.Timestamp:
<span class="log-timestamp">@token.Value</span>
<span class="log-timestamp">@this.Highlight(token.Value)</span>
break;
case LogTokenType.Level:
<span class="log-level @(this.GetLogLevelClass(log.Log.Level))">@token.Value</span>
<span class="log-level @(this.GetLogLevelClass(log.Log.Level))">@this.Highlight(token.Value)</span>
break;
case LogTokenType.Message:
<span class="log-message">@token.Value</span>
<span class="log-message">@this.Highlight(token.Value)</span>
break;
case LogTokenType.Exception:
<span class="log-exception">@token.Value</span>
<span class="log-exception">@this.Highlight(token.Value)</span>
break;
case LogTokenType.Property:
<span class="log-property">@token.Value</span>
<span class="log-property">@this.Highlight(token.Value)</span>
break;
default:
<span>@token.Value</span>
<span>@this.Highlight(token.Value)</span>
break;
}
}
@@ -64,7 +70,7 @@
@if (!this.ViewModel.Logs.Any())
{
<div class="no-logs">
<span>No logs available</span>
<span>@(string.IsNullOrEmpty(this.ViewModel.SearchTerm) ? "No logs available" : "No logs match the current filter")</span>
</div>
}
</div>
@@ -109,4 +115,42 @@
LogEventLevel.Fatal => ".critical",
_ => ".default"
};
private RenderFragment Highlight(string? text) => builder =>
{
var searchTerm = this.ViewModel.SearchTerm;
if (string.IsNullOrEmpty(text))
{
return;
}
if (string.IsNullOrEmpty(searchTerm))
{
builder.AddContent(0, text);
return;
}
var seq = 0;
var idx = 0;
while (idx < text.Length)
{
var matchIdx = text.IndexOf(searchTerm, idx, StringComparison.OrdinalIgnoreCase);
if (matchIdx < 0)
{
builder.AddContent(seq++, text[idx..]);
break;
}
if (matchIdx > idx)
{
builder.AddContent(seq++, text[idx..matchIdx]);
}
builder.OpenElement(seq++, "span");
builder.AddAttribute(seq++, "class", "log-match");
builder.AddContent(seq++, text.Substring(matchIdx, searchTerm.Length));
builder.CloseElement();
idx = matchIdx + searchTerm.Length;
}
};
}
+15 -1
View File
@@ -1,6 +1,7 @@
using Daybreak.Models;
using Daybreak.Services.Logging;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Utils;
using Microsoft.Extensions.Logging;
using Photino.NET;
using System.Collections.Immutable;
@@ -33,7 +34,13 @@ public sealed class LogsViewModel(
this.semaphore.Wait();
try
{
return [.. this.logs];
if (string.IsNullOrEmpty(this.SearchTerm))
{
return [.. this.logs];
}
return [.. this.logs.Where(l =>
StringUtils.MatchesSearchString(l.FormattedText, this.SearchTerm))];
}
finally
{
@@ -44,6 +51,7 @@ public sealed class LogsViewModel(
public bool AutoScroll { get; private set; } = true;
public bool Loading { get; private set; } = false;
public string SearchTerm { get; private set; } = string.Empty;
public override async ValueTask ParametersSet(LogsView view, CancellationToken cancellationToken)
{
@@ -87,6 +95,12 @@ public sealed class LogsViewModel(
this.RefreshView();
}
public void SetSearchTerm(string searchTerm)
{
this.SearchTerm = searchTerm ?? string.Empty;
this.RefreshView();
}
public async Task ArchiveLogs()
{
this.Loading = true;
+13 -1
View File
@@ -5,10 +5,14 @@
height: 100%;
}
.logs-search-container {
margin: 33px 8px 0 8px;
flex-shrink: 0;
}
.console-container {
flex: 1;
margin: 8px;
margin-top: 33px;
overflow: auto;
color: var(--neutral-foreground-rest);
font-family: 'Consolas', 'Courier New', 'Lucida Console', monospace;
@@ -93,6 +97,14 @@
flex-shrink: 0;
}
.console-output ::deep .log-match {
background: var(--accent-fill-rest);
color: var(--accent-foreground-rest);
border-radius: 2px;
padding: 0 1px;
font-weight: 600;
}
.log-exception,
.log-message {
user-select: text;
+5 -3
View File
@@ -1,8 +1,8 @@
using Daybreak.Shared.Models.Plugins;
using Daybreak.Shared.Services.ApplicationLauncher;
using Daybreak.Shared.Services.Plugins;
using Daybreak.Shared.Services.Shell;
using Photino.NET;
using System.Diagnostics;
using System.Extensions;
using TrailBlazr.Services;
using TrailBlazr.ViewModels;
@@ -12,13 +12,15 @@ public sealed class PluginsViewModel(
PhotinoWindow window,
IApplicationLauncher applicationLauncher,
IViewManager viewManager,
IPluginsService pluginsService)
IPluginsService pluginsService,
IShellExecutor shellExecutor)
: ViewModelBase<PluginsViewModel, PluginsView>
{
private readonly PhotinoWindow window = window;
private readonly IApplicationLauncher applicationLauncher = applicationLauncher;
private readonly IViewManager viewManager = viewManager;
private readonly IPluginsService pluginsService = pluginsService;
private readonly IShellExecutor shellExecutor = shellExecutor;
public List<AvailablePlugin> AvailablePlugins { get; init; } = [];
public bool CanSave { get; private set; } = false;
@@ -71,7 +73,7 @@ public sealed class PluginsViewModel(
public void NavigateToPlugin(AvailablePlugin plugin)
{
Process.Start("explorer.exe", plugin.Path);
this.shellExecutor.OpenPath(plugin.Path);
}
private void UpdatePlugins()
@@ -1,15 +1,23 @@
<div class="stretch-container backdrop-panel">
<div class="content">
<Virtualize Items="@this.ViewModel.Versions"
Context="version">
<div class="version-row">
<div class="version-row-center" @onclick="@(() => this.ViewModel.OpenVersionPage(version))">
<span>@version.ToString()</span>
Context="version"
ItemSize="56">
<div class="version-item" tabindex="0"
@onclick="@(() => this.ViewModel.OpenVersionPage(version))">
<div class="version-info">
<div class="version-icon">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.Tag())"
Color="Color.Neutral" />
</div>
<span class="version-name">@version.ToString()</span>
</div>
<div class="version-row-right">
<div class="version-actions"
@onclick="@(() => this.ViewModel.DownloadVersion(version))"
@onclick:stopPropagation="true">
<FluentButton Appearance="Appearance.Stealth"
IconOnly="true"
OnClick="@(() => this.ViewModel.DownloadVersion(version))">
Title="Download version">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowDownload())" />
</FluentButton>
</div>
@@ -1,5 +1,5 @@
using Daybreak.Shared.Services.Updater;
using System.Diagnostics;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.Updater;
using System.Extensions;
using TrailBlazr.Services;
using TrailBlazr.ViewModels;
@@ -7,13 +7,15 @@ using TrailBlazr.ViewModels;
namespace Daybreak.Views;
public sealed class VersionManagementViewModel(
IViewManager viewManager,
IApplicationUpdater applicationUpdater)
IApplicationUpdater applicationUpdater,
IShellExecutor shellExecutor)
: ViewModelBase<VersionManagementViewModel, VersionManagementView>
{
private const string VersionPlaceholder = "{VERSION}";
private const string ReleaseURL = $"https://github.com/AlexMacocian/Daybreak/releases/tag/v{VersionPlaceholder}";
private readonly IViewManager viewManager = viewManager;
private readonly IApplicationUpdater applicationUpdater = applicationUpdater;
private readonly IShellExecutor shellExecutor = shellExecutor;
public List<Version> Versions { get; init; } = [];
public Version? CurrentVersion { get; set; }
@@ -31,6 +33,6 @@ public sealed class VersionManagementViewModel(
public void OpenVersionPage(Version version)
{
Process.Start("explorer.exe", ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
this.shellExecutor.OpenUrl(ReleaseURL.Replace(VersionPlaceholder, version.ToString()));
}
}
@@ -13,15 +13,55 @@
margin: 50px 0px 0px 0px;
}
.version-row {
.version-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
border-bottom: 1px solid var(--neutral-stroke-divider-rest);
cursor: pointer;
transition: background-color 0.2s ease;
box-sizing: border-box;
border-radius: 6px;
height: 56px;
}
.version-row-center {
cursor: pointer;
text-align: center;
align-content: center;
.version-item:hover {
background-color: var(--accent-fill-hover);
}
.version-item:focus {
outline: 2px solid var(--accent-fill-focus);
outline-offset: -2px;
}
.version-info {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
min-width: 0;
}
.version-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.version-name {
font-weight: 600;
font-size: var(--font-size-medium);
color: var(--neutral-foreground-rest);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.version-actions {
flex-shrink: 0;
}
+2
View File
@@ -21,6 +21,8 @@
@using Daybreak.Shared.Models.Notifications
@using Daybreak.Shared.Models.Options
@using Daybreak.Shared.Models.FocusView
@using Daybreak.Shared.Models.Guildwars
@using Daybreak.Shared.Services.Formatting
@using Daybreak.Shared.Utils
@using Daybreak.Shared.Validators
@using Daybreak.Shared
+3
View File
@@ -7,6 +7,7 @@
<title>Daybreak</title>
<base href="/" />
<link href="css/site.css" rel="stylesheet" />
<link href="deps/gridstack/12.6.0/gridstack.min.css" rel="stylesheet" />
<link href="Daybreak.styles.css" rel="stylesheet" />
</head>
@@ -14,9 +15,11 @@
<div id="app"></div>
<script src="_framework/blazor.webview.js"></script>
<script src="deps/marked.js/15.0.12/marked.min.js"></script>
<script src="deps/gridstack/12.6.0/gridstack-all.js"></script>
<script src="js/wiki-renderer.js"></script>
<script src="js/hover-delay-popup.js"></script>
<script src="js/rendering.js"></script>
<script src="js/focus-grid.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0;top:0;width:var(--gs-column-width);height:var(--gs-cell-height)}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack:not(.grid-stack-rtl)>.grid-stack-item{left:0}.grid-stack.grid-stack-rtl>.grid-stack-item{right:0}.grid-stack>.grid-stack-item>.grid-stack-item-content,.grid-stack>.grid-stack-placeholder>.placeholder-content{top:var(--gs-item-margin-top);right:var(--gs-item-margin-right);bottom:var(--gs-item-margin-bottom);left:var(--gs-item-margin-left)}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23666" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 20 20"><path d="m10 3 2 2H8l2-2v14l-2-2h4l-2 2"/></svg>');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:var(--gs-item-margin-top);left:var(--gs-item-margin-left)}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:var(--gs-item-margin-top);left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:var(--gs-item-margin-top);right:var(--gs-item-margin-right)}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px;right:var(--gs-item-margin-right)}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px;bottom:var(--gs-item-margin-bottom);right:var(--gs-item-margin-right)}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:var(--gs-item-margin-bottom);right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px;bottom:var(--gs-item-margin-bottom);left:var(--gs-item-margin-left)}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px;left:var(--gs-item-margin-left)}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,right,top}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,right .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,right 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack:not(.grid-stack-rtl)>.grid-stack-item[gs-x="0"]{left:0}.grid-stack.grid-stack-rtl>.grid-stack-item[gs-x="0"]{right:0}
+94
View File
@@ -0,0 +1,94 @@
// Interop bridge between the Blazor FocusView and the vendored Gridstack.js library.
//
// Ownership model (kept deliberately simple to avoid the two engines fighting each other):
// * Blazor owns the tile *markup* and renders the initial geometry as gs-x/gs-y/gs-w/gs-h.
// * While editing, Gridstack owns drag/resize *entirely* and runs natively for the whole
// session - there are no per-move callbacks into .NET, which is what kept the previous
// implementation feeling janky.
// * .NET pulls the final geometry on demand via save() (on Save / show / hide), never on
// every mouse move.
window.focusGrid = (() => {
let grid = null;
let resizeObserver = null;
let gridElement = null;
let gridRows = 0;
function computeCellHeight(element, rows) {
const height = element.clientHeight;
if (!height || !rows) {
return 'auto';
}
return Math.max(1, Math.floor(height / rows));
}
function init(element, options) {
if (!window.GridStack) {
console.error('GridStack library is not loaded');
return;
}
destroy();
gridElement = element;
gridRows = options.rows;
grid = window.GridStack.init({
column: options.columns,
cellHeight: computeCellHeight(element, options.rows),
margin: 2,
// float:false gives "gravity" - tiles compact upwards into any free space above them.
float: false,
disableDrag: !options.editing,
disableResize: !options.editing,
staticGrid: !options.editing,
draggable: { cancel: '.tile-remove-button' },
resizable: { handles: 'se' },
animate: true
}, element);
// Keep the cells filling the available height as the window/container resizes.
resizeObserver = new ResizeObserver(() => {
if (!grid || !gridElement) {
return;
}
grid.cellHeight(computeCellHeight(gridElement, gridRows));
});
resizeObserver.observe(element);
}
// Returns the current geometry of every tile so .NET can persist it. 1-based coordinates
// to match the FocusViewTile model.
function save() {
if (!grid) {
return [];
}
return grid.getGridItems().map(el => {
const node = el.gridstackNode || {};
return {
component: el.getAttribute('data-component'),
column: (node.x || 0) + 1,
row: (node.y || 0) + 1,
columnSpan: node.w || 1,
rowSpan: node.h || 1
};
});
}
function destroy() {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (grid) {
grid.destroy(false);
grid = null;
}
gridElement = null;
}
return { init, save, destroy };
})();
+33 -12
View File
@@ -178,6 +178,7 @@ internal static class CppHeaderParser
{
inMultiLineConstexpr = false;
}
continue;
}
@@ -189,6 +190,7 @@ internal static class CppHeaderParser
{
inSkipBlock = false;
}
continue;
}
@@ -211,6 +213,7 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(trimmed.Substring(0, closeBraceIdx), currentEnum);
}
inEnum = false;
var node = namespaceStack.Peek();
node.Enums.Add(currentEnum);
@@ -231,11 +234,11 @@ internal static class CppHeaderParser
int closeBraces = CountChar(lineWithoutComments, '}');
int prevDepth = structBraceDepth;
structBraceDepth += openBraces - closeBraces;
// Debug: Track brace changes for AgentLiving
if (currentStruct.Name == "AgentLiving" && (openBraces > 0 || closeBraces > 0))
{
DebugLines.Add($"[BRACE] line {i+1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
DebugLines.Add($"[BRACE] line {i + 1}: {currentStruct.Name} depth {prevDepth}->{structBraceDepth} (open={openBraces}, close={closeBraces})");
}
// If we hit the closing brace of the struct
@@ -252,8 +255,8 @@ internal static class CppHeaderParser
pathParts.Add(n.Name);
pathParts.Reverse();
currentStruct.DebugNamespacePath = string.Join(".", pathParts);
DebugLines.Add($"[STRUCT-END] line {i+1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
DebugLines.Add($"[STRUCT-END] line {i + 1}: {currentStruct.Name} with {currentStruct.Fields.Count} fields");
node.Structs.Add(currentStruct);
currentStruct = null;
inStruct = false;
@@ -278,6 +281,7 @@ internal static class CppHeaderParser
if (field is not null)
currentStruct.Fields.Add(field);
}
continue;
}
@@ -295,7 +299,7 @@ internal static class CppHeaderParser
var trailingCommentIdx = trimmed.IndexOf("//");
if (trailingCommentIdx > 0)
trimmedWithoutTrailingComment = trimmed.Substring(0, trailingCommentIdx).TrimEnd();
if (trimmedWithoutTrailingComment.Contains("(") && (trimmedWithoutTrailingComment.Contains(")") || trimmedWithoutTrailingComment.Contains("{")))
continue;
@@ -339,6 +343,7 @@ internal static class CppHeaderParser
};
currentStruct.Fields.Add(field);
}
continue;
}
@@ -354,6 +359,7 @@ internal static class CppHeaderParser
var child = current.GetOrCreateChild(part);
namespaceStack.Push(child);
}
continue;
}
@@ -366,13 +372,14 @@ internal static class CppHeaderParser
freeBraceDepth--;
continue;
}
if (namespaceStack.Count > 1)
{
// Debug: Track where namespace was popped
var poppedNode = namespaceStack.Pop();
poppedNode.DebugPopLine = i + 1;
}
continue;
}
@@ -390,6 +397,7 @@ internal static class CppHeaderParser
}
}
}
continue;
}
@@ -436,6 +444,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -448,6 +457,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -455,17 +465,18 @@ internal static class CppHeaderParser
if (OutOfLineMethodRegex.IsMatch(trimmed))
{
int braces = CountChar(trimmed, '{') - CountChar(trimmed, '}');
DebugLines.Add($"[OUT-OF-LINE] line {i+1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
DebugLines.Add($"[OUT-OF-LINE] line {i + 1}: {trimmed.Substring(0, Math.Min(50, trimmed.Length))}... braces={braces}");
if (braces > 0)
{
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
else if (trimmed.Contains("::") && trimmed.Contains("(") && trimmed.Contains("{"))
{
DebugLines.Add($"[UNMATCHED-METHOD] line {i+1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
DebugLines.Add($"[UNMATCHED-METHOD] line {i + 1}: {trimmed.Substring(0, Math.Min(60, trimmed.Length))}");
}
// ── Track function declarations with body on separate line ─
@@ -492,6 +503,7 @@ internal static class CppHeaderParser
inMultiLineConstexpr = true;
multiLineBraceCount = braces;
}
continue;
}
@@ -501,7 +513,7 @@ internal static class CppHeaderParser
{
var structName = structMatch.Groups[1].Value;
var baseType = structMatch.Groups[2].Success ? structMatch.Groups[2].Value : null;
DebugLines.Add($"[STRUCT-START] line {i+1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
DebugLines.Add($"[STRUCT-START] line {i + 1}: {structName} (inSkip={inSkipBlock}, freeBrace={freeBraceDepth})");
// Skip some special cases
if (structName.StartsWith("__") || structName == "Packet")
@@ -512,6 +524,7 @@ internal static class CppHeaderParser
inSkipBlock = true;
skipBlockBraceCount = braces;
}
continue;
}
@@ -530,7 +543,7 @@ internal static class CppHeaderParser
if (structNobraceMatch.Success)
{
var structName = structNobraceMatch.Groups[1].Value;
// Skip forward declarations (just struct Name;)
if (trimmed.EndsWith(";"))
continue;
@@ -559,8 +572,10 @@ internal static class CppHeaderParser
i = j;
break;
}
break; // unexpected content
}
continue;
}
@@ -593,9 +608,11 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(rest, enumDef);
}
currentEnum = enumDef;
inEnum = true;
}
continue;
}
@@ -637,14 +654,18 @@ internal static class CppHeaderParser
{
ParseEnumMembersFromLine(rest, enumDef);
}
currentEnum = enumDef;
inEnum = true;
}
i = j;
break;
}
break; // unexpected content
}
continue;
}
@@ -794,7 +815,7 @@ internal static class CppHeaderParser
int slashSlash = line.IndexOf("//");
if (slashSlash >= 0)
line = line.Substring(0, slashSlash);
// Remove /* */ style comments (simple - doesn't handle nested)
int blockStart = line.IndexOf("/*");
while (blockStart >= 0)
@@ -806,7 +827,7 @@ internal static class CppHeaderParser
line = line.Substring(0, blockStart);
blockStart = line.IndexOf("/*");
}
return line;
}
+179 -117
View File
@@ -238,7 +238,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Emit
var sb = new StringBuilder(65536);
EmitHeader(sb, totalExports, skippedExports);
// Add diagnostic comment about parsed structs
sb.AppendLine(" // ═══════════════════════════════════════════════════");
sb.AppendLine(" // Parsed structs diagnostic:");
@@ -246,6 +246,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
sb.AppendLine($" // {diag}");
}
sb.AppendLine(" // ═══════════════════════════════════════════════════");
sb.AppendLine();
@@ -270,31 +271,33 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitConstantsNode(sb, grandchild, typeMap, namespaceClassNames, 4);
}
continue;
}
EmitConstantsNode(sb, child, typeMap, namespaceClassNames, 4);
}
sb.AppendLine("}"); // Close GWCA class
sb.AppendLine("}"); // Close Daybreak.API.Interop namespace
// Emit structs and enums into GuildWars namespace for consumer code compatibility
// Consumer code uses `using Daybreak.API.Interop.GuildWars;` to access types
sb.AppendLine();
sb.AppendLine("namespace Daybreak.API.Interop.GuildWars");
sb.AppendLine("{");
var emittedNames = new HashSet<string>(StringComparer.Ordinal);
// Collect inline array types needed by struct fields (for non-blittable array fields)
var inlineArrayTypes = new HashSet<(string csType, int size)>();
CollectInlineArrayTypes(headerRoot, typeMap, inlineArrayTypes);
// Emit manually-defined helper structs first (TLink, etc.)
EmitManualHelperStructs(sb, 4, emittedNames);
// Emit inline array types for non-blittable arrays (e.g., enum arrays)
EmitInlineArrayTypes(sb, 4, inlineArrayTypes, emittedNames);
// Emit enums first - they're often referenced by structs (e.g. Attribute enum vs Attribute struct)
EmitEnumsToGuildWarsNamespace(sb, headerRoot, 4, emittedNames);
EmitStructsToGuildWarsNamespace(sb, headerRoot, typeMap, 4, emittedNames, inlineArrayTypes);
@@ -468,10 +471,13 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Use C export name when available for std::function params
var entryPoint = useCExport ? info.FunctionName : entry.MangledName;
// Build LibraryImport attribute and calling convention
// Build LibraryImport attribute and calling convention. GWCA's free
// functions are __cdecl (the 'YA' in the mangled name) and members are
// __thiscall ('QAE'/'AAE'). The convention must be explicit on x86
// because stack cleanup differs from the Winapi/__stdcall default.
var conventionAttr = info.IsMember
? $"[UnmanagedCallConv(CallConvs = [typeof(CallConvThiscall)])]"
: "";
: $"[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]";
// Bool return type needs explicit marshalling
var retAttr = csRet == "bool" ? "[return: MarshalAs(UnmanagedType.U1)]" : "";
@@ -501,15 +507,20 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
Dictionary<string, string> typeMap,
HashSet<string> namespaceClassNames,
int indent,
string? emitNameOverride = null)
string? emitNameOverride = null,
Stack<string>? ancestors = null)
{
ancestors ??= new Stack<string>();
var pad = new string(' ', indent);
// If the node has nothing to emit (no enums, no consts, no children with content), skip
if (!HasAnyContent(node))
return;
var emitName = emitNameOverride ?? SanitizeIdentifier(node.Name);
// CS0542: a nested type cannot share its name with the immediately enclosing
// type. Append underscores until unique against the immediate enclosing class.
var rawName = emitNameOverride ?? SanitizeIdentifier(node.Name);
var emitName = MakeUniqueAgainstEnclosing(rawName, ancestors);
// Always emit a partial class wrapper — C# allows multiple partial
// declarations for the same nested class, so this merges cleanly
@@ -521,73 +532,92 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var innerIndent = indent + 4;
var innerPad = new string(' ', innerIndent);
// Emit enums
foreach (var enumDef in node.Enums.OrderBy(e => e.Name, StringComparer.Ordinal))
// Push ourselves so descendants see us as their immediate enclosing type.
ancestors.Push(emitName);
try
{
sb.AppendLine();
if (enumDef.Name is not null)
// Emit enums
foreach (var enumDef in node.Enums.OrderBy(e => e.Name, StringComparer.Ordinal))
{
var baseType = MapCppTypeToCs(enumDef.UnderlyingType);
sb.AppendLine($"{innerPad}public enum {SanitizeIdentifier(enumDef.Name)} : {baseType}");
sb.AppendLine($"{innerPad}{{");
foreach (var member in enumDef.Members)
sb.AppendLine();
if (enumDef.Name is not null)
{
var comment = member.Comment is not null ? $" // {member.Comment}" : "";
if (member.Value is not null)
sb.AppendLine($"{innerPad} {SanitizeIdentifier(member.Name)} = {member.Value},{comment}");
else
sb.AppendLine($"{innerPad} {SanitizeIdentifier(member.Name)},{comment}");
var baseType = MapCppTypeToCs(enumDef.UnderlyingType);
var enumName = MakeUniqueAgainstEnclosing(SanitizeIdentifier(enumDef.Name), ancestors);
sb.AppendLine($"{innerPad}public enum {enumName} : {baseType}");
sb.AppendLine($"{innerPad}{{");
foreach (var member in enumDef.Members)
{
var comment = member.Comment is not null ? $" // {member.Comment}" : "";
if (member.Value is not null)
sb.AppendLine($"{innerPad} {SanitizeIdentifier(member.Name)} = {member.Value},{comment}");
else
sb.AppendLine($"{innerPad} {SanitizeIdentifier(member.Name)},{comment}");
}
sb.AppendLine($"{innerPad}}}");
}
sb.AppendLine($"{innerPad}}}");
}
else
{
// Anonymous enum → emit as const fields
foreach (var member in enumDef.Members)
else
{
var csType = MapCppTypeToCs(enumDef.UnderlyingType);
var memberName = SanitizeIdentifier(member.Name);
// CS0542: member names cannot be the same as their enclosing type
if (memberName == emitName)
memberName += "_";
var comment = member.Comment is not null ? $" // {member.Comment}" : "";
sb.AppendLine($"{innerPad}internal const {csType} {memberName} = {member.Value};{comment}");
// Anonymous enum → emit as const fields
foreach (var member in enumDef.Members)
{
var csType = MapCppTypeToCs(enumDef.UnderlyingType);
var memberName = MakeUniqueAgainstEnclosing(SanitizeIdentifier(member.Name), ancestors);
var comment = member.Comment is not null ? $" // {member.Comment}" : "";
sb.AppendLine($"{innerPad}internal const {csType} {memberName} = {member.Value};{comment}");
}
}
}
// Emit constexpr fields
foreach (var field in node.Constants.OrderBy(f => f.Name, StringComparer.Ordinal))
{
var csType = MapCppTypeToCs(field.CppType);
var value = field.Value;
var fieldName = MakeUniqueAgainstEnclosing(SanitizeIdentifier(field.Name), ancestors);
var comment = field.Comment is not null ? $" // {field.Comment}" : "";
sb.AppendLine($"{innerPad}internal const {csType} {fieldName} = {value};{comment}");
}
// Type aliases are now emitted to GuildWars namespace, not nested GWCA classes
// Skip type alias emission here - see EmitTypeAliasesToGuildWarsNamespace
// Structs are now emitted to GuildWars namespace, not nested classes
// Skip struct emission here - see EmitStructsToGuildWarsNamespace
// Emit children. Each child computes its own unique name against the
// ancestor stack on entry, so no override is needed here.
foreach (var child in node.Children.Values.OrderBy(c => c.Name, StringComparer.Ordinal))
{
EmitConstantsNode(sb, child, typeMap, namespaceClassNames, innerIndent, emitNameOverride: null, ancestors);
}
}
// Emit constexpr fields
foreach (var field in node.Constants.OrderBy(f => f.Name, StringComparer.Ordinal))
finally
{
var csType = MapCppTypeToCs(field.CppType);
var value = field.Value;
var fieldName = SanitizeIdentifier(field.Name);
// CS0542: member names cannot be the same as their enclosing type
if (fieldName == emitName)
fieldName += "_";
var comment = field.Comment is not null ? $" // {field.Comment}" : "";
sb.AppendLine($"{innerPad}internal const {csType} {fieldName} = {value};{comment}");
}
// Type aliases are now emitted to GuildWars namespace, not nested GWCA classes
// Skip type alias emission here - see EmitTypeAliasesToGuildWarsNamespace
// Structs are now emitted to GuildWars namespace, not nested classes
// Skip struct emission here - see EmitStructsToGuildWarsNamespace
// Emit children
foreach (var child in node.Children.Values.OrderBy(c => c.Name, StringComparer.Ordinal))
{
// CS0542: nested class name cannot be the same as enclosing type
string? childNameOverride = null;
if (SanitizeIdentifier(child.Name) == emitName)
childNameOverride = SanitizeIdentifier(child.Name) + "_";
EmitConstantsNode(sb, child, typeMap, namespaceClassNames, innerIndent, childNameOverride);
ancestors.Pop();
}
sb.AppendLine($"{pad}}}");
}
/// <summary>
/// CS0542 guard: returns <paramref name="name"/> with one or more trailing
/// underscores until it no longer collides with the immediately enclosing
/// type name (the top of the <paramref name="ancestors"/> stack).
/// </summary>
private static string MakeUniqueAgainstEnclosing(string name, Stack<string> ancestors)
{
if (ancestors.Count == 0)
return name;
var enclosing = ancestors.Peek();
var unique = name;
while (string.Equals(unique, enclosing, StringComparison.Ordinal))
unique += "_";
return unique;
}
private static bool HasAnyContent(ConstantsNode node)
{
// Note: TypeAliases and Structs are emitted to GuildWars namespace, not counted here
@@ -640,10 +670,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Only add structs that can actually be emitted
if (!CanEmitStruct(structDef))
continue;
// Determine the C# name, handling collisions with namespace classes or enums
var csName = SanitizeIdentifier(structDef.Name);
// Check if name collides with a namespace class, enum, or ANOTHER struct already using this name
if (namespaceClassNames.Contains(structDef.Name))
{
@@ -667,21 +697,21 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
else
csName += "_";
}
usedGuildWarsNames.Add(csName);
// Use a qualified key that includes the C++ path to distinguish structs with the same name
// from different namespaces (e.g., GW::Attribute vs GW::SkillbarMgr::Attribute)
var mapKey = structDef.Name;
var fqCsType = "global::Daybreak.API.Interop.GuildWars." + csName;
var simpleStructKey = "struct " + structDef.Name;
if (typeMap.ContainsKey(mapKey))
{
// Use qualified key to distinguish this struct from enum or another struct
// Include the csPath to make it unique (e.g., "struct GW.Attribute" vs "struct GW.SkillbarMgr.Attribute")
mapKey = "struct " + csPath.Replace("GWCA.", "") + "." + structDef.Name;
// Also add simple "struct X" key if not already taken (for field type resolution)
// This allows MapCppFieldTypeToCs to find the struct without knowing the full path
if (!typeMap.ContainsKey(simpleStructKey))
@@ -689,7 +719,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
typeMap[simpleStructKey] = fqCsType;
}
}
// Structs go into GuildWars namespace (flat), not nested GWCA classes
typeMap[mapKey] = fqCsType;
}
@@ -719,19 +749,20 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
private static void CollectStructDiagnostics(ConstantsNode node, string path, List<string> diagnostics)
{
var currentPath = string.IsNullOrEmpty(path) ? node.Name : path + "." + node.Name;
// Show namespace pop line info
if (node.DebugPopLine > 0)
{
diagnostics.Add($"[NAMESPACE] {currentPath} popped at line {node.DebugPopLine}");
}
foreach (var structDef in node.Structs)
{
var (canEmit, reason) = CanEmitStructWithReason(structDef);
var status = canEmit ? "OK" : $"SKIP: {reason}";
diagnostics.Add($"{currentPath}.{structDef.Name}: {structDef.Fields.Count} fields [{status}]");
}
foreach (var child in node.Children.Values)
{
CollectStructDiagnostics(child, currentPath, diagnostics);
@@ -746,7 +777,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Skip structs with no fields (usually forward declarations that got parsed)
if (structDef.Fields.Count == 0)
return (false, "no fields");
// Check for mixed offset/no-offset fields (can't use Explicit layout if not all have offsets)
bool hasAnyOffset = structDef.Fields.Any(f => f.Offset.HasValue);
bool allHaveOffsets = structDef.Fields.All(f => f.Offset.HasValue);
@@ -762,7 +793,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
return (false, $"template param T in field {field.Name}");
// Skip structs with complex template containers we can't represent
// Note: TLink<T> is handled separately in MapCppFieldTypeToCs (8-byte linked list node)
if (cppType.Contains("TList<") ||
if (cppType.Contains("TList<") ||
cppType.Contains("PrioQ<") || cppType.Contains("PrioQLink<") ||
cppType.Contains("BaseArray<"))
return (false, $"complex template in field {field.Name}: {cppType}");
@@ -789,9 +820,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
if (cppType.Contains("EquipmentVTable") || cppType.Contains("VTable"))
return (false, $"vtable in field {field.Name}: {cppType}");
}
return (true, null);
}
private static bool CanEmitStruct(CppStructDef structDef) => CanEmitStructWithReason(structDef).canEmit;
/// <summary>
@@ -881,7 +913,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Start with the node's own name as the path base (GWCA for headerRoot)
EmitStructsRecursive(sb, node, node.Name, typeMap, indent, emittedNames, inlineArrayTypes);
}
private static void EmitStructsRecursive(StringBuilder sb, ConstantsNode node, string currentPath, Dictionary<string, string> typeMap, int indent, HashSet<string> emittedNames, HashSet<(string csType, int size)> inlineArrayTypes)
{
foreach (var structDef in node.Structs.OrderBy(s => s.Name, StringComparer.Ordinal))
@@ -889,13 +921,13 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Skip structs that can't be emitted
if (!CanEmitStruct(structDef))
continue;
// Get the C# name from typeMap (which includes collision-resolution suffixes like "Struct")
// Try direct name first, then qualified struct key (for collision cases)
string? fqCsName;
var directLookup = typeMap.TryGetValue(structDef.Name, out fqCsName);
var containsGuildWars = fqCsName?.Contains("GuildWars.") ?? false;
if (!directLookup || !containsGuildWars)
{
// If direct lookup failed or returned an enum (not in GuildWars namespace),
@@ -903,22 +935,22 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var qualifiedKey = "struct " + currentPath.Replace("GWCA.", "") + "." + structDef.Name;
typeMap.TryGetValue(qualifiedKey, out fqCsName);
}
if (fqCsName is null)
continue;
// Extract just the struct name from the fully-qualified name
// e.g. "global::Daybreak.API.Interop.GuildWars.ItemStruct" -> "ItemStruct"
var csName = fqCsName.Substring(fqCsName.LastIndexOf('.') + 1);
// Skip duplicates (including if an enum with the same name was emitted)
if (emittedNames.Contains(csName))
continue;
emittedNames.Add(csName);
EmitStruct(sb, structDef, typeMap, indent, inlineArrayTypes, csName);
}
// Recurse into children, appending the child's name to the current path
foreach (var child in node.Children.Values)
{
@@ -933,7 +965,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
private static void EmitManualHelperStructs(StringBuilder sb, int indent, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
// TLink<T> - doubly-linked list node used in Agent and other structs
// C++ definition: struct TLink { TLink* prev_link; T* next_node; }
// Size: 8 bytes (2 pointers on x86)
@@ -951,6 +983,21 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
sb.AppendLine($"{pad}}}");
sb.AppendLine();
}
// THash<T> - GWCA hash table, fixed 0x24 (36) bytes regardless of T.
// Treated as an opaque blob; consumers use GWCA helpers for traversal.
// See Dependencies/GWCA/Include/GWCA/GameContainers/Hash.h.
if (emittedNames.Add("THash"))
{
sb.AppendLine($"{pad}/// <summary>");
sb.AppendLine($"{pad}/// THash&lt;T&gt; - hash table (36 bytes, opaque to managed code).");
sb.AppendLine($"{pad}/// </summary>");
sb.AppendLine($"{pad}[global::System.Runtime.InteropServices.StructLayout(global::System.Runtime.InteropServices.LayoutKind.Sequential, Pack = 1, Size = 0x24)]");
sb.AppendLine($"{pad}public struct THash");
sb.AppendLine($"{pad}{{");
sb.AppendLine($"{pad}}}");
sb.AppendLine();
}
}
/// <summary>
@@ -962,16 +1009,16 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
if (!CanEmitStruct(structDef))
continue;
foreach (var field in structDef.Fields)
{
if (field.ArraySize is null)
continue;
var size = ParseArraySize(field.ArraySize);
if (size <= 0)
continue;
var csType = MapCppFieldTypeToCs(field.CppType, typeMap);
if (!IsBlittableForFixed(csType))
{
@@ -979,7 +1026,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
}
}
}
foreach (var child in node.Children.Values)
{
CollectInlineArrayTypes(child, typeMap, inlineArrayTypes);
@@ -993,16 +1040,16 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
private static void EmitInlineArrayTypes(StringBuilder sb, int indent, HashSet<(string csType, int size)> inlineArrayTypes, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
foreach (var (csType, size) in inlineArrayTypes.OrderBy(x => x.csType).ThenBy(x => x.size))
{
// Generate a name like "AttributeArray12" or "SkillIDArray8"
var simpleName = GetSimpleTypeName(csType);
var arrayTypeName = $"{simpleName}Array{size}";
if (!emittedNames.Add(arrayTypeName))
continue;
sb.AppendLine($"{pad}/// <summary>");
sb.AppendLine($"{pad}/// Inline array of {size} {simpleName} elements.");
sb.AppendLine($"{pad}/// </summary>");
@@ -1026,10 +1073,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var isPointer = csType.EndsWith("*");
if (isPointer)
csType = csType.TrimEnd('*');
var lastDot = csType.LastIndexOf('.');
var name = lastDot >= 0 ? csType.Substring(lastDot + 1) : csType;
// Append Ptr for pointer types to make valid identifier
return isPointer ? name + "Ptr" : name;
}
@@ -1051,30 +1098,30 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitEnumsRecursive(sb, node, indent, emittedNames);
}
private static void EmitEnumsRecursive(StringBuilder sb, ConstantsNode node, int indent, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
foreach (var enumDef in node.Enums.OrderBy(e => e.Name, StringComparer.Ordinal))
{
// Skip anonymous enums
if (enumDef.Name is null)
continue;
// Skip duplicates (including if a struct with the same name was emitted)
if (emittedNames.Contains(enumDef.Name))
continue;
emittedNames.Add(enumDef.Name);
// Determine base type and map C++ types to C#
var baseType = MapCppEnumBaseType(enumDef.UnderlyingType ?? "int");
sb.AppendLine();
sb.AppendLine($"{pad}public enum {SanitizeIdentifier(enumDef.Name)} : {baseType}");
sb.AppendLine($"{pad}{{");
var innerPad = new string(' ', indent + 4);
foreach (var member in enumDef.Members)
{
@@ -1083,9 +1130,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
else
sb.AppendLine($"{innerPad}{SanitizeIdentifier(member.Name)},");
}
sb.AppendLine($"{pad}}}");
}
// Recurse into children
foreach (var child in node.Children.Values)
{
@@ -1100,19 +1148,19 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
EmitTypeAliasesRecursive(sb, node, typeMap, indent, emittedNames);
}
private static void EmitTypeAliasesRecursive(StringBuilder sb, ConstantsNode node, Dictionary<string, string> typeMap, int indent, HashSet<string> emittedNames)
{
var pad = new string(' ', indent);
foreach (var alias in node.TypeAliases.OrderBy(a => a.AliasName, StringComparer.Ordinal))
{
// Skip duplicates
if (emittedNames.Contains(alias.AliasName))
continue;
emittedNames.Add(alias.AliasName);
var innerType = alias.TemplateArg.Replace("::", ".").Trim();
// Handle pointer types (e.g., "Item *" -> "nint")
if (innerType.EndsWith("*"))
@@ -1129,10 +1177,10 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Unmapped struct type - use nint as fallback
innerType = "nint";
}
sb.AppendLine($"{pad}public unsafe struct {alias.AliasName} {{ public global::Daybreak.API.Interop.GuildWars.GuildWarsArray<{innerType}> Value; }}");
}
// Recurse into children
foreach (var child in node.Children.Values)
{
@@ -1218,6 +1266,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Fallback for unmapped types
return "global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint>";
}
return "global::Daybreak.API.Interop.GuildWars.GuildWarsArray<nint>";
}
@@ -1227,6 +1276,12 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
return "global::Daybreak.API.Interop.GuildWars.TLink";
}
// Handle THash<T> - hash table, always 0x24 bytes; opaque to managed code.
if (cppType.StartsWith("THash<"))
{
return "global::Daybreak.API.Interop.GuildWars.THash";
}
// Handle pointers
bool isPointer = cppType.EndsWith("*");
if (isPointer)
@@ -1239,7 +1294,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
var parts = inner.Split(new[] { "::" }, StringSplitOptions.RemoveEmptyEntries);
innerStripped = parts[parts.Length - 1];
}
var csInner = MapCppFieldTypeToCs(inner, typeMap);
// Special case: void* and char* and wchar_t* map to nint
if (csInner is "void" or "char" or "byte")
@@ -1254,7 +1309,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Check if original type is from Constants namespace (typically enums)
// In this case, prefer enum mapping over struct mapping
var isFromConstantsNamespace = cppType.Contains("Constants::");
// Strip namespace qualifiers (GW::, GW::Constants::, etc.)
if (cppType.Contains("::"))
{
@@ -1310,6 +1365,11 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
"HMODULE" => "nint",
"HANDLE" => "nint",
"HWND" => "nint",
// EGL handles: `using EGLSurface = void*;` aliases declared inside
// GWCA/Managers/RenderMgr.h, which the header parser doesn't resolve.
"EGLSurface" => "nint",
"EGLContext" => "nint",
"EGLDisplay" => "nint",
"uintptr_t" => "nuint",
"intptr_t" => "nint",
"Vec2f" => "global::System.Numerics.Vector2",
@@ -1373,6 +1433,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
result.Append(part.Substring(1));
}
}
return result.ToString();
}
@@ -1602,7 +1663,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
_ => "nint",
};
}
/// <summary>
/// Resolves a struct name to its C# type, handling name collisions with enums.
/// </summary>
@@ -1617,7 +1678,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Unmapped struct -> nint
return "nint";
}
/// <summary>
/// Resolves a pointer inner type (the pointee) for struct or enum types,
/// handling name collisions between structs and enums.
@@ -1633,7 +1694,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
// Fall back to direct lookup result (could be an enum) or just the name
return direct ?? name;
}
/// <summary>
/// Resolves a pointer type used as a template argument (e.g., Agent* in Array&lt;Agent*&gt;).
/// In unsafe C#, pointer types can be used as generic type arguments for unmanaged structs.
@@ -1642,17 +1703,17 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
{
// arg.Name is the inner type (e.g., "Agent" for Agent*)
var innerName = arg.Name;
// Check if the inner type is mapped (prefer struct mapping for GuildWars types)
var resolved = ResolveStructOrEnumPointerInner(innerName, typeMap);
if (resolved != innerName)
return resolved + "*";
// Check for primitives
var primitive = MapPrimitiveType(innerName);
if (primitive != innerName)
return primitive + "*";
// Special cases
return innerName switch
{
@@ -1660,7 +1721,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
_ => "nint", // unmapped pointer -> nint
};
}
/// <summary>
/// Maps C/C++ primitive type names to C# primitive types.
/// </summary>
@@ -1843,6 +1904,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
child = new ConstantsNode(childName);
this.Children[childName] = child;
}
return child;
}
}
@@ -1896,4 +1958,4 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
public string SourceType { get; set; } = ""; // e.g., "Array"
public string? TemplateArg { get; set; } // e.g., "Buff"
}
}
}
+1
View File
@@ -461,6 +461,7 @@ internal sealed class MsvcDemangler
{
pos++; // skip Z - this terminates the function pointer, not the outer param list
}
break;
}
+4
View File
@@ -25,6 +25,10 @@ internal static partial class NativeMethods
[LibraryImport("kernel32.dll")]
public static partial nint OpenProcess(ProcessAccessFlags dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessID);
[LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool QueryFullProcessImageName(nint hProcess, uint dwFlags, [Out] char[] lpExeName, ref uint lpdwSize);
[LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW", StringMarshalling = StringMarshalling.Utf16)]
public static partial nint GetModuleHandle(string lpModuleName);
+1 -1
View File
@@ -17,7 +17,7 @@ public sealed class ProcessLauncher
{
var sw = Stopwatch.StartNew();
Process? process;
processId = LaunchClient(path, string.Join(" ", args), elevated, out threadId);
processId = LaunchClient(path, args, elevated, out threadId);
if (processId is 0)
{
Console.WriteLine("Failed to launch GuildWars process.");
+78
View File
@@ -0,0 +1,78 @@
using Daybreak.Shared.Models;
using System.Diagnostics;
namespace Daybreak.Injector;
/// <summary>
/// Resolves a Wine-internal process id from a Guild Wars executable path.
///
/// On Linux multiple Guild Wars instances (each in its own install directory) run under
/// the same Wine prefix. Matching by executable name alone cannot tell them apart, so we
/// enumerate the running processes and compare each one's full image path — which the
/// caller already knows from the Linux side — to find the unique owning Wine process.
///
/// This runs inside Wine, where <see cref="Process.Id"/> is the Wine pid, so the matched
/// id is exactly what the stub/winapi injectors expect.
/// </summary>
public static class ProcessResolver
{
private const string GuildWarsProcessName = "Gw";
public static InjectorResponses.ResolveResult Resolve(string executablePath, out int processId)
{
processId = 0;
var target = NormalizePath(executablePath);
foreach (var process in Process.GetProcessesByName(GuildWarsProcessName))
{
try
{
if (TryGetImagePath(process.Id) is { } imagePath &&
string.Equals(NormalizePath(imagePath), target, StringComparison.Ordinal))
{
processId = process.Id;
return InjectorResponses.ResolveResult.Success;
}
}
finally
{
process.Dispose();
}
}
return InjectorResponses.ResolveResult.ProcessNotFound;
}
private static string? TryGetImagePath(int processId)
{
var handle = NativeMethods.OpenProcess(
NativeMethods.ProcessAccessFlags.QueryLimitedInformation, false, (uint)processId);
if (handle is 0)
{
return null;
}
try
{
var buffer = new char[1024];
var size = (uint)buffer.Length;
if (!NativeMethods.QueryFullProcessImageName(handle, 0, buffer, ref size) || size is 0)
{
return null;
}
return new string(buffer, 0, (int)size);
}
finally
{
NativeMethods.CloseHandle(handle);
}
}
/// <summary>
/// Normalizes a Windows/Wine path for comparison: unifies separators, trims a trailing
/// null/whitespace, and lower-cases (Windows paths are case-insensitive).
/// </summary>
private static string NormalizePath(string path) =>
path.Trim().TrimEnd('\0').Replace('/', '\\').ToLowerInvariant();
}
+83 -1
View File
@@ -12,14 +12,60 @@ static void PrintUsage()
Console.WriteLine("- stub");
Console.WriteLine("- launch");
Console.WriteLine("- resume");
Console.WriteLine("- resolve");
Console.WriteLine("Examples:");
Console.WriteLine("1) Daybreak.Injector winapi 1234 C:\\path\\to\\dll.dll");
Console.WriteLine("2) Daybreak.Injector stub 1234 entryPoint C:\\path\\to\\dll.dll");
Console.WriteLine("3) Daybreak.Injector launch true C:\\path\\to\\dll.dll arg1 arg2 arg3");
Console.WriteLine("4) Daybreak.Injector resume 1234");
Console.WriteLine("5) Daybreak.Injector resolve \"Z:\\path\\to\\Gw.exe\"");
Console.WriteLine("======================================================");
}
// Re-quotes a single argument token using the same rules as CommandLineToArgvW so that
// values containing spaces, tabs or quotes survive being reconstructed into a command line.
// Without this, tokens are joined with plain spaces and any argument value with whitespace
// (e.g. a quoted custom argument or a path with spaces) is silently split apart.
static string EscapeArgument(string argument)
{
if (argument.Length > 0 &&
!argument.Any(c => c is ' ' or '\t' or '\n' or '\v' or '"'))
{
return argument;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
for (var i = 0; i < argument.Length; i++)
{
var backslashes = 0;
while (i < argument.Length && argument[i] == '\\')
{
i++;
backslashes++;
}
if (i == argument.Length)
{
builder.Append('\\', backslashes * 2);
break;
}
else if (argument[i] == '"')
{
builder.Append('\\', (backslashes * 2) + 1);
builder.Append('"');
}
else
{
builder.Append('\\', backslashes);
builder.Append(argument[i]);
}
}
builder.Append('"');
return builder.ToString();
}
static bool TryParseInjectWinApiArgs(
string[] args,
[NotNullWhen(true)] out Process? process,
@@ -135,7 +181,7 @@ static bool TryParseLaunchArgs(
}
elevated = parsedElevated;
gwArgs = args.Length > 3 ? string.Join(" ", args.Skip(3)) : string.Empty;
gwArgs = args.Length > 3 ? string.Join(" ", args.Skip(3).Select(EscapeArgument)) : string.Empty;
if (!File.Exists(gwPath))
{
@@ -173,6 +219,24 @@ static bool TryParseThreadResumeArgs(
return true;
}
static bool TryParseResolveArgs(
string[] args,
[NotNullWhen(true)] out string? executablePath,
out InjectorResponses.ResolveResult exitCode)
{
executablePath = default;
if (args.Length < 2 || string.IsNullOrWhiteSpace(args[1]))
{
PrintUsage();
exitCode = InjectorResponses.ResolveResult.InvalidArgs;
return false;
}
executablePath = args[1];
exitCode = InjectorResponses.ResolveResult.Success;
return true;
}
if (args.Length < 1)
{
PrintUsage();
@@ -244,6 +308,24 @@ switch (mode)
Console.WriteLine($"ExitCode: {result}");
return result;
}
case "resolve":
{
if (!TryParseResolveArgs(args, out var executablePath, out var parseResult))
{
Console.WriteLine($"ExitCode: {(int)parseResult}");
return (int)parseResult;
}
Console.WriteLine($"Resolving Wine PID for {executablePath}");
var result = ProcessResolver.Resolve(executablePath, out var processId);
if (result is InjectorResponses.ResolveResult.Success)
{
Console.WriteLine($"ProcessId: {processId}");
}
Console.WriteLine($"ExitCode: {(int)result}");
return (int)result;
}
default:
PrintUsage();
Console.WriteLine($"ExitCode: {(int)InjectorResponses.GenericResults.InvalidMode}");
@@ -13,6 +13,7 @@ using Daybreak.Linux.Services.DirectSong;
using Daybreak.Linux.Services.Registry;
using Daybreak.Linux.Services.Themes;
using Daybreak.Linux.Services.SevenZip;
using Daybreak.Linux.Services.Shell;
using Daybreak.Linux.Services.Window;
using Daybreak.Linux.Services.ExceptionHandling;
using Daybreak.Linux.Services.Wine;
@@ -32,6 +33,7 @@ using Daybreak.Shared.Services.Privilege;
using Daybreak.Shared.Services.Screens;
using Daybreak.Shared.Services.Themes;
using Daybreak.Shared.Services.SevenZip;
using Daybreak.Shared.Services.Shell;
using Daybreak.Shared.Services.UMod;
using Daybreak.Shared.Services.ExceptionHandling;
using Daybreak.Shared.Services.Window;
@@ -55,11 +57,13 @@ public sealed class LinuxPlatformConfiguration : PluginConfigurationBase
services.AddSingleton<IWinePidMapper, WinePidMapper>();
services.AddScoped<IGuildWarsReadyChecker, GuildWarsReadyChecker>();
services.AddScoped<IGuildWarsProcessFinder, GuildWarsProcessFinder>();
services.AddSingleton<ISteamService, SteamService>();
services.AddSingleton<IModPathResolver, ModPathResolver>();
services.AddSingleton<IDirectSongRegistrar, DirectSongRegistrar>();
services.AddScoped<IRegistryService, RegistryService>();
services.AddSingleton<ISystemThemeDetector, SystemThemeDetector>();
services.AddSingleton<ISevenZipExtractor, SevenZipArchiveExtractor>();
services.AddSingleton<IShellExecutor, ShellExecutor>();
services.AddSingleton<IPidProvider, PidProvider>();
services.AddSingleton<IWindowManipulationService, WindowManipulationService>();
services.AddSingleton<ICrashDumpService, CrashDumpService>();
View File
+22
View File
@@ -1,4 +1,5 @@
using Daybreak.Linux.Configuration;
using Daybreak.Linux.Utils;
namespace Daybreak.Linux;
@@ -6,8 +7,29 @@ public static partial class Launcher
{
public static void Main(string[] args)
{
ForceX11Backend();
var bootstrap = Launch.Launcher.SetupBootstrap();
var platformConfiguration = new LinuxPlatformConfiguration();
Launch.Launcher.LaunchSequence(args, bootstrap, platformConfiguration);
}
/// <summary>
/// Forces GTK to use the X11 backend (via XWayland under Wayland sessions).
/// Photino/WebKitGTK/GTK3 and Daybreak's window interop rely on X11-only APIs
/// (gdk_x11_window_get_xid); without this, GTK selects the Wayland backend on
/// Wayland sessions and those calls abort with
/// "gdk_x11_window_get_xid: assertion 'GDK_IS_X11_WINDOW (window)' failed"
/// followed by a fatal Wayland protocol error (see issue #1551).
///
/// Must run before any GTK initialization (which happens lazily when the first
/// Photino window is created) and must use the native libc setenv: managed
/// Environment.SetEnvironmentVariable does not propagate to native getenv,
/// which is how GTK reads GDK_BACKEND. overwrite=0 lets a user who explicitly
/// exports GDK_BACKEND (e.g. to test native Wayland) keep their choice.
/// </summary>
private static void ForceX11Backend()
{
NativeMethods.setenv("GDK_BACKEND", "x11", 0);
}
}
+173 -4
View File
@@ -1,22 +1,191 @@
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Services.Api;
using System.Globalization;
namespace Daybreak.Linux.Services.Api;
/// <summary>
/// Linux implementation of <see cref="IPidProvider"/>.
/// On Linux, the reported PID is a Wine-internal PID that must be
/// converted to the actual Linux system PID via <see cref="IWinePidMapper"/>.
/// On Linux, the reported PID is a Wine-internal PID. The most reliable way to map an
/// API instance to its Linux process is via the TCP port it listens on: we resolve the
/// owning Linux PID from the listening socket. This disambiguates multiple concurrent
/// Guild Wars instances, which a Wine-PID-by-name lookup cannot do.
/// </summary>
public sealed class PidProvider(IWinePidMapper winePidMapper) : IPidProvider
{
private readonly IWinePidMapper winePidMapper = winePidMapper;
/// <inheritdoc />
public int ResolveSystemPid(int reportedPid, string executableName)
public int ResolveSystemPid(int reportedPid, string executableName, int? port = null)
{
// On Linux, the reported PID is a Wine PID. Convert to Linux system PID.
if (port is not null && TryGetProcessIdForTcpListener(port.Value, executableName) is { } listenerPid)
{
return listenerPid;
}
// Fallback: the reported PID is a Wine PID. Convert to Linux system PID by name.
var linuxPid = this.winePidMapper.WinePidToLinuxPid(reportedPid, executableName);
return linuxPid ?? reportedPid;
}
/// <summary>
/// Resolves the Linux PID that owns the listening socket on <paramref name="port"/>.
/// Under Wine the listening socket fd is shared between the game process and the
/// shared <c>wineserver</c> process, so candidate processes are filtered by
/// <paramref name="executableName"/> to select the game process and exclude wineserver.
/// </summary>
private static int? TryGetProcessIdForTcpListener(int port, string executableName)
{
try
{
var socketInodes = GetTcpListenerSocketInodes("/proc/net/tcp", port)
.Concat(GetTcpListenerSocketInodes("/proc/net/tcp6", port))
.ToHashSet(StringComparer.Ordinal);
if (socketInodes.Count == 0)
{
return null;
}
foreach (var procDir in Directory.EnumerateDirectories("/proc"))
{
if (!int.TryParse(Path.GetFileName(procDir), out var pid))
{
continue;
}
// Only consider processes whose command line references the target
// executable. This excludes wineserver (which shares the socket fd)
// and limits the fd scan to the handful of Guild Wars processes.
if (!ProcessReferencesExecutable(procDir, executableName))
{
continue;
}
if (ProcessOwnsSocket(procDir, socketInodes))
{
return pid;
}
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
return null;
}
private static bool ProcessReferencesExecutable(string procDir, string executableName)
{
try
{
var cmdline = File.ReadAllText(Path.Combine(procDir, "cmdline"));
return cmdline.Contains(executableName, StringComparison.OrdinalIgnoreCase);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private static bool ProcessOwnsSocket(string procDir, HashSet<string> socketInodes)
{
var fdDir = Path.Combine(procDir, "fd");
if (!Directory.Exists(fdDir))
{
return false;
}
try
{
foreach (var fd in Directory.EnumerateFiles(fdDir))
{
string? linkTarget;
try
{
linkTarget = new FileInfo(fd).LinkTarget;
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
if (linkTarget is not null &&
TryParseSocketInode(linkTarget, out var inode) &&
socketInodes.Contains(inode))
{
return true;
}
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
return false;
}
private static IEnumerable<string> GetTcpListenerSocketInodes(string procNetPath, int port)
{
if (!File.Exists(procNetPath))
{
yield break;
}
foreach (var line in File.ReadLines(procNetPath).Skip(1))
{
var columns = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (columns.Length < 10 ||
!IsListeningSocket(columns[3]) ||
!TryParsePort(columns[1], out var socketPort) ||
socketPort != port)
{
continue;
}
yield return columns[9];
}
}
private static bool TryParsePort(string localAddress, out int port)
{
port = default;
var separatorIndex = localAddress.LastIndexOf(':');
if (separatorIndex < 0 ||
separatorIndex == localAddress.Length - 1 ||
!int.TryParse(localAddress[(separatorIndex + 1)..], NumberStyles.HexNumber, null, out port))
{
return false;
}
return true;
}
private static bool IsListeningSocket(string state) => state.Equals("0A", StringComparison.OrdinalIgnoreCase);
private static bool TryParseSocketInode(string linkTarget, out string inode)
{
inode = string.Empty;
const string socketPrefix = "socket:[";
if (!linkTarget.StartsWith(socketPrefix, StringComparison.Ordinal) || !linkTarget.EndsWith(']'))
{
return false;
}
inode = linkTarget[socketPrefix.Length..^1];
return inode.Length > 0;
}
}
@@ -52,6 +52,19 @@ public sealed class GuildWarsProcessFinder(
return this.FindProcesses(configuration).FirstOrDefault();
}
/// <summary>
/// Terminates the Wine-hosted Guild Wars process tree.
/// The process was already validated as a Gw.exe Wine process during discovery,
/// so we kill it directly. On Linux the process exposed by the context is the
/// Wine loader hosting Gw.exe; its <see cref="Process.MainModule"/> therefore
/// points at the Wine loader rather than Gw.exe, which is why platform-agnostic
/// name checks cannot be used here.
/// </summary>
public void KillProcess(GuildWarsApplicationLaunchContext guildWarsApplicationLaunchContext)
{
guildWarsApplicationLaunchContext.GuildWarsProcess.Kill(entireProcessTree: true);
}
public IEnumerable<GuildWarsApplicationLaunchContext?> FindProcesses(
params LaunchConfigurationWithCredentials[] configurations
)
@@ -0,0 +1,76 @@
using System.Diagnostics;
using Daybreak.Shared.Services.ApplicationLauncher;
namespace Daybreak.Linux.Services.ApplicationLauncher;
/// <summary>
/// Linux-specific Steam service.
/// Detects the running Steam client primarily via the Steam client's pid file
/// (~/.steam/steam.pid), which holds the live Steam PID. Falls back to matching the Steam
/// client processes by name.
/// </summary>
public sealed class SteamService : ISteamService
{
private static readonly string[] SteamPidFileRelativePaths =
[
".steam/steam.pid",
".steam/steam/steam.pid"
];
private static readonly string[] SteamProcessNames = ["steam", "steamwebhelper"];
public bool IsSteamLoginSupported => false;
public bool IsSteamRunning()
{
return IsSteamRunningViaPidFile() ?? IsSteamRunningViaProcessName();
}
private static bool? IsSteamRunningViaPidFile()
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(home))
{
return default;
}
foreach (var relativePath in SteamPidFileRelativePaths)
{
var pidFilePath = Path.Combine(home, relativePath);
if (!File.Exists(pidFilePath))
{
continue;
}
try
{
if (!int.TryParse(File.ReadAllText(pidFilePath).Trim(), out var pid) || pid <= 0)
{
continue;
}
try
{
using var _ = Process.GetProcessById(pid);
return true;
}
catch (ArgumentException)
{
// No process with the recorded PID is running, the pid file is stale.
return false;
}
}
catch
{
// Unreadable pid file, fall through to the next candidate / process-name check.
}
}
return default;
}
private static bool IsSteamRunningViaProcessName()
{
return SteamProcessNames.Any(name => Process.GetProcessesByName(name).Length > 0);
}
}
@@ -18,6 +18,7 @@ public class DaybreakInjector(
) : IDaybreakInjector
{
private const string InjectorRelativePath = "Injector/Daybreak.Injector.exe";
private const string GuildWarsExecutableName = "Gw.exe";
private readonly ILogger<DaybreakInjector> logger = logger;
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
@@ -57,8 +58,9 @@ public class DaybreakInjector(
return InjectorResponses.InjectResult.InvalidInjector;
}
// Convert Linux PID to Wine PID for the injector
var winePid = this.winePidMapper.LinuxPidToWinePid(processId);
// Resolve the Wine PID by matching the process's full image path (handles
// multiple concurrent Guild Wars instances).
var winePid = await this.ResolveWinePid(processId, cancellationToken);
if (winePid is null)
{
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
@@ -99,8 +101,9 @@ public class DaybreakInjector(
return InjectorResponses.InjectResult.InvalidInjector;
}
// Convert Linux PID to Wine PID for the injector
var winePid = this.winePidMapper.LinuxPidToWinePid(processId);
// Resolve the Wine PID by matching the process's full image path (handles
// multiple concurrent Guild Wars instances).
var winePid = await this.ResolveWinePid(processId, cancellationToken);
if (winePid is null)
{
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
@@ -193,8 +196,9 @@ public class DaybreakInjector(
// Convert Wine PID to Linux PID so the rest of Daybreak can use Process.GetProcessById()
if (processId > 0)
{
var executableName = Path.GetFileName(executablePath);
var linuxPid = this.winePidMapper.WinePidToLinuxPid(processId, executableName);
// Match on the full executable path so concurrent Guild Wars instances
// (different install directories) are not confused for one another.
var linuxPid = this.winePidMapper.WinePidToLinuxPid(processId, executablePath);
if (linuxPid is not null)
{
scopedLogger.LogInformation(
@@ -289,4 +293,88 @@ public class DaybreakInjector(
return defaultExitCode;
}
/// <summary>
/// Resolves the Wine PID for a Linux Guild Wars process by asking the injector (which runs
/// inside Wine) to match on the process's full image path. This disambiguates multiple
/// concurrent Guild Wars instances, which a name-only lookup cannot.
/// </summary>
private async Task<int?> ResolveWinePid(int linuxPid, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
var wineExecutablePath = TryGetWineExecutablePath(linuxPid);
if (wineExecutablePath is null)
{
scopedLogger.LogError("Could not read Wine executable path for Linux PID {ProcessId}", linuxPid);
return null;
}
var (output, _, _) = await this.LaunchInjector(
["resolve", $"\"{wineExecutablePath}\""],
cancellationToken,
completionChecker: (line, _) => line.StartsWith("ExitCode: ")
);
var winePid = ParseLabeledIntFromOutput(output, "ProcessId: ");
if (winePid is null)
{
scopedLogger.LogWarning(
"Injector could not resolve Wine PID for {ExecutablePath} (Linux PID {ProcessId})",
wineExecutablePath,
linuxPid
);
return null;
}
scopedLogger.LogDebug(
"Resolved Linux PID {ProcessId} -> Wine PID {WinePid} via {ExecutablePath}",
linuxPid,
winePid.Value,
wineExecutablePath
);
return winePid;
}
/// <summary>
/// Reads the Wine-form executable path (e.g. "Z:\home\...\Gw.exe") from the process's
/// command line, used to uniquely identify it among concurrent Guild Wars instances.
/// </summary>
private static string? TryGetWineExecutablePath(int linuxPid)
{
try
{
var cmdline = File.ReadAllText($"/proc/{linuxPid}/cmdline");
var segments = cmdline.Split('\0', StringSplitOptions.RemoveEmptyEntries);
return segments.FirstOrDefault(s =>
s.EndsWith(GuildWarsExecutableName, StringComparison.OrdinalIgnoreCase));
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
private static int? ParseLabeledIntFromOutput(string? output, string label)
{
if (output is null)
{
return null;
}
foreach (var line in output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith(label) &&
int.TryParse(line[label.Length..], out var value))
{
return value;
}
}
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More