Merge remote-tracking branch 'origin/master' into api14-rollup

This commit is contained in:
github-actions[bot] 2025-11-26 20:10:02 +00:00
commit 947518b3d6
20 changed files with 812 additions and 104 deletions

211
.github/generate_changelog.py vendored Normal file
View file

@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
Generate a changelog from git commits between the last two tags and post to Discord webhook.
"""
import subprocess
import re
import sys
import json
import argparse
from typing import List, Tuple, Optional
def run_git_command(args: List[str]) -> str:
"""Run a git command and return its output."""
try:
result = subprocess.run(
["git"] + args,
capture_output=True,
text=True,
check=True
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Git command failed: {e}", file=sys.stderr)
sys.exit(1)
def get_last_two_tags() -> Tuple[str, str]:
"""Get the latest two git tags."""
tags = run_git_command(["tag", "--sort=-version:refname"])
tag_list = [t for t in tags.split("\n") if t]
# Filter out old tags that start with 'v' (old versioning scheme)
tag_list = [t for t in tag_list if not t.startswith('v')]
if len(tag_list) < 2:
print("Error: Need at least 2 tags in the repository", file=sys.stderr)
sys.exit(1)
return tag_list[0], tag_list[1]
def get_submodule_commit(submodule_path: str, tag: str) -> Optional[str]:
"""Get the commit hash of a submodule at a specific tag."""
try:
# Get the submodule commit at the specified tag
result = run_git_command(["ls-tree", tag, submodule_path])
# Format is: "<mode> commit <hash>\t<path>"
parts = result.split()
if len(parts) >= 3 and parts[1] == "commit":
return parts[2]
return None
except:
return None
def get_commits_between_tags(tag1: str, tag2: str) -> List[Tuple[str, str]]:
"""Get commits between two tags. Returns list of (message, author) tuples."""
log_output = run_git_command([
"log",
f"{tag2}..{tag1}",
"--format=%s|%an|%h"
])
commits = []
for line in log_output.split("\n"):
if "|" in line:
message, author, sha = line.split("|", 2)
commits.append((message.strip(), author.strip(), sha.strip()))
return commits
def filter_commits(commits: List[Tuple[str, str]], ignore_patterns: List[str]) -> List[Tuple[str, str]]:
"""Filter out commits matching any of the ignore patterns."""
compiled_patterns = [re.compile(pattern) for pattern in ignore_patterns]
filtered = []
for message, author, sha in commits:
if not any(pattern.search(message) for pattern in compiled_patterns):
filtered.append((message, author, sha))
return filtered
def generate_changelog(version: str, prev_version: str, commits: List[Tuple[str, str]],
cs_commit_new: Optional[str], cs_commit_old: Optional[str]) -> str:
"""Generate markdown changelog."""
# Calculate statistics
commit_count = len(commits)
unique_authors = len(set(author for _, author, _ in commits))
changelog = f"# Dalamud Release v{version}\n\n"
changelog += f"We just released Dalamud v{version}, which should be available to users within a few minutes. "
changelog += f"This release includes **{commit_count} commit{'s' if commit_count != 1 else ''} from {unique_authors} contributor{'s' if unique_authors != 1 else ''}**.\n"
changelog += f"[Click here](<https://github.com/goatcorp/Dalamud/compare/{prev_version}...{version}>) to see all Dalamud changes.\n\n"
if cs_commit_new and cs_commit_old and cs_commit_new != cs_commit_old:
changelog += f"It ships with an updated **FFXIVClientStructs [`{cs_commit_new[:7]}`](<https://github.com/aers/FFXIVClientStructs/commit/{cs_commit_new}>)**.\n"
changelog += f"[Click here](<https://github.com/aers/FFXIVClientStructs/compare/{cs_commit_old}...{cs_commit_new}>) to see all CS changes.\n"
elif cs_commit_new:
changelog += f"It ships with **FFXIVClientStructs [`{cs_commit_new[:7]}`](<https://github.com/aers/FFXIVClientStructs/commit/{cs_commit_new}>)**.\n"
changelog += "## Dalamud Changes\n\n"
for message, author, sha in commits:
changelog += f"* {message} (by **{author}** as [`{sha}`](<https://github.com/goatcorp/Dalamud/commit/{sha}>))\n"
return changelog
def post_to_discord(webhook_url: str, content: str, version: str) -> None:
"""Post changelog to Discord webhook as a file attachment."""
try:
import requests
except ImportError:
print("Error: requests library is required. Install it with: pip install requests", file=sys.stderr)
sys.exit(1)
filename = f"changelog-v{version}.md"
# Prepare the payload
data = {
"content": f"Dalamud v{version} has been released!",
"attachments": [
{
"id": "0",
"filename": filename
}
]
}
# Prepare the files
files = {
"payload_json": (None, json.dumps(data)),
"files[0]": (filename, content.encode('utf-8'), 'text/markdown')
}
try:
result = requests.post(webhook_url, files=files)
result.raise_for_status()
print(f"Successfully posted to Discord webhook, code {result.status_code}")
except requests.exceptions.HTTPError as err:
print(f"Failed to post to Discord: {err}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Failed to post to Discord: {e}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
description="Generate changelog from git commits and post to Discord webhook"
)
parser.add_argument(
"--webhook-url",
required=True,
help="Discord webhook URL"
)
parser.add_argument(
"--ignore",
action="append",
default=[],
help="Regex patterns to ignore commits (can be specified multiple times)"
)
parser.add_argument(
"--submodule-path",
default="lib/FFXIVClientStructs",
help="Path to the FFXIVClientStructs submodule (default: lib/FFXIVClientStructs)"
)
args = parser.parse_args()
# Get the last two tags
latest_tag, previous_tag = get_last_two_tags()
print(f"Generating changelog between {previous_tag} and {latest_tag}")
# Get submodule commits at both tags
cs_commit_new = get_submodule_commit(args.submodule_path, latest_tag)
cs_commit_old = get_submodule_commit(args.submodule_path, previous_tag)
if cs_commit_new:
print(f"FFXIVClientStructs commit (new): {cs_commit_new[:7]}")
if cs_commit_old:
print(f"FFXIVClientStructs commit (old): {cs_commit_old[:7]}")
# Get commits between tags
commits = get_commits_between_tags(latest_tag, previous_tag)
print(f"Found {len(commits)} commits")
# Filter commits
filtered_commits = filter_commits(commits, args.ignore)
print(f"After filtering: {len(filtered_commits)} commits")
# Generate changelog
changelog = generate_changelog(latest_tag, previous_tag, filtered_commits,
cs_commit_new, cs_commit_old)
print("\n" + "="*50)
print("Generated Changelog:")
print("="*50)
print(changelog)
print("="*50 + "\n")
# Post to Discord
post_to_discord(args.webhook_url, changelog, latest_tag)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,46 @@
name: Generate Changelog
on:
workflow_dispatch:
push:
tags:
- '*'
jobs:
generate-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history and tags
submodules: true # Fetch submodules
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.14'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Generate and post changelog
run: |
python .github/generate_changelog.py \
--webhook-url "${{ secrets.DISCORD_CHANGELOG_WEBHOOK_URL }}" \
--ignore "^Merge" \
--ignore "^build:" \
--ignore "^docs:"
env:
GIT_TERMINAL_PROMPT: 0
- name: Upload changelog as artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: changelog
path: changelog-*.md
if-no-files-found: ignore

View file

@ -31,19 +31,19 @@ public class ReliableFileStorageTests
.Select(
i => Parallel.ForEachAsync(
Enumerable.Range(1, 100),
(j, _) =>
async (j, _) =>
{
if (i % 2 == 0)
{
// ReSharper disable once AccessToDisposedClosure
rfs.Instance.WriteAllText(tempFile, j.ToString());
await rfs.Instance.WriteAllTextAsync(tempFile, j.ToString());
}
else if (i % 3 == 0)
{
try
{
// ReSharper disable once AccessToDisposedClosure
rfs.Instance.ReadAllText(tempFile);
await rfs.Instance.ReadAllTextAsync(tempFile);
}
catch (FileNotFoundException)
{
@ -54,8 +54,6 @@ public class ReliableFileStorageTests
{
File.Delete(tempFile);
}
return ValueTask.CompletedTask;
})));
}
@ -112,41 +110,41 @@ public class ReliableFileStorageTests
}
[Fact]
public void Exists_WhenFileInBackup_ReturnsTrue()
public async Task Exists_WhenFileInBackup_ReturnsTrue()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1);
File.Delete(tempFile);
Assert.True(rfs.Instance.Exists(tempFile));
}
[Fact]
public void Exists_WhenFileInBackup_WithDifferentContainerId_ReturnsFalse()
public async Task Exists_WhenFileInBackup_WithDifferentContainerId_ReturnsFalse()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1);
File.Delete(tempFile);
Assert.False(rfs.Instance.Exists(tempFile, Guid.NewGuid()));
}
[Fact]
public void WriteAllText_ThrowsIfPathIsEmpty()
public async Task WriteAllText_ThrowsIfPathIsEmpty()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentException>(() => rfs.Instance.WriteAllText("", TestFileContent1));
await Assert.ThrowsAsync<ArgumentException>(async () => await rfs.Instance.WriteAllTextAsync("", TestFileContent1));
}
[Fact]
public void WriteAllText_ThrowsIfPathIsNull()
public async Task WriteAllText_ThrowsIfPathIsNull()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentNullException>(() => rfs.Instance.WriteAllText(null!, TestFileContent1));
await Assert.ThrowsAsync<ArgumentNullException>(async () => await rfs.Instance.WriteAllTextAsync(null!, TestFileContent1));
}
[Fact]
@ -155,26 +153,26 @@ public class ReliableFileStorageTests
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1);
Assert.True(File.Exists(tempFile));
Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile, forceBackup: true));
Assert.Equal(TestFileContent1, await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup: true));
Assert.Equal(TestFileContent1, await File.ReadAllTextAsync(tempFile));
}
[Fact]
public void WriteAllText_SeparatesContainers()
public async Task WriteAllText_SeparatesContainers()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
var containerId = Guid.NewGuid();
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
rfs.Instance.WriteAllText(tempFile, TestFileContent2, containerId);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent2, containerId);
File.Delete(tempFile);
Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile, forceBackup: true));
Assert.Equal(TestFileContent2, rfs.Instance.ReadAllText(tempFile, forceBackup: true, containerId));
Assert.Equal(TestFileContent1, await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup: true));
Assert.Equal(TestFileContent2, await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup: true, containerId));
}
[Fact]
@ -183,7 +181,7 @@ public class ReliableFileStorageTests
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateFailedRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1);
Assert.True(File.Exists(tempFile));
Assert.Equal(TestFileContent1, await File.ReadAllTextAsync(tempFile));
@ -195,38 +193,38 @@ public class ReliableFileStorageTests
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
rfs.Instance.WriteAllText(tempFile, TestFileContent2);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent2);
Assert.True(File.Exists(tempFile));
Assert.Equal(TestFileContent2, rfs.Instance.ReadAllText(tempFile, forceBackup: true));
Assert.Equal(TestFileContent2, await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup: true));
Assert.Equal(TestFileContent2, await File.ReadAllTextAsync(tempFile));
}
[Fact]
public void WriteAllText_SupportsNullContent()
public async Task WriteAllText_SupportsNullContent()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, null);
await rfs.Instance.WriteAllTextAsync(tempFile, null);
Assert.True(File.Exists(tempFile));
Assert.Equal("", rfs.Instance.ReadAllText(tempFile));
Assert.Equal("", await rfs.Instance.ReadAllTextAsync(tempFile));
}
[Fact]
public void ReadAllText_ThrowsIfPathIsEmpty()
public async Task ReadAllText_ThrowsIfPathIsEmpty()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentException>(() => rfs.Instance.ReadAllText(""));
await Assert.ThrowsAsync<ArgumentException>(async () => await rfs.Instance.ReadAllTextAsync(""));
}
[Fact]
public void ReadAllText_ThrowsIfPathIsNull()
public async Task ReadAllText_ThrowsIfPathIsNull()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentNullException>(() => rfs.Instance.ReadAllText(null!));
await Assert.ThrowsAsync<ArgumentNullException>(async () => await rfs.Instance.ReadAllTextAsync(null!));
}
[Fact]
@ -236,40 +234,40 @@ public class ReliableFileStorageTests
await File.WriteAllTextAsync(tempFile, TestFileContent1);
using var rfs = CreateRfs();
Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile));
Assert.Equal(TestFileContent1, await rfs.Instance.ReadAllTextAsync(tempFile));
}
[Fact]
public void ReadAllText_WhenFileMissingWithBackup_ReturnsContent()
public async Task ReadAllText_WhenFileMissingWithBackup_ReturnsContent()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1);
File.Delete(tempFile);
Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile));
Assert.Equal(TestFileContent1, await rfs.Instance.ReadAllTextAsync(tempFile));
}
[Fact]
public void ReadAllText_WhenFileMissingWithBackup_ThrowsWithDifferentContainerId()
public async Task ReadAllText_WhenFileMissingWithBackup_ThrowsWithDifferentContainerId()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
var containerId = Guid.NewGuid();
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1);
File.Delete(tempFile);
Assert.Throws<FileNotFoundException>(() => rfs.Instance.ReadAllText(tempFile, containerId: containerId));
await Assert.ThrowsAsync<FileNotFoundException>(async () => await rfs.Instance.ReadAllTextAsync(tempFile, containerId: containerId));
}
[Fact]
public void ReadAllText_WhenFileMissing_ThrowsIfDbFailed()
public async Task ReadAllText_WhenFileMissing_ThrowsIfDbFailed()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateFailedRfs();
Assert.Throws<FileNotFoundException>(() => rfs.Instance.ReadAllText(tempFile));
await Assert.ThrowsAsync<FileNotFoundException>(async () => await rfs.Instance.ReadAllTextAsync(tempFile));
}
[Fact]
@ -278,7 +276,7 @@ public class ReliableFileStorageTests
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
await File.WriteAllTextAsync(tempFile, TestFileContent1);
using var rfs = CreateRfs();
rfs.Instance.ReadAllText(tempFile, text => Assert.Equal(TestFileContent1, text));
await rfs.Instance.ReadAllTextAsync(tempFile, text => Assert.Equal(TestFileContent1, text));
}
[Fact]
@ -290,7 +288,7 @@ public class ReliableFileStorageTests
var readerCalledOnce = false;
using var rfs = CreateRfs();
Assert.Throws<FileReadException>(() => rfs.Instance.ReadAllText(tempFile, Reader));
await Assert.ThrowsAsync<FileReadException>(async () => await rfs.Instance.ReadAllTextAsync(tempFile, Reader));
return;
@ -303,7 +301,7 @@ public class ReliableFileStorageTests
}
[Fact]
public void ReadAllText_WithReader_WhenReaderThrows_ReadsContentFromBackup()
public async Task ReadAllText_WithReader_WhenReaderThrows_ReadsContentFromBackup()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
@ -311,10 +309,10 @@ public class ReliableFileStorageTests
var assertionCalled = false;
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1);
File.Delete(tempFile);
rfs.Instance.ReadAllText(tempFile, Reader);
await rfs.Instance.ReadAllTextAsync(tempFile, Reader);
Assert.True(assertionCalled);
return;
@ -335,17 +333,17 @@ public class ReliableFileStorageTests
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
await File.WriteAllTextAsync(tempFile, TestFileContent1);
using var rfs = CreateRfs();
Assert.Throws<FileNotFoundException>(() => rfs.Instance.ReadAllText(tempFile, _ => throw new FileNotFoundException()));
await Assert.ThrowsAsync<FileNotFoundException>(async () => await rfs.Instance.ReadAllTextAsync(tempFile, _ => throw new FileNotFoundException()));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ReadAllText_WhenFileDoesNotExist_Throws(bool forceBackup)
public async Task ReadAllText_WhenFileDoesNotExist_Throws(bool forceBackup)
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
Assert.Throws<FileNotFoundException>(() => rfs.Instance.ReadAllText(tempFile, forceBackup));
await Assert.ThrowsAsync<FileNotFoundException>(async () => await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup));
}
private static DisposableReliableFileStorage CreateRfs()

View file

@ -503,13 +503,13 @@ internal sealed class DalamudConfiguration : IInternalDisposableService
/// <param name="path">Path to read from.</param>
/// <param name="fs">File storage.</param>
/// <returns>The deserialized configuration file.</returns>
public static DalamudConfiguration Load(string path, ReliableFileStorage fs)
public static async Task<DalamudConfiguration> Load(string path, ReliableFileStorage fs)
{
DalamudConfiguration deserialized = null;
try
{
fs.ReadAllText(path, text =>
await fs.ReadAllTextAsync(path, text =>
{
deserialized =
JsonConvert.DeserializeObject<DalamudConfiguration>(text, SerializerSettings);
@ -580,8 +580,6 @@ internal sealed class DalamudConfiguration : IInternalDisposableService
{
this.Save();
this.isSaveQueued = false;
Log.Verbose("Config saved");
}
}
@ -630,16 +628,20 @@ internal sealed class DalamudConfiguration : IInternalDisposableService
// Wait for previous write to finish
this.writeTask?.Wait();
this.writeTask = Task.Run(() =>
this.writeTask = Task.Run(async () =>
{
Service<ReliableFileStorage>.Get().WriteAllText(
await Service<ReliableFileStorage>.Get().WriteAllTextAsync(
this.configPath,
JsonConvert.SerializeObject(this, SerializerSettings));
Log.Verbose("DalamudConfiguration saved");
}).ContinueWith(t =>
{
if (t.IsFaulted)
{
Log.Error(t.Exception, "Failed to save DalamudConfiguration to {Path}", this.configPath);
Log.Error(
t.Exception,
"Failed to save DalamudConfiguration to {Path}",
this.configPath);
}
});

View file

@ -2,6 +2,8 @@ using System.IO;
using System.Reflection;
using Dalamud.Storage;
using Dalamud.Utility;
using Newtonsoft.Json;
namespace Dalamud.Configuration;
@ -9,6 +11,7 @@ namespace Dalamud.Configuration;
/// <summary>
/// Configuration to store settings for a dalamud plugin.
/// </summary>
[Api13ToDo("Make this a service. We need to be able to dispose it reliably to write configs asynchronously. Maybe also let people write files with vfs.")]
public sealed class PluginConfigurations
{
private readonly DirectoryInfo configDirectory;
@ -36,7 +39,7 @@ public sealed class PluginConfigurations
public void Save(IPluginConfiguration config, string pluginName, Guid workingPluginId)
{
Service<ReliableFileStorage>.Get()
.WriteAllText(this.GetConfigFile(pluginName).FullName, SerializeConfig(config), workingPluginId);
.WriteAllTextAsync(this.GetConfigFile(pluginName).FullName, SerializeConfig(config), workingPluginId).GetAwaiter().GetResult();
}
/// <summary>
@ -52,12 +55,12 @@ public sealed class PluginConfigurations
IPluginConfiguration? config = null;
try
{
Service<ReliableFileStorage>.Get().ReadAllText(path.FullName, text =>
Service<ReliableFileStorage>.Get().ReadAllTextAsync(path.FullName, text =>
{
config = DeserializeConfig(text);
if (config == null)
throw new Exception("Read config was null.");
}, workingPluginId);
}, workingPluginId).GetAwaiter().GetResult();
}
catch (FileNotFoundException)
{

View file

@ -6,7 +6,7 @@
<PropertyGroup Label="Feature">
<Description>XIV Launcher addon framework</Description>
<DalamudVersion>13.0.0.9</DalamudVersion>
<DalamudVersion>13.0.0.10</DalamudVersion>
<AssemblyVersion>$(DalamudVersion)</AssemblyVersion>
<Version>$(DalamudVersion)</Version>
<FileVersion>$(DalamudVersion)</FileVersion>

View file

@ -144,7 +144,8 @@ public sealed class EntryPoint
// Load configuration first to get some early persistent state, like log level
var fs = new ReliableFileStorage(Path.GetDirectoryName(info.ConfigurationPath)!);
var configuration = DalamudConfiguration.Load(info.ConfigurationPath!, fs);
var configuration = DalamudConfiguration.Load(info.ConfigurationPath!, fs)
.GetAwaiter().GetResult();
// Set the appropriate logging level from the configuration
if (!configuration.LogSynchronously)

View file

@ -22,7 +22,7 @@ using PublicContentSheet = Lumina.Excel.Sheets.PublicContent;
namespace Dalamud.Game.UnlockState;
#pragma warning disable UnlockState
#pragma warning disable Dalamud001
/// <summary>
/// This class provides unlock state of various content in the game.

View file

@ -1060,7 +1060,7 @@ internal class DalamudInterface : IInternalDisposableService
{
ImGui.PushFont(InterfaceManager.MonoFont);
ImGui.BeginMenu(Util.GetBranch() ?? "???", false);
ImGui.BeginMenu($"{Util.GetActiveTrack() ?? "???"} on {Util.GetGitBranch() ?? "???"}", false);
ImGui.BeginMenu($"{Util.GetScmVersion()}", false);
ImGui.BeginMenu(this.FrameCount.ToString("000000"), false);
ImGui.BeginMenu(ImGui.GetIO().Framerate.ToString("000"), false);

View file

@ -46,10 +46,12 @@ public class BranchSwitcherWindow : Window
this.branches = await client.GetFromJsonAsync<Dictionary<string, VersionEntry>>(BranchInfoUrl);
Debug.Assert(this.branches != null, "this.branches != null");
var branch = Util.GetBranch();
this.selectedBranchIndex = this.branches!.Any(x => x.Value.Track == branch) ?
this.branches.TakeWhile(x => x.Value.Track != branch).Count()
: 0;
var trackName = Util.GetActiveTrack();
this.selectedBranchIndex = this.branches.IndexOf(x => x.Value.Track != trackName);
if (this.selectedBranchIndex == -1)
{
this.selectedBranchIndex = 0;
}
});
base.OnOpen();

View file

@ -52,7 +52,7 @@ internal class VfsWidget : IDataWindowWidget
for (var i = 0; i < this.reps; i++)
{
stopwatch.Restart();
service.WriteAllBytes(path, data);
service.WriteAllBytesAsync(path, data).GetAwaiter().GetResult();
stopwatch.Stop();
acc += stopwatch.ElapsedMilliseconds;
Log.Information("Turn {Turn} took {Ms}ms", i, stopwatch.ElapsedMilliseconds);
@ -70,7 +70,7 @@ internal class VfsWidget : IDataWindowWidget
for (var i = 0; i < this.reps; i++)
{
stopwatch.Restart();
service.ReadAllBytes(path);
service.ReadAllBytesAsync(path).GetAwaiter().GetResult();
stopwatch.Stop();
acc += stopwatch.ElapsedMilliseconds;
Log.Information("Turn {Turn} took {Ms}ms", i, stopwatch.ElapsedMilliseconds);

View file

@ -55,6 +55,9 @@ public abstract class Window
private Vector2 fadeOutSize = Vector2.Zero;
private Vector2 fadeOutOrigin = Vector2.Zero;
private bool hasError = false;
private Exception? lastError;
/// <summary>
/// Initializes a new instance of the <see cref="Window"/> class.
/// </summary>
@ -458,6 +461,12 @@ public abstract class Window
this.presetDirty = true;
}
if (this.hasError)
{
this.DrawErrorMessage();
}
else
{
// Draw the actual window contents
try
{
@ -466,6 +475,10 @@ public abstract class Window
catch (Exception ex)
{
Log.Error(ex, "Error during Draw(): {WindowName}", this.WindowName);
this.hasError = true;
this.lastError = ex;
}
}
}
@ -793,7 +806,7 @@ public abstract class Window
hovered = true;
// We can't use ImGui native functions here, because they don't work with clickthrough
if ((global::Windows.Win32.PInvoke.GetKeyState((int)VirtualKey.LBUTTON) & 0x8000) != 0)
if ((Windows.Win32.PInvoke.GetKeyState((int)VirtualKey.LBUTTON) & 0x8000) != 0)
{
held = true;
pressed = true;
@ -871,6 +884,52 @@ public abstract class Window
ImGui.End();
}
private void DrawErrorMessage()
{
// TODO: Once window systems are services, offer to reload the plugin
ImGui.TextColoredWrapped(ImGuiColors.DalamudRed,Loc.Localize("WindowSystemErrorOccurred", "An error occurred while rendering this window. Please contact the developer for details."));
ImGuiHelpers.ScaledDummy(5);
if (ImGui.Button(Loc.Localize("WindowSystemErrorRecoverButton", "Attempt to retry")))
{
this.hasError = false;
this.lastError = null;
}
ImGui.SameLine();
if (ImGui.Button(Loc.Localize("WindowSystemErrorClose", "Close Window")))
{
this.IsOpen = false;
this.hasError = false;
this.lastError = null;
}
ImGuiHelpers.ScaledDummy(10);
if (this.lastError != null)
{
using var child = ImRaii.Child("##ErrorDetails", new Vector2(0, 200 * ImGuiHelpers.GlobalScale), true);
using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey))
{
ImGui.TextWrapped(Loc.Localize("WindowSystemErrorDetails", "Error Details:"));
ImGui.Separator();
ImGui.TextWrapped(this.lastError.ToString());
}
var childWindowSize = ImGui.GetWindowSize();
var copyText = Loc.Localize("WindowSystemErrorCopy", "Copy");
var buttonWidth = ImGuiComponents.GetIconButtonWithTextWidth(FontAwesomeIcon.Copy, copyText);
ImGui.SetCursorPos(new Vector2(childWindowSize.X - buttonWidth - ImGui.GetStyle().FramePadding.X,
ImGui.GetStyle().FramePadding.Y));
if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.Copy, copyText))
{
ImGui.SetClipboardText(this.lastError.ToString());
}
}
}
/// <summary>
/// Structure detailing the size constraints of a window.
/// </summary>

View file

@ -362,6 +362,9 @@ internal class PluginManager : IInternalDisposableService
if (!this.configuration.DoPluginTest)
return false;
if (!manifest.TestingDalamudApiLevel.HasValue)
return false;
return manifest.IsTestingExclusive || manifest.IsAvailableForTesting;
}

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Plugin.Internal.Types;
using Dalamud.Plugin.Services;
namespace Dalamud.Plugin.SelfTest.Internal;
@ -12,7 +13,7 @@ namespace Dalamud.Plugin.SelfTest.Internal;
[PluginInterface]
[ServiceManager.ScopedService]
[ResolveVia<ISelfTestRegistry>]
internal class SelfTestRegistryPluginScoped : ISelfTestRegistry, IInternalDisposableService
internal class SelfTestRegistryPluginScoped : ISelfTestRegistry, IInternalDisposableService, IDalamudService
{
[ServiceManager.ServiceDependency]
private readonly SelfTestRegistry selfTestRegistry = Service<SelfTestRegistry>.Get();

View file

@ -0,0 +1,168 @@
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Dalamud.Configuration;
using Dalamud.Storage;
namespace Dalamud.Plugin.Services;
/// <summary>
/// Service to interact with the file system, as a replacement for standard C# file I/O.
/// Writes and reads using this service are, to the best of our ability, atomic and reliable.
///
/// All data is synced to disk immediately and written to a database, additionally to files on disk. This means
/// that in case of file corruption, data can likely be recovered from the database.
///
/// However, this also means that operations using this service duplicate data on disk, so we don't recommend
/// performing large file operations. The service will not permit files larger than <see cref="MaxFileSizeBytes"/>
/// (64MB) to be written.
///
/// Saved configuration data using the <see cref="PluginConfigurations"/> class uses this functionality implicitly.
/// </summary>
[Experimental("Dalamud001")]
public interface IReliableFileStorage : IDalamudService
{
/// <summary>
/// Gets the maximum file size, in bytes, that can be written using this service.
/// </summary>
/// <remarks>
/// The service enforces this limit when writing files and fails with an appropriate exception
/// (for example <see cref="ArgumentException"/> or a custom exception) when a caller attempts to write
/// more than this number of bytes.
/// </remarks>
long MaxFileSizeBytes { get; }
/// <summary>
/// Check whether a file exists either on the local filesystem or in the transparent backup database.
/// </summary>
/// <param name="path">The file system path to check. Must not be null or empty.</param>
/// <returns>
/// True if the file exists on disk or a backup copy exists in the storage's internal journal/backup database;
/// otherwise false.
/// </returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
bool Exists(string path);
/// <summary>
/// Write the given text into a file using UTF-8 encoding. The write is performed atomically and is persisted to
/// both the filesystem and the internal backup database used by this service.
/// </summary>
/// <param name="path">The file path to write to. Must not be null or empty.</param>
/// <param name="contents">The string contents to write. May be null, in which case an empty file is written.</param>
/// <returns>A <see cref="Task"/> that completes when the write has finished and been flushed to disk and the backup.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
Task WriteAllTextAsync(string path, string? contents);
/// <summary>
/// Write the given text into a file using the provided <paramref name="encoding"/>. The write is performed
/// atomically (to the extent possible) and is persisted to both the filesystem and the internal backup database
/// used by this service.
/// </summary>
/// <param name="path">The file path to write to. Must not be null or empty.</param>
/// <param name="contents">The string contents to write. May be null, in which case an empty file is written.</param>
/// <param name="encoding">The text encoding to use when serializing the string to bytes. Must not be null.</param>
/// <returns>A <see cref="Task"/> that completes when the write has finished and been flushed to disk and the backup.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="encoding"/> is null.</exception>
Task WriteAllTextAsync(string path, string? contents, Encoding encoding);
/// <summary>
/// Write the given bytes to a file. The write is persisted to both the filesystem and the service's internal
/// backup database. Avoid writing extremely large byte arrays because this service duplicates data on disk.
/// </summary>
/// <param name="path">The file path to write to. Must not be null or empty.</param>
/// <param name="bytes">The raw bytes to write. Must not be null.</param>
/// <returns>A <see cref="Task"/> that completes when the write has finished and been flushed to disk and the backup.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="bytes"/> is null.</exception>
Task WriteAllBytesAsync(string path, byte[] bytes);
/// <summary>
/// Read all text from a file using UTF-8 encoding. If the file is unreadable or missing on disk, the service
/// attempts to return a backed-up copy from its internal journal/backup database.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="forceBackup">
/// When true the service prefers the internal backup database and returns backed-up contents if available. When
/// false the service tries the filesystem first and falls back to the backup only on error or when the file is missing.
/// </param>
/// <returns>The textual contents of the file, decoded using UTF-8.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
Task<string> ReadAllTextAsync(string path, bool forceBackup = false);
/// <summary>
/// Read all text from a file using the specified <paramref name="encoding"/>. If the file is unreadable or
/// missing on disk, the service attempts to return a backed-up copy from its internal journal/backup database.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="encoding">The encoding to use when decoding the stored bytes into text. Must not be null.</param>
/// <param name="forceBackup">
/// When true the service prefers the internal backup database and returns backed-up contents if available. When
/// false the service tries the filesystem first and falls back to the backup only on error or when the file is missing.
/// </param>
/// <returns>The textual contents of the file decoded using the provided <paramref name="encoding"/>.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="encoding"/> is null.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
Task<string> ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false);
/// <summary>
/// Read all text from a file and invoke the provided <paramref name="reader"/> callback with the string
/// contents. If the reader throws or the initial read fails, the service attempts a backup read and invokes the
/// reader again with the backup contents. If both reads fail the service surfaces an exception to the caller.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="reader">
/// A callback invoked with the file's textual contents. Must not be null.
/// If the callback throws an exception the service treats that as a signal to retry the read using the
/// internal backup database and will invoke the callback again with the backup contents when available.
/// For example, the callback can throw when JSON deserialization fails to request the backup copy instead of
/// silently accepting corrupt data.
/// </param>
/// <returns>A <see cref="Task"/> that completes when the read (and any attempted fallback) and callback invocation have finished.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="reader"/> is null.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
/// <exception cref="FileReadException">Thrown when both the filesystem read and the backup read fail for other reasons.</exception>
Task ReadAllTextAsync(string path, Action<string> reader);
/// <summary>
/// Read all text from a file using the specified <paramref name="encoding"/> and invoke the provided
/// <paramref name="reader"/> callback with the decoded string contents. If the reader throws or the initial
/// read fails, the service attempts a backup read and invokes the reader again with the backup contents. If
/// both reads fail the service surfaces an exception to the caller.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="encoding">The encoding to use when decoding the stored bytes into text. Must not be null.</param>
/// <param name="reader">
/// A callback invoked with the file's textual contents. Must not be null.
/// If the callback throws an exception the service treats that as a signal to retry the read using the
/// internal backup database and will invoke the callback again with the backup contents when available.
/// For example, the callback can throw when JSON deserialization fails to request the backup copy instead of
/// silently accepting corrupt data.
/// </param>
/// <returns>A <see cref="Task"/> that completes when the read (and any attempted fallback) and callback invocation have finished.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="encoding"/> or <paramref name="reader"/> is null.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
/// <exception cref="FileReadException">Thrown when both the filesystem read and the backup read fail for other reasons.</exception>
Task ReadAllTextAsync(string path, Encoding encoding, Action<string> reader);
/// <summary>
/// Read all bytes from a file. If the file is unreadable or missing on disk, the service may try to return a
/// backed-up copy from its internal journal/backup database.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="forceBackup">
/// When true the service prefers the internal backup database and returns the backed-up contents
/// if available. When false the service tries the filesystem first and falls back to the backup only
/// on error or when the file is missing.
/// </param>
/// <returns>The raw bytes stored in the file.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
Task<byte[]> ReadAllBytesAsync(string path, bool forceBackup = false);
}

View file

@ -10,7 +10,7 @@ namespace Dalamud.Plugin.Services;
/// <summary>
/// Interface for determining unlock state of various content in the game.
/// </summary>
[Experimental("UnlockState")]
[Experimental("Dalamud001")]
public interface IUnlockState : IDalamudService
{
/// <summary>

View file

@ -1,5 +1,6 @@
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Dalamud.Logging.Internal;
using Dalamud.Utility;
@ -92,8 +93,9 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <param name="path">Path to write to.</param>
/// <param name="contents">The contents of the file.</param>
/// <param name="containerId">Container to write to.</param>
public void WriteAllText(string path, string? contents, Guid containerId = default)
=> this.WriteAllText(path, contents, Encoding.UTF8, containerId);
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task WriteAllTextAsync(string path, string? contents, Guid containerId = default)
=> await this.WriteAllTextAsync(path, contents, Encoding.UTF8, containerId);
/// <summary>
/// Write all text to a file.
@ -102,10 +104,11 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <param name="contents">The contents of the file.</param>
/// <param name="encoding">The encoding to write with.</param>
/// <param name="containerId">Container to write to.</param>
public void WriteAllText(string path, string? contents, Encoding encoding, Guid containerId = default)
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task WriteAllTextAsync(string path, string? contents, Encoding encoding, Guid containerId = default)
{
var bytes = encoding.GetBytes(contents ?? string.Empty);
this.WriteAllBytes(path, bytes, containerId);
await this.WriteAllBytesAsync(path, bytes, containerId);
}
/// <summary>
@ -114,7 +117,8 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <param name="path">Path to write to.</param>
/// <param name="bytes">The contents of the file.</param>
/// <param name="containerId">Container to write to.</param>
public void WriteAllBytes(string path, byte[] bytes, Guid containerId = default)
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public Task WriteAllBytesAsync(string path, byte[] bytes, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
@ -123,7 +127,7 @@ internal class ReliableFileStorage : IInternalDisposableService
if (this.db == null)
{
FilesystemUtil.WriteAllBytesSafe(path, bytes);
return;
return Task.CompletedTask;
}
this.db.RunInTransaction(() =>
@ -149,6 +153,8 @@ internal class ReliableFileStorage : IInternalDisposableService
FilesystemUtil.WriteAllBytesSafe(path, bytes);
});
}
return Task.CompletedTask;
}
/// <summary>
@ -161,8 +167,8 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <param name="containerId">The container to read from.</param>
/// <returns>All text stored in this file.</returns>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
public string ReadAllText(string path, bool forceBackup = false, Guid containerId = default)
=> this.ReadAllText(path, Encoding.UTF8, forceBackup, containerId);
public Task<string> ReadAllTextAsync(string path, bool forceBackup = false, Guid containerId = default)
=> this.ReadAllTextAsync(path, Encoding.UTF8, forceBackup, containerId);
/// <summary>
/// Read all text from a file.
@ -175,9 +181,9 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <param name="containerId">The container to read from.</param>
/// <returns>All text stored in this file.</returns>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
public string ReadAllText(string path, Encoding encoding, bool forceBackup = false, Guid containerId = default)
public async Task<string> ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false, Guid containerId = default)
{
var bytes = this.ReadAllBytes(path, forceBackup, containerId);
var bytes = await this.ReadAllBytesAsync(path, forceBackup, containerId);
return encoding.GetString(bytes);
}
@ -191,8 +197,9 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <param name="containerId">The container to read from.</param>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
/// <exception cref="FileReadException">Thrown here if the file and the backup fail their read.</exception>
public void ReadAllText(string path, Action<string> reader, Guid containerId = default)
=> this.ReadAllText(path, Encoding.UTF8, reader, containerId);
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task ReadAllTextAsync(string path, Action<string> reader, Guid containerId = default)
=> await this.ReadAllTextAsync(path, Encoding.UTF8, reader, containerId);
/// <summary>
/// Read all text from a file, and automatically try again with the backup if the file does not exist or
@ -205,7 +212,8 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <param name="containerId">The container to read from.</param>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
/// <exception cref="FileReadException">Thrown here if the file and the backup fail their read.</exception>
public void ReadAllText(string path, Encoding encoding, Action<string> reader, Guid containerId = default)
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task ReadAllTextAsync(string path, Encoding encoding, Action<string> reader, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
@ -216,7 +224,7 @@ internal class ReliableFileStorage : IInternalDisposableService
// 1.) Try without using the backup
try
{
var text = this.ReadAllText(path, encoding, false, containerId);
var text = await this.ReadAllTextAsync(path, encoding, false, containerId);
reader(text);
return;
}
@ -233,7 +241,7 @@ internal class ReliableFileStorage : IInternalDisposableService
// 2.) Try using the backup
try
{
var text = this.ReadAllText(path, encoding, true, containerId);
var text = await this.ReadAllTextAsync(path, encoding, true, containerId);
reader(text);
}
catch (Exception ex)
@ -253,7 +261,7 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <param name="containerId">The container to read from.</param>
/// <returns>All bytes stored in this file.</returns>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
public byte[] ReadAllBytes(string path, bool forceBackup = false, Guid containerId = default)
public async Task<byte[]> ReadAllBytesAsync(string path, bool forceBackup = false, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
@ -265,15 +273,12 @@ internal class ReliableFileStorage : IInternalDisposableService
var normalizedPath = NormalizePath(path);
var file = this.db.Table<DbFile>().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId);
if (file == null)
throw new FileNotFoundException();
return file.Data;
return file == null ? throw new FileNotFoundException() : file.Data;
}
// If the file doesn't exist, immediately check the backup db
if (!File.Exists(path))
return this.ReadAllBytes(path, true, containerId);
return await this.ReadAllBytesAsync(path, true, containerId);
try
{
@ -282,7 +287,7 @@ internal class ReliableFileStorage : IInternalDisposableService
catch (Exception e)
{
Log.Error(e, "Failed to read file from disk, falling back to database");
return this.ReadAllBytes(path, true, containerId);
return await this.ReadAllBytesAsync(path, true, containerId);
}
}

View file

@ -0,0 +1,199 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Plugin.Internal.Types;
using Dalamud.Plugin.Services;
namespace Dalamud.Storage;
#pragma warning disable Dalamud001
/// <summary>
/// Plugin-scoped VFS wrapper.
/// </summary>
[PluginInterface]
[ServiceManager.ScopedService]
#pragma warning disable SA1015
[ResolveVia<IReliableFileStorage>]
#pragma warning restore SA1015
public class ReliableFileStoragePluginScoped : IReliableFileStorage, IInternalDisposableService
{
private readonly Lock pendingLock = new();
private readonly HashSet<Task> pendingWrites = [];
private readonly LocalPlugin plugin;
[ServiceManager.ServiceDependency]
private readonly ReliableFileStorage storage = Service<ReliableFileStorage>.Get();
// When true, the scope is disposing and new write requests are rejected.
private volatile bool isDisposing = false;
/// <summary>
/// Initializes a new instance of the <see cref="ReliableFileStoragePluginScoped"/> class.
/// </summary>
/// <param name="plugin">The owner plugin.</param>
[ServiceManager.ServiceConstructor]
internal ReliableFileStoragePluginScoped(LocalPlugin plugin)
{
this.plugin = plugin;
}
/// <inheritdoc/>
public long MaxFileSizeBytes => 64 * 1024 * 1024;
/// <inheritdoc/>
public bool Exists(string path)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
return this.storage.Exists(path, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task WriteAllTextAsync(string path, string? contents)
{
// Route through WriteAllBytesAsync so all write tracking and size checks are centralized.
ArgumentException.ThrowIfNullOrEmpty(path);
var bytes = Encoding.UTF8.GetBytes(contents ?? string.Empty);
return this.WriteAllBytesAsync(path, bytes);
}
/// <inheritdoc/>
public Task WriteAllTextAsync(string path, string? contents, Encoding encoding)
{
// Route through WriteAllBytesAsync so all write tracking and size checks are centralized.
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(encoding);
var bytes = encoding.GetBytes(contents ?? string.Empty);
return this.WriteAllBytesAsync(path, bytes);
}
/// <inheritdoc/>
public Task WriteAllBytesAsync(string path, byte[] bytes)
{
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(bytes);
if (bytes.LongLength > this.MaxFileSizeBytes)
throw new ArgumentException($"The provided data exceeds the maximum allowed size of {this.MaxFileSizeBytes} bytes.", nameof(bytes));
// Start the underlying write task
var task = Task.Run(() => this.storage.WriteAllBytesAsync(path, bytes, this.plugin.EffectiveWorkingPluginId));
// Track the task so we can wait for it on dispose
lock (this.pendingLock)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
this.pendingWrites.Add(task);
}
// Remove when done, if the task is already done this runs synchronously here and removes immediately
_ = task.ContinueWith(t =>
{
lock (this.pendingLock)
{
this.pendingWrites.Remove(t);
}
}, TaskContinuationOptions.ExecuteSynchronously);
return task;
}
/// <inheritdoc/>
public Task<string> ReadAllTextAsync(string path, bool forceBackup = false)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
return this.storage.ReadAllTextAsync(path, forceBackup, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task<string> ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(encoding);
return this.storage.ReadAllTextAsync(path, encoding, forceBackup, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task ReadAllTextAsync(string path, Action<string> reader)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(reader);
return this.storage.ReadAllTextAsync(path, reader, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task ReadAllTextAsync(string path, Encoding encoding, Action<string> reader)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(encoding);
ArgumentNullException.ThrowIfNull(reader);
return this.storage.ReadAllTextAsync(path, encoding, reader, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task<byte[]> ReadAllBytesAsync(string path, bool forceBackup = false)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
return this.storage.ReadAllBytesAsync(path, forceBackup, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public void DisposeService()
{
Task[] tasksSnapshot;
lock (this.pendingLock)
{
// Mark disposing to reject new writes.
this.isDisposing = true;
if (this.pendingWrites.Count == 0)
return;
tasksSnapshot = this.pendingWrites.ToArray();
}
try
{
// Wait for all pending writes to complete. If some complete while we're waiting they will be in tasksSnapshot
// and are observed here; newly started writes are rejected due to isDisposing.
Task.WaitAll(tasksSnapshot);
}
catch (AggregateException)
{
// Swallow exceptions here: the underlying write failures will have been surfaced earlier to callers.
// We don't want dispose to throw and crash unload sequences.
}
}
}

View file

@ -140,10 +140,10 @@ public static partial class Util
}
/// <summary>
/// Gets the Dalamud branch name this version of Dalamud was built from, or null, if this is a Debug build.
/// Gets the Git branch name this version of Dalamud was built from, or null, if this is a Debug build.
/// </summary>
/// <returns>The branch name.</returns>
public static string? GetBranch()
public static string? GetGitBranch()
{
if (branchInternal != null)
return branchInternal;
@ -155,7 +155,17 @@ public static partial class Util
if (gitBranch == null)
return null;
return branchInternal = gitBranch == "master" ? "release" : gitBranch;
return branchInternal = gitBranch;
}
/// <summary>
/// Gets the active Dalamud track, if this instance was launched through XIVLauncher and used a version
/// downloaded from webservices.
/// </summary>
/// <returns>The name of the track, or null.</returns>
internal static string? GetActiveTrack()
{
return Environment.GetEnvironmentVariable("DALAMUD_BRANCH");
}
/// <inheritdoc cref="DescribeAddress(nint)"/>

@ -1 +1 @@
Subproject commit 0afa6b67288e5e667da74c1d3ad582e6c964644c
Subproject commit 0769d1f180f859688f47a7a99610e9ce10da946c