2.0.0 (#92)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s

2.0.0 Changes:

- Reworked shell finder UI with compact or list view with profile tags showing with the listing, allowing moderators to broadcast the syncshell as well to have it be used more.
- Reworked user list in syncshell admin screen to have filter visible and moved away from table to its own thing, allowing to copy uid/note/alias when clicking on the name.
- Reworked download bars and download box to make it look more modern, removed the jitter around, so it shouldn't vibrate around much.
- Chat has been added to the top menu, working in Zone or in Syncshells to be used there.
- Paired system has been revamped to make pausing and unpausing faster, and loading people should be faster as well.
- Moved to the internal object table to have faster load times for users; people should load in faster
- Compactor is running on a multi-threaded level instead of single-threaded; this should increase the speed of compacting files
- Nameplate Service has been reworked so it wouldn't use the nameplate handler anymore.
- Files can be resized when downloading to reduce load on users if they aren't compressed. (can be toggled to resize all).
- Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many syncshells in your list.
- Lightfinder plates have been moved away from using Nameplates, but will use an overlay.
- Main UI has been changed a bit with a gradient, and on hover will glow up now.
- Reworked Profile UI for Syncshell and Users to be more user-facing with more customizable items.
- Reworked Settings UI to look more modern.
- Performance should be better due to new systems that would dispose of the collections and better caching of items.

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Minmoose <KennethBohr@outlook.com>
Reviewed-on: #92
This commit was merged in pull request #92.
This commit is contained in:
2025-12-21 17:19:34 +00:00
parent 906f401940
commit 835a0a637d
191 changed files with 32636 additions and 8841 deletions

View File

@@ -1,4 +1,8 @@
using System.Security.Cryptography;
using Blake3;
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace LightlessSync.Utils;
@@ -9,18 +13,92 @@ public static class Crypto
private const int _bufferSize = 65536;
#pragma warning disable SYSLIB0021 // Type or member is obsolete
private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = [];
private static readonly Dictionary<string, string> _hashListSHA256 = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<(string, ushort), string> _hashListPlayersSHA256 = new();
private static readonly ConcurrentDictionary<string, string> _hashListSHA256 = new(StringComparer.Ordinal);
private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new();
public static string GetFileHash(this string filePath)
// BLAKE3 hash caches
private static readonly Dictionary<(string, ushort), string> _hashListPlayersBlake3 = [];
private static readonly Dictionary<string, string> _hashListBlake3 = new(StringComparer.Ordinal);
/// <summary>
/// Supports Blake3 or SHA1 for file transfers, no SHA256 supported on it
/// </summary>
public enum HashAlgo
{
using SHA1 sha1 = SHA1.Create();
using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return BitConverter.ToString(sha1.ComputeHash(stream)).Replace("-", "", StringComparison.Ordinal);
Blake3,
Sha1
}
public static async Task<string> GetFileHashAsync(string filePath, CancellationToken cancellationToken = default)
/// <summary>
/// Detects which algo is being used for the file
/// </summary>
/// <param name="hashHex">Hashed string</param>
/// <returns>HashAlgo</returns>
public static HashAlgo DetectAlgo(string hashHex)
{
if (hashHex.Length == 40)
return HashAlgo.Sha1;
return HashAlgo.Blake3;
}
#region File Hashing
/// <summary>
/// Compute file hash with given algorithm, supports BLAKE3 and Sha1 for file hashing
/// </summary>
/// <param name="filePath">Filepath for the hashing</param>
/// <param name="algo">BLAKE3 or Sha1</param>
/// <returns>Hashed file hash</returns>
/// <exception cref="ArgumentOutOfRangeException">Not a valid HashAlgo or Filepath</exception>
public static string ComputeFileHash(string filePath, HashAlgo algo)
{
return algo switch
{
HashAlgo.Blake3 => ComputeFileHashBlake3(filePath),
HashAlgo.Sha1 => ComputeFileHashSha1(filePath),
_ => throw new ArgumentOutOfRangeException(nameof(algo), algo, null)
};
}
/// <summary>
/// Compute file hash asynchronously with given algorithm, supports BLAKE3 and SHA1 for file hashing
/// </summary>
/// <param name="filePath">Filepath for the hashing</param>
/// <param name="algo">BLAKE3 or Sha1</param>
/// <returns>Hashed file hash</returns>
/// <exception cref="ArgumentOutOfRangeException">Not a valid HashAlgo or Filepath</exception>
public static async Task<string> ComputeFileHashAsync(string filePath, HashAlgo algo, CancellationToken cancellationToken = default)
{
return algo switch
{
HashAlgo.Blake3 => await ComputeFileHashBlake3Async(filePath, cancellationToken).ConfigureAwait(false),
HashAlgo.Sha1 => await ComputeFileHashSha1Async(filePath, cancellationToken).ConfigureAwait(false),
_ => throw new ArgumentOutOfRangeException(nameof(algo), algo, message: null)
};
}
/// <summary>
/// Computes an file hash with SHA1
/// </summary>
/// <param name="filePath">Filepath that has to be computed</param>
/// <returns>Hashed file in hex string</returns>
private static string ComputeFileHashSha1(string filePath)
{
using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var sha1 = SHA1.Create();
var hash = sha1.ComputeHash(stream);
return Convert.ToHexString(hash);
}
/// <summary>
/// Computes an file hash with SHA1 asynchronously
/// </summary>
/// <param name="filePath">Filepath that has to be computed</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Hashed file in hex string hashed in SHA1</returns>
private static async Task<string> ComputeFileHashSha1Async(string filePath, CancellationToken cancellationToken)
{
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: _bufferSize, options: FileOptions.Asynchronous);
await using (stream.ConfigureAwait(false))
@@ -29,38 +107,135 @@ public static class Crypto
var buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
{
sha1.TransformBlock(buffer, 0, bytesRead, outputBuffer: null, 0);
}
sha1.TransformFinalBlock([], 0, 0);
return Convert.ToHexString(sha1.Hash!);
}
}
/// <summary>
/// Computes an file hash with Blake3
/// </summary>
/// <param name="filePath">Filepath that has to be computed</param>
/// <returns>Hashed file in hex string hashed in Blake3</returns>
private static string ComputeFileHashBlake3(string filePath)
{
using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var hasher = Hasher.New();
var buffer = new byte[_bufferSize];
int bytesRead;
while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0)
{
hasher.Update(buffer.AsSpan(0, bytesRead));
}
var hash = hasher.Finalize();
return hash.ToString();
}
/// <summary>
/// Computes an file hash with Blake3 asynchronously
/// </summary>
/// <param name="filePath">Filepath that has to be computed</param>
/// <returns>Hashed file in hex string hashed in Blake3</returns>
private static async Task<string> ComputeFileHashBlake3Async(string filePath, CancellationToken cancellationToken)
{
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: _bufferSize, options: FileOptions.Asynchronous);
await using (stream.ConfigureAwait(false))
{
using var hasher = Hasher.New();
var buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
{
hasher.Update(buffer.AsSpan(0, bytesRead));
}
var hash = hasher.Finalize();
return hash.ToString();
}
}
#endregion
#region String hashing
public static string GetBlake3Hash(this (string, ushort) playerToHash)
{
if (_hashListPlayersBlake3.TryGetValue(playerToHash, out var hash))
return hash;
var toHash = playerToHash.Item1 + playerToHash.Item2.ToString();
hash = ComputeBlake3Hex(toHash);
_hashListPlayersBlake3[playerToHash] = hash;
return hash;
}
/// <summary>
/// Computes or gets an Blake3 hash(ed) string.
/// </summary>
/// <param name="stringToHash">String that needs to be hashsed</param>
/// <returns>Hashed string</returns>
public static string GetBlake3Hash(this string stringToHash)
{
return GetOrComputeBlake3(stringToHash);
}
private static string GetOrComputeBlake3(string stringToCompute)
{
if (_hashListBlake3.TryGetValue(stringToCompute, out var hash))
return hash;
hash = ComputeBlake3Hex(stringToCompute);
_hashListBlake3[stringToCompute] = hash;
return hash;
}
private static string ComputeBlake3Hex(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = Hasher.Hash(bytes);
return Convert.ToHexString(hash.AsSpan());
}
public static string GetHash256(this (string, ushort) playerToHash)
{
if (_hashListPlayersSHA256.TryGetValue(playerToHash, out var hash))
return hash;
return _hashListPlayersSHA256[playerToHash] =
BitConverter.ToString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(playerToHash.Item1 + playerToHash.Item2.ToString()))).Replace("-", "", StringComparison.Ordinal);
Convert.ToHexString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(playerToHash.Item1 + playerToHash.Item2.ToString())));
}
/// <summary>
/// Computes or gets an SHA256 hash(ed) string.
/// </summary>
/// <param name="stringToHash">String that needs to be hashsed</param>
/// <returns>Hashed string</returns>
public static string GetHash256(this string stringToHash)
{
return GetOrComputeHashSHA256(stringToHash);
return _hashListSHA256.GetOrAdd(stringToHash, ComputeHashSHA256);
}
private static string GetOrComputeHashSHA256(string stringToCompute)
private static string ComputeHashSHA256(string stringToCompute)
{
if (_hashListSHA256.TryGetValue(stringToCompute, out var hash))
return hash;
return _hashListSHA256[stringToCompute] =
BitConverter.ToString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(stringToCompute))).Replace("-", "", StringComparison.Ordinal);
}
Convert.ToHexString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(stringToCompute)));
}
#endregion
#pragma warning restore SYSLIB0021 // Type or member is obsolete
}

View File

@@ -1,6 +1,5 @@
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace LightlessSync.Utils
@@ -32,7 +31,7 @@ namespace LightlessSync.Utils
{
string rootPath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine))
if (OperatingSystem.IsWindows() && (!IsProbablyWine() || !isWine))
{
var info = new FileInfo(filePath);
var dir = info.Directory ?? new DirectoryInfo(filePath);
@@ -50,7 +49,7 @@ namespace LightlessSync.Utils
FilesystemType detected;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine))
if (OperatingSystem.IsWindows() && (!IsProbablyWine() || !isWine))
{
var root = new DriveInfo(rootPath);
var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty;
@@ -157,7 +156,7 @@ namespace LightlessSync.Utils
return mountOptions;
}
catch (Exception ex)
catch (Exception)
{
return string.Empty;
}
@@ -214,7 +213,7 @@ namespace LightlessSync.Utils
if (_blockSizeCache.TryGetValue(root, out int cached))
return cached;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !isWine)
if (OperatingSystem.IsWindows() && !isWine)
{
int result = GetDiskFreeSpaceW(root,
out uint sectorsPerCluster,
@@ -234,40 +233,6 @@ namespace LightlessSync.Utils
return clusterSize;
}
string realPath = fi.FullName;
if (isWine && realPath.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase))
{
realPath = "/" + realPath.Substring(3).Replace('\\', '/');
}
var psi = new ProcessStartInfo
{
FileName = "/bin/bash",
Arguments = $"-c \"stat -f -c %s '{realPath.Replace("'", "'\\''")}'\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = "/"
};
using var proc = Process.Start(psi);
string stdout = proc?.StandardOutput.ReadToEnd().Trim() ?? "";
string _stderr = proc?.StandardError.ReadToEnd() ?? "";
try { proc?.WaitForExit(); }
catch (Exception ex) { logger?.LogTrace(ex, "stat WaitForExit failed under Wine; ignoring"); }
if (!(!int.TryParse(stdout, out int block) || block <= 0))
{
_blockSizeCache[root] = block;
logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, block);
return block;
}
logger?.LogTrace("stat did not return valid block size for {file}, output: {out}", fi.FullName, stdout);
_blockSizeCache[root] = _defaultBlockSize;
return _defaultBlockSize;
}
catch (Exception ex)

View File

@@ -1,13 +1,15 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface;
using Dalamud.Interface.ImGuiSeStringRenderer;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility;
using Lumina.Text;
using System;
using Lumina.Text.Parse;
using Lumina.Text.ReadOnly;
using System.Globalization;
using System.Numerics;
using System.Threading;
using System.Reflection;
using System.Text;
using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString;
using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder;
using LuminaSeStringBuilder = Lumina.Text.SeStringBuilder;
@@ -19,6 +21,438 @@ public static class SeStringUtils
private static int _seStringHitboxCounter;
private static int _iconHitboxCounter;
public static bool TryRenderSeStringMarkupAtCursor(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
return false;
var wrapWidth = ImGui.GetContentRegionAvail().X;
if (wrapWidth <= 0f || float.IsNaN(wrapWidth) || float.IsInfinity(wrapWidth))
wrapWidth = float.MaxValue;
var normalizedPayload = payload.ReplaceLineEndings("<br>");
try
{
_ = ReadOnlySeString.FromMacroString(normalizedPayload, new MacroStringParseOptions
{
ExceptionMode = MacroStringParseExceptionMode.Throw
});
}
catch (Exception)
{
return false;
}
try
{
var drawParams = new SeStringDrawParams
{
WrapWidth = wrapWidth,
Font = ImGui.GetFont(),
Color = ImGui.GetColorU32(ImGuiCol.Text),
};
var renderId = ImGui.GetID($"SeStringMarkup##{normalizedPayload.GetHashCode(StringComparison.Ordinal)}");
var drawResult = ImGuiHelpers.CompileSeStringWrapped(normalizedPayload, drawParams, renderId);
var height = drawResult.Size.Y;
if (height <= 0f)
height = ImGui.GetTextLineHeight();
ImGui.Dummy(new Vector2(0f, height));
if (drawResult.InteractedPayloadEnvelope.Length > 0 &&
TryExtractLink(drawResult.InteractedPayloadEnvelope, drawResult.InteractedPayload, out var linkUrl, out var tooltipText))
{
var hoverText = !string.IsNullOrEmpty(linkUrl) ? linkUrl : tooltipText;
if (!string.IsNullOrEmpty(hoverText))
{
ImGui.BeginTooltip();
ImGui.TextUnformatted(hoverText);
ImGui.EndTooltip();
}
if (!string.IsNullOrEmpty(linkUrl))
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
if (drawResult.Clicked && !string.IsNullOrEmpty(linkUrl))
Dalamud.Utility.Util.OpenLink(linkUrl);
}
return true;
}
catch (Exception ex)
{
ImGui.TextDisabled($"[SeString error] {ex.Message}");
return false;
}
}
public enum SeStringSegmentType
{
Text,
Icon
}
public readonly record struct SeStringSegment(
SeStringSegmentType Type,
string? Text,
Vector4? Color,
uint IconId,
IDalamudTextureWrap? Texture,
Vector2 Size);
public static bool TryResolveSegments(
string payload,
float scale,
Func<uint, IDalamudTextureWrap?> iconResolver,
List<SeStringSegment> resolvedSegments,
out Vector2 totalSize)
{
totalSize = Vector2.Zero;
if (string.IsNullOrWhiteSpace(payload))
return false;
var parsedSegments = new List<ParsedSegment>(Math.Max(1, payload.Length / 4));
if (!ParseSegments(payload, parsedSegments))
return false;
float width = 0f;
float height = 0f;
foreach (var segment in parsedSegments)
{
switch (segment.Type)
{
case ParsedSegmentType.Text:
{
var text = segment.Text ?? string.Empty;
if (text.Length == 0)
break;
var textSize = ImGui.CalcTextSize(text);
resolvedSegments.Add(new SeStringSegment(SeStringSegmentType.Text, text, segment.Color, 0, null, textSize));
width += textSize.X;
height = MathF.Max(height, textSize.Y);
break;
}
case ParsedSegmentType.Icon:
{
var wrap = iconResolver(segment.IconId);
Vector2 iconSize;
string? fallback = null;
if (wrap != null)
{
iconSize = CalculateIconSize(wrap, scale);
}
else
{
fallback = $"[{segment.IconId}]";
iconSize = ImGui.CalcTextSize(fallback);
}
resolvedSegments.Add(new SeStringSegment(SeStringSegmentType.Icon, fallback, segment.Color, segment.IconId, wrap, iconSize));
width += iconSize.X;
height = MathF.Max(height, iconSize.Y);
break;
}
}
}
totalSize = new Vector2(width, height);
parsedSegments.Clear();
return resolvedSegments.Count > 0;
}
private enum ParsedSegmentType
{
Text,
Icon
}
private readonly record struct ParsedSegment(
ParsedSegmentType Type,
string? Text,
uint IconId,
Vector4? Color);
private static bool ParseSegments(string payload, List<ParsedSegment> segments)
{
var builder = new StringBuilder(payload.Length);
Vector4? activeColor = null;
var index = 0;
while (index < payload.Length)
{
if (payload[index] == '<')
{
var end = payload.IndexOf('>', index);
if (end == -1)
break;
var tagContent = payload.Substring(index + 1, end - index - 1);
if (TryHandleIconTag(tagContent, segments, builder, activeColor))
{
index = end + 1;
continue;
}
if (TryHandleColorTag(tagContent, segments, builder, ref activeColor))
{
index = end + 1;
continue;
}
builder.Append('<');
builder.Append(tagContent);
builder.Append('>');
index = end + 1;
}
else
{
builder.Append(payload[index]);
index++;
}
}
if (index < payload.Length)
builder.Append(payload, index, payload.Length - index);
FlushTextBuilder(builder, activeColor, segments);
return segments.Count > 0;
}
private static bool TryHandleIconTag(string tagContent, List<ParsedSegment> segments, StringBuilder textBuilder, Vector4? activeColor)
{
if (!tagContent.StartsWith("icon(", StringComparison.OrdinalIgnoreCase) || !tagContent.EndsWith(')'))
return false;
var inner = tagContent.AsSpan(5, tagContent.Length - 6).Trim();
if (!uint.TryParse(inner, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iconId))
return false;
FlushTextBuilder(textBuilder, activeColor, segments);
segments.Add(new ParsedSegment(ParsedSegmentType.Icon, null, iconId, null));
return true;
}
private static bool TryHandleColorTag(string tagContent, List<ParsedSegment> segments, StringBuilder textBuilder, ref Vector4? activeColor)
{
if (tagContent.StartsWith("color", StringComparison.OrdinalIgnoreCase))
{
var equalsIndex = tagContent.IndexOf('=');
if (equalsIndex == -1)
return false;
var value = tagContent.Substring(equalsIndex + 1).Trim().Trim('"');
if (!TryParseColor(value, out var color))
return false;
FlushTextBuilder(textBuilder, activeColor, segments);
activeColor = color;
return true;
}
if (tagContent.Equals("/color", StringComparison.OrdinalIgnoreCase))
{
FlushTextBuilder(textBuilder, activeColor, segments);
activeColor = null;
return true;
}
return false;
}
private static void FlushTextBuilder(StringBuilder builder, Vector4? color, List<ParsedSegment> segments)
{
if (builder.Length == 0)
return;
segments.Add(new ParsedSegment(ParsedSegmentType.Text, builder.ToString(), 0, color));
builder.Clear();
}
private static bool TryExtractLink(ReadOnlySpan<byte> envelope, Payload? payload, out string url, out string? tooltipText)
{
url = string.Empty;
tooltipText = null;
if (envelope.Length == 0 && payload is null)
return false;
tooltipText = envelope.Length > 0 ? DalamudSeString.Parse(envelope.ToArray()).TextValue : null;
if (payload is not null && TryReadUrlFromPayload(payload, out url))
return true;
if (!string.IsNullOrWhiteSpace(tooltipText))
{
var candidate = FindFirstUrl(tooltipText);
if (!string.IsNullOrEmpty(candidate))
{
url = candidate;
return true;
}
}
url = string.Empty;
return false;
}
private static bool TryReadUrlFromPayload(object payload, out string url)
{
url = string.Empty;
var type = payload.GetType();
static string? ReadStringProperty(Type type, object instance, string propertyName)
=> type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?.GetValue(instance) as string;
string? candidate = ReadStringProperty(type, payload, "Uri")
?? ReadStringProperty(type, payload, "Url")
?? ReadStringProperty(type, payload, "Target")
?? ReadStringProperty(type, payload, "Destination");
if (IsHttpUrl(candidate))
{
url = candidate!;
return true;
}
var dataProperty = type.GetProperty("Data", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (dataProperty?.GetValue(payload) is IEnumerable<string> sequence)
{
foreach (var entry in sequence)
{
if (IsHttpUrl(entry))
{
url = entry;
return true;
}
}
}
var extraStringProp = type.GetProperty("ExtraString", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (IsHttpUrl(extraStringProp?.GetValue(payload) as string))
{
url = (extraStringProp!.GetValue(payload) as string)!;
return true;
}
var textProp = type.GetProperty("Text", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (IsHttpUrl(textProp?.GetValue(payload) as string))
{
url = (textProp!.GetValue(payload) as string)!;
return true;
}
return false;
}
private static string? FindFirstUrl(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var index = text.IndexOf("http", StringComparison.OrdinalIgnoreCase);
while (index >= 0)
{
var end = index;
while (end < text.Length && !char.IsWhiteSpace(text[end]) && !"\"')]>".Contains(text[end]))
end++;
var candidate = text.Substring(index, end - index).TrimEnd('.', ',', ';');
if (IsHttpUrl(candidate))
return candidate;
index = text.IndexOf("http", end, StringComparison.OrdinalIgnoreCase);
}
return null;
}
private static bool IsHttpUrl(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) || string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal));
}
public static string StripMarkup(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
var builder = new StringBuilder(value.Length);
int depth = 0;
foreach (var c in value)
{
if (c == '<')
{
depth++;
continue;
}
if (c == '>' && depth > 0)
{
depth--;
continue;
}
if (depth == 0)
builder.Append(c);
}
return builder.ToString().Trim();
}
private static bool TryParseColor(string value, out Vector4 color)
{
color = default;
if (string.IsNullOrEmpty(value) || value[0] != '#')
return false;
var hex = value.AsSpan(1);
if (!uint.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed))
return false;
byte a, r, g, b;
if (hex.Length == 6)
{
a = 0xFF;
r = (byte)(parsed >> 16);
g = (byte)(parsed >> 8);
b = (byte)parsed;
}
else if (hex.Length == 8)
{
a = (byte)(parsed >> 24);
r = (byte)(parsed >> 16);
g = (byte)(parsed >> 8);
b = (byte)parsed;
}
else
{
return false;
}
const float inv = 1.0f / 255f;
color = new Vector4(r * inv, g * inv, b * inv, a * inv);
return true;
}
private static Vector2 CalculateIconSize(IDalamudTextureWrap wrap, float scale)
{
const float IconHeightScale = 1.25f;
var textHeight = ImGui.GetTextLineHeight();
var baselineHeight = MathF.Max(1f, textHeight - 2f * scale);
var targetHeight = MathF.Max(baselineHeight, baselineHeight * IconHeightScale);
var aspect = wrap.Width > 0 ? wrap.Width / (float)wrap.Height : 1f;
return new Vector2(targetHeight * aspect, targetHeight);
}
public static DalamudSeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor)
{
var b = new DalamudSeStringBuilder();
@@ -57,10 +491,9 @@ public static class SeStringUtils
continue;
var hasColor = fragment.Color.HasValue;
Vector4 color = default;
if (hasColor)
{
color = fragment.Color!.Value;
Vector4 color = fragment.Color!.Value;
builder.PushColorRgba(color);
}
@@ -106,12 +539,15 @@ public static class SeStringUtils
{
drawList ??= ImGui.GetWindowDrawList();
var usedFont = font ?? ImGui.GetFont();
var drawParams = new SeStringDrawParams
{
Font = font ?? ImGui.GetFont(),
Font = usedFont,
FontSize = usedFont.FontSize,
Color = ImGui.GetColorU32(ImGuiCol.Text),
WrapWidth = wrapWidth,
TargetDrawList = drawList
TargetDrawList = drawList,
ScreenOffset = ImGui.GetCursorScreenPos()
};
ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams);
@@ -123,22 +559,38 @@ public static class SeStringUtils
ImGui.Dummy(new Vector2(0f, textSize.Y));
}
public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, ImFontPtr? font = null, string? id = null)
{
var drawList = ImGui.GetWindowDrawList();
var usedFont = font ?? UiBuilder.MonoFont;
var textSize = ImGui.CalcTextSize(seString.TextValue);
if (textSize.Y <= 0f)
{
textSize.Y = usedFont.FontSize;
}
var style = ImGui.GetStyle();
var fontHeight = usedFont.FontSize > 0f ? usedFont.FontSize : ImGui.GetFontSize();
var frameHeight = fontHeight + style.FramePadding.Y * 2f;
var hitboxHeight = MathF.Max(frameHeight, textSize.Y);
var verticalOffset = MathF.Max((hitboxHeight - textSize.Y) * 0.5f, 0f);
var drawPos = new Vector2(position.X, position.Y + verticalOffset);
var drawParams = new SeStringDrawParams
{
Font = font ?? UiBuilder.MonoFont,
FontSize = usedFont.FontSize,
ScreenOffset = drawPos,
Font = usedFont,
Color = 0xFFFFFFFF,
WrapWidth = float.MaxValue,
TargetDrawList = drawList
};
ImGui.SetCursorScreenPos(position);
ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams);
ImGui.SetCursorScreenPos(drawPos);
var textSize = ImGui.CalcTextSize(seString.TextValue);
ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams);
ImGui.SetCursorScreenPos(position);
if (id is not null)
@@ -152,30 +604,112 @@ public static class SeStringUtils
try
{
ImGui.InvisibleButton("##hitbox", textSize);
ImGui.InvisibleButton("##hitbox", new Vector2(textSize.X, hitboxHeight));
}
finally
{
ImGui.PopID();
}
return textSize;
return new Vector2(textSize.X, hitboxHeight);
}
public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, float? targetFontSize, ImFontPtr? font = null, string? id = null)
{
var drawList = ImGui.GetWindowDrawList();
var usedFont = font ?? ImGui.GetFont();
ImGui.PushFont(usedFont);
Vector2 rawSize;
float usedEffectiveSize;
try
{
usedEffectiveSize = ImGui.GetFontSize();
rawSize = ImGui.CalcTextSize(seString.TextValue);
}
finally
{
ImGui.PopFont();
}
var desiredSize = targetFontSize ?? usedEffectiveSize;
var scale = usedEffectiveSize > 0 ? (desiredSize / usedEffectiveSize) : 1f;
var textSize = rawSize * scale;
var style = ImGui.GetStyle();
var frameHeight = desiredSize + style.FramePadding.Y * 2f;
var hitboxHeight = MathF.Max(frameHeight, textSize.Y);
var verticalOffset = MathF.Max((hitboxHeight - textSize.Y) * 0.5f, 0f);
var drawPos = new Vector2(position.X, position.Y + verticalOffset);
var drawParams = new SeStringDrawParams
{
TargetDrawList = drawList,
ScreenOffset = drawPos,
Font = usedFont,
FontSize = desiredSize,
Color = 0xFFFFFFFF,
WrapWidth = float.MaxValue,
};
ImGui.SetCursorScreenPos(drawPos);
ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams);
ImGui.SetCursorScreenPos(position);
ImGui.PushID(id ?? Interlocked.Increment(ref _seStringHitboxCounter).ToString());
try
{
ImGui.InvisibleButton("##hitbox", new Vector2(textSize.X, hitboxHeight));
}
finally
{
ImGui.PopID();
}
return new Vector2(textSize.X, hitboxHeight);
}
public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null, string? id = null)
{
var drawList = ImGui.GetWindowDrawList();
var usedFont = font ?? UiBuilder.MonoFont;
var iconMacro = $"<icon({iconId})>";
var drawParams = new SeStringDrawParams
var measureParams = new SeStringDrawParams
{
Font = font ?? UiBuilder.MonoFont,
Font = usedFont,
FontSize = usedFont.FontSize,
Color = 0xFFFFFFFF,
WrapWidth = float.MaxValue,
TargetDrawList = drawList
WrapWidth = float.MaxValue
};
var iconMacro = $"<icon({iconId})>";
var drawResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams);
var measureResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, measureParams);
var iconSize = measureResult.Size;
if (iconSize.Y <= 0f)
{
iconSize.Y = usedFont.FontSize > 0f ? usedFont.FontSize : ImGui.GetFontSize();
}
var style = ImGui.GetStyle();
var fontHeight = usedFont.FontSize > 0f ? usedFont.FontSize : ImGui.GetFontSize();
var frameHeight = fontHeight + style.FramePadding.Y * 2f;
var hitboxHeight = MathF.Max(frameHeight, iconSize.Y);
var verticalOffset = MathF.Max((hitboxHeight - iconSize.Y) * 0.5f, 0f);
var drawPos = new Vector2(position.X, position.Y + verticalOffset);
var drawParams = new SeStringDrawParams
{
Font = usedFont,
FontSize = usedFont.FontSize,
Color = 0xFFFFFFFF,
WrapWidth = float.MaxValue,
TargetDrawList = drawList,
ScreenOffset = drawPos
};
ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams);
ImGui.SetCursorScreenPos(position);
if (id is not null)
@@ -189,14 +723,14 @@ public static class SeStringUtils
try
{
ImGui.InvisibleButton("##iconHitbox", drawResult.Size);
ImGui.InvisibleButton("##iconHitbox", new Vector2(iconSize.X, hitboxHeight));
}
finally
{
ImGui.PopID();
}
return drawResult.Size;
return new Vector2(iconSize.X, hitboxHeight);
}
#region Internal Payloads
@@ -233,7 +767,7 @@ public static class SeStringUtils
protected abstract byte ChunkType { get; }
}
private class ColorPayload : AbstractColorPayload
private sealed class ColorPayload : AbstractColorPayload
{
protected override byte ChunkType => 0x13;
@@ -247,12 +781,12 @@ public static class SeStringUtils
public ColorPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { }
}
private class ColorEndPayload : AbstractColorEndPayload
private sealed class ColorEndPayload : AbstractColorEndPayload
{
protected override byte ChunkType => 0x13;
}
private class GlowPayload : AbstractColorPayload
private sealed class GlowPayload : AbstractColorPayload
{
protected override byte ChunkType => 0x14;
@@ -266,7 +800,7 @@ public static class SeStringUtils
public GlowPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { }
}
private class GlowEndPayload : AbstractColorEndPayload
private sealed class GlowEndPayload : AbstractColorEndPayload
{
protected override byte ChunkType => 0x14;
}

View File

@@ -1,9 +1,10 @@
using Dalamud.Game.ClientState.Objects.Types;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace LightlessSync.Utils;
@@ -56,9 +57,20 @@ public static class VariousExtensions
}
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(this CharacterData newData, Guid applicationBase,
CharacterData? oldData, ILogger logger, PairHandler cachedPlayer, bool forceApplyCustomization, bool forceApplyMods)
CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods)
{
oldData ??= new();
static bool FileReplacementsEquivalent(ICollection<FileReplacementData> left, ICollection<FileReplacementData> right)
{
if (left.Count != right.Count)
{
return false;
}
var comparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer.Instance;
return !left.Except(right, comparer).Any() && !right.Except(left, comparer).Any();
}
var charaDataToUpdate = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>())
{
@@ -91,7 +103,9 @@ public static class VariousExtensions
{
if (hasNewAndOldFileReplacements)
{
bool listsAreEqual = oldData.FileReplacements[objectKind].SequenceEqual(newData.FileReplacements[objectKind], PlayerData.Data.FileReplacementDataComparer.Instance);
var oldList = oldData.FileReplacements[objectKind];
var newList = newData.FileReplacements[objectKind];
var listsAreEqual = FileReplacementsEquivalent(oldList, newList);
if (!listsAreEqual || forceApplyMods)
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles);
@@ -114,9 +128,9 @@ public static class VariousExtensions
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newTail = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("tex", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("tex", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
logger.LogTrace("[BASE-{appbase}] ExistingFace: {of}, NewFace: {fc}; ExistingHair: {eh}, NewHair: {nh}; ExistingTail: {et}, NewTail: {nt}; ExistingTransient: {etr}, NewTransient: {ntr}", applicationBase,
@@ -163,7 +177,8 @@ public static class VariousExtensions
if (objectKind != ObjectKind.Player) continue;
bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
if (manipDataDifferent || forceApplyMods)
var hasManipulationData = !string.IsNullOrEmpty(newData.ManipulationData);
if (manipDataDifferent || (forceApplyMods && hasManipulationData))
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip);

View File

@@ -0,0 +1,139 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Windowing;
using LightlessSync.UI;
using LightlessSync.WebAPI.SignalR.Utils;
using System.Numerics;
namespace LightlessSync.Utils;
public sealed class WindowBuilder
{
private readonly Window _window;
private readonly List<Window.TitleBarButton> _titleButtons = new();
private WindowBuilder(Window window)
{
_window = window ?? throw new ArgumentNullException(nameof(window));
}
public static WindowBuilder For(Window window) => new(window);
public WindowBuilder AllowPinning(bool allow = true)
{
_window.AllowPinning = allow;
return this;
}
public WindowBuilder AllowClickthrough(bool allow = true)
{
_window.AllowClickthrough = allow;
return this;
}
public WindowBuilder SetFixedSize(Vector2 size) => SetSizeConstraints(size, size);
public WindowBuilder SetSizeConstraints(Vector2 min, Vector2 max)
{
_window.SizeConstraints = new Window.WindowSizeConstraints
{
MinimumSize = min,
MaximumSize = max,
};
return this;
}
public WindowBuilder AddFlags(ImGuiWindowFlags flags)
{
_window.Flags |= flags;
return this;
}
public WindowBuilder AddTitleBarButton(FontAwesomeIcon icon, string tooltip, Action onClick, Vector2? iconOffset = null)
{
_titleButtons.Add(new Window.TitleBarButton
{
Icon = icon,
IconOffset = iconOffset ?? new Vector2(2, 1),
Click = _ => onClick(),
ShowTooltip = () => UiSharedService.AttachToolTip(tooltip),
});
return this;
}
public Window Apply()
{
if (_titleButtons.Count > 0)
_window.TitleBarButtons = _titleButtons;
return _window;
}
}
public static class WindowUtils
{
public static Vector4 GetUidColor(this ServerState state)
{
return state switch
{
ServerState.Connecting => UIColors.Get("LightlessYellow"),
ServerState.Reconnecting => UIColors.Get("DimRed"),
ServerState.Connected => UIColors.Get("LightlessPurple"),
ServerState.Disconnected => UIColors.Get("LightlessYellow"),
ServerState.Disconnecting => UIColors.Get("LightlessYellow"),
ServerState.Unauthorized => UIColors.Get("DimRed"),
ServerState.VersionMisMatch => UIColors.Get("DimRed"),
ServerState.Offline => UIColors.Get("DimRed"),
ServerState.RateLimited => UIColors.Get("LightlessYellow"),
ServerState.NoSecretKey => UIColors.Get("LightlessYellow"),
ServerState.MultiChara => UIColors.Get("LightlessYellow"),
ServerState.OAuthMisconfigured => UIColors.Get("DimRed"),
ServerState.OAuthLoginTokenStale => UIColors.Get("DimRed"),
ServerState.NoAutoLogon => UIColors.Get("LightlessYellow"),
_ => UIColors.Get("DimRed"),
};
}
public static string GetUidText(this ServerState state, string displayName)
{
return state switch
{
ServerState.Reconnecting => "Reconnecting",
ServerState.Connecting => "Connecting",
ServerState.Disconnected => "Disconnected",
ServerState.Disconnecting => "Disconnecting",
ServerState.Unauthorized => "Unauthorized",
ServerState.VersionMisMatch => "Version mismatch",
ServerState.Offline => "Unavailable",
ServerState.RateLimited => "Rate Limited",
ServerState.NoSecretKey => "No Secret Key",
ServerState.MultiChara => "Duplicate Characters",
ServerState.OAuthMisconfigured => "Misconfigured OAuth2",
ServerState.OAuthLoginTokenStale => "Stale OAuth2",
ServerState.NoAutoLogon => "Auto Login disabled",
ServerState.Connected => displayName,
_ => string.Empty,
};
}
public static string GetServerError(this ServerState state, string authFailureMessage)
{
return state switch
{
ServerState.Connecting => "Attempting to connect to the server.",
ServerState.Reconnecting => "Connection to server interrupted, attempting to reconnect to the server.",
ServerState.Disconnected => "You are currently disconnected from the Lightless Sync server.",
ServerState.Disconnecting => "Disconnecting from the server",
ServerState.Unauthorized => "Server Response: " + authFailureMessage,
ServerState.Offline => "Your selected Lightless Sync server is currently offline.",
ServerState.VersionMisMatch =>
"Your plugin or the server you are connecting to is out of date. Please update your plugin now. If you already did so, contact the server provider to update their server to the latest version.",
ServerState.RateLimited => "You are rate limited for (re)connecting too often. Disconnect, wait 10 minutes and try again.",
ServerState.NoSecretKey => "You have no secret key set for this current character. Open Settings -> Service Settings and set a secret key for the current character. You can reuse the same secret key for multiple characters.",
ServerState.MultiChara => "Your Character Configuration has multiple characters configured with same name and world. You will not be able to connect until you fix this issue. Remove the duplicates from the configuration in Settings -> Service Settings -> Character Management and reconnect manually after.",
ServerState.OAuthMisconfigured => "OAuth2 is enabled but not fully configured, verify in the Settings -> Service Settings that you have OAuth2 connected and, importantly, a UID assigned to your current character.",
ServerState.OAuthLoginTokenStale => "Your OAuth2 login token is stale and cannot be used to renew. Go to the Settings -> Service Settings and unlink then relink your OAuth2 configuration.",
ServerState.NoAutoLogon => "This character has automatic login into Lightless disabled. Press the connect button to connect to Lightless.",
_ => string.Empty,
};
}
}