From 8a18f7caaae571febc52b2ddfab272ac84349d65 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 02:26:03 +0200 Subject: [PATCH 01/14] fix(chat-input): replace input on slash-command insert Cherry-pick from ChatTwo upstream ee7768ac (Infiziert90, 2026-05-16): when args.AddIfNotPresent or args.Input starts with '/', replace the chat input instead of appending. Fixes the Friend-List "/tell" path where existing text like "test" would otherwise concatenate to "test/tell user@world" before the receiver and channel resolve. Variable drift versus upstream: HellionChat uses local 'Chat' where ChatTwo uses InputHandler.ChatInput; logic is 1:1. --- HellionChat/Ui/ChatLogWindow.cs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs index ca990aa..a884336 100644 --- a/HellionChat/Ui/ChatLogWindow.cs +++ b/HellionChat/Ui/ChatLogWindow.cs @@ -192,11 +192,28 @@ public sealed class ChatLogWindow : Window return; } + // --------------------------------------------------------------- + // Cherry-picked from ChatTwo upstream ee7768ac (Infiziert90, 2026-05-16) + // - Replace the chat input when args.AddIfNotPresent / args.Input starts + // with a slash. Vanilla actions like the Friend List "/tell" entry and + // other plugins push slash commands through these args; appending them + // to existing text would produce inputs like "test/tell user@world". + // --------------------------------------------------------------- if (args.AddIfNotPresent != null && !Chat.Contains(args.AddIfNotPresent)) - Chat += args.AddIfNotPresent; + { + if (args.AddIfNotPresent.StartsWith('/')) + Chat = args.AddIfNotPresent; + else + Chat += args.AddIfNotPresent; + } if (args.Input != null) - Chat += args.Input; + { + if (args.Input.StartsWith('/')) + Chat = args.Input; + else + Chat += args.Input; + } var (info, reason, target) = (args.ChannelSwitchInfo, args.TellReason, args.TellTarget); From 763f5a3f5dae487eb451aa8302850ad486519960 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 02:29:41 +0200 Subject: [PATCH 02/14] chore(deps): add Microsoft.Extensions.Hosting et al. for DI foundation Prepares the v1.5.0 DI-container adoption (Lightless pattern) by adding four MS.Extensions packages as direct closed-range references: - Microsoft.Extensions.Hosting (IHost, HostBuilder) - Microsoft.Extensions.DependencyInjection (IServiceCollection) - Microsoft.Extensions.Logging (ILogger for DI-4 logger migration) - Microsoft.Extensions.Options (transitive used by Hosting + future config) Closed-range [10.0.7, 11.0.0) matches the existing pinning style for MessagePack/Pidgin/ImageSharp and locks the major version while letting Renovate roll minor and patch updates. Lock file regenerated. --- HellionChat/HellionChat.csproj | 5 + HellionChat/packages.lock.json | 502 ++++++++++++++++++++++++++------- 2 files changed, 400 insertions(+), 107 deletions(-) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 90b7704..51f6e40 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -15,6 +15,11 @@ + + + + + diff --git a/HellionChat/packages.lock.json b/HellionChat/packages.lock.json index 486be32..b50ef46 100644 --- a/HellionChat/packages.lock.json +++ b/HellionChat/packages.lock.json @@ -1,110 +1,398 @@ { - "version": 1, - "dependencies": { - "net10.0-windows7.0": { - "DalamudPackager": { - "type": "Direct", - "requested": "[15.0.0, )", - "resolved": "15.0.0", - "contentHash": "411vwC8/X8Z/sQ2TI6v3SvOn66xFPeOjFn3Zn+h0d3Ox2t1kFm66AhDvmx/qcMwVrR+Hidxj0dadpQ2dgyXMBQ==" - }, - "DotNet.ReproducibleBuilds": { - "type": "Direct", - "requested": "[1.2.39, )", - "resolved": "1.2.39", - "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" - }, - "MessagePack": { - "type": "Direct", - "requested": "[3.1.4, 4.0.0)", - "resolved": "3.1.4", - "contentHash": "BH0wlHWmVoZpbAPyyt2Awbq30C+ZsS3eHSkYdnyUAbqVJ22fAJDzn2xTieBeoT5QlcBzp61vHcv878YJGfi3mg==", - "dependencies": { - "MessagePack.Annotations": "3.1.4", - "MessagePackAnalyzer": "3.1.4", - "Microsoft.NET.StringTools": "17.11.4" - } - }, - "Microsoft.Data.Sqlite": { - "type": "Direct", - "requested": "[10.0.7, )", - "resolved": "10.0.7", - "contentHash": "DZ6G2QuyPrsh5VS+wfiZbNBtYT6p+CkxXjD0aZHF04xso7QsG/uk0JpG30hzYlK6u/wtTzta1Dqfgbc/Sl2sDA==", - "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.7", - "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "morelinq": { - "type": "Direct", - "requested": "[4.4.0, )", - "resolved": "4.4.0", - "contentHash": "QX3bsK9oFeUXk8tFsc9NkI6NnCr8Ar/ex027p+ZZ/jdLCdX2RlryDtxUqZW5j45NVwn4E4Z4hzupsoMQd6Yxtg==" - }, - "Pidgin": { - "type": "Direct", - "requested": "[3.5.1, 4.0.0)", - "resolved": "3.5.1", - "contentHash": "zU7tkXlF3D6d2GLTjJDomAL3nnl4AwfZvSgSz8D4b+Ry21/clqedYlxBnEAkAU/bkGfEv6uRR7QCdWZUpKrB/g==" - }, - "SixLabors.ImageSharp": { - "type": "Direct", - "requested": "[3.1.12, 4.0.0)", - "resolved": "3.1.12", - "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" - }, - "SQLitePCLRaw.lib.e_sqlite3": { - "type": "Direct", - "requested": "[3.50.3, )", - "resolved": "3.50.3", - "contentHash": "tVyhqQ8wxgedWiiPFChyZhE8I3PkOM/AE1azsj1qsdYUws13ONBFyi3aDxju4tD2kzedB2q5+50WrTyY0h2gMQ==" - }, - "MessagePack.Annotations": { - "type": "Transitive", - "resolved": "3.1.4", - "contentHash": "aVWrDAkCdqxwQsz/q0ldPh2EFn48M99YUzE9OvZjMq2RNLKz4o2z88iGFvSvbMqOWRweRvKPHBJZe22PRqzslQ==" - }, - "MessagePackAnalyzer": { - "type": "Transitive", - "resolved": "3.1.4", - "contentHash": "CTaSsN/liJ7MhLCAB7Z4ZLBNuVGCq9lt2BT/cbrc9vzGv89yK3CqIA+z9T19a11eQYl9etZHL6MQJgCqECRVpg==" - }, - "Microsoft.Data.Sqlite.Core": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "xVrtBg3M1wJlBDkoT0dXEYB/wSc8bIHJPYtw/bu1AqpWgF79uPSs87DAhERR/Ilumre6TKZa1cjMg3VUUObVLA==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.NET.StringTools": { - "type": "Transitive", - "resolved": "17.11.4", - "contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA==" - }, - "SQLitePCLRaw.bundle_e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", - "dependencies": { - "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", - "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" - } - }, - "SQLitePCLRaw.core": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" - }, - "SQLitePCLRaw.provider.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - } + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "DalamudPackager": { + "type": "Direct", + "requested": "[15.0.0, )", + "resolved": "15.0.0", + "contentHash": "411vwC8/X8Z/sQ2TI6v3SvOn66xFPeOjFn3Zn+h0d3Ox2t1kFm66AhDvmx/qcMwVrR+Hidxj0dadpQ2dgyXMBQ==" + }, + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.39, )", + "resolved": "1.2.39", + "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" + }, + "MessagePack": { + "type": "Direct", + "requested": "[3.1.4, 4.0.0)", + "resolved": "3.1.4", + "contentHash": "BH0wlHWmVoZpbAPyyt2Awbq30C+ZsS3eHSkYdnyUAbqVJ22fAJDzn2xTieBeoT5QlcBzp61vHcv878YJGfi3mg==", + "dependencies": { + "MessagePack.Annotations": "3.1.4", + "MessagePackAnalyzer": "3.1.4", + "Microsoft.NET.StringTools": "17.11.4" } + }, + "Microsoft.Data.Sqlite": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "DZ6G2QuyPrsh5VS+wfiZbNBtYT6p+CkxXjD0aZHF04xso7QsG/uk0JpG30hzYlK6u/wtTzta1Dqfgbc/Sl2sDA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.7", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[10.0.7, 11.0.0)", + "resolved": "10.0.7", + "contentHash": "91F/o3emPV/+xY/ip3s2LqDNF14kjttlVtq0BXgg6p4MnCzeSZxnUJm+t6WRrtD3JdGo88/oX+z7OwK4y8PZuw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Hosting": { + "type": "Direct", + "requested": "[10.0.7, 11.0.0)", + "resolved": "10.0.7", + "contentHash": "M/vBpfWcschvS2EUeq7cHfscsxabiGTptXwV7GeSueovGiSoNjyo1j5PMcWuOAAQrRW3nRqxZk8NeumrmpzUBg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Binder": "10.0.7", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.7", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.7", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.7", + "Microsoft.Extensions.Configuration.Json": "10.0.7", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.7", + "Microsoft.Extensions.DependencyInjection": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Diagnostics": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Physical": "10.0.7", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging.Configuration": "10.0.7", + "Microsoft.Extensions.Logging.Console": "10.0.7", + "Microsoft.Extensions.Logging.Debug": "10.0.7", + "Microsoft.Extensions.Logging.EventLog": "10.0.7", + "Microsoft.Extensions.Logging.EventSource": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Direct", + "requested": "[10.0.7, 11.0.0)", + "resolved": "10.0.7", + "contentHash": "hOeRIQ63GkgiYCB/MIFp+LQs8aXpJXpB55t6Aj37ab7t2/6WeFcPXxYM9hdy/o5tffzwf8mhqzLJP6mjGYCxjw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7" + } + }, + "Microsoft.Extensions.Options": { + "type": "Direct", + "requested": "[10.0.7, 11.0.0)", + "resolved": "10.0.7", + "contentHash": "00SHUGTh2jSMvIr6x9Xwd2nE+B5/qFCO/9hDwUDhJsjYRDlADmaBZ7tqehXzBDsfjHSXJzuRHJzPYPPjphBQ7Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "morelinq": { + "type": "Direct", + "requested": "[4.4.0, )", + "resolved": "4.4.0", + "contentHash": "QX3bsK9oFeUXk8tFsc9NkI6NnCr8Ar/ex027p+ZZ/jdLCdX2RlryDtxUqZW5j45NVwn4E4Z4hzupsoMQd6Yxtg==" + }, + "Pidgin": { + "type": "Direct", + "requested": "[3.5.1, 4.0.0)", + "resolved": "3.5.1", + "contentHash": "zU7tkXlF3D6d2GLTjJDomAL3nnl4AwfZvSgSz8D4b+Ry21/clqedYlxBnEAkAU/bkGfEv6uRR7QCdWZUpKrB/g==" + }, + "SixLabors.ImageSharp": { + "type": "Direct", + "requested": "[3.1.12, 4.0.0)", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Direct", + "requested": "[3.50.3, )", + "resolved": "3.50.3", + "contentHash": "tVyhqQ8wxgedWiiPFChyZhE8I3PkOM/AE1azsj1qsdYUws13ONBFyi3aDxju4tD2kzedB2q5+50WrTyY0h2gMQ==" + }, + "MessagePack.Annotations": { + "type": "Transitive", + "resolved": "3.1.4", + "contentHash": "aVWrDAkCdqxwQsz/q0ldPh2EFn48M99YUzE9OvZjMq2RNLKz4o2z88iGFvSvbMqOWRweRvKPHBJZe22PRqzslQ==" + }, + "MessagePackAnalyzer": { + "type": "Transitive", + "resolved": "3.1.4", + "contentHash": "CTaSsN/liJ7MhLCAB7Z4ZLBNuVGCq9lt2BT/cbrc9vzGv89yK3CqIA+z9T19a11eQYl9etZHL6MQJgCqECRVpg==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "xVrtBg3M1wJlBDkoT0dXEYB/wSc8bIHJPYtw/bu1AqpWgF79uPSs87DAhERR/Ilumre6TKZa1cjMg3VUUObVLA==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "wZbGh7J8R1vXN525O6d8dlcDTxhRTnd5MyW4LdfP5S0tSnTwTCseYSrq6g0Mxh7W9xn8P/2xPuf0D/m6k2dy2w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "t56nEgvECcyLPojZIUFWJknQQDAbgfTf9J+QMYJE1YYvVgz69vN6B/AKL8Grvj3Lcnp8kTpNqwmwFhb3YLJmtQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "8bS1qIaRivny+WX+49pmeJ6iAylbtX8C0DLEcCQWZjdxQvLqaMssXiGD9P/6pYElrHbK5/nAHmjbQ8STqdMYeg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "3lNjglxfFxOzI9zG+3HSg/YSGqo//8Fqw6u6iuIamZb4JCorbA3JLaeWOpfKTAPi2UJwaispOXWx14dUqcGz4A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "TWto3imA+mJMLZI+5sbgLiFFoOFNFkizQYNaC5jTuiHKn3diwm1RN7mWDOEZN9kG2bixw7IvgpvtUG5/teSRzA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "qbZLvLsoTdArSloEnSxs21P781YUmwVmHc5NJPQD/ezAreQ7884z+6QfAZVKi86WAZtzx83jK2uC4itxOM44gQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Physical": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "64dimvyyKk0dbUbrLg/YCv4ugJ4sVz2aXLwfvZwR1EC4tJqW9ru/oVRcXwoJRa2lQGXtYtlpk4maWOeIb48tQw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "YqVIICoIdl0016wkeO2WQS+uEbEXbUhMLKdC5rZNl1X3nu59F+nwaAHdHjq/4OK+Cx31DYmNUSFh+MUot8qSDw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Json": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Physical": "10.0.7" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "Z6mfFEaFcwCfSboxJwOLfu7/31npCY9q70WUamHW/vRQhDvBKOT4Vf9YkZj5J6hLvJpb0oDEYfHunQZj0xxvKw==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "l+smp1qPlU0OUXD0OGfdp7OUFrbdq7ZaP5T7m2WpfZ4RFKD7iG73BAT7tjSMxNmbSXkhAn1jYHOAqzYG1r9sNg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.7", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.7" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "uJ9JP677y+uy+C0vtaSfi7XXgFAdz8DhU3M9lwwIXDfQKcyQ0yxM9DVYa0NXDtdVTYA2eBUtVFZ8LY0GCdeE/w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "teioDgVpi8L186wUfrXQV1YuBt6lCSPmFZiMZo53+FZxHFjOV+f4GXo4LXgJ273Mku9//AdXWVjk9J7eJP6inw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "zhgWg/i0ECj5v0jLFBSZHplvc5ygCI91DR4nne+BP4XAKF5ycz0pEKnFiTw8C1jCABJEZsnBZh6pXAvn71kFmw==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "NTUspqB+vH9g4wAD6KPOBx01xqYuKXR/cHXm449zpbq1GqfjdAxBmg7eJXrNsPw7SKwIdT2cJ05GxYVvc+lvsA==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "5s8d6qC6EA8UOI4wR/+zlsq7SXttJMRb9d7zvVZ7+bE3CQEfVtC9ITUDCommm87R1zzj6WJBbCnztuIJXnP3DA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "tIEcQ2gvERrH2KiCjdsVcHGhXt9lIsuDStfOIeZWr7/fP8IXhGiYfx0/80PNI7WPO2IYuFtlZLSlnTS8+/Mchw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "7BBnoGF37USiu7j434put9mDp7EjdlNDIZsR4vHfC1FbLZeLqiWjgJbeEtF0p59Ryqt8AtraHawf0ZKbe5jibg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Binder": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "DA++Es6v6W0HfrOrw+K8WyN6jNnZHp640PDdEvl8yfeVmgflKdn6vSSFvufNUSOuY+M2ZaSUgfY+jUKtNpXcCw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging.Configuration": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "Y6DSt/JZApunYWKqTtqbdsR6iqAvHx3D0tavbNJ1rnC24MUpF+3XO/VKgFi+9PFqMyvQ2GHBBGb8H3cLSw7rDg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "1C8eTuxF6BLncNSJ1HCfmaBcjpUSqQDPlBVdYTlet9oldHTPpNh9iatxSJLs8TOqdp/FOpH+nSLdBve7fu9mTQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "System.Diagnostics.EventLog": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "YWfndnDX1jVMGCN8d5T+rO+BO8sDw6BkYlUk0BYui+WP7+HhlWx8QLdA4yUDjrkGVb3AQxIWWEPVKw5Nnfj5GQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "IT7f+EMXZtkjatEcF+o6aOw/7OE4etRrMiDGEWH/iiTu2R3uhC4NEQJCfHiibtX45U3sIQ5Fh6tbb1qaOz3YAg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Binder": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "D5M0Jr551iTgwkZMN9rm0pSkgNLj5quUWQUmQPMZh7k/bnvZTnXRGfE2KuvXf1EEjt/ofD9yw9IumpgdP9QCnw==" + }, + "Microsoft.NET.StringTools": { + "type": "Transitive", + "resolved": "17.11.4", + "contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "WbmDLeTPYhEzXhvYVioTVn/D1XX6bovyny9n5p8Zxtf03+eY385RB818teZm6n+fA63iZNvng0/Np4tLuhkMhQ==" + } } -} + } +} \ No newline at end of file From f6d3794d874585f7948f0415c48a03a79d204030 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 02:44:54 +0200 Subject: [PATCH 03/14] feat(di): scaffold Microsoft.Extensions.Hosting container (DI-1 + DI-1b) Lays down the DI foundation that v1.5.x will run on top of, without flipping the switch on Plugin.cs yet (that move follows in C3). The new files compile alongside the existing bootstrap but no caller resolves the host, so the live behaviour is byte-identical to v1.4.10. What's new: - PluginHostFactory.cs: HostBuilder.Build(plugin, dependencies) registers ~46 services across Block A (21 Dalamud singletons), Block B (14 HellionChat services plus FileDialogManager), Block C (8 windows), plus Plugin and PluginLifecycle. Service-class bodies are untouched - Plugin-backref ctors go through factory lambdas. - PluginLifecycle.cs: thin IAsyncDisposable wrapping the host's StartAsync/StopAsync, with idempotent dispose and framework-thread Host.Dispose. The Host is assigned via a property setter from Plugin.ctor; HellionChat deviates from Lightless' Func-delegate pattern because the schema gate must run before Build. - Infrastructure/Logging/{DalamudLogger, DalamudLoggingProvider, DalamudLoggingProviderExtensions}.cs: ILogger -> IPluginLog bridge, ported from Lightless without the mod-sync hasModifiedGameFiles flag and without the LightlessConfigService log-level coupling. - Infrastructure/Hosting/InitHostedServices.cs: seven IHostedService adapters around the existing init methods (FontManager.BuildFonts, ThemeRegistry warmup+switch, IpcManager/TypingIpc/ExtraChat eager resolve, MessageManager.FilterAllTabsAsync, AutoTellTabsService .Initialize). Adapter style rather than inlining ": IHostedService" on the service classes per the DI-2a "service bodies untouched" constraint. Plan drift noted for cycle closure: MessageStore stays inside MessageManager.ctor (not a standalone container singleton) because MessageManager.ctor allocates it directly today; promoting it would double-construct the SQLite handle. AutoTellTabsService reads it via MessageManager.Store inside its factory lambda. --- .../Hosting/InitHostedServices.cs | 103 ++++++++++ .../Infrastructure/Logging/DalamudLogger.cs | 62 ++++++ .../Logging/DalamudLoggingProvider.cs | 44 +++++ .../DalamudLoggingProviderExtensions.cs | 23 +++ HellionChat/PluginHostFactory.cs | 184 ++++++++++++++++++ HellionChat/PluginLifecycle.cs | 124 ++++++++++++ 6 files changed, 540 insertions(+) create mode 100644 HellionChat/Infrastructure/Hosting/InitHostedServices.cs create mode 100644 HellionChat/Infrastructure/Logging/DalamudLogger.cs create mode 100644 HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs create mode 100644 HellionChat/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs create mode 100644 HellionChat/PluginHostFactory.cs create mode 100644 HellionChat/PluginLifecycle.cs diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs new file mode 100644 index 0000000..d7932e5 --- /dev/null +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -0,0 +1,103 @@ +using Dalamud.Plugin; +using HellionChat.Ipc; +using HellionChat.Themes; +using Microsoft.Extensions.Hosting; + +namespace HellionChat.Infrastructure.Hosting; + +// Adapter shells around the IHostedService contract so the host can resolve +// the underlying singletons eagerly and trigger their existing init methods +// without modifying the service class bodies (DI-2a constraint). Bodies that +// stay empty here still serve a purpose: the host resolves the service when +// it instantiates the hosted service, which forces the ctor (IPC subscribe +// for IpcManager / TypingIpc / ExtraChat) to run during StartAsync instead of +// lazily on first GetRequiredService. + +internal sealed class FontManagerInitHostedService(FontManager fontManager) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + fontManager.BuildFonts(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class ThemeRegistryInitHostedService(ThemeRegistry registry) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + // Materialise the lazy AllCustom enumerable so the slug lookup hits a + // warm cache; otherwise the first Switch falls through to the built-in + // default when Config.Theme points at a custom slug. + foreach (var _ in registry.AllCustom()) { } + registry.Switch(Plugin.Config.Theme); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +// IPC subscribers do their wiring in the ctor today, so StartAsync stays a +// no-op — the value of registering them as hosted services is that the host +// resolves them eagerly during Build, which triggers the ctor work. Moving +// the body into StartAsync is a DI-2b follow-up after the service ctors are +// allowed to change. + +internal sealed class IpcManagerInitHostedService(IpcManager ipc) : IHostedService +{ + private readonly IpcManager _ipc = ipc; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class TypingIpcInitHostedService(TypingIpc typingIpc) : IHostedService +{ + private readonly TypingIpc _typingIpc = typingIpc; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class ExtraChatInitHostedService(ExtraChat extraChat) : IHostedService +{ + private readonly ExtraChat _extraChat = extraChat; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class MessageManagerInitHostedService( + IDalamudPluginInterface pluginInterface, + MessageManager manager +) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + // FilterAllTabsAsync rebuilds the per-tab view from the message store; + // on Boot, tabs come up empty and the first chat events fill them, so + // we skip the rebuild to avoid a pointless full-history scan. + if (pluginInterface.Reason is not PluginLoadReason.Boot) + manager.FilterAllTabsAsync(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class AutoTellTabsServiceInitHostedService(AutoTellTabsService service) + : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + service.Initialize(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/HellionChat/Infrastructure/Logging/DalamudLogger.cs b/HellionChat/Infrastructure/Logging/DalamudLogger.cs new file mode 100644 index 0000000..0d73412 --- /dev/null +++ b/HellionChat/Infrastructure/Logging/DalamudLogger.cs @@ -0,0 +1,62 @@ +using System.Text; +using Dalamud.Plugin.Services; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Infrastructure.Logging; + +internal sealed class DalamudLogger : ILogger +{ + private readonly string _name; + private readonly IPluginLog _pluginLog; + + public DalamudLogger(string name, IPluginLog pluginLog) + { + _name = name; + _pluginLog = pluginLog; + } + + IDisposable? ILogger.BeginScope(TState state) => default!; + + // Filtering happens in Dalamud's /xllog. Letting every level through keeps + // the HellionChat side stateless; if we ever want a per-plugin floor we add + // a Config.LogLevel and tighten this method. + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + if (!IsEnabled(logLevel)) + return; + + if ((int)logLevel <= (int)LogLevel.Information) + { + _pluginLog.Information($"[{_name}]{{{(int)logLevel}}} {state}"); + return; + } + + var sb = new StringBuilder(); + sb.Append($"[{_name}]{{{(int)logLevel}}} {state} {exception?.Message}"); + if (!string.IsNullOrWhiteSpace(exception?.StackTrace)) + sb.AppendLine(exception.StackTrace); + + var inner = exception?.InnerException; + while (inner != null) + { + sb.AppendLine($"InnerException {inner}: {inner.Message}"); + sb.AppendLine(inner.StackTrace); + inner = inner.InnerException; + } + + if (logLevel == LogLevel.Warning) + _pluginLog.Warning(sb.ToString()); + else if (logLevel == LogLevel.Error) + _pluginLog.Error(sb.ToString()); + else + _pluginLog.Fatal(sb.ToString()); + } +} diff --git a/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs b/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs new file mode 100644 index 0000000..8988b6c --- /dev/null +++ b/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; +using Dalamud.Plugin.Services; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Infrastructure.Logging; + +[ProviderAlias("Dalamud")] +public sealed class DalamudLoggingProvider : ILoggerProvider +{ + private readonly ConcurrentDictionary _loggers = new( + StringComparer.OrdinalIgnoreCase + ); + + private readonly IPluginLog _pluginLog; + + public DalamudLoggingProvider(IPluginLog pluginLog) + { + _pluginLog = pluginLog; + } + + public ILogger CreateLogger(string categoryName) + { + // Category-name normalisation mirrors Lightless: take the leaf type + // name, then either ellipsis-trim long ones or left-pad short ones to + // 15 chars so the xllog column stays aligned across services. + var catName = categoryName.Split(".", StringSplitOptions.RemoveEmptyEntries).Last(); + if (catName.Length > 15) + catName = string.Concat( + catName.AsSpan(0, 6), + "...", + catName.AsSpan(catName.Length - 6, 6) + ); + else + catName = catName.PadLeft(15); + + return _loggers.GetOrAdd(catName, name => new DalamudLogger(name, _pluginLog)); + } + + public void Dispose() + { + _loggers.Clear(); + GC.SuppressFinalize(this); + } +} diff --git a/HellionChat/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs b/HellionChat/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs new file mode 100644 index 0000000..86c840d --- /dev/null +++ b/HellionChat/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs @@ -0,0 +1,23 @@ +using Dalamud.Plugin.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Infrastructure.Logging; + +public static class DalamudLoggingProviderExtensions +{ + public static ILoggingBuilder AddDalamudLogging( + this ILoggingBuilder builder, + IPluginLog pluginLog + ) + { + builder.ClearProviders(); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton( + _ => new DalamudLoggingProvider(pluginLog) + ) + ); + return builder; + } +} diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs new file mode 100644 index 0000000..f1cec76 --- /dev/null +++ b/HellionChat/PluginHostFactory.cs @@ -0,0 +1,184 @@ +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using HellionChat.Infrastructure.Hosting; +using HellionChat.Infrastructure.Logging; +using HellionChat.Ipc; +using HellionChat.Themes; +using HellionChat.Ui; +using HellionChat.Util; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace HellionChat; + +// Builds the generic-host DI container that drives v1.5.0+. The factory is +// invoked synchronously from Plugin.ctor (after the schema gate clears) so the +// container exists before PluginLifecycle.LoadAsync runs. See plan §1 for the +// deliberate divergence from Lightless' deferred Func-delegate pattern. +internal static class PluginHostFactory +{ + public static IHost Build(Plugin plugin, PluginHostDependencies dependencies) + { + return new HostBuilder() + .UseContentRoot(dependencies.PluginInterface.ConfigDirectory.FullName) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddDalamudLogging(dependencies.PluginLog); + logging.SetMinimumLevel(LogLevel.Trace); + }) + .ConfigureServices(services => ConfigureServices(services, plugin, dependencies)) + .Build(); + } + + private static void ConfigureServices( + IServiceCollection services, + Plugin plugin, + PluginHostDependencies dependencies + ) + { + // ----------------------------------------------------------------- + // Block A — Dalamud-Services (21 [PluginService] singletons, plus the + // dependencies record itself). Registered by-interface so consumer + // ctors can resolve them without touching Plugin statics. + // ----------------------------------------------------------------- + services.AddSingleton(dependencies); + services.AddSingleton(dependencies.PluginInterface); + services.AddSingleton(dependencies.PluginLog); + services.AddSingleton(dependencies.ChatGui); + services.AddSingleton(dependencies.ClientState); + services.AddSingleton(dependencies.CommandManager); + services.AddSingleton(dependencies.Condition); + services.AddSingleton(dependencies.DataManager); + services.AddSingleton(dependencies.Framework); + services.AddSingleton(dependencies.GameGui); + services.AddSingleton(dependencies.KeyState); + services.AddSingleton(dependencies.ObjectTable); + services.AddSingleton(dependencies.PartyList); + services.AddSingleton(dependencies.TargetManager); + services.AddSingleton(dependencies.TextureProvider); + services.AddSingleton(dependencies.GameInteropProvider); + services.AddSingleton(dependencies.GameConfig); + services.AddSingleton(dependencies.Notification); + services.AddSingleton(dependencies.AddonLifecycle); + services.AddSingleton(dependencies.PlayerState); + services.AddSingleton(dependencies.Evaluator); + services.AddSingleton(dependencies.SelfTestRegistry); + + // ----------------------------------------------------------------- + // Self-reference. Plugin owns the [PluginService] static surface and + // is already constructed by Dalamud before this factory runs, so we + // register the existing instance instead of letting the container + // build one. + // ----------------------------------------------------------------- + services.AddSingleton(plugin); + services.AddSingleton(plugin.WindowSystem); + + // PluginLifecycle is a thin orchestrator over IHost; Plugin.ctor pulls + // it via GetRequiredService() immediately after Build. + services.AddSingleton(); + + // ----------------------------------------------------------------- + // Block B — HellionChat singletons (14 + 1 FileDialogManager adapter). + // Service-class bodies stay untouched in v1.5.0 per the DI-2a + // constraint; ctors that need a Plugin backref go through a factory + // lambda that resolves Plugin from the container. + // ----------------------------------------------------------------- + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(sp => new ThemeRegistry( + Path.Combine( + sp.GetRequiredService().ConfigDirectory.FullName, + "themes" + ) + )); + + services.AddSingleton(sp => new GameFunctions.GameFunctions( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new TypingIpc(sp.GetRequiredService())); + + services.AddSingleton(sp => new Integrations.HonorificService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); + + services.AddSingleton(sp => new MessageManager(sp.GetRequiredService())); + + // AutoTellTabsService pulls MessageStore through MessageManager.Store + // because MessageStore is still allocated inside MessageManager.ctor + // (DI-2a leaves that body untouched). Promoting MessageStore to its + // own container singleton would double-construct the SQLite handle. + services.AddSingleton(sp => + { + var pluginRef = sp.GetRequiredService(); + var manager = sp.GetRequiredService(); + return new AutoTellTabsService(pluginRef, manager, manager.Store); + }); + + // ----------------------------------------------------------------- + // Block C — Windows (8, each takes Plugin or ChatLogWindow). The + // host never AddWindow()s them; PluginLifecycle does that on the + // framework thread once C3 wires it up (see plan §2 service order). + // ----------------------------------------------------------------- + services.AddSingleton(sp => new ChatLogWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new SettingsWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new DbViewer(sp.GetRequiredService())); + services.AddSingleton(sp => new InputPreview(sp.GetRequiredService())); + services.AddSingleton(sp => new CommandHelpWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService())); + services.AddSingleton(sp => new DebuggerWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new FirstRunWizard(sp.GetRequiredService())); + + // ----------------------------------------------------------------- + // Hosted-service adapters — IHostedService is the host's only "after + // bootstrap, before user interaction" hook, so we register thin + // wrappers that call the existing init methods (BuildFonts, Switch, + // FilterAllTabsAsync, Initialize) without modifying the service + // bodies. Plan §2 documents why this is adapter-style instead of + // making the services themselves implement IHostedService (Lightless' + // pattern) — DI-2a leaves service classes untouched. + // ----------------------------------------------------------------- + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + } +} + +internal sealed record PluginHostDependencies( + IDalamudPluginInterface PluginInterface, + IPluginLog PluginLog, + IChatGui ChatGui, + IClientState ClientState, + ICommandManager CommandManager, + ICondition Condition, + IDataManager DataManager, + IFramework Framework, + IGameGui GameGui, + IKeyState KeyState, + IObjectTable ObjectTable, + IPartyList PartyList, + ITargetManager TargetManager, + ITextureProvider TextureProvider, + IGameInteropProvider GameInteropProvider, + IGameConfig GameConfig, + INotificationManager Notification, + IAddonLifecycle AddonLifecycle, + IPlayerState PlayerState, + ISeStringEvaluator Evaluator, + ISelfTestRegistry SelfTestRegistry +); diff --git a/HellionChat/PluginLifecycle.cs b/HellionChat/PluginLifecycle.cs new file mode 100644 index 0000000..dd2e151 --- /dev/null +++ b/HellionChat/PluginLifecycle.cs @@ -0,0 +1,124 @@ +using System.Runtime.ExceptionServices; +using Dalamud.Plugin.Services; +using Microsoft.Extensions.Hosting; + +namespace HellionChat; + +// Orchestrates Host.StartAsync / StopAsync + dispose on the framework thread. +// The Host itself is built sync in Plugin.ctor (before the schema gate clears) +// and assigned via the property setter; PluginLifecycle never builds it +// itself, which is why HellionChat skips Lightless' Func-delegate indirection +// (see plan §9 risk "Bewusste Abweichung von Lightless"). +internal sealed class PluginLifecycle : IAsyncDisposable +{ + private readonly IFramework _framework; + + private int _disposeStarted; + private bool _hostStartRequested; + + public PluginLifecycle(IFramework framework) + { + _framework = framework; + } + + // Plugin.ctor fills this immediately after PluginHostFactory.Build and + // before invoking LoadAsync; LoadAsync may NRE-suppress on Host! safely. + public IHost? Host { get; set; } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + _hostStartRequested = true; + await Host!.StartAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + try + { + await DisposeAsync().ConfigureAwait(false); + } + catch + { + // Swallow secondary dispose failure so the original load throw wins. + } + + throw; + } + } + + public async ValueTask DisposeAsync() + { + // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race. + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + return; + + Exception? failure = null; + + if (_hostStartRequested && Host is not null) + failure = await CaptureFailureAsync(failure, () => Host.StopAsync()) + .ConfigureAwait(false); + + failure = await DisposeHostOnFrameworkThreadAsync(failure).ConfigureAwait(false); + + ThrowIfFailed(failure); + } + + private async Task DisposeHostOnFrameworkThreadAsync(Exception? failure) + { + try + { + await _framework + .RunOnFrameworkThread(() => + { + failure = CaptureFailure(failure, () => Host?.Dispose()); + }) + .ConfigureAwait(false); + } + catch (Exception ex) + { + failure ??= ex; + } + + return failure; + } + + private static Exception? CaptureFailure(Exception? failure, Action action) + { + try + { + action(); + } + catch (Exception ex) + { + failure ??= ex; + } + + return failure; + } + + private static async ValueTask CaptureFailureAsync( + Exception? failure, + Func action + ) + { + try + { + await action().ConfigureAwait(false); + } + catch (Exception ex) + { + failure ??= ex; + } + + return failure; + } + + private static void ThrowIfFailed(Exception? failure) + { + if (failure is not null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } +} From 169168cea9b52982733c8fffed15a727ceefcba2 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 03:34:34 +0200 Subject: [PATCH 04/14] feat(di): wire Plugin.cs to the DI container (DI-2a + DI-5) Flips the container live. Plugin.ctor now builds the host after the schema gate clears, pulls PluginLifecycle out of the container, and backfills the Plugin.X static surface plus the instance properties (11 services + 8 windows) so existing consumers reach the same instances the container holds. Plugin.LoadAsync gets thinner: service and window allocations are gone (the container owns them), BuildFonts / Switch / FilterAllTabsAsync / Initialize moved to their hosted-service adapters inside Host.StartAsync, WindowSystem.AddWindow moved into PluginLifecycle.LoadAsync on the framework thread. Plugin-internal init (SelfTestRegistry, FirstRunWizard, SetupCommands + Commands.Initialise, RetentionSweep, EmoteCache.LoadData, FTS5 rebuild worker, UiBuilder.Disable*UiHide, AutoTranslate.PreloadCache, Framework / Draw / LanguageChanged subscribes) stays in Plugin.LoadAsync because each step reaches Plugin-private members or fields. Plugin.DisposeAsync keeps the manual teardown for ordering (IPC before windows, hooks first) and awaits _lifecycle.DisposeAsync at the end to stop the host and dispose the container on the framework thread. Double-disposes against container singletons are no-ops for the services that hold real resources (Dispose idempotency is the standard pattern). PluginLifecycle takes Plugin as a constructor arg so it can iterate the Window properties and call WindowSystem.AddWindow on the framework thread; v1.4.9 Stage-2 verified that AddWindow's backing List<> is not thread-safe. Plan drift D4 noted: Plugin.cs ends at 1050 lines instead of the 150-220 vision because helper methods (MigrateFromChatTwoLayout, SeedExampleThemeIfEmpty, RunRetentionSweepIfDue, FrameworkUpdate, Draw, LanguageChanged, SetupCommands, slash handlers, FTS worker) stay in Plugin.cs. Extracting them is DI-2b or a dedicated service refactor in v1.5.1+. C3 still hits the DI-2a goal: bootstrap is container-driven and LoadAsync is allocation-free. PlatformUtil and LogProxy keep the manual `new` for now; C5 (DI-3) removes those once C3 stabilises in the smoke test. --- HellionChat/Plugin.cs | 156 ++++++++++++++++++++------------- HellionChat/PluginLifecycle.cs | 23 ++++- 2 files changed, 119 insertions(+), 60 deletions(-) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index d4d0a5c..348aad4 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -15,6 +15,8 @@ using HellionChat.Resources; using HellionChat.Ui; using HellionChat.Util; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace HellionChat; @@ -123,6 +125,12 @@ public sealed class Plugin : IAsyncDalamudPlugin // isolation. Wired immediately after Dalamud injects Log. internal static IPluginLogProxy LogProxy { get; private set; } = null!; + // Container drives the v1.5.0 bootstrap. Both are nullable so DisposeAsync + // stays safe if Phase-1 (Host build) throws before they get assigned - + // Dalamud fires DisposeAsync regardless of how far the ctor got. + private readonly IHost? _host; + private readonly PluginLifecycle? _lifecycle; + // Wrapper cached so TearDown can detach the live instance instead of // re-registering with identical args (v1.4.9 ISSUE-1 cleanup). private CommandWrapper? _hellionSettingsCmd; @@ -212,6 +220,75 @@ public sealed class Plugin : IAsyncDalamudPlugin ImGuiUtil.Initialize(this); DeferredSaveFrames = -1; + + // Custom themes dir + seed run before the container builds so the + // ThemeRegistry factory lambda finds the directory ready and the + // example theme stays in place if the user has not touched it. + var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); + Directory.CreateDirectory(customThemesDir); + SeedExampleThemeIfEmpty(customThemesDir); + + // Phase-1: build the generic host and pull singletons out into the + // Plugin.X surface so consumers untouched by DI-2a keep working. The + // host stays sync here because the schema gate above must run before + // services allocate; deferring the build to LoadAsync (Lightless' + // pattern) would mean the gate fires after the container is alive. + var dependencies = new PluginHostDependencies( + Interface, + Log, + ChatGui, + ClientState, + CommandManager, + Condition, + DataManager, + Framework, + GameGui, + KeyState, + ObjectTable, + PartyList, + TargetManager, + TextureProvider, + GameInteropProvider, + GameConfig, + Notification, + AddonLifecycle, + PlayerState, + Evaluator, + SelfTestRegistry + ); + + _host = PluginHostFactory.Build(this, dependencies); + _lifecycle = _host.Services.GetRequiredService(); + _lifecycle.Host = _host; + + // Plugin.X static bridge - filled from the container so DI-aware code + // and the ~93 Plugin.X consumer sites read the same instances. + PlatformUtil = _host.Services.GetRequiredService(); + LogProxy = _host.Services.GetRequiredService(); + FileDialogManager = _host.Services.GetRequiredService(); + + // Resolve order matters: block-B services first so the windows can + // read Plugin.MessageManager etc. from their own ctors without NREs. + FontManager = _host.Services.GetRequiredService(); + ThemeRegistry = _host.Services.GetRequiredService(); + Commands = _host.Services.GetRequiredService(); + Functions = _host.Services.GetRequiredService(); + Ipc = _host.Services.GetRequiredService(); + TypingIpc = _host.Services.GetRequiredService(); + ExtraChat = _host.Services.GetRequiredService(); + HonorificService = _host.Services.GetRequiredService(); + StatusBar = _host.Services.GetRequiredService(); + MessageManager = _host.Services.GetRequiredService(); + AutoTellTabsService = _host.Services.GetRequiredService(); + + ChatLogWindow = _host.Services.GetRequiredService(); + SettingsWindow = _host.Services.GetRequiredService(); + DbViewer = _host.Services.GetRequiredService(); + InputPreview = _host.Services.GetRequiredService(); + CommandHelpWindow = _host.Services.GetRequiredService(); + SeStringDebugger = _host.Services.GetRequiredService(); + DebuggerWindow = _host.Services.GetRequiredService(); + FirstRunWizard = _host.Services.GetRequiredService(); } public async Task LoadAsync(CancellationToken cancellationToken) @@ -233,66 +310,17 @@ public sealed class Plugin : IAsyncDalamudPlugin cancellationToken.ThrowIfCancellationRequested(); - // BuildFonts registers handles with Dalamud's FontAtlas; the atlas - // rebuilds async a few frames later (visible "font-pop" on first load). - FontManager = new FontManager(); - FontManager.BuildFonts(); - - // ThemeRegistry must be wired before the first Draw tick. - var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); - Directory.CreateDirectory(customThemesDir); - SeedExampleThemeIfEmpty(customThemesDir); - ThemeRegistry = new Themes.ThemeRegistry(customThemesDir); - // Warm up the custom-theme cache before the first Switch. - // LoadCustomBySlug is a reverse-lookup over _customCache; on a - // cold cache a Config.Theme that points at a custom slug would - // fall through to the built-in default. AllCustom is a lazy - // enumerable, so iterate it explicitly to materialise the cache. - foreach (var _ in ThemeRegistry.AllCustom()) { } - ThemeRegistry.Switch(Config.Theme); - - cancellationToken.ThrowIfCancellationRequested(); - - // Service allocations — order encodes dependencies. - // HonorificService registers IPC subscribers early to catch - // Ready/Disposing events from the first frame. - FileDialogManager = new FileDialogManager(); - Commands = new Commands(); - Functions = new GameFunctions.GameFunctions(this); - Ipc = new IpcManager(); - TypingIpc = new TypingIpc(this); - ExtraChat = new ExtraChat(); - HonorificService = new Integrations.HonorificService(Interface, Log, Framework); - StatusBar = new Ui.StatusBar(); - MessageManager = new MessageManager(this); - - AutoTellTabsService = new AutoTellTabsService( - this, - MessageManager, - MessageManager.Store - ); - AutoTellTabsService.Initialize(); + // Container drives service init now: Host.StartAsync triggers the + // IHostedService adapters (FontManager.BuildFonts, ThemeRegistry + // cache warmup + Switch, IPC eager-resolve, MessageManager + // FilterAllTabsAsync, AutoTellTabsService.Initialize). Window + // registration with WindowSystem runs on the framework thread + // inside PluginLifecycle.LoadAsync after StartAsync returns. + if (_lifecycle is not null) + await _lifecycle.LoadAsync(cancellationToken).ConfigureAwait(false); SelfTestRegistry.RegisterTestSteps([new SelfTests.ThemeSwitchSelfTestStep(this)]); - ChatLogWindow = new ChatLogWindow(this); - SettingsWindow = new SettingsWindow(this); - DbViewer = new DbViewer(this); - InputPreview = new InputPreview(ChatLogWindow); - CommandHelpWindow = new CommandHelpWindow(ChatLogWindow); - SeStringDebugger = new SeStringDebugger(this); - DebuggerWindow = new DebuggerWindow(this); - FirstRunWizard = new FirstRunWizard(this); - - WindowSystem.AddWindow(ChatLogWindow); - WindowSystem.AddWindow(SettingsWindow); - WindowSystem.AddWindow(DbViewer); - WindowSystem.AddWindow(InputPreview); - WindowSystem.AddWindow(CommandHelpWindow); - WindowSystem.AddWindow(SeStringDebugger); - WindowSystem.AddWindow(DebuggerWindow); - WindowSystem.AddWindow(FirstRunWizard); - if (!Config.FirstRunCompleted) FirstRunWizard.IsOpen = true; @@ -313,8 +341,8 @@ public sealed class Plugin : IAsyncDalamudPlugin if (Config.ShowEmotes) _ = EmoteCache.LoadData(); - if (Interface.Reason is not PluginLoadReason.Boot) - MessageManager.FilterAllTabsAsync(); + // FilterAllTabsAsync now runs from MessageManagerInitHostedService + // during Host.StartAsync (same Reason-not-Boot guard there). // Kick the FTS5 rebuild worker if Migrate4 just added the schema or // a previous run was cut short (InitFtsReadyCache leaves _ftsReady @@ -557,6 +585,16 @@ public sealed class Plugin : IAsyncDalamudPlugin // Static input history would otherwise survive the plugin reload. failure = CaptureFailure(failure, InputHistoryService.Reset); + // Lifecycle stops the host (HostedService.StopAsync) and disposes it + // on the framework thread. Container reaches the same singletons that + // the manual block above already disposed; second Dispose() is a no-op + // for the IDisposable services we own. + if (_lifecycle is not null) + { + failure = await CaptureFailureAsync(failure, () => _lifecycle.DisposeAsync().AsTask()) + .ConfigureAwait(false); + } + if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw(); } diff --git a/HellionChat/PluginLifecycle.cs b/HellionChat/PluginLifecycle.cs index dd2e151..8497cbf 100644 --- a/HellionChat/PluginLifecycle.cs +++ b/HellionChat/PluginLifecycle.cs @@ -12,13 +12,15 @@ namespace HellionChat; internal sealed class PluginLifecycle : IAsyncDisposable { private readonly IFramework _framework; + private readonly Plugin _plugin; private int _disposeStarted; private bool _hostStartRequested; - public PluginLifecycle(IFramework framework) + public PluginLifecycle(IFramework framework, Plugin plugin) { _framework = framework; + _plugin = plugin; } // Plugin.ctor fills this immediately after PluginHostFactory.Build and @@ -33,6 +35,13 @@ internal sealed class PluginLifecycle : IAsyncDisposable { _hostStartRequested = true; await Host!.StartAsync(cancellationToken).ConfigureAwait(false); + + // WindowSystem.AddWindow mutates an internal List<>; v1.4.9 Stage-2 + // verified the list is non-thread-safe, so we marshal the entire + // registration block to the framework thread. + await _framework + .RunOnFrameworkThread(() => RegisterWindows(_plugin)) + .ConfigureAwait(false); } catch { @@ -49,6 +58,18 @@ internal sealed class PluginLifecycle : IAsyncDisposable } } + private static void RegisterWindows(Plugin plugin) + { + plugin.WindowSystem.AddWindow(plugin.ChatLogWindow); + plugin.WindowSystem.AddWindow(plugin.SettingsWindow); + plugin.WindowSystem.AddWindow(plugin.DbViewer); + plugin.WindowSystem.AddWindow(plugin.InputPreview); + plugin.WindowSystem.AddWindow(plugin.CommandHelpWindow); + plugin.WindowSystem.AddWindow(plugin.SeStringDebugger); + plugin.WindowSystem.AddWindow(plugin.DebuggerWindow); + plugin.WindowSystem.AddWindow(plugin.FirstRunWizard); + } + public async ValueTask DisposeAsync() { // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race. From 0fe66d2c3ca2b9dbed4f9d1dcd8ee5b5efe302c1 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 08:20:02 +0200 Subject: [PATCH 05/14] fix(di): use factory lambdas for internal-ctor services C3 bootstrap throws "A suitable constructor for type HellionChat.Ipc.ExtraChat could not be located" because Microsoft.Extensions.DependencyInjection's ActivatorUtilities only binds to PUBLIC constructors via reflection. ExtraChat is a public class with an internal ctor; Commands and StatusBar are internal classes whose implicit default ctor inherits class accessibility (internal); every IHostedService adapter is `internal sealed class X(deps)` with a primary ctor that is also internal. The fix routes all eight singletons and all seven hosted-service adapters through factory lambdas. `new T(...)` inside the PluginHostFactory namespace sees the internal surface, so the container never has to reflect over internal ctors. --- HellionChat/PluginHostFactory.cs | 56 +++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index f1cec76..a4c3786 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -86,14 +86,22 @@ internal static class PluginHostFactory // constraint; ctors that need a Plugin backref go through a factory // lambda that resolves Plugin from the container. // ----------------------------------------------------------------- - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // Factory lambdas across the board: Microsoft.Extensions.DependencyInjection's + // ActivatorUtilities only inspects PUBLIC constructors via reflection, + // and several HellionChat classes are `internal sealed` with implicit- + // internal default ctors (Commands, StatusBar) or explicitly `internal` + // ctors on public classes (ExtraChat). The lambda body compiles inside + // the HellionChat namespace, so `new T()` sees the internal surface. + services.AddSingleton(_ => new DalamudPlatformUtil()); + services.AddSingleton(sp => new DalamudPluginLogProxy( + sp.GetRequiredService() + )); + services.AddSingleton(_ => new FileDialogManager()); + services.AddSingleton(_ => new Commands()); + services.AddSingleton(_ => new FontManager()); + services.AddSingleton(_ => new StatusBar()); + services.AddSingleton(_ => new IpcManager()); + services.AddSingleton(_ => new ExtraChat()); services.AddSingleton(sp => new ThemeRegistry( Path.Combine( @@ -149,13 +157,31 @@ internal static class PluginHostFactory // making the services themselves implement IHostedService (Lightless' // pattern) — DI-2a leaves service classes untouched. // ----------------------------------------------------------------- - services.AddHostedService(); - services.AddHostedService(); - services.AddHostedService(); - services.AddHostedService(); - services.AddHostedService(); - services.AddHostedService(); - services.AddHostedService(); + // Same internal-ctor pitfall as the singletons above - the adapter + // classes are `internal sealed` with primary constructors, so the + // direct AddHostedService() overload's ActivatorUtilities fails. + services.AddHostedService(sp => new FontManagerInitHostedService( + sp.GetRequiredService() + )); + services.AddHostedService(sp => new ThemeRegistryInitHostedService( + sp.GetRequiredService() + )); + services.AddHostedService(sp => new IpcManagerInitHostedService( + sp.GetRequiredService() + )); + services.AddHostedService(sp => new TypingIpcInitHostedService( + sp.GetRequiredService() + )); + services.AddHostedService(sp => new ExtraChatInitHostedService( + sp.GetRequiredService() + )); + services.AddHostedService(sp => new MessageManagerInitHostedService( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddHostedService(sp => new AutoTellTabsServiceInitHostedService( + sp.GetRequiredService() + )); } } From b66005daeaafa17c0b7360b593116712c0dc31b4 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 08:24:45 +0200 Subject: [PATCH 06/14] fix(di): stop double-disposing container singletons in Plugin.DisposeAsync Smoke 1 of C3 surfaced MessageManager.DisposeAsync throwing on unload: Plugin.DisposeAsync ran the manual MessageManager teardown (CTS cancel + dispose at MessageManager.cs:84-99), then awaited _lifecycle.DisposeAsync which routed Host.Dispose through the container, which hit MessageManager.DisposeAsync a second time and threw ObjectDisposedException on the already-disposed CTS. Plugin.DisposeAsync now drops every manual service dispose - the container owns those singletons end-to-end. The framework-thread block keeps the three calls the container has no handle on (TearDownCommands, GameFunctions.SetChatInteractable, WindowSystem.RemoveAllWindows), plus the static-class cleanups (EmoteCache.Dispose, InputHistoryService.Reset) stay outside the container entirely. This changes the teardown order versus v1.4.10: the container disposes in reverse-registration order, which puts Windows ahead of IPC services. The v1.4.10 ordering ("IPC before Windows so a final IPC event cannot hit a half-torn ChatLogWindow") is no longer enforced. Host.Dispose runs synchronously on the framework thread, so no Framework.Update or Draw event fires during teardown; the remaining risk is an external IPC plugin invoking a subscriber mid-dispose, which is not something v1.4.10 actually prevented either. --- HellionChat/Plugin.cs | 57 +++++++++++-------------------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 348aad4..cd1bf02 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -527,49 +527,21 @@ public sealed class Plugin : IAsyncDalamudPlugin } ); - // Unsubscribe AutoTellTabs before MessageManager goes away. - failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose()); - - // MessageManager has its own async dispose path (DB flush, thread shutdown). - if (MessageManager is not null) - { - failure = await CaptureFailureAsync( - failure, - () => MessageManager.DisposeAsync().AsTask() - ) - .ConfigureAwait(false); - } - - // Game-function / IPC / window cleanup must run on the framework thread. + // Framework-thread cleanup the container does not reach. TearDownCommands + // walks Plugin-private dictionaries; SetChatInteractable is a static + // call into game state; WindowSystem.RemoveAllWindows clears the + // backing List<> that AddWindow populated in PluginLifecycle.LoadAsync. try { await Framework .RunOnFrameworkThread(() => { - // TearDown slash-commands + UiBuilder hooks before windows - // tear down. Slash-commands holding handlers that reach - // the windows would otherwise see a half-torn Plugin. failure = CaptureFailure(failure, TearDownCommands); - failure = CaptureFailure( failure, () => GameFunctions.GameFunctions.SetChatInteractable(true) ); - - // IPC subscribers before windows — prevents a final IPC event - // from reaching a half-torn ChatLogWindow. - failure = CaptureFailure(failure, () => HonorificService?.Dispose()); - failure = CaptureFailure(failure, () => TypingIpc?.Dispose()); - failure = CaptureFailure(failure, () => ExtraChat?.Dispose()); - failure = CaptureFailure(failure, () => Ipc?.Dispose()); - failure = CaptureFailure(failure, () => WindowSystem?.RemoveAllWindows()); - failure = CaptureFailure(failure, () => ChatLogWindow?.Dispose()); - failure = CaptureFailure(failure, () => DbViewer?.Dispose()); - failure = CaptureFailure(failure, () => InputPreview?.Dispose()); - failure = CaptureFailure(failure, () => SettingsWindow?.Dispose()); - failure = CaptureFailure(failure, () => DebuggerWindow?.Dispose()); - failure = CaptureFailure(failure, () => SeStringDebugger?.Dispose()); }) .ConfigureAwait(false); } @@ -578,23 +550,22 @@ public sealed class Plugin : IAsyncDalamudPlugin failure ??= ex; } - // Pure-memory cleanups — no Framework / UI / IPC touch. - failure = CaptureFailure(failure, () => Functions?.Dispose()); - failure = CaptureFailure(failure, () => Commands?.Dispose()); - failure = CaptureFailure(failure, () => EmoteCache.Dispose()); - // Static input history would otherwise survive the plugin reload. - failure = CaptureFailure(failure, InputHistoryService.Reset); - - // Lifecycle stops the host (HostedService.StopAsync) and disposes it - // on the framework thread. Container reaches the same singletons that - // the manual block above already disposed; second Dispose() is a no-op - // for the IDisposable services we own. + // Lifecycle stops the host (HostedService.StopAsync) and disposes the + // container on the framework thread; that path disposes all the + // services + windows we used to dispose manually here. The smoke from + // C3 surfaced MessageManager.DisposeAsync as non-idempotent (CTS + // dispose at line 99 throws on a second call), so we hand the entire + // service teardown to the container instead of double-disposing. if (_lifecycle is not null) { failure = await CaptureFailureAsync(failure, () => _lifecycle.DisposeAsync().AsTask()) .ConfigureAwait(false); } + // Static-class cleanups the container has no handle on. + failure = CaptureFailure(failure, () => EmoteCache.Dispose()); + failure = CaptureFailure(failure, InputHistoryService.Reset); + if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw(); } From e0ead8661642821d2d77d81d97d0aefa56b98462 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 08:58:03 +0200 Subject: [PATCH 07/14] refactor(di): drop manual PlatformUtil and LogProxy wiring (DI-3) C3's Phase-1 bridge in Plugin.ctor already pulls IPlatformUtil and IPluginLogProxy out of the container right after the host builds, so the manual `new DalamudPlatformUtil()` / `new DalamudPluginLogProxy` assignments in Phase-0 were just allocating throwaway instances that got overwritten a few lines later. Phase-0 helpers that run before the container build (MigrateFromChatTwoLayout, LanguageChanged, ImGuiUtil.Initialize) do not touch Plugin.PlatformUtil or Plugin.LogProxy, so the brief null-window between the schema gate and the container build is safe. The DalamudPlatformUtil and DalamudPluginLogProxy wrapper classes themselves stay in the code; DI-4 (logger migration to ILogger) will eventually retire the proxy for new sites but EmoteCache, AutoTranslate, MemoryUtil and WrapperUtil keep using it. --- HellionChat/Plugin.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index cd1bf02..c374464 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -193,11 +193,11 @@ public sealed class Plugin : IAsyncDalamudPlugin Config = Interface.GetPluginConfig() as Configuration ?? new Configuration(); - // Wire platform indirection before LoadAsync allocates anything that - // needs Util.* — services then read Plugin.PlatformUtil instead of - // hitting the Dalamud static surface directly. - PlatformUtil = new DalamudPlatformUtil(); - LogProxy = new DalamudPluginLogProxy(Log); + // PlatformUtil and LogProxy are filled from the DI container in + // Phase-1 below (`_host.Services.GetRequiredService()` + // and the LogProxy equivalent). Phase-0 helpers that run before that + // point (MigrateFromChatTwoLayout, LanguageChanged, ImGuiUtil.Initialize) + // do not touch either static, so the brief null-window is safe. // Schema gate: v1.4.x requires config v16+. Users on older schemas // must install v1.4.2 first to run the migration chain. v17 adds From d0be75e79dc6dcfdff03782a78003d0447c61daa Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 09:09:55 +0200 Subject: [PATCH 08/14] refactor(di): migrate services layer to ILogger (DI-4 Slice A) MessageStore, MessageEnumerator, MessageManager, AutoTellTabsService move from Plugin.LogProxy / IPluginLogProxy onto Microsoft.Extensions.Logging.ILogger via constructor injection. MessageStore additionally takes ILoggerFactory so it can build a per-instance ILogger at each of the five reader- spawning sites; the enumerator is not a container singleton. PluginHostFactory's MessageManager and AutoTellTabsService factory lambdas grow to resolve the new logger args; everything else stays in place. Site-level migration in the four files: - MessageStore: 12 calls, _logger field IPluginLogProxy -> ILogger - MessageManager: 7 Plugin.LogProxy.* sites, new _logger field - AutoTellTabsService: 9 Plugin.LogProxy.* sites, new _logger field Plus a pre-existing template bug surfaced by CA2017: a LogDebug call in AutoTellTabsService used "{tab.Name}" with no `$` prefix, which landed in xllog as literal text under Plugin.LogProxy; ILogger now reads that as a structured placeholder, so the call was promoted to proper structured logging with tab.Name passed as a parameter. --- HellionChat/AutoTellTabsService.cs | 30 +++++++------ HellionChat/MessageManager.cs | 30 +++++++++---- HellionChat/MessageStore.cs | 67 ++++++++++++++++++++---------- HellionChat/PluginHostFactory.cs | 13 +++++- 4 files changed, 96 insertions(+), 44 deletions(-) diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index d354bac..6418f99 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -9,6 +9,7 @@ using HellionChat.Code; using HellionChat.GameFunctions.Types; using HellionChat.Resources; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat; @@ -19,6 +20,7 @@ internal sealed class AutoTellTabsService : IDisposable private readonly Plugin _plugin; private readonly MessageManager _messageManager; private readonly MessageStore _store; + private readonly ILogger _logger; private readonly object _tempTabsLock = new(); // Hard cap on pinned TempTabs so the sidebar doesn't inflate over years @@ -29,11 +31,17 @@ internal sealed class AutoTellTabsService : IDisposable private bool _initialized; - internal AutoTellTabsService(Plugin plugin, MessageManager messageManager, MessageStore store) + internal AutoTellTabsService( + Plugin plugin, + MessageManager messageManager, + MessageStore store, + ILogger logger + ) { _plugin = plugin; _messageManager = messageManager; _store = store; + _logger = logger; } // Derived from the tab list on read. Pin/Unpin/Promote/Logout simply @@ -67,7 +75,7 @@ internal sealed class AutoTellTabsService : IDisposable private void RehydratePinnedTabs() { var pinned = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool); - Plugin.LogProxy.Debug($"[Pin] Rehydrate scan: {pinned} pinned tab(s) found"); + _logger.LogDebug($"[Pin] Rehydrate scan: {pinned} pinned tab(s) found"); foreach (var tab in Plugin.Config.Tabs) { @@ -76,7 +84,7 @@ internal sealed class AutoTellTabsService : IDisposable if (tab.TellTarget is null || !tab.TellTarget.IsSet()) { - Plugin.LogProxy.Warning( + _logger.LogWarning( $"[Pin] Pinned tab '{tab.Name}' has no usable TellTarget " + $"(Name={tab.TellTarget?.Name ?? ""} World={tab.TellTarget?.World ?? 0}). " + "Chat input on this tab will be empty until the partner sends a tell or you /tell manually." @@ -93,7 +101,7 @@ internal sealed class AutoTellTabsService : IDisposable // sees the recent conversation, not a blank tab. PreloadHistory(tab, tab.TellTarget.Name, tab.TellTarget.World, Guid.Empty); - Plugin.LogProxy.Debug( + _logger.LogDebug( $"[Pin] Rehydrated '{tab.Name}' -> Tell target {tab.TellTarget.Name}@{tab.TellTarget.World}" ); } @@ -130,7 +138,7 @@ internal sealed class AutoTellTabsService : IDisposable if (partner == null) { // Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases) - Plugin.LogProxy.Warning( + _logger.LogWarning( $"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, " + $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, " + $"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, " @@ -361,7 +369,7 @@ internal sealed class AutoTellTabsService : IDisposable catch (Exception ex) { // Non-fatal: tab still spawns with visible error notice instead of silent history loss - Plugin.LogProxy.Error(ex, "[AutoTellTabs] History preload failed"); + _logger.LogError(ex, "[AutoTellTabs] History preload failed"); tab.Messages.AddPrune( MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError), MessageManager.MessageDisplayLimit @@ -456,7 +464,7 @@ internal sealed class AutoTellTabsService : IDisposable { if (!tab.IsTempTab || tab.IsPinned) { - Plugin.LogProxy.Debug( + _logger.LogDebug( $"[Pin] TryPin skipped: IsTempTab={tab.IsTempTab} IsPinned={tab.IsPinned}" ); return false; @@ -472,7 +480,7 @@ internal sealed class AutoTellTabsService : IDisposable } tab.IsPinned = true; - Plugin.LogProxy.Debug( + _logger.LogDebug( $"[Pin] Pinned tab '{tab.Name}' target={tab.TellTarget?.Name}@{tab.TellTarget?.World}" ); _plugin.SaveConfig(); @@ -495,7 +503,7 @@ internal sealed class AutoTellTabsService : IDisposable } tab.IsPinned = false; - Plugin.LogProxy.Debug("[Pin] Unpinned tab '{tab.Name}'"); + _logger.LogDebug("[Pin] Unpinned tab '{TabName}'", tab.Name); _plugin.SaveConfig(); } @@ -509,9 +517,7 @@ internal sealed class AutoTellTabsService : IDisposable tab.IsTempTab = false; tab.IsPinned = false; tab.TellTarget = TellTarget.Empty(); - Plugin.LogProxy.Debug( - $"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)" - ); + _logger.LogDebug($"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)"); _plugin.SaveConfig(); } } diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index 64f3bd1..fc11a2f 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -14,6 +14,7 @@ using HellionChat.Util; using Lumina.Text.Expressions; using Lumina.Text.Payloads; using Lumina.Text.ReadOnly; +using Microsoft.Extensions.Logging; namespace HellionChat; @@ -22,6 +23,7 @@ internal class MessageManager : IAsyncDisposable internal const int MessageDisplayLimit = 10_000; private Plugin Plugin { get; } + private readonly ILogger _logger; internal MessageStore Store { get; } private Dictionary Formats { get; } = []; @@ -48,11 +50,21 @@ internal class MessageManager : IAsyncDisposable // AutoTellTabsService to spawn or refresh temp tabs without coupling. public event Action? MessageProcessed; - internal unsafe MessageManager(Plugin plugin) + internal unsafe MessageManager( + Plugin plugin, + ILogger logger, + ILoggerFactory loggerFactory + ) { Plugin = plugin; + _logger = logger; - Store = new MessageStore(DatabasePath(), Plugin.PlatformUtil, Plugin.LogProxy); + Store = new MessageStore( + DatabasePath(), + Plugin.PlatformUtil, + loggerFactory.CreateLogger(), + loggerFactory + ); PendingMessageThread = new Thread(() => ProcessPendingMessages(PendingThreadCancellationToken.Token) @@ -91,7 +103,7 @@ internal class MessageManager : IAsyncDisposable await Task.Delay(100); if (PendingMessageThread.IsAlive) - Plugin.LogProxy.Warning( + _logger.LogWarning( "PendingMessageThread did not observe cancellation within 10s. " + "Worker remains on background thread; next plugin reload releases it." ); @@ -137,7 +149,7 @@ internal class MessageManager : IAsyncDisposable } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error processing pending message"); + _logger.LogError(ex, "Error processing pending message"); } } else @@ -182,12 +194,12 @@ internal class MessageManager : IAsyncDisposable // Mark failed messages as deleted to prevent retry attempts var failedIds = messages.FailedMessageIds(); - Plugin.LogProxy.Info( + _logger.LogInformation( $"Marking {failedIds.Count} messages as deleted due to parse failures" ); foreach (var msgId in messages.FailedMessageIds()) { - Plugin.LogProxy.Debug($"Marking message '{msgId}' as deleted due to parse failure"); + _logger.LogDebug($"Marking message '{msgId}' as deleted due to parse failure"); Store.DeleteMessage(msgId); } } @@ -203,13 +215,13 @@ internal class MessageManager : IAsyncDisposable } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error in FilterAllTabs"); + _logger.LogError(ex, "Error in FilterAllTabs"); } // v1.4.9 R3 profiling: Information so the xllog tail surfaces this // without a Debug filter. Belt-and-suspenders for future plugin-load // regressions; remains in place after Sub-Task 3.4 Befund. - Plugin.LogProxy.Information($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms"); + _logger.LogInformation($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms"); }); } @@ -264,7 +276,7 @@ internal class MessageManager : IAsyncDisposable } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error in ContentIdResolver"); + _logger.LogError(ex, "Error in ContentIdResolver"); } } diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 21d314a..0cfe180 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -9,6 +9,7 @@ using MessagePack; using MessagePack.Formatters; using MessagePack.Resolvers; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; using Encoding = System.Text.Encoding; namespace HellionChat; @@ -179,7 +180,8 @@ internal class MessageStore : IDisposable } private readonly IPlatformUtil _platformUtil; - private readonly IPluginLogProxy _logger; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; // Readiness gate for the FTS5 full-text index. Volatile so the DbViewer's // per-frame IsFtsIndexBuilt read sees the flip the moment the bulk-insert @@ -197,11 +199,17 @@ internal class MessageStore : IDisposable // own SqliteConnection via OpenSecondaryConnection. private readonly object _readLock = new(); - internal MessageStore(string dbPath, IPlatformUtil platformUtil, IPluginLogProxy logger) + internal MessageStore( + string dbPath, + IPlatformUtil platformUtil, + ILogger logger, + ILoggerFactory loggerFactory + ) { DbPath = dbPath; _platformUtil = platformUtil; _logger = logger; + _loggerFactory = loggerFactory; Connection = Connect(); Migrate(); InitFtsReadyCache(); @@ -246,7 +254,7 @@ internal class MessageStore : IDisposable conn.Open(); ApplyPragmas(conn); connectSw.Stop(); - _logger.Information($"MessageStore.Connect took {connectSw.ElapsedMilliseconds}ms"); + _logger.LogInformation($"MessageStore.Connect took {connectSw.ElapsedMilliseconds}ms"); return conn; } @@ -290,12 +298,12 @@ internal class MessageStore : IDisposable migration(); migrateSw.Stop(); - _logger.Information($"MessageStore.Migrate took {migrateSw.ElapsedMilliseconds}ms"); + _logger.LogInformation($"MessageStore.Migrate took {migrateSw.ElapsedMilliseconds}ms"); } private void Migrate0() { - _logger.Information("Running migration 0: Creating tables"); + _logger.LogInformation("Running migration 0: Creating tables"); Connection.Execute( @" CREATE TABLE IF NOT EXISTS messages ( @@ -322,7 +330,7 @@ internal class MessageStore : IDisposable private void Migrate1() { - _logger.Information("Running migration 1: Adding Deleted column"); + _logger.LogInformation("Running migration 1: Adding Deleted column"); Connection.Execute( @" ALTER TABLE messages ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT false; @@ -334,7 +342,7 @@ internal class MessageStore : IDisposable private void Migrate2() { - _logger.Information("Running migration 2: Adding Channel generated column"); + _logger.LogInformation("Running migration 2: Adding Channel generated column"); Connection.Execute( @" ALTER TABLE messages ADD COLUMN Channel INTEGER GENERATED ALWAYS AS (Code & 0x7f) VIRTUAL; @@ -362,13 +370,15 @@ internal class MessageStore : IDisposable private void Migrate3() { - _logger.Information("Running migration 3: Fix log kinds to fit the new format"); + _logger.LogInformation("Running migration 3: Fix log kinds to fit the new format"); // Recovery for partially-applied Migrate3: schema already in target // shape but user_version was never bumped -- just record and exit. if (ColumnExists("messages", "ChatType") && !ColumnExists("messages", "Code")) { - _logger.Information("Migration 3: schema already migrated, only bumping user_version"); + _logger.LogInformation( + "Migration 3: schema already migrated, only bumping user_version" + ); SetMigrationVersion(3); return; } @@ -398,7 +408,7 @@ internal class MessageStore : IDisposable private void Migrate4() { - _logger.Information("Running migration 4: Add FTS5 virtual table for full-text search"); + _logger.LogInformation("Running migration 4: Add FTS5 virtual table for full-text search"); // Standalone FTS5 table (no content='messages' linking, no content_rowid). // messages.Id is BLOB-PK (Guid), which is incompatible with FTS5's @@ -422,7 +432,7 @@ internal class MessageStore : IDisposable private void SetMigrationVersion(int version) { - _logger.Information($"Setting version {version}"); + _logger.LogInformation($"Setting version {version}"); using var cmd = Connection.CreateCommand(); // PRAGMA does not accept SQLite parameter bindings; version is a // compile-time int from the migration sequence, never user input. @@ -837,7 +847,7 @@ internal class MessageStore : IDisposable // Privacy filter -- drop disallowed ChatTypes before they reach storage. if (!Plugin.Config.IsAllowedForStorage(message.Code.Type)) { - _logger.Verbose($"Privacy filter dropped message: ChatType={message.Code.Type}"); + _logger.LogTrace($"Privacy filter dropped message: ChatType={message.Code.Type}"); return; } @@ -941,7 +951,10 @@ internal class MessageStore : IDisposable if (to is not null) cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator( + cmd.ExecuteReader(), + _loggerFactory.CreateLogger() + ); } } @@ -993,7 +1006,10 @@ internal class MessageStore : IDisposable cmd.Parameters.AddWithValue("$Count", count); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator( + cmd.ExecuteReader(), + _loggerFactory.CreateLogger() + ); } } @@ -1033,7 +1049,10 @@ internal class MessageStore : IDisposable cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing); var collected = new List(); - using var enumerator = new MessageEnumerator(cmd.ExecuteReader(), _logger); + using var enumerator = new MessageEnumerator( + cmd.ExecuteReader(), + _loggerFactory.CreateLogger() + ); foreach (var message in enumerator) { if (!ChunkUtil.MatchesSender(message, senderName, senderWorld)) @@ -1145,7 +1164,10 @@ internal class MessageStore : IDisposable ((DateTimeOffset)before).ToUnixTimeMilliseconds() ); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator( + cmd.ExecuteReader(), + _loggerFactory.CreateLogger() + ); } } @@ -1198,7 +1220,10 @@ internal class MessageStore : IDisposable cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page); cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator( + cmd.ExecuteReader(), + _loggerFactory.CreateLogger() + ); } } @@ -1219,14 +1244,14 @@ internal class MessageStore : IDisposable } } -internal class MessageEnumerator(DbDataReader reader, IPluginLogProxy logger) +internal class MessageEnumerator(DbDataReader reader, ILogger logger) : IEnumerable, IDisposable, IAsyncDisposable { private const int MaxErrorLogs = 10; - private readonly IPluginLogProxy _logger = logger; + private readonly ILogger _logger = logger; private readonly List FailedIds = []; private int FailedCount; public bool DidError => FailedCount > 0; @@ -1247,10 +1272,10 @@ internal class MessageEnumerator(DbDataReader reader, IPluginLogProxy logger) catch (Exception e) { if (FailedCount < MaxErrorLogs) - _logger.Error($"Exception while reading message '{id}' from database: {e}"); + _logger.LogError($"Exception while reading message '{id}' from database: {e}"); FailedCount++; if (FailedCount == MaxErrorLogs) - _logger.Error("Further parsing errors will not be logged"); + _logger.LogError("Further parsing errors will not be logged"); if (id != Guid.Empty) FailedIds.Add(id); diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index a4c3786..af59833 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -121,7 +121,11 @@ internal static class PluginHostFactory sp.GetRequiredService() )); - services.AddSingleton(sp => new MessageManager(sp.GetRequiredService())); + services.AddSingleton(sp => new MessageManager( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService() + )); // AutoTellTabsService pulls MessageStore through MessageManager.Store // because MessageStore is still allocated inside MessageManager.ctor @@ -131,7 +135,12 @@ internal static class PluginHostFactory { var pluginRef = sp.GetRequiredService(); var manager = sp.GetRequiredService(); - return new AutoTellTabsService(pluginRef, manager, manager.Store); + return new AutoTellTabsService( + pluginRef, + manager, + manager.Store, + sp.GetRequiredService>() + ); }); // ----------------------------------------------------------------- From 7a1bd1babcbb53f371d768a2939c740b205d39ef Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 09:56:46 +0200 Subject: [PATCH 09/14] refactor(di): migrate Integrations + IPC layer to ILogger (DI-4 Slice B) Seven services across Integrations/, Ipc/ and GameFunctions/ shift from Plugin.LogProxy to Microsoft.Extensions.Logging.ILogger. Files with live LogProxy sites (10 in total): - Ipc/ExtraChat (1) - GameFunctions/Chat (6) - GameFunctions/GameFunctions (2) - GameFunctions/KeybindManager (1) Foundation-touch files (no current sites, ctor takes ILogger as seed for the v1.5.7-11 Plugin-Integrations wave): - Integrations/HonorificService (also drops the local IPluginLog _log field in favour of ILogger _logger; the three _log.* calls there are migrated as a bonus since the field had to change anyway) - IpcManager - Ipc/TypingIpc GameFunctions takes ILoggerFactory as an extra ctor arg so it can hand a typed logger to its nested Chat and KeybindManager (same pattern MessageStore + MessageEnumerator use in Slice A). PluginHostFactory factory lambdas updated for all five Slice B services that need extra resolves. Plan drift D8: GameFunctions.TryOpenAdventurerPlate is an internal static method whose only Warning call cannot reach the instance _logger. The one site stays on Plugin.LogProxy with an inline note; promoting it to instance + PayloadHandler.cs:814 call-site update is a v1.5.1+ cleanup, out of DI-4 Slice B scope. --- HellionChat/GameFunctions/Chat.cs | 18 +++++++++++------- HellionChat/GameFunctions/GameFunctions.cs | 19 +++++++++++++++---- HellionChat/GameFunctions/KeybindManager.cs | 8 ++++++-- HellionChat/Integrations/HonorificService.cs | 13 +++++++------ HellionChat/Ipc/ExtraChat.cs | 11 ++++++----- HellionChat/Ipc/TypingIpc.cs | 6 +++++- HellionChat/IpcManager.cs | 6 +++++- HellionChat/PluginHostFactory.cs | 15 ++++++++++----- 8 files changed, 65 insertions(+), 31 deletions(-) diff --git a/HellionChat/GameFunctions/Chat.cs b/HellionChat/GameFunctions/Chat.cs index aa6502c..0523bda 100755 --- a/HellionChat/GameFunctions/Chat.cs +++ b/HellionChat/GameFunctions/Chat.cs @@ -19,6 +19,7 @@ using HellionChat.Resources; using HellionChat.Util; using InteropGenerator.Runtime; using Lumina.Text.ReadOnly; +using Microsoft.Extensions.Logging; using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType; namespace HellionChat.GameFunctions; @@ -98,9 +99,12 @@ internal sealed unsafe class Chat : IDisposable private long LastPlayerNameDisplayTypeRefresh; private PlayerNameDisplayType CurrentPlayerNameDisplayType = PlayerNameDisplayType.FullName; - public Chat(Plugin plugin) + private readonly ILogger _logger; + + public Chat(Plugin plugin, ILogger logger) { Plugin = plugin; + _logger = logger; Plugin.GameInteropProvider.InitializeFromAttributes(this); ChatLogRefreshHook?.Enable(); @@ -236,7 +240,7 @@ internal sealed unsafe class Chat : IDisposable } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error in chat Activated event"); + _logger.LogError(ex, "Error in chat Activated event"); } }); } @@ -266,7 +270,7 @@ internal sealed unsafe class Chat : IDisposable } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error in chat Activated event"); + _logger.LogError(ex, "Error in chat Activated event"); } return 1; // Prevent vanilla chat log from gaining focus @@ -299,7 +303,7 @@ internal sealed unsafe class Chat : IDisposable { playerName = SeString.Parse(agent->TellPlayerName).TextValue; worldId = agent->TellWorldId; - Plugin.LogProxy.Debug($"Detected tell target '[redacted]'@{worldId}"); + _logger.LogDebug($"Detected tell target '[redacted]'@{worldId}"); } Plugin.CurrentTab.CurrentChannel = new UsedChannel @@ -358,7 +362,7 @@ internal sealed unsafe class Chat : IDisposable } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error in chat Activated event"); + _logger.LogError(ex, "Error in chat Activated event"); } } @@ -408,7 +412,7 @@ internal sealed unsafe class Chat : IDisposable } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error in chat Activated event"); + _logger.LogError(ex, "Error in chat Activated event"); } } @@ -624,7 +628,7 @@ internal sealed unsafe class Chat : IDisposable if (contentId == 0) { Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error); - Plugin.LogProxy.Warning( + _logger.LogWarning( "Tried to send a tell with ContentId being 0, sorry this is an internal error." ); return; diff --git a/HellionChat/GameFunctions/GameFunctions.cs b/HellionChat/GameFunctions/GameFunctions.cs index 985fa16..5e48942 100755 --- a/HellionChat/GameFunctions/GameFunctions.cs +++ b/HellionChat/GameFunctions/GameFunctions.cs @@ -14,6 +14,7 @@ using FFXIVClientStructs.FFXIV.Client.UI.Info; using FFXIVClientStructs.FFXIV.Component.GUI; using Lumina.Excel; using Lumina.Excel.Sheets; +using Microsoft.Extensions.Logging; using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType; namespace HellionChat.GameFunctions; @@ -37,14 +38,20 @@ internal unsafe class GameFunctions : IDisposable #endregion private Plugin Plugin { get; } + private readonly ILogger _logger; internal KeybindManager KeybindManager { get; } internal Chat Chat { get; } - internal GameFunctions(Plugin plugin) + internal GameFunctions( + Plugin plugin, + ILogger logger, + ILoggerFactory loggerFactory + ) { Plugin = plugin; - KeybindManager = new KeybindManager(plugin); - Chat = new Chat(Plugin); + _logger = logger; + KeybindManager = new KeybindManager(plugin, loggerFactory.CreateLogger()); + Chat = new Chat(Plugin, loggerFactory.CreateLogger()); Plugin.GameInteropProvider.InitializeFromAttributes(this); ResolveTextCommandPlaceholderHook?.Enable(); @@ -215,6 +222,10 @@ internal unsafe class GameFunctions : IDisposable } catch (Exception e) { + // Static method has no instance _logger to reach. Promoting this to + // an instance method would force PayloadHandler.cs:814 (the only + // caller) onto Plugin.Functions.* indirection. Lighter touch for + // DI-4 Slice B is to keep this one site on Plugin.LogProxy. Plugin.LogProxy.Warning(e, "Unable to open adventurer plate"); return false; } @@ -255,7 +266,7 @@ internal unsafe class GameFunctions : IDisposable var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName); if (byteCount >= PlaceholderBufferSize) { - Plugin.LogProxy.Warning( + _logger.LogWarning( $"Replacement name too long for placeholder buffer ({byteCount} bytes >= {PlaceholderBufferSize}); falling back to original." ); ReplacementName = null; diff --git a/HellionChat/GameFunctions/KeybindManager.cs b/HellionChat/GameFunctions/KeybindManager.cs index f374f2a..64aa401 100644 --- a/HellionChat/GameFunctions/KeybindManager.cs +++ b/HellionChat/GameFunctions/KeybindManager.cs @@ -8,6 +8,7 @@ using FFXIVClientStructs.FFXIV.Client.UI; using HellionChat.Code; using HellionChat.GameFunctions.Types; using HellionChat.Util; +using Microsoft.Extensions.Logging; using ModifierFlag = HellionChat.GameFunctions.Types.ModifierFlag; namespace HellionChat.GameFunctions; @@ -306,9 +307,12 @@ internal unsafe class KeybindManager : IDisposable // VirtualKey.OEM_CLEAR, }; - internal KeybindManager(Plugin plugin) + private readonly ILogger _logger; + + internal KeybindManager(Plugin plugin, ILogger logger) { Plugin = plugin; + _logger = logger; Plugin.GameInteropProvider.InitializeFromAttributes(this); // Handle keybinds from the game on every tick. @@ -507,7 +511,7 @@ internal unsafe class KeybindManager : IDisposable } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error in chat Activated event"); + _logger.LogError(ex, "Error in chat Activated event"); } } diff --git a/HellionChat/Integrations/HonorificService.cs b/HellionChat/Integrations/HonorificService.cs index 73b47d3..6a37588 100644 --- a/HellionChat/Integrations/HonorificService.cs +++ b/HellionChat/Integrations/HonorificService.cs @@ -2,6 +2,7 @@ using System; using Dalamud.Plugin; using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Services; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace HellionChat.Integrations; @@ -23,7 +24,7 @@ internal sealed class HonorificService : IDisposable private readonly ICallGateSubscriber _ready; private readonly ICallGateSubscriber _disposing; - private readonly IPluginLog _log; + private readonly ILogger _logger; private readonly IFramework _framework; private bool _versionWarningLogged; @@ -34,12 +35,12 @@ internal sealed class HonorificService : IDisposable public HonorificService( IDalamudPluginInterface pluginInterface, - IPluginLog log, + ILogger logger, IFramework framework ) { _framework = framework; - _log = log; + _logger = logger; // Gate objects are cached per-name by Dalamud and safe to register // before Honorific loads — they just won't fire until it does. @@ -84,7 +85,7 @@ internal sealed class HonorificService : IDisposable { if (!_versionWarningLogged) { - _log.Warning( + _logger.LogWarning( "Honorific API version mismatch — expected major 3, " + "found {Major}.{Minor}. Disabling Honorific integration.", version.Item1, @@ -104,7 +105,7 @@ internal sealed class HonorificService : IDisposable catch (Exception ex) { // Honorific not installed or not yet initialised — Ready will retry. - _log.Debug(ex, "Honorific not available at HellionChat startup; awaiting Ready."); + _logger.LogDebug(ex, "Honorific not available at HellionChat startup; awaiting Ready."); IsAvailable = false; CurrentTitle = null; } @@ -149,7 +150,7 @@ internal sealed class HonorificService : IDisposable { // Warning not Debug — a silent unsubscribe failure leaks a live // subscription across plugin reloads. - _log.Warning( + _logger.LogWarning( ex, "Honorific unsubscribe failed (likely API break or gate already gone)." ); diff --git a/HellionChat/Ipc/ExtraChat.cs b/HellionChat/Ipc/ExtraChat.cs index f8a668c..f69547c 100644 --- a/HellionChat/Ipc/ExtraChat.cs +++ b/HellionChat/Ipc/ExtraChat.cs @@ -1,9 +1,12 @@ using Dalamud.Plugin.Ipc; +using Microsoft.Extensions.Logging; namespace HellionChat.Ipc; public sealed class ExtraChat : IDisposable { + private readonly ILogger _logger; + #pragma warning disable CS0649 // Assigned through IPC [Serializable] private struct OverrideInfo @@ -36,8 +39,9 @@ public sealed class ExtraChat : IDisposable private volatile Dictionary ChannelNamesInternal = new(); internal IReadOnlyDictionary ChannelNames => ChannelNamesInternal; - internal ExtraChat() + internal ExtraChat(ILogger logger) { + _logger = logger; OverrideChannelGate = Plugin.Interface.GetIpcSubscriber( "ExtraChat.OverrideChannelColour" ); @@ -62,10 +66,7 @@ public sealed class ExtraChat : IDisposable catch (Exception ex) { // ExtraChat is optional; IPC failure is normal when the plugin isn't loaded. - Plugin.LogProxy.Verbose( - ex, - "ExtraChat IPC initial state query failed (peer not loaded?)" - ); + _logger.LogTrace(ex, "ExtraChat IPC initial state query failed (peer not loaded?)"); } } diff --git a/HellionChat/Ipc/TypingIpc.cs b/HellionChat/Ipc/TypingIpc.cs index 33b6fd6..394cc97 100644 --- a/HellionChat/Ipc/TypingIpc.cs +++ b/HellionChat/Ipc/TypingIpc.cs @@ -1,5 +1,6 @@ using Dalamud.Plugin.Ipc; using HellionChat.Code; +using Microsoft.Extensions.Logging; namespace HellionChat.Ipc; @@ -33,9 +34,12 @@ internal sealed class TypingIpc : IDisposable private ChatInputState LastState; private bool HasState; - internal TypingIpc(Plugin plugin) + private readonly ILogger _logger; + + internal TypingIpc(Plugin plugin, ILogger logger) { Plugin = plugin; + _logger = logger; StateQueryGate = Plugin.Interface.GetIpcProvider( "HellionChat.GetChatInputState" diff --git a/HellionChat/IpcManager.cs b/HellionChat/IpcManager.cs index cbade56..c8cfbd6 100755 --- a/HellionChat/IpcManager.cs +++ b/HellionChat/IpcManager.cs @@ -1,11 +1,14 @@ using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Plugin.Ipc; +using Microsoft.Extensions.Logging; namespace HellionChat; internal sealed class IpcManager : IDisposable { + private readonly ILogger _logger; + private ICallGateProvider RegisterGate { get; } private ICallGateProvider UnregisterGate { get; } private ICallGateProvider AvailableGate { get; } @@ -41,8 +44,9 @@ internal sealed class IpcManager : IDisposable internal List Registered { get; } = []; - public IpcManager() + public IpcManager(ILogger logger) { + _logger = logger; RegisterGate = Plugin.Interface.GetIpcProvider("HellionChat.Register"); RegisterGate.RegisterFunc(Register); diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index af59833..0545d8f 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -100,8 +100,8 @@ internal static class PluginHostFactory services.AddSingleton(_ => new Commands()); services.AddSingleton(_ => new FontManager()); services.AddSingleton(_ => new StatusBar()); - services.AddSingleton(_ => new IpcManager()); - services.AddSingleton(_ => new ExtraChat()); + services.AddSingleton(sp => new IpcManager(sp.GetRequiredService>())); + services.AddSingleton(sp => new ExtraChat(sp.GetRequiredService>())); services.AddSingleton(sp => new ThemeRegistry( Path.Combine( @@ -111,13 +111,18 @@ internal static class PluginHostFactory )); services.AddSingleton(sp => new GameFunctions.GameFunctions( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new TypingIpc( + sp.GetRequiredService(), + sp.GetRequiredService>() )); - services.AddSingleton(sp => new TypingIpc(sp.GetRequiredService())); services.AddSingleton(sp => new Integrations.HonorificService( sp.GetRequiredService(), - sp.GetRequiredService(), + sp.GetRequiredService>(), sp.GetRequiredService() )); From c955f304229cf9c53435cda9fc0459163c76d2c7 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 10:26:47 +0200 Subject: [PATCH 10/14] refactor(di): migrate UI Window-Layer to ILogger (DI-4 Slice C) Six UI files shift from Plugin.LogProxy to ILogger via constructor injection. Container singletons (each takes a typed ILogger plus, where it owns nested allocations, an ILoggerFactory to spawn child loggers): - Ui/ChatLogWindow (15 sites, plus an ILoggerFactory for the Popout new-call at Ui/ChatLogWindow.cs:2417) - Ui/Settings (SettingsWindow): no own sites, but takes an ILoggerFactory so it can hand typed loggers to its three migrated settings tabs (General, the other six tabs stay unchanged) - Ui/DbViewer (3 sites) Nested instances allocated by parent containers: - Ui/Popout (7 sites, ILogger as the new 4th ctor arg passed from ChatLogWindow) - Ui/SettingsTabs/ThemeAndLayout (1 site) - Ui/SettingsTabs/FontsAndColours (1 site) - Ui/SettingsTabs/DataManagement (15 sites) PluginHostFactory factory lambdas updated for ChatLogWindow, SettingsWindow and DbViewer to resolve the new logger args. --- HellionChat/PluginHostFactory.cs | 16 +++++-- HellionChat/Ui/ChatLogWindow.cs | 44 ++++++++++++------- HellionChat/Ui/DbViewer.cs | 12 +++-- HellionChat/Ui/Popout.cs | 19 ++++---- HellionChat/Ui/Settings.cs | 9 ++-- HellionChat/Ui/SettingsTabs/DataManagement.cs | 39 ++++++++-------- .../Ui/SettingsTabs/FontsAndColours.cs | 7 ++- HellionChat/Ui/SettingsTabs/ThemeAndLayout.cs | 7 ++- 8 files changed, 93 insertions(+), 60 deletions(-) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 0545d8f..7c77afc 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -153,9 +153,19 @@ internal static class PluginHostFactory // host never AddWindow()s them; PluginLifecycle does that on the // framework thread once C3 wires it up (see plan §2 service order). // ----------------------------------------------------------------- - services.AddSingleton(sp => new ChatLogWindow(sp.GetRequiredService())); - services.AddSingleton(sp => new SettingsWindow(sp.GetRequiredService())); - services.AddSingleton(sp => new DbViewer(sp.GetRequiredService())); + services.AddSingleton(sp => new ChatLogWindow( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new SettingsWindow( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new DbViewer( + sp.GetRequiredService(), + sp.GetRequiredService>() + )); services.AddSingleton(sp => new InputPreview(sp.GetRequiredService())); services.AddSingleton(sp => new CommandHelpWindow(sp.GetRequiredService())); services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService())); diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs index a884336..421fed4 100644 --- a/HellionChat/Ui/ChatLogWindow.cs +++ b/HellionChat/Ui/ChatLogWindow.cs @@ -22,6 +22,7 @@ using HellionChat.Resources; using HellionChat.Util; using Lumina.Excel.Sheets; using Lumina.Extensions; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui; @@ -98,10 +99,19 @@ public sealed class ChatLogWindow : Window private long FrameTime; // set every frame internal long LastActivityTime = Environment.TickCount64; - internal ChatLogWindow(Plugin plugin) + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + + internal ChatLogWindow( + Plugin plugin, + ILogger logger, + ILoggerFactory loggerFactory + ) : base($"{Plugin.PluginName}###chat2") { Plugin = plugin; + _logger = logger; + _loggerFactory = loggerFactory; Salt = new Random().Next().ToString(); Size = new Vector2(500, 250); @@ -297,7 +307,7 @@ public sealed class ChatLogWindow : Window || !GameFunctions.Chat.IsChannelOrExistingLinkshell(targetChannel.Value) ) { - Plugin.LogProxy.Warning( + _logger.LogWarning( $"Channel was set to an invalid value '{targetChannel}', ignoring" ); return; @@ -351,11 +361,11 @@ public sealed class ChatLogWindow : Window { case "hide": CurrentHideState = HideState.User; - Plugin.LogProxy.Verbose("HideState: → User (chat hide command)"); + _logger.LogTrace("HideState: → User (chat hide command)"); break; case "show": CurrentHideState = HideState.None; - Plugin.LogProxy.Verbose("HideState: → None (chat show command)"); + _logger.LogTrace("HideState: → None (chat show command)"); break; case "toggle": CurrentHideState = CurrentHideState switch @@ -365,7 +375,7 @@ public sealed class ChatLogWindow : Window HideState.None => HideState.User, _ => CurrentHideState, }; - Plugin.LogProxy.Verbose($"HideState: → {CurrentHideState} (chat toggle command)"); + _logger.LogTrace($"HideState: → {CurrentHideState} (chat toggle command)"); break; } } @@ -475,7 +485,7 @@ public sealed class ChatLogWindow : Window else if (newTab.CurrentChannel.Channel is InputChannel.Invalid) { newTab.CurrentChannel = previousTab.CurrentChannel.Clone(); - Plugin.LogProxy.Debug( + _logger.LogDebug( $"[Tab] '{newTab.Name}' seeded channel from '{previousTab.Name}' " + $"(Channel={newTab.CurrentChannel.Channel}, TellTarget={newTab.CurrentChannel.TellTarget?.ToTargetString() ?? "null"})" ); @@ -503,14 +513,14 @@ public sealed class ChatLogWindow : Window if (Plugin.Config.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle) { CurrentHideState = HideState.Battle; - Plugin.LogProxy.Verbose("HideState: None → Battle"); + _logger.LogTrace("HideState: None → Battle"); } // If the chat is hidden because of battle, we reset it here if (CurrentHideState is HideState.Battle && !Plugin.InBattle) { CurrentHideState = HideState.None; - Plugin.LogProxy.Verbose("HideState: Battle → None"); + _logger.LogTrace("HideState: Battle → None"); } // if the chat has no hide state and in a cutscene, set the hide state to cutscene @@ -523,7 +533,7 @@ public sealed class ChatLogWindow : Window if (Plugin.Functions.Chat.CheckHideFlags()) { CurrentHideState = HideState.Cutscene; - Plugin.LogProxy.Verbose("HideState: None → Cutscene"); + _logger.LogTrace("HideState: None → Cutscene"); } } @@ -534,7 +544,7 @@ public sealed class ChatLogWindow : Window && !Plugin.GposeActive ) { - Plugin.LogProxy.Verbose($"HideState: {CurrentHideState} → None (cutscene/gpose ended)"); + _logger.LogTrace($"HideState: {CurrentHideState} → None (cutscene/gpose ended)"); CurrentHideState = HideState.None; } @@ -542,14 +552,14 @@ public sealed class ChatLogWindow : Window if (CurrentHideState == HideState.Cutscene && Activate) { CurrentHideState = HideState.CutsceneOverride; - Plugin.LogProxy.Verbose("HideState: Cutscene → CutsceneOverride (user activate)"); + _logger.LogTrace("HideState: Cutscene → CutsceneOverride (user activate)"); } // if the user hid the chat and is now activating chat, reset the hide state if (CurrentHideState == HideState.User && Activate) { CurrentHideState = HideState.None; - Plugin.LogProxy.Verbose("HideState: User → None (activate)"); + _logger.LogTrace("HideState: User → None (activate)"); } if ( @@ -680,7 +690,7 @@ public sealed class ChatLogWindow : Window } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error drawing Chat Log window"); + _logger.LogError(ex, "Error drawing Chat Log window"); if (!NotifiedDrawFailure) { Plugin.Notification.AddNotification( @@ -1722,7 +1732,7 @@ public sealed class ChatLogWindow : Window } catch (Exception ex) { - Plugin.LogProxy.Warning(ex, "Error drawing chat log"); + _logger.LogWarning(ex, "Error drawing chat log"); } } @@ -2270,7 +2280,7 @@ public sealed class ChatLogWindow : Window { Plugin.Config.SeenPopOutHeaderHint = true; Plugin.SaveConfig(); - Plugin.LogProxy.Debug("v0.6.1 pop-out header hint dismissed"); + _logger.LogDebug("v0.6.1 pop-out header hint dismissed"); if (openSettings) Plugin.SettingsWindow.Toggle(); } @@ -2408,7 +2418,7 @@ public sealed class ChatLogWindow : Window if (PopOutWindows.Contains(tab.Identifier)) continue; - var window = new Popout(this, tab, i); + var window = new Popout(this, tab, i, _loggerFactory.CreateLogger()); Plugin.WindowSystem.AddWindow(window); PopOutWindows.Add(tab.Identifier); @@ -2925,7 +2935,7 @@ public sealed class ChatLogWindow : Window var viewport = ImGui.GetMainViewport(); var safePos = viewport.WorkPos + SafeDefaultOffset; Position = safePos; - Plugin.LogProxy.Info( + _logger.LogInformation( $"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}." ); diff --git a/HellionChat/Ui/DbViewer.cs b/HellionChat/Ui/DbViewer.cs index 09481fa..93afbad 100644 --- a/HellionChat/Ui/DbViewer.cs +++ b/HellionChat/Ui/DbViewer.cs @@ -17,6 +17,7 @@ using HellionChat.Resources; using HellionChat.Util; using Lumina.Data.Files; using Lumina.Text.ReadOnly; +using Microsoft.Extensions.Logging; using MoreLinq; namespace HellionChat.Ui; @@ -67,10 +68,13 @@ public class DbViewer : Window private bool NeedsScrollReset; - public DbViewer(Plugin plugin) + private readonly ILogger _logger; + + public DbViewer(Plugin plugin, ILogger logger) : base("DBViewer###chat2-dbviewer") { Plugin = plugin; + _logger = logger; SelectedChannels = TabsUtil.MostlyPlayer; DateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern; @@ -320,7 +324,7 @@ public class DbViewer : Window } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Failed reading messages from database"); + _logger.LogError(ex, "Failed reading messages from database"); } finally { @@ -483,7 +487,7 @@ public class DbViewer : Window } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "FTS filter worker failed"); + _logger.LogError(ex, "FTS filter worker failed"); } }); } @@ -625,7 +629,7 @@ public class DbViewer : Window } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Failed creating txt backup"); + _logger.LogError(ex, "Failed creating txt backup"); Notification.Content = "Error ..."; Notification.Type = NotificationType.Error; diff --git a/HellionChat/Ui/Popout.cs b/HellionChat/Ui/Popout.cs index 3b6ddd6..750cc55 100644 --- a/HellionChat/Ui/Popout.cs +++ b/HellionChat/Ui/Popout.cs @@ -3,6 +3,7 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface.Style; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui; @@ -11,6 +12,7 @@ internal class Popout : Window private readonly ChatLogWindow ChatLogWindow; private readonly Tab Tab; private readonly int Idx; + private readonly ILogger _logger; private long FrameTime; private long LastActivityTime = Environment.TickCount64; @@ -23,12 +25,13 @@ internal class Popout : Window // Exposed so AutoTellTabsService can locate this window during LRU eviction. internal Guid TabIdentifier => Tab.Identifier; - public Popout(ChatLogWindow chatLogWindow, Tab tab, int idx) + public Popout(ChatLogWindow chatLogWindow, Tab tab, int idx, ILogger logger) : base($"{tab.Name}##popout") { ChatLogWindow = chatLogWindow; Tab = tab; Idx = idx; + _logger = logger; Size = new Vector2(350, 350); SizeCondition = ImGuiCond.FirstUseEver; @@ -175,7 +178,7 @@ internal class Popout : Window { Plugin.Config.SeenPopOutInputHint = true; ChatLogWindow.Plugin.SaveConfig(); - Plugin.LogProxy.Debug("Pop-Out input hint dismissed"); + _logger.LogDebug("Pop-Out input hint dismissed"); if (openSettings) ChatLogWindow.Plugin.SettingsWindow.Toggle(); } @@ -214,13 +217,13 @@ internal class Popout : Window if (Tab.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle) { CurrentHideState = HideState.Battle; - Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: None -> Battle"); + _logger.LogTrace($"Popout HideState [{Tab.Name}]: None -> Battle"); } if (CurrentHideState is HideState.Battle && !Plugin.InBattle) { CurrentHideState = HideState.None; - Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: Battle -> None"); + _logger.LogTrace($"Popout HideState [{Tab.Name}]: Battle -> None"); } if ( @@ -232,7 +235,7 @@ internal class Popout : Window if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags()) { CurrentHideState = HideState.Cutscene; - Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: None -> Cutscene"); + _logger.LogTrace($"Popout HideState [{Tab.Name}]: None -> Cutscene"); } } @@ -242,7 +245,7 @@ internal class Popout : Window && !Plugin.GposeActive ) { - Plugin.LogProxy.Verbose( + _logger.LogTrace( $"Popout HideState [{Tab.Name}]: {CurrentHideState} -> None (cutscene/gpose ended)" ); CurrentHideState = HideState.None; @@ -251,7 +254,7 @@ internal class Popout : Window if (CurrentHideState == HideState.Cutscene && ChatLogWindow.Activate) { CurrentHideState = HideState.CutsceneOverride; - Plugin.LogProxy.Verbose( + _logger.LogTrace( $"Popout HideState [{Tab.Name}]: Cutscene -> CutsceneOverride (user activate)" ); } @@ -259,7 +262,7 @@ internal class Popout : Window if (CurrentHideState == HideState.User && ChatLogWindow.Activate) { CurrentHideState = HideState.None; - Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: User -> None (activate)"); + _logger.LogTrace($"Popout HideState [{Tab.Name}]: User -> None (activate)"); } return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle diff --git a/HellionChat/Ui/Settings.cs b/HellionChat/Ui/Settings.cs index 573cdc2..2fc611b 100755 --- a/HellionChat/Ui/Settings.cs +++ b/HellionChat/Ui/Settings.cs @@ -6,6 +6,7 @@ using Dalamud.Utility; using HellionChat.Resources; using HellionChat.Ui.SettingsTabs; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui; @@ -25,7 +26,7 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window private SettingsView View = SettingsView.Overview; private readonly SettingsOverview Overview; - internal SettingsWindow(Plugin plugin) + internal SettingsWindow(Plugin plugin, ILoggerFactory loggerFactory) : base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings") { Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse; @@ -45,13 +46,13 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window Tabs = [ new General(Plugin, Mutable), - new ThemeAndLayout(Plugin, Mutable), - new FontsAndColours(Plugin, Mutable), + new ThemeAndLayout(Plugin, Mutable, loggerFactory.CreateLogger()), + new FontsAndColours(Plugin, Mutable, loggerFactory.CreateLogger()), new SettingsTabs.Window(Plugin, Mutable), new Chat(Plugin, Mutable), new SettingsTabs.Tabs(Plugin, Mutable), new SettingsTabs.Privacy(Plugin, Mutable), - new DataManagement(Plugin, Mutable), + new DataManagement(Plugin, Mutable, loggerFactory.CreateLogger()), new SettingsTabs.Integrations(Plugin, Mutable), new Information(Mutable), ]; diff --git a/HellionChat/Ui/SettingsTabs/DataManagement.cs b/HellionChat/Ui/SettingsTabs/DataManagement.cs index bdcaef1..3ceab5b 100644 --- a/HellionChat/Ui/SettingsTabs/DataManagement.cs +++ b/HellionChat/Ui/SettingsTabs/DataManagement.cs @@ -11,6 +11,7 @@ using HellionChat.Export; using HellionChat.Privacy; using HellionChat.Resources; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui.SettingsTabs; @@ -18,6 +19,7 @@ internal sealed class DataManagement : ISettingsTab { private Plugin Plugin { get; } private Configuration Mutable { get; } + private readonly ILogger _logger; public string Name => HellionStrings.Settings_Card_DataManagement_Title + "###tabs-datamanagement"; @@ -136,10 +138,11 @@ internal sealed class DataManagement : ISettingsTab ), ]; - internal DataManagement(Plugin plugin, Configuration mutable) + internal DataManagement(Plugin plugin, Configuration mutable, ILogger logger) { Plugin = plugin; Mutable = mutable; + _logger = logger; } public void Draw(bool changed) @@ -229,7 +232,7 @@ internal sealed class DataManagement : ISettingsTab } catch (Exception e) { - Plugin.LogProxy.Error(e, "Unable to delete old database"); + _logger.LogError(e, "Unable to delete old database"); WrapperUtil.AddNotification( Language.Options_Database_Old_Delete_Error, NotificationType.Error @@ -391,9 +394,7 @@ internal sealed class DataManagement : ISettingsTab Plugin.Config.RetentionLastRunAt = DateTimeOffset.UtcNow; Plugin.SaveConfig(); - Plugin.LogProxy.Information( - $"Manual retention run deleted {deleted} expired messages." - ); + _logger.LogInformation($"Manual retention run deleted {deleted} expired messages."); if (deleted > 0) { @@ -407,7 +408,7 @@ internal sealed class DataManagement : ISettingsTab .Wait(TimeSpan.FromSeconds(5)) ) { - Plugin.LogProxy.Warning( + _logger.LogWarning( "Retention sweep: framework refresh timed out after 5s." ); } @@ -420,7 +421,7 @@ internal sealed class DataManagement : ISettingsTab } catch (Exception e) { - Plugin.LogProxy.Error(e, "Manual retention run failed"); + _logger.LogError(e, "Manual retention run failed"); WrapperUtil.AddNotification(HellionStrings.Retention_Error, NotificationType.Error); } finally @@ -568,7 +569,7 @@ internal sealed class DataManagement : ISettingsTab } catch (Exception e) { - Plugin.LogProxy.Error(e, "Failed to compute cleanup preview"); + _logger.LogError(e, "Failed to compute cleanup preview"); WrapperUtil.AddNotification( HellionStrings.Cleanup_PreviewError, NotificationType.Error @@ -589,7 +590,7 @@ internal sealed class DataManagement : ISettingsTab try { var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed); - Plugin.LogProxy.Information($"Privacy cleanup: deleted {deleted} messages"); + _logger.LogInformation($"Privacy cleanup: deleted {deleted} messages"); if ( !Plugin @@ -601,9 +602,7 @@ internal sealed class DataManagement : ISettingsTab .Wait(TimeSpan.FromSeconds(5)) ) { - Plugin.LogProxy.Warning( - "Privacy cleanup: framework refresh timed out after 5s." - ); + _logger.LogWarning("Privacy cleanup: framework refresh timed out after 5s."); } WrapperUtil.AddNotification( @@ -613,7 +612,7 @@ internal sealed class DataManagement : ISettingsTab } catch (Exception e) { - Plugin.LogProxy.Error(e, "Privacy cleanup failed"); + _logger.LogError(e, "Privacy cleanup failed"); WrapperUtil.AddNotification(HellionStrings.Cleanup_Error, NotificationType.Error); } finally @@ -773,7 +772,7 @@ internal sealed class DataManagement : ISettingsTab } catch (Exception e) { - Plugin.LogProxy.Error(e, "Export failed"); + _logger.LogError(e, "Export failed"); WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error); } finally @@ -853,7 +852,7 @@ internal sealed class DataManagement : ISettingsTab ) ) { - Plugin.LogProxy.Warning("Clearing messages from database"); + _logger.LogWarning("Clearing messages from database"); Plugin.MessageManager.Store.ClearMessages(); Plugin.MessageManager.ClearAllTabs(); @@ -911,7 +910,7 @@ internal sealed class DataManagement : ISettingsTab private void InsertMessages(int count) { - Plugin.LogProxy.Info($"Inserting {count} messages due to user request"); + _logger.LogInformation($"Inserting {count} messages due to user request"); var stopwatch = Stopwatch.StartNew(); var playerName = Plugin.PlayerState.CharacterName; @@ -956,7 +955,7 @@ internal sealed class DataManagement : ISettingsTab var elapsedTicks = stopwatch.ElapsedTicks; stopwatch.Stop(); - Plugin.LogProxy.Info( + _logger.LogInformation( $"Crafted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" ); @@ -966,7 +965,7 @@ internal sealed class DataManagement : ISettingsTab elapsedTicks = stopwatch.ElapsedTicks; stopwatch.Stop(); - Plugin.LogProxy.Info( + _logger.LogInformation( $"Upserted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" ); @@ -977,7 +976,7 @@ internal sealed class DataManagement : ISettingsTab Plugin.MessageManager.ClearAllTabs(); elapsedTicks = stopwatch.ElapsedTicks; stopwatch.Stop(); - Plugin.LogProxy.Info( + _logger.LogInformation( $"Cleared {Plugin.Config.Tabs.Count} tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" ); }) @@ -990,7 +989,7 @@ internal sealed class DataManagement : ISettingsTab Plugin.MessageManager.FilterAllTabs(); elapsedTicks = stopwatch.ElapsedTicks; stopwatch.Stop(); - Plugin.LogProxy.Info( + _logger.LogInformation( $"Fetched and filtered all tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" ); }) diff --git a/HellionChat/Ui/SettingsTabs/FontsAndColours.cs b/HellionChat/Ui/SettingsTabs/FontsAndColours.cs index 7276549..a436715 100644 --- a/HellionChat/Ui/SettingsTabs/FontsAndColours.cs +++ b/HellionChat/Ui/SettingsTabs/FontsAndColours.cs @@ -7,6 +7,7 @@ using Dalamud.Interface.Utility.Raii; using HellionChat.Code; using HellionChat.Resources; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui.SettingsTabs; @@ -14,14 +15,16 @@ internal sealed class FontsAndColours : ISettingsTab { private Plugin Plugin { get; } private Configuration Mutable { get; } + private readonly ILogger _logger; public string Name => HellionStrings.Settings_Card_FontsAndColours_Title + "###tabs-fontsandcolours"; - internal FontsAndColours(Plugin plugin, Configuration mutable) + internal FontsAndColours(Plugin plugin, Configuration mutable, ILogger logger) { Plugin = plugin; Mutable = mutable; + _logger = logger; } public void Draw(bool changed) @@ -312,6 +315,6 @@ internal sealed class FontsAndColours : ISettingsTab } Plugin.SaveConfig(); GlobalParametersCache.Refresh(); - Plugin.LogProxy.Debug($"Applied chat colour preset: {preset.DisplayName}"); + _logger.LogDebug($"Applied chat colour preset: {preset.DisplayName}"); } } diff --git a/HellionChat/Ui/SettingsTabs/ThemeAndLayout.cs b/HellionChat/Ui/SettingsTabs/ThemeAndLayout.cs index 34baf54..63f6707 100644 --- a/HellionChat/Ui/SettingsTabs/ThemeAndLayout.cs +++ b/HellionChat/Ui/SettingsTabs/ThemeAndLayout.cs @@ -4,6 +4,7 @@ using Dalamud.Interface.Utility.Raii; using HellionChat.Resources; using HellionChat.Themes; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui.SettingsTabs; @@ -11,16 +12,18 @@ internal sealed class ThemeAndLayout : ISettingsTab { private Plugin Plugin { get; } private Configuration Mutable { get; } + private readonly ILogger _logger; private string? _applyDismissedFor; public string Name => HellionStrings.Settings_Card_ThemeAndLayout_Title + "###tabs-themeandlayout"; - internal ThemeAndLayout(Plugin plugin, Configuration mutable) + internal ThemeAndLayout(Plugin plugin, Configuration mutable, ILogger logger) { Plugin = plugin; Mutable = mutable; + _logger = logger; } public void Draw(bool changed) @@ -90,7 +93,7 @@ internal sealed class ThemeAndLayout : ISettingsTab var path = Path.Combine(dir, fileName); var json = ThemeJsonWriter.Serialize(active); File.WriteAllText(path, json); - Plugin.LogProxy.Information($"Exported active theme '{active.Slug}' to {path}"); + _logger.LogInformation($"Exported active theme '{active.Slug}' to {path}"); } } } From 54ff88d6d486e683f5c55b7f515495c790d0d682 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 11:02:08 +0200 Subject: [PATCH 11/14] refactor(di): migrate Root + Misc to ILogger (DI-4 Slice D) Slice D shrinks vs the original plan: three of the six files cannot take an ILogger ctor arg without breaking external contracts. Migrated (8 LogProxy sites across 4 files): - Commands: 2 sites (Warning, Error). New ctor takes ILogger. - Themes/ThemeRegistry: 1 site (Debug). ILogger? is optional (default null) so the existing Build-Suite tests that construct `new ThemeRegistry()` parameterless keep working without changes. _logger?.LogDebug guards the call site. - PayloadHandler: 3 sites (Error, Warning, Error). New ctor takes ILogger. ChatLogWindow's two `new PayloadHandler(this)` sites (the direct field and the Lender lambda) now hand a fresh CreateLogger() from the existing _loggerFactory. Not migrated (5 sites stay on Plugin.LogProxy, plan drifts D12-D14): - D12 - Configuration (1 site): IPluginConfiguration, instantiated by Dalamud's Interface.GetPluginConfig() via reflection on the parameterless ctor. Adding an ILogger arg would break GetPluginConfig. - D13 - Message (4 sites): partial data class with two ctor overloads, mass-instantiated across 3 plugin sites plus Newtonsoft JSON deserialisation. Ctor extension would be invasive across ~20 call sites with low payoff (data-class logger is unusual). - D14 - FontManager (2 sites): both Plugin.LogProxy calls live in static methods (TryGetHellionFontBytes, AddFontWithFallback) that cannot reach an instance _logger. Same root cause as D8 in GameFunctions. FontManager joins the static-bucket alongside EmoteCache et al.; the ctor + _logger field added mid-Slice-D were rolled back to keep the class clean. Plugin.LogProxy surface after C9 (8 file buckets, ~12 sites total): - 4 originally-static consumers: EmoteCache, AutoTranslate, MemoryUtil, WrapperUtil - 3 cannot-take-ctor-arg consumers: Configuration, Message, FontManager - 1 single-static-method consumer: GameFunctions.TryOpenAdventurerPlate (D8 from Slice B) Smoke 2 is now due. --- HellionChat/Commands.cs | 11 +++++++++-- HellionChat/FontManager.cs | 13 ++++++++++++- HellionChat/PayloadHandler.cs | 12 ++++++++---- HellionChat/PluginHostFactory.cs | 5 +++-- HellionChat/Themes/ThemeRegistry.cs | 8 ++++++-- HellionChat/Ui/ChatLogWindow.cs | 6 ++++-- 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/HellionChat/Commands.cs b/HellionChat/Commands.cs index dfe79c0..4298097 100755 --- a/HellionChat/Commands.cs +++ b/HellionChat/Commands.cs @@ -1,10 +1,17 @@ using Dalamud.Game.Command; +using Microsoft.Extensions.Logging; namespace HellionChat; internal sealed class Commands : IDisposable { private readonly Dictionary Registered = []; + private readonly ILogger _logger; + + public Commands(ILogger logger) + { + _logger = logger; + } public void Dispose() { @@ -52,7 +59,7 @@ internal sealed class Commands : IDisposable { if (!Registered.TryGetValue(command, out var wrapper)) { - Plugin.LogProxy.Warning($"Missing registration for command {command}"); + _logger.LogWarning($"Missing registration for command {command}"); return; } @@ -62,7 +69,7 @@ internal sealed class Commands : IDisposable } catch (Exception ex) { - Plugin.LogProxy.Error(ex, $"Error while executing command {command}"); + _logger.LogError(ex, $"Error while executing command {command}"); } } } diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index 9a25dcd..afb6f04 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -8,6 +8,12 @@ using Dalamud.Interface.Utility; namespace HellionChat; +// FontManager's two LogProxy sites both live in static methods +// (TryGetHellionFontBytes, AddFontWithFallback) that the BuildFonts pipeline +// invokes; an instance _logger field would be unreachable from those scopes. +// DI-4 Slice D leaves the class on Plugin.LogProxy and counts it under the +// "static consumers" bucket alongside EmoteCache / AutoTranslate / +// MemoryUtil / WrapperUtil. public class FontManager { internal IFontHandle Axis = null!; @@ -58,6 +64,9 @@ public class FontManager ); if (stream is null) { + // Static method has no instance _logger to reach. The resource- + // missing path is rare (only fires when the embedded font is + // stripped from the build), so Plugin.LogProxy is acceptable. Plugin.LogProxy.Warning( "Hellion font resource missing — falling back to system default font." ); @@ -236,7 +245,9 @@ public class FontManager { // Atlas-toolkit throws span IO and validation failures; routing the // wider set through the fallback keeps a corrupt font config from - // taking down the whole atlas build. + // taking down the whole atlas build. Static method has no instance + // _logger to reach (Plugin.Config-driven font swap, called from + // BuildFonts). Plugin.LogProxy.Warning( e, $"Configured {slot} font failed to load ({e.GetType().Name}), " diff --git a/HellionChat/PayloadHandler.cs b/HellionChat/PayloadHandler.cs index 6dac11a..6cda470 100755 --- a/HellionChat/PayloadHandler.cs +++ b/HellionChat/PayloadHandler.cs @@ -20,6 +20,7 @@ using HellionChat.Resources; using HellionChat.Ui; using HellionChat.Util; using Lumina.Excel.Sheets; +using Microsoft.Extensions.Logging; using Action = System.Action; using ChatTwoPartyFinderPayload = HellionChat.Util.PartyFinderPayload; using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload; @@ -40,9 +41,12 @@ public sealed class PayloadHandler private const uint PopupSfx = 1; - internal PayloadHandler(ChatLogWindow logWindow) + private readonly ILogger _logger; + + internal PayloadHandler(ChatLogWindow logWindow, ILogger logger) { LogWindow = logWindow; + _logger = logger; } internal void Draw() @@ -131,7 +135,7 @@ public sealed class PayloadHandler } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error executing integration"); + _logger.LogError(ex, "Error executing integration"); } } @@ -535,7 +539,7 @@ public sealed class PayloadHandler ) ) { - Plugin.LogProxy.Warning("Could not find DalamudLinkHandlers"); + _logger.LogWarning("Could not find DalamudLinkHandlers"); return; } @@ -546,7 +550,7 @@ public sealed class PayloadHandler } catch (Exception ex) { - Plugin.LogProxy.Error(ex, "Error executing DalamudLinkPayload handler"); + _logger.LogError(ex, "Error executing DalamudLinkPayload handler"); } } diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 7c77afc..32d5199 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -97,7 +97,7 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(_ => new FileDialogManager()); - services.AddSingleton(_ => new Commands()); + services.AddSingleton(sp => new Commands(sp.GetRequiredService>())); services.AddSingleton(_ => new FontManager()); services.AddSingleton(_ => new StatusBar()); services.AddSingleton(sp => new IpcManager(sp.GetRequiredService>())); @@ -107,7 +107,8 @@ internal static class PluginHostFactory Path.Combine( sp.GetRequiredService().ConfigDirectory.FullName, "themes" - ) + ), + sp.GetRequiredService>() )); services.AddSingleton(sp => new GameFunctions.GameFunctions( diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index 4649bec..c0e70b4 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -1,9 +1,12 @@ using HellionChat.Themes.Builtin; +using Microsoft.Extensions.Logging; namespace HellionChat.Themes; public sealed class ThemeRegistry { + private readonly ILogger? _logger; + public const string DefaultSlug = HellionArctic.Slug; // 1Hz throttle for the v1.4.8 B2 auto-refresh-on-active path. The @@ -29,8 +32,9 @@ public sealed class ThemeRegistry private long _lastActiveStampCheckMs = -ActiveStampPollIntervalMs; private DateTime _lastActiveStamp = DateTime.MinValue; - public ThemeRegistry(string? customThemesDir = null) + public ThemeRegistry(string? customThemesDir = null, ILogger? logger = null) { + _logger = logger; // Insertion order drives the Theme-Picker grid layout (3 columns). // Row 1: blue family. Row 2: purple to magenta family. // Row 3: green / warm / classic. Row 4: Synthwave Sunset as a @@ -206,7 +210,7 @@ public sealed class ThemeRegistry catch (Exception ex) when (IsRecoverableFileLock(ex)) { // Editor mid-save: keep last known good, retry on next refresh. - Plugin.LogProxy.Debug( + _logger?.LogDebug( $"Custom theme {Path.GetFileName(path)} is locked, keeping last known good" ); if (cached.Theme is not null) diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs index 421fed4..7800315 100644 --- a/HellionChat/Ui/ChatLogWindow.cs +++ b/HellionChat/Ui/ChatLogWindow.cs @@ -124,8 +124,10 @@ public sealed class ChatLogWindow : Window DisableWindowSounds = true; // AllowBackgroundBlur is set centrally in Plugin.Setup after AddWindow. - PayloadHandler = new PayloadHandler(this); - HandlerLender = new Lender(() => new PayloadHandler(this)); + PayloadHandler = new PayloadHandler(this, _loggerFactory.CreateLogger()); + HandlerLender = new Lender(() => + new PayloadHandler(this, _loggerFactory.CreateLogger()) + ); SetUpTextCommandChannels(); SetUpAllCommands(); From 624ad20404faae3b1ee2cbca4a843a9d28084849 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 11:15:40 +0200 Subject: [PATCH 12/14] feat(logging): add dev signature to DalamudLogger output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EUPL-1.2 reuse with attribution is valid; this commit catches the case where attribution was stripped. Two layers of provenance markers, combined so removing one still leaves the other. Layer 1 (subtle, kopier-resistent): - DalamudLogger.Log emits "[name]{level} message" — a zero-width space (U+200B) between the category bracket and the level value. Visually identical to the previous format in xllog; a hex dump of the log file shows e2 80 8b between 5d and 7b. Survives 1:1 code copies. A copier who reformats whitespace will strip it, which is itself a tell (the original Lightless pattern does not have the marker, so its absence in a port is a positive signal of derived origin). Layer 2 (overt, abrasiv-kopier-resistent): - DalamudLoggingProvider's ctor emits a one-shot bootstrap line: "HellionChat DI-Logger bootstrap v{AssemblyVersion} fingerprint={hash}". Visible in xllog as the first plugin INFO line. Fingerprint is the first 8 hex chars of SHA256("HellionForgeBronzeC2410C-{version}"), so the same plugin version always produces the same marker (handy for cross-checking). A copier who keeps the banner is plagiarising in plain sight; a copier who rips it out has to find every reference inside DalamudLoggingProvider — quite explicit work. Hellion Forge Bronze #C2410C is the branding-anchor const used by the fingerprint, so the marker stays meaningful even if the plugin version cycles. --- .../Infrastructure/Logging/DalamudLogger.cs | 9 +++-- .../Logging/DalamudLoggingProvider.cs | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/HellionChat/Infrastructure/Logging/DalamudLogger.cs b/HellionChat/Infrastructure/Logging/DalamudLogger.cs index 0d73412..09375ff 100644 --- a/HellionChat/Infrastructure/Logging/DalamudLogger.cs +++ b/HellionChat/Infrastructure/Logging/DalamudLogger.cs @@ -33,14 +33,19 @@ internal sealed class DalamudLogger : ILogger if (!IsEnabled(logLevel)) return; + // The U+200B zero-width space between the bracket and the level + // value is a quiet provenance marker. The Hellion DI-Logger format + // is byte-distinguishable from any other port of this pattern even + // after the visible text is identical. EUPL-1.2 reuse stays valid; + // attribution traces stay possible. if ((int)logLevel <= (int)LogLevel.Information) { - _pluginLog.Information($"[{_name}]{{{(int)logLevel}}} {state}"); + _pluginLog.Information($"[{_name}]​{{{(int)logLevel}}} {state}"); return; } var sb = new StringBuilder(); - sb.Append($"[{_name}]{{{(int)logLevel}}} {state} {exception?.Message}"); + sb.Append($"[{_name}]​{{{(int)logLevel}}} {state} {exception?.Message}"); if (!string.IsNullOrWhiteSpace(exception?.StackTrace)) sb.AppendLine(exception.StackTrace); diff --git a/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs b/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs index 8988b6c..befb215 100644 --- a/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs +++ b/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs @@ -1,4 +1,7 @@ using System.Collections.Concurrent; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; using Dalamud.Plugin.Services; using Microsoft.Extensions.Logging; @@ -7,6 +10,11 @@ namespace HellionChat.Infrastructure.Logging; [ProviderAlias("Dalamud")] public sealed class DalamudLoggingProvider : ILoggerProvider { + // Hellion Forge Bronze (#C2410C). Stable marker that the build pipeline + // never touches; mixed into the bootstrap fingerprint so the banner stays + // distinguishable from any 1:1 port of the Lightless pattern. + private const string HellionMarker = "HellionForgeBronzeC2410C"; + private readonly ConcurrentDictionary _loggers = new( StringComparer.OrdinalIgnoreCase ); @@ -16,6 +24,33 @@ public sealed class DalamudLoggingProvider : ILoggerProvider public DalamudLoggingProvider(IPluginLog pluginLog) { _pluginLog = pluginLog; + EmitBootstrapBanner(); + } + + // Runs once per plugin load (the provider is a container singleton). The + // banner is intentionally visible in xllog: anyone copying the + // DalamudLogger trio without re-branding will keep emitting "HellionChat + // DI-Logger bootstrap …", which makes uncredited reuse trivial to spot. + // EUPL-1.2 reuse with attribution stays valid; this only catches the + // case where attribution was stripped. + private void EmitBootstrapBanner() + { + var version = + typeof(DalamudLoggingProvider).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + var fingerprint = ComputeFingerprint(version); + _pluginLog.Information( + $"HellionChat DI-Logger bootstrap v{version} fingerprint={fingerprint}" + ); + } + + private static string ComputeFingerprint(string version) + { + var seed = Encoding.UTF8.GetBytes($"{HellionMarker}-{version}"); + var hash = SHA256.HashData(seed); + var sb = new StringBuilder(8); + for (var i = 0; i < 4; i++) + sb.Append(hash[i].ToString("x2")); + return sb.ToString(); } public ILogger CreateLogger(string categoryName) From fe84fd558ef77f48a6c9631f00d5f2fd147d4447 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 11:35:44 +0200 Subject: [PATCH 13/14] docs(di): trim cycle-internal codes and verbose block comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code comments were drifting into plan-internal shorthand (DI-2a, Slice B, "see plan §9") that nobody outside the cycle authors can decode. They also tended toward AI-generated paragraph blocks where a two-line WHY would have done. This commit tightens the comment surface from the v1.5.0 work: - IPluginLogProxy header lists the consumer buckets without naming the cycle items that decided them. - DalamudLogger / DalamudLoggingProvider provenance markers explain themselves in two lines each; the long EUPL-rationale paragraph moves to the commit message. - PluginHostFactory block headers shrink to one line each, ASCII dividers come out, plan-internal codes go. - Plugin.cs field doc and Phase-1 / DisposeAsync comments lose the cycle-name references; the file gains nothing from "C3 surfaced X" in code. - FontManager / GameFunctions static-method notes shrink to one sentence each. - InitHostedServices class header keeps the eager-resolve WHY in three lines, drops the constraint label. Csharpier reformatted the .csproj layout (long PackageReference multi-lined). No functional change, no behavior change. --- HellionChat/FontManager.cs | 20 ++----- HellionChat/GameFunctions/GameFunctions.cs | 5 +- HellionChat/HellionChat.csproj | 5 +- .../Hosting/InitHostedServices.cs | 19 +++--- .../Infrastructure/Logging/DalamudLogger.cs | 7 +-- .../Logging/DalamudLoggingProvider.cs | 12 +--- HellionChat/Plugin.cs | 30 ++++------ HellionChat/PluginHostFactory.cs | 58 ++++--------------- HellionChat/PluginLifecycle.cs | 8 +-- HellionChat/Util/IPluginLogProxy.cs | 8 +-- 10 files changed, 51 insertions(+), 121 deletions(-) diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index afb6f04..e0b7453 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -8,12 +8,9 @@ using Dalamud.Interface.Utility; namespace HellionChat; -// FontManager's two LogProxy sites both live in static methods -// (TryGetHellionFontBytes, AddFontWithFallback) that the BuildFonts pipeline -// invokes; an instance _logger field would be unreachable from those scopes. -// DI-4 Slice D leaves the class on Plugin.LogProxy and counts it under the -// "static consumers" bucket alongside EmoteCache / AutoTranslate / -// MemoryUtil / WrapperUtil. +// Two LogProxy sites live in static methods (TryGetHellionFontBytes, +// AddFontWithFallback); a ctor-injected ILogger would not be reachable +// from those scopes, so the class stays on Plugin.LogProxy. public class FontManager { internal IFontHandle Axis = null!; @@ -64,9 +61,6 @@ public class FontManager ); if (stream is null) { - // Static method has no instance _logger to reach. The resource- - // missing path is rare (only fires when the embedded font is - // stripped from the build), so Plugin.LogProxy is acceptable. Plugin.LogProxy.Warning( "Hellion font resource missing — falling back to system default font." ); @@ -243,11 +237,9 @@ public class FontManager or ArgumentException ) { - // Atlas-toolkit throws span IO and validation failures; routing the - // wider set through the fallback keeps a corrupt font config from - // taking down the whole atlas build. Static method has no instance - // _logger to reach (Plugin.Config-driven font swap, called from - // BuildFonts). + // Atlas-toolkit throws span IO and validation failures; routing + // the wider set through the fallback keeps a corrupt font config + // from taking down the whole atlas build. Plugin.LogProxy.Warning( e, $"Configured {slot} font failed to load ({e.GetType().Name}), " diff --git a/HellionChat/GameFunctions/GameFunctions.cs b/HellionChat/GameFunctions/GameFunctions.cs index 5e48942..c807760 100755 --- a/HellionChat/GameFunctions/GameFunctions.cs +++ b/HellionChat/GameFunctions/GameFunctions.cs @@ -222,10 +222,7 @@ internal unsafe class GameFunctions : IDisposable } catch (Exception e) { - // Static method has no instance _logger to reach. Promoting this to - // an instance method would force PayloadHandler.cs:814 (the only - // caller) onto Plugin.Functions.* indirection. Lighter touch for - // DI-4 Slice B is to keep this one site on Plugin.LogProxy. + // Static method, no instance _logger reachable here. Plugin.LogProxy.Warning(e, "Unable to open adventurer plate"); return false; } diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 51f6e40..e2dd1cb 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -16,7 +16,10 @@ - + diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs index d7932e5..e43f482 100644 --- a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -5,13 +5,11 @@ using Microsoft.Extensions.Hosting; namespace HellionChat.Infrastructure.Hosting; -// Adapter shells around the IHostedService contract so the host can resolve -// the underlying singletons eagerly and trigger their existing init methods -// without modifying the service class bodies (DI-2a constraint). Bodies that -// stay empty here still serve a purpose: the host resolves the service when -// it instantiates the hosted service, which forces the ctor (IPC subscribe -// for IpcManager / TypingIpc / ExtraChat) to run during StartAsync instead of -// lazily on first GetRequiredService. +// Adapter shells around IHostedService so the host triggers each service's +// existing init method without touching the service class itself. Empty +// adapters still earn their place: registering them forces an eager resolve +// at Build, which runs the service ctor (IPC subscribe etc.) right then +// instead of lazily on first GetRequiredService. internal sealed class FontManagerInitHostedService(FontManager fontManager) : IHostedService { @@ -39,11 +37,8 @@ internal sealed class ThemeRegistryInitHostedService(ThemeRegistry registry) : I public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } -// IPC subscribers do their wiring in the ctor today, so StartAsync stays a -// no-op — the value of registering them as hosted services is that the host -// resolves them eagerly during Build, which triggers the ctor work. Moving -// the body into StartAsync is a DI-2b follow-up after the service ctors are -// allowed to change. +// IPC subscribers do their wiring in the ctor, so StartAsync stays empty — +// the registration alone forces an eager resolve which runs that wiring. internal sealed class IpcManagerInitHostedService(IpcManager ipc) : IHostedService { diff --git a/HellionChat/Infrastructure/Logging/DalamudLogger.cs b/HellionChat/Infrastructure/Logging/DalamudLogger.cs index 09375ff..0bb8462 100644 --- a/HellionChat/Infrastructure/Logging/DalamudLogger.cs +++ b/HellionChat/Infrastructure/Logging/DalamudLogger.cs @@ -33,11 +33,8 @@ internal sealed class DalamudLogger : ILogger if (!IsEnabled(logLevel)) return; - // The U+200B zero-width space between the bracket and the level - // value is a quiet provenance marker. The Hellion DI-Logger format - // is byte-distinguishable from any other port of this pattern even - // after the visible text is identical. EUPL-1.2 reuse stays valid; - // attribution traces stay possible. + // U+200B between the bracket and the level is a quiet provenance + // marker; byte-distinguishable from any 1:1 port of this format. if ((int)logLevel <= (int)LogLevel.Information) { _pluginLog.Information($"[{_name}]​{{{(int)logLevel}}} {state}"); diff --git a/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs b/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs index befb215..ad948cb 100644 --- a/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs +++ b/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs @@ -10,9 +10,7 @@ namespace HellionChat.Infrastructure.Logging; [ProviderAlias("Dalamud")] public sealed class DalamudLoggingProvider : ILoggerProvider { - // Hellion Forge Bronze (#C2410C). Stable marker that the build pipeline - // never touches; mixed into the bootstrap fingerprint so the banner stays - // distinguishable from any 1:1 port of the Lightless pattern. + // Hellion Forge Bronze (#C2410C). Mixed into the bootstrap fingerprint. private const string HellionMarker = "HellionForgeBronzeC2410C"; private readonly ConcurrentDictionary _loggers = new( @@ -27,12 +25,8 @@ public sealed class DalamudLoggingProvider : ILoggerProvider EmitBootstrapBanner(); } - // Runs once per plugin load (the provider is a container singleton). The - // banner is intentionally visible in xllog: anyone copying the - // DalamudLogger trio without re-branding will keep emitting "HellionChat - // DI-Logger bootstrap …", which makes uncredited reuse trivial to spot. - // EUPL-1.2 reuse with attribution stays valid; this only catches the - // case where attribution was stripped. + // One-shot per plugin load. Intentionally visible in xllog so uncredited + // ports of the DalamudLogger trio keep announcing their origin. private void EmitBootstrapBanner() { var version = diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index c374464..31ceff3 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -125,9 +125,8 @@ public sealed class Plugin : IAsyncDalamudPlugin // isolation. Wired immediately after Dalamud injects Log. internal static IPluginLogProxy LogProxy { get; private set; } = null!; - // Container drives the v1.5.0 bootstrap. Both are nullable so DisposeAsync - // stays safe if Phase-1 (Host build) throws before they get assigned - - // Dalamud fires DisposeAsync regardless of how far the ctor got. + // Nullable so DisposeAsync stays safe if Host-build throws before the + // fields get assigned — Dalamud fires DisposeAsync regardless. private readonly IHost? _host; private readonly PluginLifecycle? _lifecycle; @@ -222,17 +221,14 @@ public sealed class Plugin : IAsyncDalamudPlugin DeferredSaveFrames = -1; // Custom themes dir + seed run before the container builds so the - // ThemeRegistry factory lambda finds the directory ready and the - // example theme stays in place if the user has not touched it. + // ThemeRegistry factory lambda finds the directory ready. var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); Directory.CreateDirectory(customThemesDir); SeedExampleThemeIfEmpty(customThemesDir); - // Phase-1: build the generic host and pull singletons out into the - // Plugin.X surface so consumers untouched by DI-2a keep working. The - // host stays sync here because the schema gate above must run before - // services allocate; deferring the build to LoadAsync (Lightless' - // pattern) would mean the gate fires after the container is alive. + // Phase-1: build the host synchronously (the schema gate must clear + // before services allocate; Lightless' deferred build would invert + // that order) and pull singletons into the Plugin.X surface. var dependencies = new PluginHostDependencies( Interface, Log, @@ -527,10 +523,7 @@ public sealed class Plugin : IAsyncDalamudPlugin } ); - // Framework-thread cleanup the container does not reach. TearDownCommands - // walks Plugin-private dictionaries; SetChatInteractable is a static - // call into game state; WindowSystem.RemoveAllWindows clears the - // backing List<> that AddWindow populated in PluginLifecycle.LoadAsync. + // Framework-thread cleanup the container does not reach. try { await Framework @@ -550,12 +543,9 @@ public sealed class Plugin : IAsyncDalamudPlugin failure ??= ex; } - // Lifecycle stops the host (HostedService.StopAsync) and disposes the - // container on the framework thread; that path disposes all the - // services + windows we used to dispose manually here. The smoke from - // C3 surfaced MessageManager.DisposeAsync as non-idempotent (CTS - // dispose at line 99 throws on a second call), so we hand the entire - // service teardown to the container instead of double-disposing. + // Container disposes services + windows on the framework thread. + // MessageManager.DisposeAsync is not idempotent, so we let the + // container do it once instead of double-disposing. if (_lifecycle is not null) { failure = await CaptureFailureAsync(failure, () => _lifecycle.DisposeAsync().AsTask()) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 32d5199..c8781bc 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -39,11 +39,7 @@ internal static class PluginHostFactory PluginHostDependencies dependencies ) { - // ----------------------------------------------------------------- - // Block A — Dalamud-Services (21 [PluginService] singletons, plus the - // dependencies record itself). Registered by-interface so consumer - // ctors can resolve them without touching Plugin statics. - // ----------------------------------------------------------------- + // Block A — Dalamud services (21 [PluginService] singletons). services.AddSingleton(dependencies); services.AddSingleton(dependencies.PluginInterface); services.AddSingleton(dependencies.PluginLog); @@ -67,31 +63,14 @@ internal static class PluginHostFactory services.AddSingleton(dependencies.Evaluator); services.AddSingleton(dependencies.SelfTestRegistry); - // ----------------------------------------------------------------- - // Self-reference. Plugin owns the [PluginService] static surface and - // is already constructed by Dalamud before this factory runs, so we - // register the existing instance instead of letting the container - // build one. - // ----------------------------------------------------------------- + // Self-references: Plugin and its WindowSystem already exist. services.AddSingleton(plugin); services.AddSingleton(plugin.WindowSystem); - - // PluginLifecycle is a thin orchestrator over IHost; Plugin.ctor pulls - // it via GetRequiredService() immediately after Build. services.AddSingleton(); - // ----------------------------------------------------------------- - // Block B — HellionChat singletons (14 + 1 FileDialogManager adapter). - // Service-class bodies stay untouched in v1.5.0 per the DI-2a - // constraint; ctors that need a Plugin backref go through a factory - // lambda that resolves Plugin from the container. - // ----------------------------------------------------------------- - // Factory lambdas across the board: Microsoft.Extensions.DependencyInjection's - // ActivatorUtilities only inspects PUBLIC constructors via reflection, - // and several HellionChat classes are `internal sealed` with implicit- - // internal default ctors (Commands, StatusBar) or explicitly `internal` - // ctors on public classes (ExtraChat). The lambda body compiles inside - // the HellionChat namespace, so `new T()` sees the internal surface. + // Block B — HellionChat singletons. Factory lambdas because most + // classes are internal-sealed and the default activator only sees + // public ctors. services.AddSingleton(_ => new DalamudPlatformUtil()); services.AddSingleton(sp => new DalamudPluginLogProxy( sp.GetRequiredService() @@ -133,10 +112,8 @@ internal static class PluginHostFactory sp.GetRequiredService() )); - // AutoTellTabsService pulls MessageStore through MessageManager.Store - // because MessageStore is still allocated inside MessageManager.ctor - // (DI-2a leaves that body untouched). Promoting MessageStore to its - // own container singleton would double-construct the SQLite handle. + // MessageStore is allocated inside MessageManager.ctor; a separate + // container singleton would double-construct the SQLite handle. services.AddSingleton(sp => { var pluginRef = sp.GetRequiredService(); @@ -149,11 +126,8 @@ internal static class PluginHostFactory ); }); - // ----------------------------------------------------------------- - // Block C — Windows (8, each takes Plugin or ChatLogWindow). The - // host never AddWindow()s them; PluginLifecycle does that on the - // framework thread once C3 wires it up (see plan §2 service order). - // ----------------------------------------------------------------- + // Block C — Windows. WindowSystem.AddWindow is called from + // PluginLifecycle.LoadAsync on the framework thread. services.AddSingleton(sp => new ChatLogWindow( sp.GetRequiredService(), sp.GetRequiredService>(), @@ -173,18 +147,8 @@ internal static class PluginHostFactory services.AddSingleton(sp => new DebuggerWindow(sp.GetRequiredService())); services.AddSingleton(sp => new FirstRunWizard(sp.GetRequiredService())); - // ----------------------------------------------------------------- - // Hosted-service adapters — IHostedService is the host's only "after - // bootstrap, before user interaction" hook, so we register thin - // wrappers that call the existing init methods (BuildFonts, Switch, - // FilterAllTabsAsync, Initialize) without modifying the service - // bodies. Plan §2 documents why this is adapter-style instead of - // making the services themselves implement IHostedService (Lightless' - // pattern) — DI-2a leaves service classes untouched. - // ----------------------------------------------------------------- - // Same internal-ctor pitfall as the singletons above - the adapter - // classes are `internal sealed` with primary constructors, so the - // direct AddHostedService() overload's ActivatorUtilities fails. + // Hosted-service adapters: thin wrappers around the existing init + // methods so the service class bodies stay unchanged. services.AddHostedService(sp => new FontManagerInitHostedService( sp.GetRequiredService() )); diff --git a/HellionChat/PluginLifecycle.cs b/HellionChat/PluginLifecycle.cs index 8497cbf..052fdf8 100644 --- a/HellionChat/PluginLifecycle.cs +++ b/HellionChat/PluginLifecycle.cs @@ -4,11 +4,9 @@ using Microsoft.Extensions.Hosting; namespace HellionChat; -// Orchestrates Host.StartAsync / StopAsync + dispose on the framework thread. -// The Host itself is built sync in Plugin.ctor (before the schema gate clears) -// and assigned via the property setter; PluginLifecycle never builds it -// itself, which is why HellionChat skips Lightless' Func-delegate indirection -// (see plan §9 risk "Bewusste Abweichung von Lightless"). +// Orchestrates Host.StartAsync / StopAsync and the framework-thread dispose. +// Plugin.ctor builds the host and assigns it via the Host property, so +// PluginLifecycle never constructs the host itself. internal sealed class PluginLifecycle : IAsyncDisposable { private readonly IFramework _framework; diff --git a/HellionChat/Util/IPluginLogProxy.cs b/HellionChat/Util/IPluginLogProxy.cs index 40fd4bc..f3a7d35 100644 --- a/HellionChat/Util/IPluginLogProxy.cs +++ b/HellionChat/Util/IPluginLogProxy.cs @@ -2,10 +2,10 @@ using System; namespace HellionChat.Util; -// Indirection over Dalamud's IPluginLog so MessageStore can be constructed -// in an isolated xUnit AppDomain without loading Dalamud.dll — same pattern -// as IPlatformUtil from F12.1. A later DI-container cycle (v1.5.x) may -// replace this with Microsoft.Extensions.Logging's ILogger. +// Plugin.LogProxy bridge for consumers that cannot take a logger via the +// constructor: static helpers (EmoteCache et al.), Dalamud-reflected types +// (Configuration), data classes with mass instantiation (Message) and +// instance classes that only log from static methods (FontManager). internal interface IPluginLogProxy { void Verbose(string message); From 7d87f1c4feb22cd2a30d0019ac6355d4e55feab2 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 11:43:07 +0200 Subject: [PATCH 14/14] chore(release): v1.5.0 manifest bump Version strings bumped across all eight tracked surfaces: - HellionChat/HellionChat.csproj 1.5.0 - repo.json AssemblyVersion + TestingAssemblyVersion = 1.5.0.0 - repo.json three DownloadLink* URLs -> /v1.5.0/latest.zip - repo.json Changelog field synced with yaml - HellionChat/HellionChat.yaml new v1.5.0 changelog block on top; v1.4.7 drops out per the four-block slim rule - docs/CHANGELOG.md v1.5.0 entry prepended - docs/ROADMAP.md Next Cycle pointer moves to v1.5.1, v1.5.0 joins the released-cycle archive block - README.md three status surfaces (badge, header, Project Status long-form) on v1.5.0 - .github/forge-posts/v1.5.0.md Discord announcement body (German) Preflight blocks A-F all green. Changelog embed total 2050 / 5500 chars (four subblocks), forge-post frontmatter inside the 60/40 char caps. Tag, push, merge are reserved for Flo. --- .github/forge-posts/v1.5.0.md | 37 ++++++++++++++ HellionChat/HellionChat.csproj | 2 +- HellionChat/HellionChat.yaml | 91 ++++++++++++++++++---------------- README.md | 34 ++++++------- docs/CHANGELOG.md | 40 +++++++++++++++ docs/ROADMAP.md | 36 +++++++++++--- repo.json | 12 ++--- 7 files changed, 177 insertions(+), 75 deletions(-) create mode 100644 .github/forge-posts/v1.5.0.md diff --git a/.github/forge-posts/v1.5.0.md b/.github/forge-posts/v1.5.0.md new file mode 100644 index 0000000..a5e1d01 --- /dev/null +++ b/.github/forge-posts/v1.5.0.md @@ -0,0 +1,37 @@ +--- +subtitle: DI Foundation und Service-Refactor +versionsnatur: Architektur-Cycle +--- + +- **Architektur-Umbau ohne User-spürbare Verhaltens-Änderung:** der + Plugin-Bootstrap wechselt auf einen Generic-Host DI-Container + (`Microsoft.Extensions.Hosting` + `IServiceCollection`) nach dem + Lightless-Sync-Muster. 18 Service-Klassen wandern von einem + statischen `Plugin.LogProxy`-Locator auf typisierte + `ILogger`-Constructor-Injection. `DalamudLogger` brückt + `Microsoft.Extensions.Logging` über auf Dalamuds `IPluginLog` — + im xllog erscheinen jetzt Service-spezifische Spalten wie + `[ MessageManager]` und `[Honori...ervice]`. +- **Plugin.LogProxy bleibt für die acht Buckets erhalten,** die + Constructor-Injection nicht erreicht: Static-Helper (EmoteCache, + AutoTranslate, MemoryUtil, WrapperUtil), Dalamud-Reflektion + (Configuration), Data-Class mit Massen-Instanziierung (Message) + und Instanz-Klassen die nur aus Static-Methods loggen (FontManager, + eine GameFunctions-Stelle). +- **Performance bestätigt durch Cross-Plugin-Baseline:** HellionChat + First-Frame-HITCH 77 ms Median, Chat 2 v1.40.2 74 ms Median — kein + DI-Penalty gegenüber dem Upstream-Fork-Origin. Lightless und + XIVInstantMessenger liegen bei ~7 ms weil sie ihren FontAtlas-Build + deferren; das wird das v1.5.1-Item. +- **User-sichtbarer Bug-Fix nebenbei:** Slash-Command-Einfügen in das + Chat-Eingabefeld (Friend-List "/tell"-Action plus Plugin-Inserts + von Artisan, AllaganTools und ähnlichen) ersetzt jetzt den + vorhandenen Input, statt anzukonkatenieren. Cherry-Pick aus ChatTwo + upstream `ee7768ac` mit Namespace-Anpassung. +- **Foundation für die Plugin-Integrations-Wave:** v1.5.7-11 + (Context-Menu, NotificationMaster, Moodles, ExtraChat, XIVIM + Quick-DM) werden ab jetzt strukturell handhabbar — neue Services + sind ein `services.AddSingleton` plus ein paar Factory-Lambda- + Zeilen, kein Plugin.cs-Anflanschen mehr. +- Migration v17 unverändert: kein Schema-Bump, kein + Config-Migrations-Aufwand. diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index e2dd1cb..b8a1cc3 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.4.10 + 1.5.0 enable enable diff --git a/HellionChat/HellionChat.yaml b/HellionChat/HellionChat.yaml index 1166aee..45f4045 100755 --- a/HellionChat/HellionChat.yaml +++ b/HellionChat/HellionChat.yaml @@ -35,6 +35,54 @@ tags: - Replacement - Privacy changelog: |- + **v1.5.0 — DI Foundation and Service Refactor (2026-05-17)** + + Major architecture cycle. The plugin bootstrap moves to a + generic-host DI container (Microsoft.Extensions.Hosting + + IServiceCollection) modelled on Lightless Sync. Service logging + moves from a static Plugin.LogProxy locator to typed + Microsoft.Extensions.Logging.ILogger via constructor injection, + bridged over Dalamud's IPluginLog by a custom DalamudLogger trio. + + What changes under the hood: + + - 18 instance-class services migrate to ILogger via constructor + injection across four slices: data layer (MessageStore, + MessageManager, AutoTellTabsService), IPC and integrations + (HonorificService, IpcManager, TypingIpc, ExtraChat, the three + GameFunctions classes), UI window layer (ChatLogWindow, + DbViewer, Popout, three settings tabs), and root (Commands, + ThemeRegistry, PayloadHandler). + - Plugin.LogProxy stays in place for the eight buckets ctor + injection cannot reach: static helpers (EmoteCache, + AutoTranslate, MemoryUtil, WrapperUtil), Dalamud-reflected + types (Configuration), the Message data class, and instance + classes that only log from static methods (FontManager, one + GameFunctions site). + - Plugin.cs finishes at 1012 lines — virtually identical to the + pre-cycle 1013. The new Phase-1 host build and Plugin.X bridge + wiring trade out exactly the service and window allocations + that previously lived in LoadAsync. + - Cross-plugin baseline confirms no performance penalty against + Chat 2: HellionChat first-frame HITCH 77 ms median, Chat 2 + 74 ms median. Lightless and XIVInstantMessenger sit around + 7 ms by deferring their font-atlas build past Finished + loading — that pattern is the v1.5.1 follow-up. + + User-visible: + + - Slash-command insert fix: pasting a slash command into the + chat input (Friend List "/tell" action, plugin-driven inserts + from Artisan, AllaganTools etc.) now replaces the existing + input instead of concatenating. Cherry-picked from ChatTwo + upstream ee7768ac with namespace adaptation. + + Migration v17 stays (no schema bump). + + Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2). + + --- + **v1.4.10 — Symbol-Picker and Tell-History Fix (2026-05-16)** Eleventh and final sub-patch of the v1.4.x polish-sweep series. @@ -151,47 +199,4 @@ changelog: |- --- - **v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)** - - Eighth sub-patch of the v1.4.x polish-sweep series. First - user-visible feature bundle since v1.4.5 — pinned tell tabs that - survive relog, opt-in Honorific glow rendering, and a configurable - sidebar. - - - TempTell Pin: right-click a TempTell tab in the sidebar to pin - it. Pinned tabs survive relog, keep their conversation history - (loaded on demand from the message store), and stay bound to - the same /tell partner. Hard cap of 5 pinned tabs in a pool - separate from the 15-tab auto-tell pool — total ceiling is 20 - tabs. New 'Pinned' section in the sidebar with its own divider - header - - Honorific Glow outline now renders when the title carries a - Glow colour. Opt-in via Settings → Integrations → 'Render glow - outlines (Honorific)' (default off, dodges the per-frame - DrawList overhead on low-end hardware). Gradient (Color3 / - GradientColourSet / Wave / Pulse) is parsed but rendered - statically — a later cycle will port the full animation - - Sidebar width is now configurable in Theme & Layout (range - 44–160 px). Default stays icon-only; widen to fit section - headers like 'Active Tells (3)' without truncation - - Settings Save no longer pops the chat input back to /tell with - a pinned partner — Configuration.UpdateFrom now preserves the - runtime CurrentChannel across the persistent-tab merge, and - TabSwitched deep-clones the seeded channel instead of sharing - the previous tab's UsedChannel - - Util/ImGuiUtil.cs DrawArrows IconButton id now uses - (id + 1).ToString() instead of the operator-precedence quirk - id + 1.ToString() — generated IDs stay numerically stable - - Internal: IPluginLogProxy indirection over Dalamud's IPluginLog - routes all ~91 Plugin.Log call sites through a testable proxy. - MessageStore.Migrate0 can now run in xUnit without loading - Dalamud.dll, closing the gap F12.1 left in v1.4.6 - - Internal: TempTab counter switched from an Interlocked cached - field to a derived Tabs.Count(predicate) — pin-state transitions - are cold-path and don't need lock-free reads - - Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2). - - --- - Full history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases diff --git a/README.md b/README.md index c310630..bc444fe 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml/badge.svg?branch=main)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml) [![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) -[![Latest release](https://img.shields.io/badge/release-v1.4.10-brightgreen)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest) +[![Latest release](https://img.shields.io/badge/release-v1.5.0-brightgreen)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest) [![Dalamud API](https://img.shields.io/badge/Dalamud-API_15-purple)](https://github.com/goatcorp/Dalamud) [![.NET](https://img.shields.io/badge/.NET-10.0-512BD4)](https://dotnet.microsoft.com/) [![FFXIV](https://img.shields.io/badge/FFXIV-Dawntrail-c3a37f)](https://www.finalfantasyxiv.com/) @@ -11,7 +11,7 @@ Hellion Forge

-**Version 1.4.10** — Privacy-first chat plugin for FINAL FANTASY XIV / Dalamud, built on +**Version 1.5.0** — Privacy-first chat plugin for FINAL FANTASY XIV / Dalamud, built on [Chat 2](https://github.com/Infiziert90/ChatTwo) (EUPL-1.2). Hellion Chat is a privacy-first plugin built on the Chat 2 foundation. The majority of the engine comes from Chat 2 @@ -286,23 +286,19 @@ An optional submission to the Dalamud main plugin repo (in addition to the custo ## Project Status -**Version 1.4.10** — Symbol-Picker and Tell-History Fix. Eleventh and final sub-patch of the v1.4.x polish sweep -series. A new symbol-picker popup hangs off a smile-icon button left of the channel indicator: tab one lists all -161 FFXIV PUA glyphs (Dalamud's `SeIconChar` enum); tab two carries 97 server-verified BMP symbols (latin marks, -currency, the full Greek alphabet, geometric shapes, suits, notes) — each one round-tripped through `/echo` and -`/say` in a four-round whitelist probe so the in-channel render matches what the picker shows. Click drops the -glyph at the caret, multi-insert keeps the popup open, recent-used strip floats the last sixteen picks across -both tabs. Toggle in Settings → Chat → Message behaviour, default on. Mid-cycle hotfix for pinned auto-tell tabs: -PreloadHistory had a hidden 500-row SQL scan cap that overrode the user-configurable `AutoTellTabsHistoryPreload` -setting — active users with many tell partners lost the backlog of less-frequent pinned partners. The cap is -removed; the `(Receiver, Date)` index keeps SQL fast, the client-side loop respects the user setting as the upper -bound. Slash-command teardown cleanup: `/hellion`, `/hellionView`, `/hellionDebugger` (and `#if DEBUG /hellionSeString`) -wrappers are cached as private fields so plugin teardown detaches the live registration instead of re-Register'ing -with identical args. The original Reserve-A `ImGuiListClipper` refactor for `DrawMessages` was cancelled after -cross-platform smoke showed the scroll rubber-band is a Wine/Linux render-pipeline quirk, not universal — Windows -users on v1.4.9 never saw it; the spike that targets the Wine path lives in a later patch. Migration v17 stays -(no schema bump). v1.4.x polish sweep wraps up here; next major cycle is v1.5.0 with the DI-container adoption -(`Microsoft.Extensions.Hosting` + `ILogger`) modelled on Lightless (as of 2026-05-16). +**Version 1.5.0** — DI Foundation and Service Refactor. Major architecture cycle: the plugin bootstrap moves to a +generic-host DI container (`Microsoft.Extensions.Hosting` + `IServiceCollection`) modelled on Lightless Sync. All +18 instance-class services migrate from a static `Plugin.LogProxy` locator to `Microsoft.Extensions.Logging.ILogger` +via constructor injection, with a custom `DalamudLogger` bridging the framework over to Dalamud's `IPluginLog`. The +proxy stays for the eight buckets ctor-injection cannot reach (static helpers like `EmoteCache`, Dalamud-reflected +`Configuration`, the `Message` data class, and static methods inside `FontManager` / `GameFunctions`). Plugin.cs +finishes the cycle at 1012 lines — virtually identical to the pre-cycle 1013 — because the new Phase-1 host build +and Plugin.X bridge wiring trade out exactly the service and window allocations that left `LoadAsync`. Cross-plugin +baseline confirms no performance penalty vs Chat 2: HellionChat first-frame HITCH 77 ms median, Chat 2 74 ms. +Lightless and XIVInstantMessenger sit around 7 ms by deferring their font-atlas build past `Finished loading` — +that pattern is the v1.5.1 follow-up item. One user-visible fix bundled in from upstream: pasting a slash command +into the chat input (Friend List "/tell" action, plugin-driven inserts) now replaces the existing input instead of +concatenating onto whatever the user was typing. Migration v17 stays (no schema bump). Hellion Chat is a standalone plugin, no longer a fork in the repository sense. Fully completed: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 62c10da..074d10d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,46 @@ to the release pages for details. --- +## Hellion Chat 1.5.0 — DI Foundation and Service Refactor (2026-05-17) + +Major architecture cycle. The plugin bootstrap moves to a generic-host DI container +(`Microsoft.Extensions.Hosting` + `IServiceCollection`) modelled on Lightless Sync. Service +logging migrates from a static `Plugin.LogProxy` locator to typed +`Microsoft.Extensions.Logging.ILogger` via constructor injection, bridged over Dalamud's +`IPluginLog` by a custom `DalamudLogger` trio. + +### Under the hood + +- 18 instance-class services migrate to `ILogger` via constructor injection across four + slices: data layer (`MessageStore`, `MessageManager`, `AutoTellTabsService`), IPC and + integrations (`HonorificService`, `IpcManager`, `TypingIpc`, `ExtraChat`, three + `GameFunctions` classes), UI window layer (`ChatLogWindow`, `DbViewer`, `Popout`, three + settings tabs), and root (`Commands`, `ThemeRegistry`, `PayloadHandler`). +- `Plugin.LogProxy` stays in place for the eight buckets ctor injection cannot reach: + static helpers (`EmoteCache`, `AutoTranslate`, `MemoryUtil`, `WrapperUtil`), + Dalamud-reflected types (`Configuration`), the `Message` data class, and instance classes + that only log from static methods (`FontManager`, one `GameFunctions` site). +- Plugin.cs finishes at 1012 lines — virtually identical to the pre-cycle 1013. The new + Phase-1 host build and `Plugin.X` bridge wiring trade out exactly the service and window + allocations that previously lived in `LoadAsync`. +- Cross-plugin baseline confirms no performance penalty against Chat 2: HellionChat + first-frame HITCH 77 ms median, Chat 2 74 ms median. Lightless and XIVInstantMessenger sit + around 7 ms by deferring their font-atlas build past `Finished loading` — that pattern is + the v1.5.1 follow-up item. + +### User-visible + +- Slash-command insert fix: pasting a slash command into the chat input (Friend List + "/tell" action, plugin-driven inserts from Artisan, AllaganTools etc.) now replaces the + existing input instead of concatenating onto whatever the user was typing. Cherry-picked + from ChatTwo upstream `ee7768ac` with namespace adaptation. + +Migration v17 stays (no schema bump). + +Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2). + +--- + ## Hellion Chat 1.4.10 — Symbol-Picker and Tell-History Fix (2026-05-16) Eleventh and final sub-patch of the v1.4.x Polish-Sweep series. Symbol picker for the chat input, a tell-history reload fix diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5dca8f9..44e3041 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,13 +10,37 @@ the plugin's privacy-first scope during brainstorming. --- -## Next Cycle (v1.5.0) +## Next Cycle (v1.5.1) -**DI-container adoption.** Microsoft.Extensions.Hosting plus `ILogger` modelled on Lightless's `PluginHostFactory` -pattern. The v1.4.x Polish-Sweep series is closed; v1.5.0 starts the structural cycle that the smaller F12.x indirection -shims (`IPluginLogProxy`, `IPlatformUtil`) were paving the way for. After that, the Wine/Linux scroll-rubber-band spike -deferred from v1.4.10 (Reserve-A cancelled — Windows users never saw it) plus the First-Run-Wizard rework that lets users -opt into the curated defaults instead of just picking a privacy profile. +**Honorific Full Gradient Port plus FontAtlas-Defer for a 10× HITCH cut.** v1.5.0 closed the DI-container cycle with +no performance penalty against Chat 2 (77 ms vs 74 ms median first-frame HITCH), but the cross-plugin baseline against +Lightless Sync and XIVInstantMessenger surfaced a clean optimisation: both plugins defer their font-atlas build until +after `Finished loading` and sit at 6-7 ms HITCH, an order of magnitude below the ~75 ms floor that Chat 2 and HellionChat +share. v1.5.1 ports that pattern. Plus the Honorific gradient render path — DTO is gradient-ready since v1.4.7, only the +Wave / Pulse animation port remains. After that, First-Run-Wizard rework with curated defaults beyond the three privacy +profiles, then FR localisation (Hezcal native-speaker review confirmed), then the Plugin Integrations Wave 2-6 +(Context-Menu, NotificationMaster, Moodles, ExtraChat, XIVIM Quick-DM). Wine/Linux scroll-rubber-band spike sits as a +low-priority Linux-only investigation at the tail. + +--- + +## v1.5.0 — DI Foundation and Service Refactor (released 2026-05-17) + +Major architecture cycle. Plugin bootstrap moves to a generic-host DI container +(`Microsoft.Extensions.Hosting` + `IServiceCollection`) modelled on Lightless Sync's `PluginHostFactory`. Service +logging migrates from the static `Plugin.LogProxy` locator (the F12.2 shim from v1.4.7) to typed +`Microsoft.Extensions.Logging.ILogger` via constructor injection, bridged over Dalamud's `IPluginLog` by a custom +`DalamudLogger` trio. 18 instance-class services move to ctor-injected loggers across four slices: data layer, +IPC/integrations, UI window layer, and root. `Plugin.LogProxy` stays for the eight buckets ctor injection cannot +reach — static helpers (`EmoteCache`, `AutoTranslate`, `MemoryUtil`, `WrapperUtil`), Dalamud-reflected types +(`Configuration`), the `Message` data class, and instance classes that only log from static methods (`FontManager`, +one `GameFunctions` site). Plugin.cs finishes at 1012 lines, virtually identical to the pre-cycle 1013 (-1 netto): the +new Phase-1 host build and `Plugin.X` bridge wiring trade out exactly the service and window allocations that previously +lived in `LoadAsync`. Cross-plugin baseline (10 reload-stress runs, 51 active plugins): HellionChat first-frame HITCH +77 ms median, Chat 2 v1.40.2 74 ms median — no DI penalty. The deferred-font-atlas pattern from Lightless and +XIVInstantMessenger is the v1.5.1 follow-up. User-visible: slash-command insert fix cherry-picked from ChatTwo upstream +`ee7768ac` — pasting a slash command into the chat input now replaces existing input instead of concatenating. +Migration v17 stays. --- diff --git a/repo.json b/repo.json index 6e3095d..80457d2 100644 --- a/repo.json +++ b/repo.json @@ -3,7 +3,7 @@ "Author": "Jon Kazama (Hellion Forge)", "Name": "Hellion Chat", "InternalName": "HellionChat", - "AssemblyVersion": "1.4.10.0", + "AssemblyVersion": "1.5.0.0", "Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with three profiles: Privacy-First, Casual, Full History\n- Bilingual UI (EN/DE) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR", "ApplicableVersion": "any", "RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat", @@ -14,12 +14,12 @@ "CanUnloadAsync": false, "LoadPriority": 0, "Punchline": "A Hellion Forge plugin. Privacy-first chat for FFXIV, built to stay out of your way.", - "Changelog": "**v1.4.10 — Symbol-Picker and Tell-History Fix (2026-05-16)**\n\nEleventh and final sub-patch of the v1.4.x polish-sweep series. Symbol picker for the chat input, a tell-history reload fix for users with many active partners, and a closing cleanup sweep before v1.5.0 picks up the DI-container adoption.\n\n- Symbol picker: a small smile-icon button left of the channel indicator opens a popup with two tabs. The first lists all 161 FFXIV PUA glyphs (Dalamud's SeIconChar enum); the second carries 97 server-verified BMP symbols (latin marks, currency, the full Greek alphabet, geometric shapes, suits, notes) — every one of them round-tripped through /echo and /say in a four-round probe so the in-channel render matches what the picker shows. Click drops the glyph at the caret, multi-insert keeps the popup open, and a recent-used strip floats the last sixteen picks across both tabs. Toggle in Settings → Chat → Message behaviour, default on.\n- Pinned auto-tell tabs reload their full history again: a hidden 500-row scan cap in PreloadHistory used to override the user-configurable AutoTellTabsHistoryPreload setting, so less-frequent pinned partners (rare /tell sessions in an otherwise busy week) lost their backlog. The cap is removed; the (Receiver, Date) index keeps SQL fast, the client-side loop still respects your setting as the upper bound.\n- Slash-command teardown: /hellion, /hellionView, /hellionDebugger (and #if DEBUG /hellionSeString) wrappers are now cached as private fields. Plugin teardown detaches the live registration instead of re-Register'ing with identical args — closes a latent maintenance hazard from v1.4.9.\n- v1.4.x polish-sweep wraps up here. The ImGuiListClipper render refactor that was on the v1.4.10 reserve list got dropped after cross-platform smoke showed the scroll rubber-band is a Wine / Linux render-pipeline quirk, not universal — Windows users never saw it. It will get its own platform-targeted spike in a later patch. Next major cycle is v1.5.0 with the DI-container adoption (Microsoft.Extensions.Hosting + ILogger) modelled on Lightless.\n- Migration v17 stays (no schema bump).\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.9 — Plugin-Load Render Polish (2026-05-15)**\n\nTenth sub-patch of the v1.4.x polish-sweep series. First-frame render cost drops from ~127 ms median to ~76 ms median, comfortably under Dalamud's 100 ms HITCH warning threshold.\n\n- First-frame defer: six non-essential rendering sections inside ChatLogWindow skip their first Draw and run one frame later (bottom status bar, channel-name SeString chunks, window bounds check, v0.6.1 hint banner, autocomplete, input-preview calculation). User-visible delay is ~17 ms at 60 fps, hidden inside the post-reload font-atlas build window.\n- Slash-command centralisation: /hellion, /hellionView, /hellionSeString and /hellionDebugger are registered in LoadAsync instead of inside the corresponding window constructors. The plugin-manager Open and configuration buttons hang on the same path.\n- Plugin-load profiling logs stay on at Information level (MessageStore connect/migrate, FilterAllTabs, auto-translate warmup) as a regression tripwire — a future load past 100 ms will show up in /xllog without a Debug filter.\n- ChatTwo IPC compatibility layer: HellionChat now mirrors ChatTwo's full IPC surface (GetChatInputState, ChatInputStateChanged, Register, Unregister, Available, Invoke) under the ChatTwo.* namespace in addition to our existing HellionChat.* provider gates. Third-party integrations that historically only subscribe to ChatTwo's IPC — for example Artisan's and AllaganTools' context-menu hooks — keep working without requiring a code change on their side. Conflict detection prevents ChatTwo from loading in parallel with HellionChat, so there is no slot-collision risk at runtime.\n- Migration v17 stays (no schema bump).\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.8 — Hook-Layer and Polish Quick-Wins (2026-05-14)**\n\nNinth sub-patch of the v1.4.x polish-sweep series. Hook-layer cluster (DbViewer FTS5 full-text search, ad-block foundation investigation) plus three polish quick-wins.\n\n- DbViewer full-text search: optional FTS5 index across the full chat history. Built asynchronously on first load after the update with a progress toast. The local page-filter remains available as the default mode. Queries match as exact phrases -- multi-word terms must appear together in order; advanced users can opt into raw FTS5 MATCH syntax by wrapping their own double-quotes.\n- Custom theme files now auto-reload when edited while the theme is active -- no need to re-click the theme in the picker.\n- Retention sweep no longer blocks the framework thread, removing the ~194ms mini-hitch per sweep.\n- Status bar renders correctly at Windows display scaling > 100%.\n- Receive-suppressed-tells routing investigated this cycle and postponed to v1.5.x: when other plugins suppress tells via CheckMessageHandled, the FFXIV chat pipeline skips the RaptureLogModule.AddMsgSourceEntry path so HellionChat's ContentIdResolverHook does not fire and tell-partner identification breaks. The fix belongs next to the planned ad-block hook layer where the same patch surface comes up.\n- Internal: messages.Id is declared BLOB but stored as TEXT (Microsoft.Data.Sqlite Guid binding). FTS bulk insert and LoadByGuids match the TEXT storage form on both sides. Migration v17 stays (no schema bump).\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)**\n\nEighth sub-patch of the v1.4.x polish-sweep series. First user-visible feature bundle since v1.4.5 — pinned tell tabs that survive relog, opt-in Honorific glow rendering, and a configurable sidebar.\n\n- TempTell Pin: right-click a TempTell tab in the sidebar to pin it. Pinned tabs survive relog, keep their conversation history (loaded on demand from the message store), and stay bound to the same /tell partner. Hard cap of 5 pinned tabs in a pool separate from the 15-tab auto-tell pool — total ceiling is 20 tabs. New 'Angepinnt' / 'Pinned' section in the sidebar with its own divider header\n- Honorific Glow outline now renders when the title carries a Glow colour. Opt-in via Settings → Integrations → 'Render glow outlines (Honorific)' (default off, dodges the per-frame DrawList overhead on low-end hardware). Gradient (Color3 / GradientColourSet / Wave / Pulse) is parsed but rendered statically — a later cycle will port the full animation\n- Sidebar width is now configurable in Theme & Layout (range 44–160 px). Default stays icon-only; widen to fit section headers like 'Aktive Tells (3)' without truncation\n- Settings Save no longer pops the chat input back to /tell with a pinned partner — Configuration.UpdateFrom now preserves the runtime CurrentChannel across the persistent-tab merge, and TabSwitched deep-clones the seeded channel instead of sharing the previous tab's UsedChannel\n- Util/ImGuiUtil.cs DrawArrows IconButton id now uses (id + 1).ToString() instead of the operator-precedence quirk id + 1.ToString() — generated IDs stay numerically stable\n- Internal: IPluginLogProxy indirection over Dalamud's IPluginLog routes all ~91 Plugin.Log call sites through a testable proxy. MessageStore.Migrate0 can now run in xUnit without loading Dalamud.dll, closing the gap F12.1 left in v1.4.6\n- Internal: TempTab counter switched from an Interlocked cached field to a derived Tabs.Count(predicate) — pin-state transitions are cold-path and don't need lock-free reads\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\nFull history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases", + "Changelog": "**v1.5.0 \u2014 DI Foundation and Service Refactor (2026-05-17)**\n\nMajor architecture cycle. The plugin bootstrap moves to a\ngeneric-host DI container (Microsoft.Extensions.Hosting +\nIServiceCollection) modelled on Lightless Sync. Service logging\nmoves from a static Plugin.LogProxy locator to typed\nMicrosoft.Extensions.Logging.ILogger via constructor injection,\nbridged over Dalamud's IPluginLog by a custom DalamudLogger trio.\n\nWhat changes under the hood:\n\n- 18 instance-class services migrate to ILogger via constructor\n injection across four slices: data layer (MessageStore,\n MessageManager, AutoTellTabsService), IPC and integrations\n (HonorificService, IpcManager, TypingIpc, ExtraChat, the three\n GameFunctions classes), UI window layer (ChatLogWindow,\n DbViewer, Popout, three settings tabs), and root (Commands,\n ThemeRegistry, PayloadHandler).\n- Plugin.LogProxy stays in place for the eight buckets ctor\n injection cannot reach: static helpers (EmoteCache,\n AutoTranslate, MemoryUtil, WrapperUtil), Dalamud-reflected\n types (Configuration), the Message data class, and instance\n classes that only log from static methods (FontManager, one\n GameFunctions site).\n- Plugin.cs finishes at 1012 lines \u2014 virtually identical to the\n pre-cycle 1013. The new Phase-1 host build and Plugin.X bridge\n wiring trade out exactly the service and window allocations\n that previously lived in LoadAsync.\n- Cross-plugin baseline confirms no performance penalty against\n Chat 2: HellionChat first-frame HITCH 77 ms median, Chat 2\n 74 ms median. Lightless and XIVInstantMessenger sit around\n 7 ms by deferring their font-atlas build past Finished\n loading \u2014 that pattern is the v1.5.1 follow-up.\n\nUser-visible:\n\n- Slash-command insert fix: pasting a slash command into the\n chat input (Friend List \"/tell\" action, plugin-driven inserts\n from Artisan, AllaganTools etc.) now replaces the existing\n input instead of concatenating. Cherry-picked from ChatTwo\n upstream ee7768ac with namespace adaptation.\n\nMigration v17 stays (no schema bump).\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.10 \u2014 Symbol-Picker and Tell-History Fix (2026-05-16)**\n\nEleventh and final sub-patch of the v1.4.x polish-sweep series.\nSymbol picker for the chat input, a tell-history reload fix for\nusers with many active partners, and a closing cleanup sweep\nbefore v1.5.0 picks up the DI-container adoption.\n\n- Symbol picker: a small smile-icon button left of the channel\n indicator opens a popup with two tabs. The first lists all 161\n FFXIV PUA glyphs (Dalamud's SeIconChar enum); the second\n carries 97 server-verified BMP symbols (latin marks, currency,\n the full Greek alphabet, geometric shapes, suits, notes) \u2014\n every one of them round-tripped through /echo and /say in a\n four-round probe so the in-channel render matches what the\n picker shows. Click drops the glyph at the caret, multi-insert\n keeps the popup open, and a recent-used strip floats the last\n sixteen picks across both tabs. Toggle in Settings \u2192 Chat \u2192\n Message behaviour, default on.\n- Pinned auto-tell tabs reload their full history again: a\n hidden 500-row scan cap in PreloadHistory used to override the\n user-configurable AutoTellTabsHistoryPreload setting, so\n less-frequent pinned partners (rare /tell sessions in an\n otherwise busy week) lost their backlog. The cap is removed;\n the (Receiver, Date) index keeps SQL fast, the client-side\n loop still respects your setting as the upper bound.\n- Slash-command teardown: /hellion, /hellionView,\n /hellionDebugger (and #if DEBUG /hellionSeString) wrappers are\n now cached as private fields. Plugin teardown detaches the\n live registration instead of re-Register'ing with identical\n args \u2014 closes a latent maintenance hazard from v1.4.9.\n- v1.4.x polish-sweep wraps up here. The ImGuiListClipper render\n refactor that was on the v1.4.10 reserve list got dropped\n after cross-platform smoke showed the scroll rubber-band is a\n Wine / Linux render-pipeline quirk, not universal \u2014 Windows\n users never saw it. It will get its own platform-targeted\n spike in a later patch. Next major cycle is v1.5.0 with the\n DI-container adoption (Microsoft.Extensions.Hosting +\n ILogger) modelled on Lightless.\n- Migration v17 stays (no schema bump).\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.9 \u2014 Plugin-Load Render Polish (2026-05-15)**\n\nTenth sub-patch of the v1.4.x polish-sweep series. First-frame\nrender cost drops from ~127 ms median to ~76 ms median,\ncomfortably under Dalamud's 100 ms HITCH warning threshold.\n\n- First-frame defer: six non-essential rendering sections inside\n ChatLogWindow skip their first Draw and run one frame later\n (bottom status bar, channel-name SeString chunks, window bounds\n check, v0.6.1 hint banner, autocomplete, input-preview\n calculation). User-visible delay is ~17 ms at 60 fps, hidden\n inside the post-reload font-atlas build window.\n- Slash-command centralisation: /hellion, /hellionView,\n /hellionSeString and /hellionDebugger are registered in\n LoadAsync instead of inside the corresponding window\n constructors. The plugin-manager Open and configuration buttons\n hang on the same path.\n- Plugin-load profiling logs stay on at Information level\n (MessageStore connect/migrate, FilterAllTabs, auto-translate\n warmup) as a regression tripwire \u2014 a future load past 100 ms\n will show up in /xllog without a Debug filter.\n- ChatTwo IPC compatibility layer: HellionChat now mirrors\n ChatTwo's full IPC surface (GetChatInputState,\n ChatInputStateChanged, Register, Unregister, Available,\n Invoke) under the ChatTwo.* namespace in addition to our\n existing HellionChat.* provider gates. Third-party\n integrations that historically only subscribe to ChatTwo's\n IPC \u2014 for example Artisan's and AllaganTools' context-menu\n hooks \u2014 keep working without requiring a code change on their\n side. Conflict detection prevents ChatTwo from loading in\n parallel with HellionChat, so there is no slot-collision risk\n at runtime.\n- Migration v17 stays (no schema bump).\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.8 \u2014 Hook-Layer and Polish Quick-Wins (2026-05-14)**\n\nNinth sub-patch of the v1.4.x polish-sweep series. Hook-layer\ncluster (DbViewer FTS5 full-text search, ad-block foundation\ninvestigation) plus three polish quick-wins.\n\n- DbViewer full-text search: optional FTS5 index across the full\n chat history. Built asynchronously on first load after the\n update with a progress toast. The local page-filter remains\n available as the default mode. Queries match as exact phrases\n -- multi-word terms must appear together in order; advanced\n users can opt into raw FTS5 MATCH syntax by wrapping their own\n double-quotes.\n- Custom theme files now auto-reload when edited while the theme\n is active -- no need to re-click the theme in the picker.\n- Retention sweep no longer blocks the framework thread, removing\n the ~194ms mini-hitch per sweep.\n- Status bar renders correctly at Windows display scaling > 100%.\n- Receive-suppressed-tells routing investigated this cycle and\n postponed to v1.5.x: when other plugins suppress tells via\n CheckMessageHandled, the FFXIV chat pipeline skips the\n RaptureLogModule.AddMsgSourceEntry path so HellionChat's\n ContentIdResolverHook does not fire and tell-partner\n identification breaks. The fix belongs next to the planned\n ad-block hook layer where the same patch surface comes up.\n- Internal: messages.Id is declared BLOB but stored as TEXT\n (Microsoft.Data.Sqlite Guid binding). FTS bulk insert and\n LoadByGuids match the TEXT storage form on both sides.\n Migration v17 stays (no schema bump).\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\nFull history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases", "AcceptsFeedback": true, - "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.10/latest.zip", - "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.10/latest.zip", - "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.10/latest.zip", - "TestingAssemblyVersion": "1.4.10.0", + "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.0/latest.zip", + "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.0/latest.zip", + "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.0/latest.zip", + "TestingAssemblyVersion": "1.5.0.0", "IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png", "ImageUrls": [ "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png",