mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-09-16 13:29:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4720c5e1 | ||
|
|
b57d2ad5d3 | ||
|
|
5faeae64d3 | ||
|
|
fdcf238d68 | ||
|
|
d9f7d05672 | ||
|
|
c61ee2ecd7 | ||
|
|
82512f9461 | ||
|
|
2dc13b7184 | ||
|
|
da691e3310 | ||
|
|
5e020f51d9 | ||
|
|
1f63e59b8a | ||
|
|
dcb9c29505 | ||
|
|
db83e5a954 | ||
|
|
c7c5506ee0 | ||
|
|
2988b6b38d | ||
|
|
debef9a36f | ||
|
|
839bc57b48 | ||
|
|
175c16bd00 | ||
|
|
ad356f431e | ||
|
|
ebe368e61e | ||
|
|
d800a630b5 | ||
|
|
239f91a65f | ||
|
|
aff7c24b6a | ||
|
|
5bf0af61ba | ||
|
|
03ed31e85b | ||
|
|
b21a4482bd | ||
|
|
2ad8ae79ce | ||
|
|
a4be747dd5 | ||
|
|
a6cd5d6f37 | ||
|
|
0e80fb7d86 | ||
|
|
0793f181bb | ||
|
|
a9ee0424f7 | ||
|
|
49f45b6b76 | ||
|
|
4216a03438 | ||
|
|
a9a8346a5b | ||
|
|
70a95b5e08 | ||
|
|
1e8c2e44e2 | ||
|
|
d553aadead | ||
|
|
fabb98f74e | ||
|
|
0a0ec5cefc | ||
|
|
0d31587855 | ||
|
|
f4a08e36dd | ||
|
|
c71dd6ed9c | ||
|
|
a61cff8dfd | ||
|
|
9d7f145c25 | ||
|
|
dc340189d6 | ||
|
|
5edb5ff780 | ||
|
|
890bb26c01 | ||
|
|
6c2574c722 | ||
|
|
8760f743ab | ||
|
|
97da183bd3 | ||
|
|
65439f25dc | ||
|
|
a755319fb1 | ||
|
|
7e72cb3d3f | ||
|
|
ca154827b2 | ||
|
|
089a10a056 | ||
|
|
65767d6ab5 | ||
|
|
5828263717 |
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
+113
@@ -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).
|
||||
@@ -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")]
|
||||
@@ -192,7 +197,8 @@ public class EntryPoint
|
||||
{
|
||||
if (module.ModuleName?.Equals("gwca.dll", StringComparison.OrdinalIgnoreCase) is true)
|
||||
{
|
||||
NativeLibrary.Load(module.FileName);
|
||||
var handle = NativeLibrary.Load(module.FileName);
|
||||
RegisterGwcaResolver(handle, module.FileName, logger);
|
||||
logger.LogDebug($"Reused already-loaded gwca.dll from {module.FileName}");
|
||||
return;
|
||||
}
|
||||
@@ -213,7 +219,8 @@ public class EntryPoint
|
||||
var gwcaPath = Path.Combine(daybreakApiDir, "gwca.dll");
|
||||
if (File.Exists(gwcaPath))
|
||||
{
|
||||
NativeLibrary.Load(gwcaPath);
|
||||
var handle = NativeLibrary.Load(gwcaPath);
|
||||
RegisterGwcaResolver(handle, gwcaPath, logger);
|
||||
logger.LogDebug($"Preloaded gwca.dll from {gwcaPath}");
|
||||
}
|
||||
else
|
||||
@@ -221,4 +228,31 @@ public class EntryPoint
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+1356
-334
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 <= 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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>();
|
||||
@@ -568,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)
|
||||
|
||||
@@ -66,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;
|
||||
|
||||
@@ -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 <value></c>,
|
||||
/// <c>-password <value></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,86 +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)
|
||||
{
|
||||
Span<int> rValues = stackalloc int[Vector<int>.Count];
|
||||
Span<int> gValues = stackalloc int[Vector<int>.Count];
|
||||
Span<int> bValues = stackalloc int[Vector<int>.Count];
|
||||
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;
|
||||
|
||||
// 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)
|
||||
{
|
||||
|
||||
@@ -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() => [];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]" : "";
|
||||
@@ -548,6 +554,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
else
|
||||
sb.AppendLine($"{innerPad} {SanitizeIdentifier(member.Name)},{comment}");
|
||||
}
|
||||
|
||||
sb.AppendLine($"{innerPad}}}");
|
||||
}
|
||||
else
|
||||
@@ -663,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))
|
||||
{
|
||||
@@ -690,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))
|
||||
@@ -712,7 +719,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
typeMap[simpleStructKey] = fqCsType;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Structs go into GuildWars namespace (flat), not nested GWCA classes
|
||||
typeMap[mapKey] = fqCsType;
|
||||
}
|
||||
@@ -742,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);
|
||||
@@ -769,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);
|
||||
@@ -785,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}");
|
||||
@@ -812,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>
|
||||
@@ -904,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))
|
||||
@@ -912,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),
|
||||
@@ -926,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)
|
||||
{
|
||||
@@ -956,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)
|
||||
@@ -974,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<T> - 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>
|
||||
@@ -985,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))
|
||||
{
|
||||
@@ -1002,7 +1026,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach (var child in node.Children.Values)
|
||||
{
|
||||
CollectInlineArrayTypes(child, typeMap, inlineArrayTypes);
|
||||
@@ -1016,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>");
|
||||
@@ -1049,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;
|
||||
}
|
||||
@@ -1074,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)
|
||||
{
|
||||
@@ -1106,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)
|
||||
{
|
||||
@@ -1123,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("*"))
|
||||
@@ -1152,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)
|
||||
{
|
||||
@@ -1241,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>";
|
||||
}
|
||||
|
||||
@@ -1250,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)
|
||||
@@ -1262,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")
|
||||
@@ -1277,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("::"))
|
||||
{
|
||||
@@ -1333,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",
|
||||
@@ -1396,6 +1433,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
result.Append(part.Substring(1));
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
@@ -1625,7 +1663,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
_ => "nint",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a struct name to its C# type, handling name collisions with enums.
|
||||
/// </summary>
|
||||
@@ -1640,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.
|
||||
@@ -1656,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<Agent*>).
|
||||
/// In unsafe C#, pointer types can be used as generic type arguments for unmanaged structs.
|
||||
@@ -1665,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
|
||||
{
|
||||
@@ -1683,7 +1721,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
_ => "nint", // unmapped pointer -> nint
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps C/C++ primitive type names to C# primitive types.
|
||||
/// </summary>
|
||||
@@ -1866,6 +1904,7 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
child = new ConstantsNode(childName);
|
||||
this.Children[childName] = child;
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -1919,4 +1958,4 @@ public sealed class GenerateGWCABindings : IIncrementalGenerator
|
||||
public string SourceType { get; set; } = ""; // e.g., "Array"
|
||||
public string? TemplateArg { get; set; } // e.g., "Buff"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +461,7 @@ internal sealed class MsvcDemangler
|
||||
{
|
||||
pos++; // skip Z - this terminates the function pointer, not the outer param list
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
Regular → Executable
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +348,7 @@ public sealed class KeyboardHookService : IHostedService, IKeyboardHookService,
|
||||
result = window;
|
||||
break;
|
||||
}
|
||||
|
||||
current = NativeMethods.G_list_next(current);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Daybreak.Configuration.Options;
|
||||
using Daybreak.Linux.Utils;
|
||||
using Daybreak.Shared.Models;
|
||||
using Daybreak.Shared.Services.Options;
|
||||
using Daybreak.Shared.Services.Screens;
|
||||
@@ -9,26 +10,49 @@ using Photino.NET;
|
||||
using System.Core.Extensions;
|
||||
using System.Drawing;
|
||||
using System.Extensions;
|
||||
using System.Extensions.Core;
|
||||
|
||||
namespace Daybreak.Linux.Services.Screens;
|
||||
|
||||
// TODO: Implement proper Linux screen management using X11/Wayland APIs
|
||||
/// <summary>
|
||||
/// Linux screen management backed by GDK monitor enumeration. Reports real
|
||||
/// monitor geometry and tracks each monitor's integer scale factor so the window
|
||||
/// spawns sized to (and is restored on) the correct monitor. Scale is persisted
|
||||
/// as DPI (scale * 96) in <see cref="ScreenManagerOptions"/>, mirroring the
|
||||
/// Windows implementation, so a window saved on one monitor is rescaled when
|
||||
/// restored on a monitor with a different scale.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// GDK reports an integer scale factor; under X11/XWayland (Daybreak forces the
|
||||
/// X11 backend) this reflects GDK_SCALE / Xft.dpi-derived scaling. True
|
||||
/// fractional per-monitor scaling would require the Wayland backend.
|
||||
/// </remarks>
|
||||
internal sealed class ScreenManager(
|
||||
PhotinoWindow photinoWindow,
|
||||
IOptionsProvider optionsProvider,
|
||||
IOptionsMonitor<ScreenManagerOptions> liveUpdateableOptions,
|
||||
ILogger<ScreenManager> logger) : IScreenManager, IHostedService
|
||||
{
|
||||
private const int DefaultDpi = 96;
|
||||
|
||||
private readonly PhotinoWindow photinoWindow = photinoWindow.ThrowIfNull();
|
||||
private readonly IOptionsProvider optionsProvider = optionsProvider.ThrowIfNull();
|
||||
private readonly IOptionsMonitor<ScreenManagerOptions> liveUpdateableOptions = liveUpdateableOptions.ThrowIfNull();
|
||||
private readonly ILogger<ScreenManager> logger = logger.ThrowIfNull();
|
||||
|
||||
// TODO: Implement proper screen enumeration for Linux
|
||||
public IEnumerable<Screen> Screens => GetDummyScreens();
|
||||
private readonly record struct MonitorInfo(Screen Screen, bool IsPrimary);
|
||||
|
||||
// Enumerated once (lazily) and cached; provides real monitor geometry for
|
||||
// window placement. Monitor scale comes from DisplayScale (not GDK), because
|
||||
// under the forced X11 backend GDK always reports a scale of 1.
|
||||
private IReadOnlyList<MonitorInfo>? cachedMonitors;
|
||||
private double? cachedScale;
|
||||
|
||||
public IEnumerable<Screen> Screens => this.GetMonitors().Select(static m => m.Screen);
|
||||
|
||||
Task IHostedService.StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this.ApplyDisplayScale();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -38,6 +62,31 @@ internal sealed class ScreenManager(
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zooms the Photino webview to match the desktop UI scale so the interface is
|
||||
/// rendered at the correct physical size on HiDPI monitors. Photino's built-in
|
||||
/// scale detection is defeated by the forced X11 backend, so this is applied
|
||||
/// explicitly. Must run on the UI thread, hence the <see cref="PhotinoWindow.Invoke"/>.
|
||||
/// </summary>
|
||||
private void ApplyDisplayScale()
|
||||
{
|
||||
var scale = this.GetEffectiveScale();
|
||||
var zoom = (int)Math.Round(scale * 100.0);
|
||||
if (zoom == 100)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.LogDebug("Applying webview zoom {Zoom}% for display scale {Scale}", zoom, scale);
|
||||
this.photinoWindow.Invoke(() => this.photinoWindow.Zoom = zoom);
|
||||
}
|
||||
|
||||
private double GetEffectiveScale()
|
||||
{
|
||||
this.cachedScale ??= DisplayScale.GetEffectiveScale(this.logger);
|
||||
return this.cachedScale.Value;
|
||||
}
|
||||
|
||||
public void MoveWindowToSavedPosition()
|
||||
{
|
||||
var savedPosition = this.GetSavedPosition();
|
||||
@@ -55,11 +104,15 @@ internal sealed class ScreenManager(
|
||||
this.photinoWindow.Width,
|
||||
this.photinoWindow.Height);
|
||||
|
||||
var dpi = this.GetDpiForPosition(position.Left, position.Top);
|
||||
|
||||
var options = this.liveUpdateableOptions.CurrentValue;
|
||||
options.X = position.Left;
|
||||
options.Y = position.Top;
|
||||
options.Width = position.Width;
|
||||
options.Height = position.Height;
|
||||
options.DpiX = dpi;
|
||||
options.DpiY = dpi;
|
||||
this.optionsProvider.SaveOption(options);
|
||||
}
|
||||
|
||||
@@ -70,6 +123,8 @@ internal sealed class ScreenManager(
|
||||
options.Y = 0;
|
||||
options.Width = 0;
|
||||
options.Height = 0;
|
||||
options.DpiX = DefaultDpi;
|
||||
options.DpiY = DefaultDpi;
|
||||
this.optionsProvider.SaveOption(options);
|
||||
}
|
||||
|
||||
@@ -82,35 +137,169 @@ internal sealed class ScreenManager(
|
||||
|
||||
public Rectangle GetSavedPosition()
|
||||
{
|
||||
var options = this.liveUpdateableOptions.CurrentValue;
|
||||
var savedPosition = new Rectangle(
|
||||
(int)this.liveUpdateableOptions.CurrentValue.X,
|
||||
(int)this.liveUpdateableOptions.CurrentValue.Y,
|
||||
(int)this.liveUpdateableOptions.CurrentValue.Width,
|
||||
(int)this.liveUpdateableOptions.CurrentValue.Height);
|
||||
(int)options.X,
|
||||
(int)options.Y,
|
||||
(int)options.Width,
|
||||
(int)options.Height);
|
||||
|
||||
if (savedPosition.Width is 0 || savedPosition.Height is 0)
|
||||
{
|
||||
var firstScreen = this.Screens.FirstOrDefault();
|
||||
if (firstScreen.Size.IsEmpty)
|
||||
{
|
||||
// Return a reasonable default instead of throwing
|
||||
return new Rectangle(0, 100, 1000, 900);
|
||||
}
|
||||
|
||||
return new Rectangle(
|
||||
firstScreen.Size.X + (firstScreen.Size.Width / 4),
|
||||
firstScreen.Size.Y + (firstScreen.Size.Height / 4),
|
||||
firstScreen.Size.Width / 2,
|
||||
firstScreen.Size.Height / 2);
|
||||
return this.GetDefaultPosition();
|
||||
}
|
||||
|
||||
return savedPosition;
|
||||
// Rescale the saved geometry if the monitor under it now has a different
|
||||
// scale than when it was saved (e.g. monitor swapped, scale changed).
|
||||
var savedDpiX = options.DpiX > 0 ? options.DpiX : DefaultDpi;
|
||||
var savedDpiY = options.DpiY > 0 ? options.DpiY : DefaultDpi;
|
||||
var currentDpi = this.GetDpiForPosition(savedPosition.Left, savedPosition.Top);
|
||||
|
||||
if (savedDpiX != currentDpi || savedDpiY != currentDpi)
|
||||
{
|
||||
var scaleX = (double)currentDpi / savedDpiX;
|
||||
var scaleY = (double)currentDpi / savedDpiY;
|
||||
savedPosition = new Rectangle(
|
||||
(int)(savedPosition.X * scaleX),
|
||||
(int)(savedPosition.Y * scaleY),
|
||||
(int)(savedPosition.Width * scaleX),
|
||||
(int)(savedPosition.Height * scaleY));
|
||||
}
|
||||
|
||||
return this.EnsurePositionIsOnScreen(savedPosition);
|
||||
}
|
||||
|
||||
private static List<Screen> GetDummyScreens()
|
||||
private Rectangle GetDefaultPosition()
|
||||
{
|
||||
// TODO: Use X11/Wayland APIs to enumerate actual screens
|
||||
// For now, return a single dummy screen with reasonable defaults
|
||||
return [new Screen(0, new Rectangle(0, 0, 1920, 1080))];
|
||||
var monitor = this.GetPrimaryMonitor();
|
||||
if (monitor is not { } m || m.Screen.Size.IsEmpty)
|
||||
{
|
||||
// Reasonable fallback when GDK could not enumerate any monitor.
|
||||
return new Rectangle(0, 100, 1000, 900);
|
||||
}
|
||||
|
||||
var bounds = m.Screen.Size;
|
||||
return new Rectangle(
|
||||
bounds.X + (bounds.Width / 4),
|
||||
bounds.Y + (bounds.Height / 4),
|
||||
bounds.Width / 2,
|
||||
bounds.Height / 2);
|
||||
}
|
||||
|
||||
private Rectangle EnsurePositionIsOnScreen(Rectangle position)
|
||||
{
|
||||
var monitors = this.GetMonitors();
|
||||
if (monitors.Count == 0)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
|
||||
var centerX = position.Left + (position.Width / 2);
|
||||
var centerY = position.Top + (position.Height / 2);
|
||||
var isOnScreen = monitors.Any(m =>
|
||||
centerX >= m.Screen.Size.Left && centerX <= m.Screen.Size.Right &&
|
||||
centerY >= m.Screen.Size.Top && centerY <= m.Screen.Size.Bottom);
|
||||
|
||||
if (isOnScreen)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
|
||||
// Off-screen (e.g. a disconnected monitor): re-home onto the primary.
|
||||
var primary = this.GetPrimaryMonitor() ?? monitors[0];
|
||||
var bounds = primary.Screen.Size;
|
||||
return new Rectangle(
|
||||
bounds.X + (bounds.Width / 4),
|
||||
bounds.Y + (bounds.Height / 4),
|
||||
Math.Min(position.Width, bounds.Width / 2),
|
||||
Math.Min(position.Height, bounds.Height / 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective DPI (scale * 96) used for the saved-position rescale.
|
||||
/// A single effective scale is used for the whole desktop: webview zoom is one
|
||||
/// global value, and under the forced X11 backend GDK cannot report reliable
|
||||
/// per-monitor scales anyway.
|
||||
/// </summary>
|
||||
private int GetDpiForPosition(int _, int __)
|
||||
{
|
||||
return (int)Math.Round(this.GetEffectiveScale() * DefaultDpi);
|
||||
}
|
||||
|
||||
private MonitorInfo? GetPrimaryMonitor()
|
||||
{
|
||||
var monitors = this.GetMonitors();
|
||||
if (monitors.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var m in monitors)
|
||||
{
|
||||
if (m.IsPrimary)
|
||||
{
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
return monitors[0];
|
||||
}
|
||||
|
||||
private IReadOnlyList<MonitorInfo> GetMonitors()
|
||||
{
|
||||
if (this.cachedMonitors is { Count: > 0 })
|
||||
{
|
||||
return this.cachedMonitors;
|
||||
}
|
||||
|
||||
var monitors = EnumerateMonitors(this.logger);
|
||||
if (monitors.Count > 0)
|
||||
{
|
||||
// Only cache a successful enumeration so an early (pre-GTK) call can retry.
|
||||
this.cachedMonitors = monitors;
|
||||
}
|
||||
|
||||
return monitors;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MonitorInfo> EnumerateMonitors(ILogger<ScreenManager> logger)
|
||||
{
|
||||
var scopedLogger = logger.CreateScopedLogger();
|
||||
|
||||
// Ensure a default display exists in case monitors are queried before
|
||||
// Photino created its first window. gtk_init_check is idempotent.
|
||||
NativeMethods.gtk_init_check(nint.Zero, nint.Zero);
|
||||
|
||||
var display = NativeMethods.gdk_display_get_default();
|
||||
if (display == nint.Zero)
|
||||
{
|
||||
scopedLogger.LogWarning("No default GdkDisplay available; cannot enumerate monitors");
|
||||
return [];
|
||||
}
|
||||
|
||||
var count = NativeMethods.gdk_display_get_n_monitors(display);
|
||||
if (count <= 0)
|
||||
{
|
||||
scopedLogger.LogWarning("GDK reported {Count} monitors", count);
|
||||
return [];
|
||||
}
|
||||
|
||||
var primary = NativeMethods.gdk_display_get_primary_monitor(display);
|
||||
var result = new List<MonitorInfo>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var monitor = NativeMethods.gdk_display_get_monitor(display, i);
|
||||
if (monitor == nint.Zero)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
NativeMethods.gdk_monitor_get_geometry(monitor, out var geometry);
|
||||
var screen = new Screen(i, new Rectangle(geometry.X, geometry.Y, geometry.Width, geometry.Height));
|
||||
result.Add(new MonitorInfo(screen, monitor == primary && primary != nint.Zero));
|
||||
}
|
||||
|
||||
scopedLogger.LogDebug("Enumerated {Count} monitor(s) via GDK", result.Count);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Daybreak.Shared.Services.Shell;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Daybreak.Linux.Services.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Linux implementation of <see cref="IShellExecutor"/>.
|
||||
/// Both URLs and file-system paths are delegated to <c>xdg-open</c>, which routes to the
|
||||
/// user's default browser or file manager as appropriate.
|
||||
/// </summary>
|
||||
internal sealed class ShellExecutor(
|
||||
ILogger<ShellExecutor> logger) : IShellExecutor
|
||||
{
|
||||
private const string XdgOpenExecutable = "xdg-open";
|
||||
|
||||
private readonly ILogger<ShellExecutor> logger = logger;
|
||||
|
||||
public void OpenUrl(string url) => this.Open(url);
|
||||
|
||||
public void OpenPath(string path) => this.Open(path);
|
||||
|
||||
private void Open(string target)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = XdgOpenExecutable,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
startInfo.ArgumentList.Add(target);
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.LogError(ex, "Failed to open target {target}", target);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,7 @@ internal sealed class WindowManipulationService(ILogger<WindowManipulationServic
|
||||
result = window;
|
||||
break;
|
||||
}
|
||||
|
||||
current = NativeMethods.G_list_next(current);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
namespace Daybreak.Linux.Services.Wine;
|
||||
|
||||
/// <summary>
|
||||
/// Stateless translator between Wine-internal PIDs and Linux system PIDs.
|
||||
/// Uses /proc scanning and winedbg to resolve mappings on demand.
|
||||
/// Translates Wine-internal PIDs to Linux system PIDs by scanning /proc.
|
||||
/// The reverse direction (Linux → Wine PID) is handled by the injector, which runs
|
||||
/// inside Wine and can disambiguate concurrent instances by full image path.
|
||||
/// </summary>
|
||||
public interface IWinePidMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a Wine-internal PID to a Linux system PID.
|
||||
/// Scans /proc for a process whose cmdline contains the executable name
|
||||
/// and whose environment references our Wine prefix.
|
||||
/// Converts a Wine-internal PID to a Linux system PID by scanning /proc.
|
||||
/// When <paramref name="executable"/> is a full path it is matched against each
|
||||
/// candidate's full executable path, which uniquely identifies the process even
|
||||
/// when multiple Guild Wars instances (different install directories) run at once.
|
||||
/// When only a file name is supplied, the first matching process is returned.
|
||||
/// </summary>
|
||||
/// <param name="winePid">The Wine-internal process ID (unused for lookup, kept for logging).</param>
|
||||
/// <param name="executableName">The executable name (e.g. "Gw.exe") to search for in /proc.</param>
|
||||
/// <param name="executable">The executable to match: a full Linux path (preferred, for
|
||||
/// unambiguous matching) or a bare file name (e.g. "Gw.exe") as a best-effort fallback.</param>
|
||||
/// <returns>The Linux PID, or null if not found.</returns>
|
||||
int? WinePidToLinuxPid(int winePid, string executableName);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Linux system PID back to a Wine-internal PID.
|
||||
/// Reads the process's cmdline to determine the executable, then queries
|
||||
/// winedbg to find the corresponding Wine PID.
|
||||
/// </summary>
|
||||
/// <param name="linuxPid">The Linux system PID.</param>
|
||||
/// <returns>The Wine PID, or null if not found.</returns>
|
||||
int? LinuxPidToWinePid(int linuxPid);
|
||||
int? WinePidToLinuxPid(int winePid, string executable);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ public interface IWinePrefixManager : IModService
|
||||
/// </summary>
|
||||
string GetWinePrefixPath();
|
||||
|
||||
/// <summary>
|
||||
/// Configures the Windows computer name from the Linux host name.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if successful, false otherwise.</returns>
|
||||
Task<bool> ConfigureComputerName(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Launches a Windows executable through Wine with the managed prefix.
|
||||
/// Uses event-based stdout/stderr reading to avoid pipe deadlocks.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Diagnostics;
|
||||
using Daybreak.Shared.Utils;
|
||||
|
||||
namespace Daybreak.Linux.Services.Wine;
|
||||
|
||||
/// <summary>
|
||||
/// Debug-build support for capturing Wine diagnostics, most importantly the
|
||||
/// <c>winedbg --auto</c> backtrace produced when Guild Wars crashes.
|
||||
///
|
||||
/// Guild Wars is launched by Daybreak.Injector.exe, so it is a *grandchild* of the
|
||||
/// Wine process Daybreak starts and inherits that process's stderr. Daybreak stops
|
||||
/// reading that pipe as soon as the injector prints its result, which means a later
|
||||
/// crash backtrace is written into a pipe nobody drains: the output is lost, and if
|
||||
/// the pipe buffer fills, the game blocks inside a stderr write.
|
||||
///
|
||||
/// Redirecting stderr to a file inside the shell makes the descriptor outlive
|
||||
/// Daybreak's interest in the process, so every descendant (injector, Gw.exe,
|
||||
/// winedbg) appends to a durable log instead.
|
||||
/// </summary>
|
||||
internal static class WineDebugLog
|
||||
{
|
||||
private const string LogFileName = "wine-debug.log";
|
||||
private const string EnableVariable = "DAYBREAK_WINE_DEBUG";
|
||||
private const string ChannelsVariable = "DAYBREAK_WINE_DEBUG_CHANNELS";
|
||||
|
||||
/// <summary>
|
||||
/// Enabled by default for Debug builds. Either build configuration can opt in or
|
||||
/// out explicitly with <c>DAYBREAK_WINE_DEBUG=1</c> / <c>=0</c>.
|
||||
/// </summary>
|
||||
public static bool IsEnabled =>
|
||||
Environment.GetEnvironmentVariable(EnableVariable) switch
|
||||
{
|
||||
"1" or "true" or "TRUE" => true,
|
||||
"0" or "false" or "FALSE" => false,
|
||||
#if DEBUG
|
||||
_ => true,
|
||||
#else
|
||||
_ => false,
|
||||
#endif
|
||||
};
|
||||
|
||||
public static string LogPath => PathUtils.GetAbsolutePathFromRoot(LogFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Extra WINEDEBUG channels. Left unset by default: Wine's default <c>err</c> class
|
||||
/// already reports unhandled exceptions and drives winedbg, while trace channels such
|
||||
/// as <c>+seh</c> or <c>+relay</c> slow the game to a crawl. Set
|
||||
/// <c>DAYBREAK_WINE_DEBUG_CHANNELS=+seh</c> when a specific investigation needs them.
|
||||
/// </summary>
|
||||
public static string? Channels =>
|
||||
Environment.GetEnvironmentVariable(ChannelsVariable) is { Length: > 0 } channels
|
||||
? channels
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites <paramref name="startInfo"/> to run the original command under
|
||||
/// <c>/bin/sh</c> with stderr appended to <see cref="LogPath"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The command is split here and handed to the shell as separate positional parameters,
|
||||
/// then executed with <c>exec "$@"</c>. The shell must never re-parse the command as source:
|
||||
/// doing so applies a second round of expansion to values Daybreak has already quoted, so a
|
||||
/// launch argument containing <c>$</c>, a backtick or a glob would be rewritten before the
|
||||
/// game ever saw it. Splitting with <see cref="CommandLineUtils.SplitCommandLine"/> keeps the
|
||||
/// argument vector identical to the one .NET builds when the log is disabled.
|
||||
/// </remarks>
|
||||
public static void Apply(ProcessStartInfo startInfo)
|
||||
{
|
||||
var logPath = LogPath;
|
||||
var arguments = CommandLineUtils.SplitCommandLine(startInfo.Arguments);
|
||||
var fileName = startInfo.FileName;
|
||||
|
||||
startInfo.FileName = "/bin/sh";
|
||||
startInfo.Arguments = string.Empty;
|
||||
startInfo.ArgumentList.Add("-c");
|
||||
startInfo.ArgumentList.Add("exec \"$@\" 2>>\"$0\"");
|
||||
startInfo.ArgumentList.Add(logPath);
|
||||
startInfo.ArgumentList.Add(fileName);
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
if (Channels is { } channels)
|
||||
{
|
||||
startInfo.Environment["WINEDEBUG"] = channels;
|
||||
}
|
||||
}
|
||||
|
||||
public static void WriteSessionHeader(string description)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.AppendAllText(
|
||||
LogPath,
|
||||
$"{Environment.NewLine}===== {DateTime.Now:yyyy-MM-dd HH:mm:ss} {description} ====={Environment.NewLine}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Diagnostics only - never fail a launch because the log is unwritable.
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user