Compare commits

...

17 Commits

Author SHA1 Message Date
JonKazama-Hellion 02cbfff748 merge Update and Fixes for Branding and Security Fixes Merge branch 'feature/audit-fixes-phase-2' 2026-05-02 04:02:28 +02:00
JonKazama-Hellion 9c86619c9f Bump to 0.3.0 with the audit, brand and command-rename changelog
Phase 2 of the audit follow-ups, the Hellion Online Media brand
sweep and the rename of the slash commands all land in one release.
The slash command rename is breaking for users who had macros bound
to /chat2, /chat2Viewer or /clearlog2, which is the main reason
this is a 0.3.0 rather than a 0.2.1.

csproj, plugin manifest yaml, custom-repo repo.json (assembly
version, testing assembly version and the three download links)
and the README version banner are all moved over together so the
Dalamud plugin list, the manifest and the install instructions
agree. The README project status checklist is updated to reflect
that Phase 2 is closed; Phase 3 holds the remaining backlog
(MySQL backend, encryption, libnotify, etc).

The yaml and repo.json changelogs gain a 0.3.0 block that walks
through the four substantial groups of changes (slash command
rename, audit hardening, brand sweep, About tab) in plain prose.
The 0.2.0 block stays underneath in chronological order.

Build (Release) verified — ChatTwo/bin/Release/HellionChat/latest.zip
(~17.5 MB) and HellionChat.json regenerate cleanly with no warnings.
The tag itself is created by hand alongside the GitHub release.
2026-05-02 03:59:25 +02:00
JonKazama-Hellion 6b44310e04 Fix the About tab so the translator list is reachable
The translator section sat inside a child window whose height was
computed as "whatever is left in the content region minus a line".
Once the About copy grew with the new mission and rewritten built-on
sections, that remaining space dropped close to zero on smaller
settings windows, so the tree node was rendered but its content was
either invisible or unscrollable from the parent.

Drop the fixed-height child entirely. The settings window already
provides a scroll container around each tab, so rendering the tree
node and the translator list directly into it lets the parent
handle the scroll. The translators get a manual indent push to keep
the visual nesting that the child-frame used to suggest.
2026-05-02 03:54:34 +02:00
JonKazama-Hellion 59332ce9ea Move About-tab copy to HellionStrings and rephrase neutrally
The About tab copy was hand-written in English directly in About.cs,
which left the German users on the upstream Chat 2 wording for the
Hellion-specific blocks. The copy itself also leaned on em-dashes
mid-sentence and on a tone that could read as accusing Chat 2 of
GDPR violations, which was never the intent. This commit moves the
six About-tab sections (Maintainer, Why this fork exists, Built on
Chat 2, License, FFXIV disclaimer, Localization) into HellionStrings
and tightens the wording in both languages.

Tone change is the substantive part. Chat 2's full-history default
is now described as "the right one for most users" rather than a
problem the fork is fixing, and the webinterface removal is framed
as a focus mismatch — Chat 2's webinterface targets remote chat
access from a second device, this fork targets a smaller default
footprint, neither approach is wrong. The personal trigger for the
fork (two million logged messages over two years, mostly /say and
/yell from strangers) stays as it is honest context rather than
criticism.

The same neutralised wording is mirrored in three more places that
described the webinterface removal: the README "Was gegenüber
Chat 2 fehlt" block, the HellionChat.yaml description and changelog,
and the matching repo.json fields. The 0.2.0 changelog no longer
recites the upstream auth-flow internals; "different use case,
substantial rebuild, removed" is enough for users.

Em-dashes were also removed from two body strings that previously
used them as comma replacements (Privacy filter storage-only help
and the retention default description). Heading-level dashes
("Hellion Chat — Welcome", "Export (GDPR Art. 15 — right of
access)") stay because dashes are appropriate as separators in
titles.

The Theme description was already inaccurate — it still talked
about slate-violet tabs and amber highlights even though the brand
sweep moved everything onto Arctic Cyan plus Ember Orange. Updated
to describe the current palette honestly.
2026-05-02 03:50:08 +02:00
JonKazama-Hellion 462530dec5 Add a mission statement to the About tab
The About tab already credits Chat 2 and the maintainers, but it
never said why this fork exists in the first place. New users
discovering Hellion Chat through the Dalamud plugin list could
reasonably read it as a replacement attempt rather than what it is:
a niche alternative for users who care about chat persistence and
data minimisation.

The new "Why this fork exists" block sits between the Maintainer
and the Built-on-Chat-2 sections so the reading order goes from
"who" to "why" to "from what". It states three things plainly:

  - Hellion Chat is not trying to replace Chat 2
  - The trigger was the maintainer's own database (two years,
    two million messages, mostly public-chat from strangers)
  - Source is open under the same EUPL-1.2 licence; the upstream
    authors are welcome to look, take ideas, ask, or ignore

The tone matches the rest of the About tab — direct, no marketing
voice — and stays in English with the other legal-ish copy so a
single source covers every locale.
2026-05-02 03:40:57 +02:00
JonKazama-Hellion 8e964ca498 Align HellionStyle with the Hellion Online Media brand palette
The plugin theme drifted from the website palette over time: cyan
sat at #00B8D4 instead of the brand #00BED2, the warm highlights
were industrial amber rather than Ember Orange, and active tabs and
title bars were rendered in slate violet — a shade not part of the
brand at all.

This commit moves every HellionStyle slot onto the Arctic Cyan +
Ember Glow tokens documented in the website's BRANDING.md:

  - Primary cyan slots (Button, CheckMark, Slider, Separator) now
    use brand-color / brand-color-light / brand-color-dark
  - Window title bars and the active tab use brand-color-dark as
    Identity teal — slightly varied so it reads as identity rather
    than as another action surface
  - Unfocused-active tabs drop to a deeper teal so the unfocused
    window's tab is still visible without pulling focus
  - Resize grips and scrollbar grabs lift into Ember Orange on hover
    and active states, replacing industrial amber
  - Window, child, popup, frame and header surfaces follow the
    brand background ladder (#070B12, #0C1220, #141E30, #1A2538,
    #22303F)
  - Borders use the brand cyan at 40% alpha (matches --border-brand
    on the website) instead of neutral steel grey

The slate violet tertiary palette is gone. Brand tokens are
declared once and the slot constants alias them, so a future brand
shift only needs to touch the Identity / Accent / Primary stages.
2026-05-02 03:25:29 +02:00
JonKazama-Hellion 1f2cb000a2 Rename slash commands from /chat2 to /hellion family
Hellion Chat is an independent fork with its own assembly name and
plugin slot, but it kept registering the upstream /chat2* slash
commands. That mixed naming caused two friction points: users could
not tell from the in-game help which plugin owned the command, and
running both Hellion Chat and the upstream Chat 2 side by side would
collide on the registration.

Five commands change:

  /chat2          -> /hellion           (settings + chat toggle)
  /chat2Viewer    -> /hellionView       (database viewer)
  /chat2Debugger  -> /hellionDebugger   (internal, not shown in help)
  /chat2SeString  -> /hellionSeString   (internal, debug-only)
  /clearlog2      -> /clearhellion      (clear chat log)

Help strings ("Perform various actions with Chat 2.", "Clear the
Chat 2 chat log") are reworded to match. ImGui internal window IDs
(###chat2-settings, ###chat2-dbviewer) are left untouched on purpose
so existing user layouts for those windows do not snap back to
default. Resource files do not reference any of these command names,
so no localisation work needed.
2026-05-02 03:08:25 +02:00
JonKazama-Hellion 4f25c2756b Tighten DbViewer paging — int constant and matching SQL parameter name
Audit findings M-1 and M-2. Two small consistency issues in the
upstream DbViewer paging path that we now own as a fork:

  - RowPerPage is a row count and should be an int. The upstream
    declaration was 1000.0f, which forced an implicit float divide
    in Math.Ceiling and an implicit float-to-integer conversion when
    SQLite bound the LIMIT parameter. Switching the constant to int
    and casting Count to double right at the division keeps the
    ceiling math intact while making the type story honest.

  - GetPagedDateRange's SQL uses the placeholder $OffsetCount, but
    the matching AddWithValue call passed the unprefixed name
    "OffsetCount". Microsoft.Data.Sqlite tolerates this today, so
    paging still worked; another provider or a stricter future
    version would not. Re-aligned the parameter name with the SQL.

No behavioural change for users — paging continues to return 1000
rows per page. The fixes are kept on the fork rather than offered
upstream because the project's recent triage history makes a
non-trivial PR turnaround unlikely.
2026-05-02 02:56:40 +02:00
JonKazama-Hellion de0d2c80cd Serialise retention sweeps so the auto and manual paths cannot overlap
Audit findings M-3 and M-4. The 24h auto-sweep launched from
Plugin's constructor and the manual button in the Privacy tab were
both starting a background thread that called DeleteByRetentionPolicy
on the shared MessageStore connection without coordinating. With
unfortunate timing — manual click moments after a fresh plugin load
— two sweeps would race for the same connection and the second
would just re-do work the first one already did, while still
overwriting RetentionLastRunAt.

Move the running flag and a lock object to Plugin so both paths see
the same gate. Each entry point takes the lock long enough to check
and set the flag, then runs the actual delete on its background
thread without holding the lock (other DB operations already happen
without locking; spreading the lock further would suggest a
guarantee we do not actually provide). The Privacy tab keeps a
read-only property that surfaces the shared flag for its UI disable
state — ImGui is single-threaded and bool reads are atomic, so the
lock-free read is fine.
2026-05-02 02:52:34 +02:00
JonKazama-Hellion 2ce30383d9 Refuse to write emote cache files outside the cache directory
Audit finding H-1. Defense-in-depth fix for EmoteCache.LoadAsync,
which interpolated the BetterTTV-supplied Id and ImageType straight
into a Path.Join. HTTPS protects the wire today, but a compromised
upstream that hands back Id values like "../foo" would land outside
EmoteCacheV1, anywhere under pluginConfigs that the plugin can write.

Resolve the candidate path with Path.GetFullPath, then assert it
starts with the cache directory plus a directory separator (so
"EmoteCacheV1Sibling" cannot match "EmoteCacheV1"). Throw
InvalidOperationException on mismatch — the surrounding load
already swallows exceptions and logs them, so a tampered entry
becomes a visible error in the log instead of a silent miss.
2026-05-02 02:50:29 +02:00
JonKazama-Hellion a857714064 Clarify the privacy filter only governs storage, not the live chat log
Audit finding M-5. The master switch description told users what the
filter does to the database, but nothing in the UI ruled out the
common misreading "if I disable a channel, it will also disappear
from the chat log". A help-text line under the toggle now states
explicitly that the filter is storage-only and points at the
in-game chat tab filters for hiding channels visually. EN and DE
strings added together.
2026-05-02 02:49:45 +02:00
JonKazama-Hellion 705c7d3116 Bump to 0.2.0 with the webinterface-removal changelog
Project version goes from 0.1.2 to 0.2.0 — a minor bump rather than a
patch because the webinterface removal is a behavioural change for
anyone who relied on it. csproj, plugin manifest yaml and the
custom-repo repo.json are all moved over together so the Dalamud
plugin list, the manifest and the download links agree.

The yaml description picks up a leading paragraph that names the
removal up front; pre-installation users see it in the Dalamud
plugin browser. Both yaml and repo.json gain a 0.2.0 changelog block
that explains the audit context, lists every concrete code change
and reassures that the privacy filter, retention sweep, first-run
wizard and exporter are untouched. Older changelog entries are
preserved in chronological order behind it.

repo.json AssemblyVersion and TestingAssemblyVersion go to 0.2.0.0
and the three download links point at v0.2.0/latest.zip; the tag
itself is created by hand alongside the GitHub release.

Build (Release) produces ChatTwo/bin/Release/HellionChat/latest.zip
(~17 MB) and HellionChat.json with no warnings.
2026-05-02 02:30:42 +02:00
JonKazama-Hellion bf5d03c7ea Update README, About tab and project doc for the webinterface removal
README's Stability section gains a "what is missing compared to Chat 2"
block that names the five concrete reasons the upstream webinterface
could not stay (System.Random auth code, bind on all interfaces,
unflagged cookies, SSE stream that bypassed the privacy filter, and
the cumulative hardening cost). The project status list reflects
0.2.0 with Phase 1.5 marked done and the remaining audit follow-ups
queued under Phase 2.

The About tab gets a single-line acknowledgement that the upstream
webinterface is intentionally absent, so users coming from Chat 2 do
not look for it under settings or assume it broke.

The Obsidian project note is updated separately under
Vault/Ideen/Hellion Chat Plugin (ChatTwo Fork).md to record the audit
decision and the six-commit cleanup.
2026-05-02 02:28:36 +02:00
JonKazama-Hellion 960ce980d3 Bump configuration version to 8 with a webinterface-removal notice
The webinterface fields are gone from the Configuration class so any
existing entries (WebinterfacePassword, AuthStore, WebinterfacePort
and friends) get dropped on the next save automatically — Newtonsoft
silently skips properties the target type does not declare. The
version bump itself is what stops the one-shot notification from
firing on every launch.

A new pair of HellionStrings entries (EN + DE) explains the change to
users coming from 0.1.x. Title: "Hellion Chat 0.2.0", body points at
the README for context. Notification fires once per upgrade.
2026-05-02 02:25:13 +02:00
JonKazama-Hellion c09aa26ffc Remove webinterface dependencies and build artifacts
Drops Watson.Lite (the HTTP server) and Newtonsoft.Json (only used by
the webinterface JSON wire format) from the package references and
removes websiteBuild.zip plus the UnzipBuild target that extracted it
into the build output. The commented-out NodeJS compile blocks for
the Svelte frontend go with them.

DbViewer used to ship a CreateTempJsonFile button that exported the
database in the webinterface message-protocol shape (MessageResponse,
MessageTemplate, WebPayloadType). With no client able to consume that
shape any more the button, the method and the two helper methods are
removed. The Privacy tab's MessageExporter already covers Markdown,
JSON and CSV exports with channel and date filters and is the
supported way to get history out of the plugin.

Build verified clean (Release, 0 warnings, 0 errors). The lockfile
shrinks accordingly.
2026-05-02 02:23:56 +02:00
JonKazama-Hellion c2801c4113 Remove webinterface server, HTTP routes and Svelte frontend
Drops the entire ChatTwo/Http/ tree (ServerCore, HostContext,
RouteController, Processing, SSEConnection, the message protocol DTOs
and the bundled Svelte frontend) plus WebinterfaceUtil. Also removes
every ServerCore.Send* call site that fed the SSE stream:

  - MessageManager.ProcessMessage no longer broadcasts new messages
  - Chat.cs no longer notifies on login
  - PayloadHandler no longer rebroadcasts on screenshot-mode toggle
  - ChatLogWindow no longer announces tab and channel switches

The Plugin class drops the ServerCore field, the auto-start branch and
the Dispose hook. The DbViewer still imported a stale namespace from
the message protocol; the using is removed.

Language.resx and its generated Designer file keep the Webinterface
string keys for now so future upstream cherry-picks do not break on
missing resources. They are dead code from our perspective but harmless.
2026-05-02 02:20:43 +02:00
JonKazama-Hellion 7bacd1aaba Remove webinterface settings tab and configuration fields
Webinterface adds a third-party HTTP surface that contradicts the
DSGVO-by-default promise: it broadcasts every chat message including
filtered ChatTypes (privacy filter only covers DB writes), ships a
five-digit numeric auth code seeded from System.Random, binds on all
interfaces by default and sets cookies without security flags.

Hardening it for our threat model would land 500+ lines of code and
permanent maintenance for a feature very few users actually use. The
forks audience wants less surface, not more, so the entire feature is
being removed in a focused commit cluster. This first commit drops
the user-facing surface: settings tab class, tab registration and
the Configuration fields plus their UpdateFrom mirror.
2026-05-02 02:18:43 +02:00
64 changed files with 510 additions and 4888 deletions
+1 -24
View File
@@ -4,7 +4,7 @@
0.1.0 is our bootstrap release; the underlying Chat 2 base is
called out in the yaml changelog so users can see what it
derives from. -->
<Version>0.1.2</Version>
<Version>0.3.0</Version>
<ImplicitUsings>enable</ImplicitUsings>
<!-- HellionChat fork: assembly is renamed so Dalamud uses
pluginConfigs/HellionChat instead of pluginConfigs/ChatTwo,
@@ -21,7 +21,6 @@
<PackageReference Include="morelinq" Version="4.4.0" />
<PackageReference Include="Pidgin" Version="3.3.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Watson.Lite" Version="6.3.9" />
</ItemGroup>
<ItemGroup>
@@ -73,26 +72,4 @@
</None>
</ItemGroup>
<!--This doesn't work until Plogon is updated to include NodeJS-->
<!-- <Target Name="NodeJS Compile" BeforeTargets="BeforeCompile">-->
<!-- <Exec Command="npm install" WorkingDirectory="Http\Frontend"/>-->
<!-- <Exec Command="npm run build" WorkingDirectory="Http\Frontend"/>-->
<!-- </Target>-->
<!-- -->
<!-- <Target Name="CopyFiles" AfterTargets="Build">-->
<!-- <ItemGroup>-->
<!-- <Files Include="$(MSBuildThisFileDirectory)\Http\Frontend\build\**" />-->
<!-- </ItemGroup>-->
<!-- -->
<!-- <Copy SourceFiles="@(Files)" DestinationFolder="$(TargetDir)\Frontend\%(RecursiveDir)" />-->
<!-- </Target>-->
<!-- <Target Name="NodeJS Compile" BeforeTargets="BeforeCompile" Condition="'$(Configuration)' == 'Debug'">-->
<!-- <Exec Command="npm install" WorkingDirectory="Http\Frontend"/>-->
<!-- <Exec Command="npm run build" WorkingDirectory="Http\Frontend"/>-->
<!-- </Target>-->
<Target Name="UnzipBuild" AfterTargets="Build">
<Unzip SourceFiles="websiteBuild.zip" DestinationFolder="$(TargetDir)\Frontend"/>
</Target>
</Project>
+1 -14
View File
@@ -33,7 +33,7 @@ public class ConfigKeyBind
[Serializable]
public class Configuration : IPluginConfiguration
{
private const int LatestVersion = 7;
private const int LatestVersion = 8;
public int Version { get; set; } = LatestVersion;
@@ -171,14 +171,6 @@ public class Configuration : IPluginConfiguration
public ConfigKeyBind? ChatTabForward;
public ConfigKeyBind? ChatTabBackward;
// Webinterface
public bool WebinterfaceEnabled;
public bool WebinterfaceAutoStart;
public string WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
public int WebinterfacePort = 9000;
public HashSet<string> AuthStore = [];
public int WebinterfaceMaxLinesToSend = 1000; // 1-10000
public void UpdateFrom(Configuration other, bool backToOriginal)
{
if (backToOriginal)
@@ -243,11 +235,6 @@ public class Configuration : IPluginConfiguration
ChosenStyle = other.ChosenStyle;
ChatTabForward = other.ChatTabForward;
ChatTabBackward = other.ChatTabBackward;
WebinterfaceEnabled = other.WebinterfaceEnabled;
WebinterfaceAutoStart = other.WebinterfaceAutoStart;
WebinterfacePassword = other.WebinterfacePassword;
WebinterfacePort = other.WebinterfacePort;
WebinterfaceMaxLinesToSend = other.WebinterfaceMaxLinesToSend;
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
PrivacyPersistChannels = [..other.PrivacyPersistChannels];
+11 -2
View File
@@ -168,10 +168,19 @@ public static class EmoteCache
internal async Task<byte[]> LoadAsync(Emote emote)
{
var dir = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1");
// BetterTTV-supplied Id and ImageType are interpolated straight
// into the filename. HTTPS protects the wire, but a compromised
// upstream could still hand us "../foo" and write into the
// pluginConfigs root (or worse). Resolve the candidate path and
// refuse anything that escapes the cache directory.
var dir = Path.GetFullPath(Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1"));
Directory.CreateDirectory(dir);
var filePath = Path.Join(dir, $"{emote.Id}.{emote.ImageType}");
var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar) ? dir : dir + Path.DirectorySeparatorChar;
var filePath = Path.GetFullPath(Path.Join(dir, $"{emote.Id}.{emote.ImageType}"));
if (!filePath.StartsWith(dirPrefix, StringComparison.Ordinal))
throw new InvalidOperationException($"Emote path escapes cache directory: id={emote.Id}, type={emote.ImageType}");
if (File.Exists(filePath))
{
RawData = await File.ReadAllBytesAsync(filePath);
-3
View File
@@ -156,9 +156,6 @@ internal sealed unsafe class Chat : IDisposable
return;
ChangeChannelNameDetour(agent);
// Inform all clients that a new login happened
Plugin.ServerCore.SendNewLogin();
}
private byte ChatLogRefreshDetour(nint log, ushort eventId, AtkValue* value)
+103 -4
View File
@@ -2,10 +2,13 @@ name: Hellion Chat
author: JonKazama-Hellion
punchline: Chat 2 with privacy controls aligned to EU, US and JP rules
description: |-
Hellion Chat is built on top of Chat 2 — every Chat 2 feature, command
and shortcut you already know works the same. The /chat2 command, tabs,
channel filters, RGB colours, emotes, screenshot mode, IPC integration
and the chat replacement window itself are all unchanged.
Hellion Chat is built on top of Chat 2 with one removal and a stack
of privacy controls on top. Tabs, channel filters, RGB colours,
emotes, screenshot mode, IPC integration and the chat replacement
window itself work the same. The optional webinterface that Chat 2
ships is intentionally not part of this fork because it serves a
different use case from the smaller default footprint Hellion Chat
is built around.
On top of that, Hellion Chat adds privacy and data-handling controls
designed to align with the modern data protection rules that apply
@@ -37,6 +40,102 @@ tags:
- Replacement
- Privacy
changelog: |-
**Hellion Chat 0.3.0 — Audit hardening, brand sweep and rebrand of slash commands**
This release closes the remaining audit follow-ups from the
0.2.0 cleanup and finishes turning Hellion Chat into a properly
branded fork rather than a Chat 2 with a different name.
Slash commands have been renamed across the board so they no
longer collide with the upstream plugin and tell you which
plugin owns them at a glance:
- /chat2 becomes /hellion
- /chat2Viewer becomes /hellionView
- /clearlog2 becomes /clearhellion
- /chat2Debugger becomes /hellionDebugger (internal)
- /chat2SeString becomes /hellionSeString (internal)
This is a breaking change for anyone with macros bound to the
old command names. The upstream Chat 2 commands keep working
if you also have that plugin installed.
Privacy and storage hardening based on the post-0.2.0 audit:
- Privacy filter master switch now states explicitly that the
filter only governs storage, not the live chat log
- Emote cache refuses to write outside its own directory if a
third-party API ever returns a path that escapes
- Retention sweep is serialised so the 24h auto-sweep and the
manual button cannot launch in parallel and race for the
SQLite connection
- DbViewer paging uses an int constant and the matching SQL
parameter name (the upstream code passed a float and a name
without the parameter prefix; both worked in practice but
were inconsistent)
Visual identity now matches the Hellion Online Media website:
- Theme palette switched to Arctic Cyan plus Ember Orange,
matching the website's BRANDING.md tokens
- Active tabs and window title bars use a brand-color-dark teal
variation as identity colour, replacing the previous slate
violet that did not appear in the brand
- Resize grips and scrollbar grabs picked up Ember Orange
instead of industrial amber on hover and active states
About tab rewritten and properly localised:
- New "Why this fork exists" block sets out the mission in
neutral terms, framing Chat 2's full-history default as the
right one for most users while explaining the narrower
default footprint this fork chose
- All Hellion-specific About copy now lives in HellionStrings
in EN and DE, so German users see the Hellion sections in
German rather than the upstream English fallback
- Webinterface absence is described as a focus mismatch
(different use case, substantial rebuild) rather than as
a security issue with the upstream code
- Translator list at the bottom of the About tab is reachable
again on smaller settings windows
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
**Hellion Chat 0.2.0 — Webinterface removed**
The upstream webinterface has been removed in its entirety. It
serves a different use case from the smaller default footprint
this fork is built around, namely remote access to chat from a
second device. Aligning it with the data minimisation defaults
Hellion Chat ships with would have meant a substantial rebuild.
Removing it was the cleaner path for this particular fork.
What changed in this release:
- Settings tab "Webinterface" is gone, the corresponding
Configuration fields (WebinterfaceEnabled / AutoStart / Password /
Port / AuthStore / MaxLinesToSend) are dropped and stale entries
fall out of the JSON on the next save automatically
- The whole ChatTwo/Http tree, the bundled Svelte frontend in
websiteBuild.zip and the WebinterfaceUtil helper are deleted
- Watson.Lite (the HTTP server) and Newtonsoft.Json (only used by
the webinterface JSON wire format) are removed from the
package references
- DbViewer's "Chat2 JSON Export" button is dropped because it
serialised the database into the webinterface message protocol;
the Privacy tab's MessageExporter (Markdown, JSON, CSV with
channel and date filters) covers the same ground without the
proprietary shape
- About tab notes the absence so users coming from Chat 2 do not
look for it
- Configuration version bumps from 7 to 8 with a one-shot
notification (EN + DE)
No changes to the privacy filter, retention sweep, first-run wizard
or export pipeline. Existing chat history is preserved.
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
**Hellion Chat 0.1.2 — About tab rebrand, DBViewer polish**
- About tab now shows Hellion-specific maintainer, license, EU/US/JP
-23
View File
@@ -1,23 +0,0 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
-1
View File
@@ -1 +0,0 @@
engine-strict=true
-38
View File
@@ -1,38 +0,0 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"svelte": "^5.39.2",
"svelte-check": "^4.0.0",
"sveltekit-sse": "^0.14.3",
"typescript": "^5.0.0",
"vite": "^7.0.4"
},
"dependencies": {
"@sveltestrap/sveltestrap": "^7.1.0"
}
}
-23
View File
@@ -1,23 +0,0 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Error {
code: string;
id: string;
}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
interface Warning {
hasWarning: boolean;
content: string;
}
}
interface Element { scrollTopMax: number } // Firefox only property
}
export {};
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -1,79 +0,0 @@
<script lang="ts">
import {isChannelLocked, channelOptions} from "$lib/shared.svelte";
let selectElement: HTMLSelectElement;
async function requestChannelSwitch(event: Event) {
if (!event.currentTarget)
return;
let element = (event.currentTarget as HTMLSelectElement);
let requestedChannel = element.value;
console.log(element.value)
element.value = '0';
const rawResponse = await fetch('/channel', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ channel: requestedChannel })
});
// const content = await rawResponse.json();
// TODO: use the response
}
let canvas: HTMLCanvasElement | null = null;
function getTextWidth(text: string): number {
// re-use canvas object for better performance
if (canvas === null)
canvas = document.createElement("canvas");
const context: CanvasRenderingContext2D | null = canvas.getContext("2d");
if (!context)
return 0;
context.font = getCanvasFont(selectElement);
const metrics = context.measureText(text);
return metrics.width;
}
function getCssStyle(element: Element, prop: string): string {
return window.getComputedStyle(element, null).getPropertyValue(prop);
}
function getCanvasFont(el = document.body) {
const fontWeight = getCssStyle(el, 'font-weight') || 'normal';
const fontSize = getCssStyle(el, 'font-size') || '16px';
const fontFamily = getCssStyle(el, 'font-family') || 'Times New Roman';
return `${fontWeight} ${fontSize} ${fontFamily}`;
}
</script>
<select
bind:this={selectElement}
id="channel-select"
style="pointer-events: {isChannelLocked.locked ? 'none' : 'inherit'}; width: {(channelOptions.length > 1 ? getTextWidth(channelOptions[0].text) : 1) + 40}px"
onchange={(e) => requestChannelSwitch(e)}>
{#each channelOptions as channelOption}
{#if channelOption.preview }
<option selected disabled hidden value={channelOption.value}>
{channelOption.text}
</option>
{:else}
<option value={channelOption.value}>
{channelOption.text}
</option>
{/if}
{/each}
</select>
<style>
select {
border: none;
background-color: transparent;
}
</style>
@@ -1,103 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { subscribe } from "$lib/utils.svelte";
import { chatInput, messagesList, scrollMessagesToBottom } from "$lib/shared.svelte";
let textarea: HTMLTextAreaElement;
let skipNextCheck: boolean = $state(false);
let requiresResize: boolean = $state(true);
subscribe(
() => chatInput,
(v) => {
if (skipNextCheck) {
skipNextCheck = false;
return;
}
// Input box has been reset to empty, so resize it back to smaller box
if (v.content === '') {
console.log("Empty chatbox, resize");
requiresResize = true;
return;
}
// Remove newline characters
let original = v.content;
v.content = v.content.replace(/(\r\n|\n|\r)/gm,"");
console.log(`${original.length} vs ${v.content.length}`);
let hasChanged = original.length != v.content.length;
if (hasChanged) {
skipNextCheck = true;
requiresResize = true;
}
}
);
function preventNewlines(e: KeyboardEvent) {
if (e.key === 'Enter') {
// Prevent key from creating a newline
e.preventDefault();
// submit the data
const newEvent = new Event('submit', {bubbles: true, cancelable: true});
if (e.currentTarget !== null) {
(e.currentTarget as HTMLTextAreaElement).closest('form')?.dispatchEvent(newEvent);
}
}
}
function resize() {
if (!textarea)
return;
const scrolledToBottom = messagesList.scrolledToBottom;
textarea.style.height = '1px';
textarea.style.height = `${textarea.scrollHeight + 10}px`; // with +10px extra padding
if (scrolledToBottom)
scrollMessagesToBottom();
}
$effect(() => {
console.log(`Checking effect: ${requiresResize}`)
if (requiresResize) {
requiresResize = false;
resize();
}
})
</script>
<textarea
bind:this={textarea}
bind:value={chatInput.content}
oninput={() => resize()}
onkeydown={(e) => preventNewlines(e)}
id="chat-input"
autocomplete="off"
placeholder="Message"
enterkeyhint="send"
maxlength="500">
</textarea>
<style>
textarea {
flex-grow: 0;
font-size: 1rem;
border: 3px solid transparent;
border-radius: 20px;
background-color: var(--bg-input);
&:focus {
outline: 2px solid var(--focus-color);
}
width: 100%;
min-height: 2.5em;
line-height: 1.25;
}
</style>
@@ -1,62 +0,0 @@
<script lang="ts">
import { selectedTab, knownTabs, tabPaneState, tabPaneAnimationState, closeTabPane, messagesList, scrollMessagesToBottom } from "$lib/shared.svelte";
async function selectTab(index: number) {
const rawResponse = await fetch('/tab', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ index })
});
// const content = await rawResponse.json();
// TODO: use the response
}
function handleClose() {
tabPaneAnimationState.noAnimation = false;
closeTabPane();
}
let scrolledToBottom = true;
function ontransitionstart() {
scrolledToBottom = messagesList.scrolledToBottom;
}
function ontransitionend() {
if (scrolledToBottom) {
scrollMessagesToBottom();
}
}
</script>
<aside
id="tabs"
class:no-animation={tabPaneAnimationState.noAnimation}
class:hidden={!tabPaneState.visible}
{ontransitionstart}
{ontransitionend}
>
<div class="inner">
<header>
<span>Tabs</span>
<button type="button" onclick={() => handleClose()}>
<!-- "chevron-left" icon from https://github.com/feathericons/feather, under MIT license -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</header>
<hr>
<ol id="tabs-list">
{#each knownTabs as tab}
<li class:active={selectedTab.index === tab.index}>
<button type="button" onclick={() => selectTab(tab.index)}>
{ tab.name } {tab.unreadCount > 0 ? `(${tab.unreadCount})`: '' }
</button>
</li>
{/each}
</ol>
</div>
</aside>
@@ -1,37 +0,0 @@
<script lang="ts">
import {tabPaneState, tabPaneAnimationState, openTabPane, knownTabs} from "$lib/shared.svelte";
function onclick() {
tabPaneAnimationState.noAnimation = false;
openTabPane();
}
</script>
<button type="button" aria-label="Open tab pane" class:visible={!tabPaneState.visible} class:unread={knownTabs.some((tab) => tab.unreadCount > 0)} {onclick} disabled={tabPaneState.visible}>
<!-- "chevron-right" icon from https://github.com/feathericons/feather, under MIT license -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<style>
button {
position: absolute;
top: 50%;
left: 0;
padding: 25px 0;
border: none;
background-color: transparent;
transform: translateY(-50%);
z-index: 100;
opacity: 0;
transition: opacity 250ms ease;
}
button.visible {
opacity: 1;
}
button.unread svg {
stroke: var(--unread-color);
filter: drop-shadow(0 0 2px var(--unread-color));
}
</style>
-11
View File
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>%sveltekit.error.message%</title>
</head>
<body>
<p>Status: %sveltekit.status%</p>
<p>Message: %sveltekit.error.message%</p>
</body>
</html>
@@ -1,405 +0,0 @@
import { channelOptions, isChannelLocked, selectedTab, knownTabs, chatInput, messagesList, scrollMessagesToBottom } from "$lib/shared.svelte";
import { WebPayloadType } from "$lib/payload";
import { source, type Source } from "sveltekit-sse";
interface ChatElements {
messagesContainer: Element | null,
messagesList: HTMLElement | null,
timestampWidthProbe: HTMLElement | null,
inputForm: Element | null,
}
// ref `DataStructure.Messages`
interface Messages {
messages: MessageResponse[]
}
// ref `DataStructure.MessageResponse`
interface MessageResponse {
id: string;
timestamp: string;
templates: Template[];
}
// ref `DataStructure.MessageTemplate`
interface Template {
payloadType: WebPayloadType;
content: string;
iconId: number;
color: number;
}
// ref `DataStructure.SwitchChannel`
interface SwitchChannel {
channelName: Template[];
channelValue: number;
channelLocked: boolean;
}
// ref `DataStructure.ChannelList`
interface ChannelList {
channels: {[key: string]: number};
}
// ref `DataStructure.ChatTab`
export interface ChatTab {
name: string;
index: number;
unreadCount: number;
}
// ref `DataStructure.ChatTabList`
interface ChatTabList {
tabs: ChatTab[];
}
// ref `DataStructure.ChatTabUnreadState`
interface ChatTabUnreadState {
index: number;
unreadCount: number;
}
export class ChatTwoWeb {
elements!: ChatElements;
maxTimestampWidth: number = 0;
sse!: EventSource;
connection!: Source;
constructor() {
this.setupDOMElements();
this.setupSSEConnection();
}
setupDOMElements() {
this.elements = {
messagesContainer: document.querySelector('#messages > .scroll-container')!,
messagesList: document.getElementById('messages-list'),
timestampWidthProbe: document.getElementById('timestamp-width-probe'),
inputForm: document.querySelector('#input > form'),
};
messagesList.element = this.elements.messagesList;
// add indicator signaling more messages below
this.elements.messagesContainer?.addEventListener('scroll', (event) => {
if (event.currentTarget === null)
return;
let parentElement = (event.currentTarget as HTMLDivElement).parentElement;
if (!this.messagesAreScrolledToBottom()) {
parentElement?.classList.add('more-messages');
} else {
parentElement?.classList.remove('more-messages');
}
});
// adjust scroll when the window size changes; mostly for mobile (opening/closing the keyboard)
window.addEventListener('resize', () => {
if (messagesList.scrolledToBottom) {
scrollMessagesToBottom();
}
})
// handle message sending
this.elements.inputForm?.addEventListener('submit', async (event) => {
event.preventDefault();
if (chatInput.content.length > 500) {
return;
}
const rawResponse = await fetch('/send', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ message: chatInput.content })
});
// const content = await rawResponse.json();
// TODO: use the response
chatInput.content = '';
});
}
messagesAreScrolledToBottom() {
if (this.elements.messagesContainer === null) {
return messagesList.scrolledToBottom;
}
messagesList.scrolledToBottom =
(
this.elements.messagesContainer.scrollHeight -
this.elements.messagesContainer.clientHeight -
this.elements.messagesContainer.scrollTop
) < 1;
return messagesList.scrolledToBottom;
}
updateChannelHint(channel: SwitchChannel) {
// Set storage to the current lock state
isChannelLocked.locked = channel.channelLocked;
const channelElement = this.processTemplate(channel.channelName);
if (!channelElement.firstChild)
return;
let channelName = (channelElement.firstChild as HTMLSpanElement).innerText;
if (channel.channelLocked)
channelName = `(Locked) ${channelName}`;
channelOptions[0] = {text: channelName, value: 0, preview: true }
}
updateChannels(channelList: ChannelList) {
channelOptions.length = 1;
for (const [ label, channel ] of Object.entries(channelList.channels)) {
channelOptions.push( { text: label, value: channel, preview: false } )
}
}
// calculate timestamp width to ensure that all timestamps have the same width.
// some typefaces have the same width across all number glyphs, others do not.
// then there's AM/PM vs 24 hour, and so on
calculateTimestampWidth(timestamp: string) {
if (this.elements.timestampWidthProbe === null)
return;
this.elements.timestampWidthProbe.innerText = timestamp;
if (this.elements.timestampWidthProbe.clientWidth > this.maxTimestampWidth) {
this.maxTimestampWidth = this.elements.timestampWidthProbe.clientWidth;
document.body.style.setProperty('--timestamp-width', (Math.ceil(this.maxTimestampWidth) + 1) + 'px');
}
}
addMessage(messageData: MessageResponse) {
if (this.elements.messagesList === null)
return;
const scrolledToBottom = this.messagesAreScrolledToBottom();
this.calculateTimestampWidth(messageData.timestamp);
const liMessage = document.createElement('li');
const spanTimestamp = document.createElement('span');
spanTimestamp.classList.add('timestamp');
spanTimestamp.innerText = messageData.timestamp;
const spanMessage = document.createElement('span');
spanMessage.classList.add('message');
spanMessage.appendChild(this.processTemplate(messageData.templates))
liMessage.appendChild(spanTimestamp);
liMessage.appendChild(spanMessage);
this.elements.messagesList.appendChild(liMessage);
if (scrolledToBottom) {
scrollMessagesToBottom();
}
}
processTemplate(templates: Template[]) {
const frag = document.createDocumentFragment();
for( const template of templates ) {
const spanElement = document.createElement('span');
switch (template.payloadType) {
case WebPayloadType.RawText:
this.processTextTemplate(template, spanElement);
break;
case WebPayloadType.CustomUri:
this.processUrlTemplate(template, spanElement);
break;
case WebPayloadType.CustomEmote:
this.processEmote(template, spanElement);
break;
case WebPayloadType.Icon:
this.processIcon(template, spanElement);
break;
default:
continue;
}
frag.appendChild(spanElement);
}
return frag;
}
processTextTemplate(template: Template, spanElement: HTMLSpanElement) {
spanElement.innerText = template.content;
if (template.color !== 0)
{
this.processColor(template, spanElement);
}
}
processUrlTemplate(template: Template, spanElement: HTMLSpanElement) {
const urlElement = document.createElement('a');
let url = template.content;
if (!url.startsWith('https://')) {
url = `https://${url}`;
}
urlElement.innerText = template.content;
urlElement.href = encodeURI(url);
urlElement.target = '_blank'
if (template.color !== 0)
{
this.processColor(template, spanElement);
}
spanElement.appendChild(urlElement);
}
// converts a RGBA uint number to components
processColor(template: Template, spanElement: HTMLSpanElement) {
const r = (template.color & 0xFF000000) >>> 24;
const g = (template.color & 0xFF0000) >>> 16;
const b = (template.color & 0xFF00) >>> 8;
const a = (template.color & 0xFF) / 255.0;
spanElement.style.color = `rgba(${r}, ${g}, ${b}, ${a})`;
}
processEmote(template: Template, spanElement: HTMLSpanElement) {
const imgElement = document.createElement('img');
imgElement.src = `/emote/${template.content}`;
spanElement.classList.add('emote-icon');
spanElement.appendChild(imgElement);
}
processIcon(template: Template, spanElement: HTMLSpanElement) {
spanElement.classList.add('gfd-icon');
spanElement.classList.add(`gfd-icon-hq-${template.iconId}`);
}
clearAllMessages() {
if (this.elements.messagesList === null)
return;
this.elements.messagesList.innerHTML = '';
}
setupSSEConnection() {
this.connection = source('/sse')
this.connection.select('close').subscribe((data: string) => {
console.log(`close: ${data}`)
if (data) {
console.log('Closing SSE connection.');
this.connection.close();
}
});
// new messages to be appended to the message list
this.connection.select('new-message').subscribe((data: string) => {
console.log(`new-message: ${data}`)
if (data) {
try {
let message: MessageResponse = JSON.parse(data);
this.addMessage(message);
} catch (error) {
console.error(error);
}
}
});
// a bulk of new messages, with a clear of the message list beforehand
this.connection.select('bulk-messages').subscribe((data: string) => {
console.log(`bulk-messages: ${data}`)
if (data) {
this.clearAllMessages();
try {
let messages: Messages = JSON.parse(data);
for (const message of messages.messages) {
this.addMessage(message);
}
} catch (error) {
console.error(error);
}
}
});
this.connection.select('channel-switched').subscribe((data: string) => {
console.log(`channel-switched: ${data}`)
if (data) {
try {
let channel: SwitchChannel = JSON.parse(data);
this.updateChannelHint(channel);
} catch (error) {
console.error(error);
}
}
});
// list of all channels
this.connection.select('channel-list').subscribe((data: string) => {
console.log(`channel-list: ${data}`)
if (data) {
try {
let channelList: ChannelList = JSON.parse(data);
this.updateChannels(channelList);
} catch (error) {
console.error(error);
}
}
});
// tab switched
this.connection.select('tab-switched').subscribe((data: string) => {
console.log(`tab-switched: ${data}`)
if (data) {
try {
const chatTab: ChatTab = JSON.parse(data);
selectedTab.index = chatTab.index;
} catch (error) {
console.error(error);
}
}
});
// list of all tabs
this.connection.select('tab-list').subscribe((data: string) => {
console.log(`tab-list: ${data}`)
if (data) {
try {
const chatTabList: ChatTabList = JSON.parse(data);
knownTabs.length = 0;
for (const tab of chatTabList.tabs) {
knownTabs.push(tab);
}
} catch (error) {
console.error(error);
}
}
});
// the unread state of a specific tab has changed
this.connection.select('tab-unread-state').subscribe((data: string) => {
console.log(`tab-unread-state`, data)
if (data) {
try {
const chatTabUnreadState: ChatTabUnreadState = JSON.parse(data);
let tab = knownTabs.find((tab) => tab.index === chatTabUnreadState.index);
if (tab) {
tab.unreadCount = chatTabUnreadState.unreadCount;
}
else {
console.error("Unable to find tab!")
console.error(chatTabUnreadState)
}
} catch (error) {
console.error(error);
}
}
});
}
}
-134
View File
@@ -1,134 +0,0 @@
// from kizer, gfd icons
interface GdfEntry {
id: number,
left: number,
top: number,
width: number,
height: number,
unk0A: number,
redirect: number,
unk0E: number,
}
interface StylesheetEntry {
ids: number[],
style1: string,
style2: string,
width: number,
}
export async function addGfdStylesheet(gfdPath: string, texPath: string) {
const texPromise = loadTexAsBlob(texPath);
const gfdPromise = loadGfd(gfdPath);
const texUrl = URL.createObjectURL(await texPromise);
const gfd = await gfdPromise;
const stylesheets: {[id: number]: StylesheetEntry} = [];
for (const entry of gfd) {
if (entry.width * entry.height <= 0)
continue;
if (entry.redirect !== 0) {
stylesheets[entry.redirect].ids.push(entry.id);
continue;
}
stylesheets[entry.id] = {
ids: [entry.id],
style1: [
`background-position: -${entry.left}px -${entry.top}px`,
`background-image: url('${texUrl}')`,
`width: ${entry.width}px`,
`height: ${entry.height}px`
].join(';'),
style2: [
`background-position: -${entry.left * 2}px -${entry.top * 2 + 341}px`,
`background-image: url('${texUrl}')`,
`width: ${entry.width * 2}px`,
`height: ${entry.height * 2}px`
].join(';'),
width: entry.width
};
}
let stylesheet = '';
for (const entry of Object.values(stylesheets)) {
if (!entry)
continue;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry.style1};}`;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry.style2};}`;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}`).join(', ')}{width:${entry.width}px;}`;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}`).join(', ')}{width:${entry.width * 2}px;}`;
}
const styleNode = document.createElement('style');
styleNode.appendChild(document.createTextNode(stylesheet));
document.head.appendChild(styleNode);
}
async function loadTexAsBlob(path: string) {
const tex = parseTex(await (await fetch(path)).arrayBuffer());
if (tex.format !== 0x1450) // B8G8R8A8
throw 'Not supported';
const dataArray = new Uint8ClampedArray(tex.buffer, tex.offsetToSurface[0], tex.width * tex.height * 4);
for (let i = 0; i < dataArray.length; i += 4) {
const t = dataArray[i];
dataArray[i] = dataArray[i + 2];
dataArray[i + 2] = t;
}
const imageData = new ImageData(dataArray, tex.width, tex.height);
const bitmap = await createImageBitmap(imageData);
const canvas = new OffscreenCanvas(tex.width, tex.height);
canvas.getContext('bitmaprenderer')?.transferFromImageBitmap(bitmap);
return await canvas.convertToBlob();
}
async function loadGfd(path: string) {
const buffer = new DataView(await (await fetch(path)).arrayBuffer());
const count = buffer.getInt32(8, true);
const entries: GdfEntry[] = new Array(count);
for (let i = 0; i < count; i++) {
const offset = 0x10 + (i * 0x10);
entries[i] = {
id: buffer.getInt16(offset, true),
left: buffer.getInt16(offset + 2, true),
top: buffer.getInt16(offset + 4, true),
width: buffer.getInt16(offset + 6, true),
height: buffer.getInt16(offset + 8, true),
unk0A: buffer.getInt16(offset + 10, true),
redirect: buffer.getInt16(offset + 12, true),
unk0E: buffer.getInt16(offset + 14, true),
};
}
return entries;
}
function parseTex(arrayBuffer: ArrayBuffer) {
const buffer = new DataView(arrayBuffer);
const type = buffer.getInt32(0, true);
const format = buffer.getInt32(4, true);
const width = buffer.getInt16(8, true);
const height = buffer.getInt16(10, true);
const depth = buffer.getInt16(12, true);
const mipsAndFlag = buffer.getInt8(14);
const arraySize = buffer.getInt8(15);
const lodOffsets = [buffer.getInt32(16, true), buffer.getInt32(20, true), buffer.getInt32(24, true)];
const offsetToSurface = [buffer.getInt32(28, true), buffer.getInt32(32, true), buffer.getInt32(36, true), buffer.getInt32(40, true), buffer.getInt32(44, true), buffer.getInt32(48, true), buffer.getInt32(52, true), buffer.getInt32(56, true), buffer.getInt32(60, true), buffer.getInt32(64, true), buffer.getInt32(68, true), buffer.getInt32(72, true), buffer.getInt32(76, true)];
return {
buffer: arrayBuffer,
type,
format,
width,
height,
depth,
mipsAndFlag,
arraySize,
lodOffsets,
offsetToSurface,
};
}
-1
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
-25
View File
@@ -1,25 +0,0 @@
export enum WebPayloadType {
// Dalamud
Unknown,
Player,
Item,
Status,
RawText,
UIForeground,
UIGlow,
MapLink,
AutoTranslateText,
EmphasisItalic,
Icon,
Quest,
DalamudLink,
NewLine,
SeHyphen,
PartyFinder,
// Custom
CustomPartyFinder = 0x50,
CustomAchievement = 0x51,
CustomUri = 0x52,
CustomEmote = 0x53,
}
@@ -1,39 +0,0 @@
import type { ChatTab } from "./chat.svelte";
export const isChannelLocked: { locked: boolean } = $state({ locked: false });
export const channelOptions: ChannelOption[] = $state([ { text: 'Invalid', value: 0, preview: true } ]);
export interface ChannelOption {
text: string;
value: number;
preview: boolean;
}
export const selectedTab: { index: number } = $state({ index: 0 });
export const knownTabs: ChatTab[] = $state([]);
export const tabPaneState: { visible: boolean } = $state({ visible: true });
export const tabPaneAnimationState: { noAnimation: boolean } = $state({ noAnimation: true });
export const persistentTabPabeStateKey = 'chat2_tab_pane_visible';
export function openTabPane() {
tabPaneState.visible = true;
window.localStorage.setItem(persistentTabPabeStateKey, 'true');
}
export function closeTabPane() {
tabPaneState.visible = false;
window.localStorage.setItem(persistentTabPabeStateKey, 'false');
}
export const chatInput: { content: string } = $state({ content: ''} );
export const messagesList: {
element: HTMLElement | null,
scrolledToBottom: boolean
} = $state({ element: null, scrolledToBottom: true });
export function scrollMessagesToBottom() {
if (messagesList.element === null)
return;
messagesList.element.lastElementChild?.scrollIntoView();
}
@@ -1,11 +0,0 @@
import {writable} from "svelte/store";
// https://stackoverflow.com/a/79696571
export const subscribe = <T>(functionToState: () => T, callback: (v: T) => void) => {
let value = writable<T>(functionToState());
value.subscribe(callback);
$effect(() => {
value.set(functionToState());
});
};
@@ -1,11 +0,0 @@
<script lang="ts">
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/static/bootstrap.min.css">
<link rel="stylesheet" href="/static/start.css">
</svelte:head>
{@render children?.()}
@@ -1 +0,0 @@
export const prerender = true;
@@ -1,33 +0,0 @@
<script lang="ts">
import { page } from '$app/state'
import { Alert } from '@sveltestrap/sveltestrap';
let data: App.Warning = $state({ hasWarning: false, content: '' });
$effect.pre(() => {
if (page.url.searchParams.has('message')) {
data = {
hasWarning: true,
content: page.url.searchParams.get('message') ?? '',
};
} else {
data = {
hasWarning: false,
content: '',
};
}
});
</script>
<main class="auth">
<h1>Authcode</h1>
{#if data?.hasWarning }
<Alert content={data.content} color="warning" dismissible={true}/>
{/if}
<form action="/auth" method="POST">
<label><input type="password" name="authcode"></label>
<button type="submit" class="submitButton">Submit</button>
</form>
<div data-sveltekit-preload-data="false">
<img src="/emote/Sure" alt=":Sure:" data-sveltekit-preload-data="off">
</div>
</main>
@@ -1,86 +0,0 @@
<script lang="ts">
import { page } from '$app/state'
import { Alert } from "@sveltestrap/sveltestrap";
import { onMount } from 'svelte';
import { ChatTwoWeb } from '$lib/chat.svelte'
import { tabPaneState, persistentTabPabeStateKey } from "$lib/shared.svelte";
import { addGfdStylesheet } from "$lib/gfd";
import DynamicTextArea from "../../components/DynamicTextArea.svelte";
import ChannelSelector from "../../components/ChannelSelector.svelte";
import TabPane from "../../components/TabPane.svelte";
import TabPaneOpener from "../../components/TabPaneOpener.svelte";
let data: App.Warning = $state({ hasWarning: false, content: '' });
$effect.pre(() => {
if (page.url.searchParams.has('message')) {
data = {
hasWarning: true,
content: page.url.searchParams.get('message') ?? '',
};
} else {
data = {
hasWarning: false,
content: '',
};
}
});
onMount(() => {
console.log('the component has mounted');
// Populate the stylesheet with gfd data
addGfdStylesheet('/files/gfdata.gfd', '/files/fonticon_ps5.tex');
// read saved tab pane state from localStorage
try {
const tabPaneVisible = window.localStorage.getItem(persistentTabPabeStateKey);
if (tabPaneVisible !== null) {
tabPaneState.visible = JSON.parse(tabPaneVisible);
}
} catch (e) {
// JSON.parse() failed, let's reset what's in localStorage
window.localStorage.removeItem(persistentTabPabeStateKey);
}
// Load all web functions in the background
const _ = new ChatTwoWeb();
});
</script>
<main class="chat">
<TabPane />
<div class="main-content">
<TabPaneOpener />
<section id="messages">
<div class="scroll-container">
<ol id="messages-list"></ol>
</div>
<div id="more-messages-indicator">
<!-- "arrow-down" icon from https://github.com/feathericons/feather, under MIT license -->
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</div>
</section>
{#if data?.hasWarning }
<section id="warnings">
<Alert content={data.content} color="warning" dismissible={true}/>
</section>
{/if}
<section id="input">
<form>
<div class="input-container">
<DynamicTextArea />
<ChannelSelector />
</div>
<button type="submit">Send</button>
</form>
</section>
</div>
</main>
<div id="timestamp-width-probe"></div>
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
# allow crawling everything by default
User-agent: *
Disallow:
@@ -1,472 +0,0 @@
/* fonts */
@font-face {
font-family: Lodestone;
src: url('/files/FFXIV_Lodestone_SSF.ttf') format('truetype');
unicode-range: U+E020-E0DB;
}
@font-face {
font-family: 'Inter var';
font-weight: 100 900;
font-style: oblique 0deg 10deg;
src: url('/static/Inter.var.woff2') format('woff2');
}
/* variables */
:root {
--fg: white;
--fg-faint: #a0a0a0;
--fg-scrollbar: #404040;
--bg: #101010;
--bg-sidebar: #080808;
--bg-input: #202020;
--bg-input-hover: #282828;
--focus-color: #4060a0;
--unread-color: #beffa0;
--gradient-clickable: linear-gradient(to bottom, #404040, var(--bg-input) 65%, var(--bg-input));
--gradient-clickable-hover: linear-gradient(to bottom, #505050, var(--bg-input-hover) 65%, var(--bg-input-hover));
--timestamp-width: 70px;
}
/* reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
* {
color: var(--fg);
font-family: Lodestone, 'Inter var', sans-serif;
font-feature-settings: 'tnum', 'calt' 0; /* calt appears to be on by default */
}
html {
font-size: 16px;
}
span > a {
color: inherit;
}
/* layout and global styles */
body {
padding: 25px;
height: 100dvh;
background-color: var(--bg-input-hover);
}
main.chat {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--bg);
border-radius: 20px;
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.5);
& > .main-content {
display: flex;
flex-direction: column;
flex-grow: 1;
}
}
main.auth {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
width: 100%;
height: 100%;
h1 {
font-size: 1.5rem;
font-weight: 400;
}
input { width: 150px; }
input, .submitButton {
padding: 5px 20px;
font-size: 1rem;
border: 3px solid transparent;
border-radius: 20px;
background-color: var(--bg-input);
&:focus {
outline: 2px solid var(--focus-color);
}
}
.submitButton {
padding: 5px 15px;
border: 3px solid var(--bg-input);
background-image: var(--gradient-clickable);
cursor: pointer;
&:hover {
border-color: var(--bg-input-hover);
background-color: var(--bg-input-hover);
background-image: var(--gradient-clickable-hover);
}
}
}
/* tab list */
aside#tabs {
flex: 0 0 auto;
overflow-x: hidden;
scrollbar-color: var(--fg-scrollbar) var(--bg-sidebar);
background-color: var(--bg-sidebar);
transition: width 250ms ease;
width: 200px;
&.hidden { width: 0px; }
&.no-animation {
transition: none;
}
div.inner {
width: 200px;
padding: 20px;
}
header {
display: flex;
align-items: flex-end;
justify-content: space-between;
font-size: 1.1rem;
font-weight: 550;
button {
margin-bottom: 2px;
border: none;
background-color: transparent;
}
}
hr {
margin: 0.6rem 0 0.75rem;
border-color: var(--fg-faint);
}
ol#tabs-list {
margin: 0 -5px;
padding: 0;
list-style-type: none;
}
li {
padding: 3px 5px;
color: var(--fg-faint);
border-radius: 3px;
button {
width: 100%;
text-align: left;
color: inherit;
border: none;
background-color: transparent;
}
}
li + li {
margin-top: 3px;
}
li:has(button:hover) {
color: var(--fg);
background-color: rgb(from var(--bg-input) r g b / 0.5);
}
li.active {
color: var(--fg);
background-color: var(--bg-input);
}
li.unread button {
color: var(--unread-color);
text-shadow: 0 0 5px var(--unread-color);
}
}
/* message list */
section#messages {
position: relative;
flex: 1;
min-width: 0;
min-height: 0;
padding: 20px;
line-height: 1.5;
.scroll-container {
height: 100%;
overflow-y: scroll;
scrollbar-color: var(--fg-scrollbar) var(--bg);
}
ol#messages-list {
margin: 0;
padding: 0;
list-style-type: none;
}
li {
display: flex;
align-items: flex-start;
gap: 10px;
.timestamp {
flex: 0 0 var(--timestamp-width);
color: var(--fg-faint);
text-align: right;
}
.message {
white-space: pre-wrap;
}
}
#more-messages-indicator {
position: absolute;
display: none;
right: 30px;
bottom: 0;
pointer-events: none;
svg {
filter: drop-shadow(0 0 5px #60a0ff) drop-shadow(0 0 15px #60a0ff);
}
}
&.more-messages #more-messages-indicator {
display: block;
}
}
#timestamp-width-probe {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
}
/* alerts */
section#warnings {
flex-grow: 0;
padding: 20px 20px 0 20px;
}
/* input bar, channel selector, ... */
section#input {
flex-grow: 0;
padding: 20px;
form {
display: flex;
gap: 10px;
}
input, button {
font-size: 1rem;
border: 3px solid transparent;
border-radius: 20px;
background-color: var(--bg-input);
&:focus {
outline: 2px solid var(--focus-color);
}
}
button {
padding: 5px 15px;
border: 3px solid var(--bg-input);
background-image: var(--gradient-clickable);
cursor: pointer;
&:hover {
border-color: var(--bg-input-hover);
background-color: var(--bg-input-hover);
background-image: var(--gradient-clickable-hover);
}
}
button {
position: relative;
flex-grow: 0;
flex-shrink: 0;
}
button {
padding-left: calc(20px + 1.5rem);
&::before {
/* "send" icon from https://github.com/feathericons/feather, under MIT license */
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>');
}
}
button::before {
content: '';
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
width: 1.3rem;
height: 1.3rem;
background-color: var(--fg);
mask-size: 100%;
pointer-events: none;
}
.input-container {
flex: 1;
position: relative;
#chat-input {
width: 100%;
padding: 5px 20px;
}
#channel-select {
position: absolute;
top: -1.5em;
left: 23px;
max-width: 100%;
overflow: hidden;
font-size: 1.1rem;
font-weight: 550;
white-space: nowrap;
text-overflow: ellipsis;
border: 0;
border-radius: 0;
background-color: transparent;
padding: 5px 15px;
cursor: pointer;
&:hover {
border-color: var(--bg-input-hover);
background-color: var(--bg-input-hover);
background-image: var(--gradient-clickable-hover);
}
&:focus {
outline: 2px solid var(--focus-color);
}
}
}
}
/* icons, emotes */
.gfd-icon {
display: inline-block;
position: relative;
vertical-align: middle;
zoom: 0.75;
&::before {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
transform: translateY(-50%);
pointer-events: none;
}
}
.emote-icon {
display: inline-block;
position: relative;
width: 2rem;
height: 1px;
vertical-align: middle;
overflow: visible;
img {
display: block;
position: absolute;
top: 0;
left: 0;
width: 2rem;
height: 2rem;
transform: translateY(-50%);
pointer-events: none;
}
}
/*** mobile ***/
@media ((max-width: 600px) and (orientation: portrait)) or (max-height: 400px) {
body {
padding: 0;
}
main.chat {
border-radius: 0;
box-shadow: none;
}
section#messages {
font-size: 0.9rem;
li {
align-items: baseline;
.timestamp {
font-size: 0.8rem;
}
}
}
#timestamp-width-probe {
font-size: 0.8rem;
}
section#input {
button {
max-width: 0;
padding-left: 1.5rem;
padding-right: 1.5rem;
color: transparent;
}
button::before {
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
.input-container #channel-select {
font-size: 0.9rem;
}
}
.gfd-icon { zoom: 0.65; }
.emote-icon {
width: 1.5rem;
img {
width: 1.5rem;
height: 1.5rem;
}
}
aside#tabs {
position: fixed;
width: 100vw;
height: 100vh;
z-index: 1000;
div.inner {
width: 100vw;
}
}
}
-25
View File
@@ -1,25 +0,0 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
prerender: {
handleHttpError: 'warn'
},
adapter: adapter({
// default options are shown. On some platforms
// these options are set automatically — see below
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true,
})
},
};
export default config;
-19
View File
@@ -1,19 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
-6
View File
@@ -1,6 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});
-137
View File
@@ -1,137 +0,0 @@
using WatsonWebserver.Core;
using WatsonWebserver.Lite;
namespace ChatTwo.Http;
public class HostContext
{
public readonly ServerCore Core;
public bool IsActive;
public bool IsStopping;
// Initialized at webserver start
public WebserverLite Host = null!;
public Processing Processing = null!;
public RouteController RouteController = null!;
public readonly List<SSEConnection> EventConnections = [];
public readonly CancellationTokenSource TokenSource = new();
public readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Frontend/");
public HostContext(ServerCore core)
{
Core = core;
}
public bool Start()
{
try
{
Host = new WebserverLite(new WebserverSettings("*", Plugin.Config.WebinterfacePort), DefaultRoute);
Processing = new Processing(this);
RouteController = new RouteController(this);
Host.Routes.PreAuthentication.Content.BaseDirectory = StaticDir;
Host.Routes.AuthenticateRequest = CheckAuthenticationCookie;
Host.Events.ExceptionEncountered += ExceptionEncountered;
// Settings
#if DEBUG
Host.Settings.Debug.Requests = true;
Host.Settings.Debug.Routing = true;
Host.Settings.Debug.Responses = true;
Host.Settings.Debug.AccessControl = true;
#endif
Host.Events.Logger = logMessage => Plugin.Log.Debug(logMessage);
IsActive = true;
return true;
}
catch (Exception ex)
{
IsActive = false;
Plugin.Log.Error(ex, "Initialization of the webserver failed.");
return false;
}
}
public void Run()
{
try
{
Host.Start(TokenSource.Token);
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Webserver failed to boot up.");
}
}
public async ValueTask<bool> Stop()
{
// Is already stopped
if (!IsActive)
return true;
try
{
IsActive = false;
IsStopping = true;
Host.Stop();
// Save our session tokens
Core.Plugin.SaveConfig();
// We get a copy, so that the original can be cleaned up successfully
foreach (var eventServer in EventConnections.ToArray())
await eventServer.DisposeAsync();
EventConnections.Clear();
Host.Dispose();
RouteController.Dispose();
IsStopping = false;
return true;
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Webserver failed to stop and dispose all resources.");
return false;
}
}
public async ValueTask DisposeAsync()
{
await Stop();
}
#region GeneralHandlers
private static void ExceptionEncountered(object? _, ExceptionEventArgs args)
{
Plugin.Log.Error(args.Exception, "Webserver threw an exception.");
}
private async Task<bool> DefaultRoute(HttpContextBase ctx)
{
return await ctx.Response.Send("Nothing to see here.");
}
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
{
if (Plugin.Config.AuthStore.Count == 0)
{
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
return;
}
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
if (!cookies.TryGetValue("ChatTwo-token", out var token) || !Plugin.Config.AuthStore.Contains(token))
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
// Do nothing to let auth pass
}
#endregion
}
@@ -1,140 +0,0 @@
using ChatTwo.Code;
using Newtonsoft.Json;
namespace ChatTwo.Http.MessageProtocol;
#region Outgoing SSE
/// <summary>
/// Contains a valid tab with its assigned index
/// </summary>
public struct ChatTab(string name, int index, uint unreadCount)
{
[JsonProperty("name")] public string Name = name;
[JsonProperty("index")] public int Index = index;
[JsonProperty("unreadCount")] public uint UnreadCount = unreadCount;
}
/// <summary>
/// Contains a number of tabs that are valid for the user to pick from
/// </summary>
public struct ChatTabList(ChatTab[] tabs)
{
[JsonProperty("tabs")] public ChatTab[] Tabs = tabs;
}
/// <summary>
/// Contains a valid tab index and the current unread state as a number unread of messages
/// </summary>
public struct ChatTabUnreadState(int index, uint unreadCount)
{
[JsonProperty("index")] public int Index = index;
[JsonProperty("unreadCount")] public uint UnreadCount = unreadCount;
}
/// <summary>
/// Contains the current channel name
/// </summary>
public struct SwitchChannel((MessageTemplate[] Name, bool Locked) channel)
{
[JsonProperty("channelName")] public MessageTemplate[] ChannelName = channel.Name;
[JsonProperty("channelLocked")] public bool Locked = channel.Locked;
}
/// <summary>
/// Contains a number of channels that are valid for the user to pick from
/// </summary>
public struct ChannelList(Dictionary<string, uint> channels)
{
[JsonProperty("channels")] public Dictionary<string, uint> Channels = channels;
}
/// <summary>
/// Contains one or multiple messages
/// </summary>
public struct Messages(MessageResponse[] set)
{
[JsonProperty("messages")] public MessageResponse[] Set = set;
}
/// <summary>
/// Contains a single message with all its templates and a timestamp
/// </summary>
public struct MessageResponse()
{
[JsonProperty("id")] public Guid Id = Guid.Empty;
[JsonProperty("timestamp")] public string Timestamp = "";
[JsonProperty("templates")] public MessageTemplate[] Templates = [];
}
/// <summary>
/// Template that is used for the channel name or any message posted to the chatlog
/// </summary>
public struct MessageTemplate()
{
/// <summary>
/// The type of payload.
/// Dalamuds enum is just a baseline, there exists more that are expressed through raw values.
/// </summary>
[JsonProperty("payloadType")] public WebPayloadType PayloadType = WebPayloadType.Unknown;
/// <summary>
/// Used for text and emote.
/// </summary>
[JsonProperty("content")] public string Content = "";
/// <summary>
/// Used for an icon.
/// </summary>
[JsonProperty("iconId")] public uint IconId;
/// <summary>
/// Used for text and url
///
/// Note:
/// 0 is used for invalid colors
/// </summary>
[JsonProperty("color")] public uint Color;
public static MessageTemplate Empty => new();
}
#endregion
#region Outgoing POST
public struct OkResponse(string message)
{
[JsonProperty("message")] public string Message = message;
}
public struct ErrorResponse(string reason)
{
[JsonProperty("reason")] public string Reason = reason;
}
#endregion
#region Incoming POST
/// <summary>
/// Message must fulfill the posting requirement
/// Greater than or equal 2 characters
/// Less than or equal 500 characters
/// </summary>
public struct IncomingMessage()
{
[JsonProperty("message")] public string Message = string.Empty;
}
/// <summary>
/// The channel type must be a valid <see cref="InputChannel"/>
/// </summary>
public struct IncomingChannel()
{
[JsonProperty("channel")] public InputChannel Channel = InputChannel.Invalid;
}
/// <summary>
/// The tabs index must be a valid int
/// </summary>
public struct IncomingTab()
{
[JsonProperty("index")] public int Index = -1;
}
#endregion
@@ -1,32 +0,0 @@
using System.Text;
using Newtonsoft.Json;
namespace ChatTwo.Http.MessageProtocol;
// General
public class CloseEvent() : BaseEvent("close");
// Tab related
public class ChatTabListEvent(ChatTabList list) : BaseEvent("tab-list", JsonConvert.SerializeObject(list));
public class ChatTabSwitchedEvent(ChatTab chatTab) : BaseEvent("tab-switched", JsonConvert.SerializeObject(chatTab));
public class ChatTabUnreadStateEvent(ChatTabUnreadState unreadState) : BaseEvent("tab-unread-state", JsonConvert.SerializeObject(unreadState));
// Input channel related
public class ChannelListEvent(ChannelList channelList) : BaseEvent("channel-list", JsonConvert.SerializeObject(channelList));
public class SwitchChannelEvent(SwitchChannel switchChannel) : BaseEvent("channel-switched", JsonConvert.SerializeObject(switchChannel));
// Chat message related
public class BulkMessagesEvent(Messages messages) : BaseEvent("bulk-messages", JsonConvert.SerializeObject(messages));
public class NewMessageEvent(MessageResponse message) : BaseEvent("new-message", JsonConvert.SerializeObject(message));
public class BaseEvent(string eventType, string? data = null)
{
private string Event = eventType;
private string Data = data ?? "0"; // SSE requires data on each response
public byte[] Build()
{
// SSE always ends with \n\n
return Encoding.UTF8.GetBytes($"event: {Event}\ndata: {Data}\n\n");
}
}
@@ -1,31 +0,0 @@
namespace ChatTwo.Http.MessageProtocol;
/// <summary>
/// Baseline: <see cref="Dalamud.Game.Text.SeStringHandling.PayloadType"/>
/// </summary>
public enum WebPayloadType
{
// Dalamud
Unknown,
Player,
Item,
Status,
RawText,
UIForeground,
UIGlow,
MapLink,
AutoTranslateText,
EmphasisItalic,
Icon,
Quest,
DalamudLink,
NewLine,
SeHyphen,
PartyFinder,
// Custom
CustomPartyFinder = 0x50,
CustomAchievement = 0x51,
CustomUri = 0x52,
CustomEmote = 0x53,
}
-117
View File
@@ -1,117 +0,0 @@
using System.Globalization;
using ChatTwo.Code;
using ChatTwo.Http.MessageProtocol;
using ChatTwo.Util;
using Dalamud.Game.Text.SeStringHandling.Payloads;
namespace ChatTwo.Http;
public class Processing
{
private readonly HostContext HostContext;
public Processing(HostContext hostContext)
{
HostContext = hostContext;
}
internal (MessageTemplate[] Name, bool Locked) ReadChannelName(Chunk[] channelName)
{
var locked = HostContext.Core.Plugin.CurrentTab is not { Channel: null };
return (channelName.Select(ProcessChunk).ToArray(), locked);
}
internal async Task<MessageResponse[]> ReadMessageList()
{
var tabMessages = await HostContext.Core.Plugin.CurrentTab.Messages.GetCopy();
return tabMessages.TakeLast(Plugin.Config.WebinterfaceMaxLinesToSend).Select(ReadMessageContent).ToArray();
}
internal MessageResponse ReadMessageContent(Message message)
{
var response = new MessageResponse
{
Id = message.Id,
Timestamp = message.Date.ToLocalTime().ToString("t", !Plugin.Config.Use24HourClock ? null : CultureInfo.CreateSpecificCulture("es-ES"))
};
var sender = message.Sender.Select(ProcessChunk);
var content = message.Content.Select(ProcessChunk);
response.Templates = sender.Concat(content).ToArray();
return response;
}
private MessageTemplate ProcessChunk(Chunk chunk)
{
if (chunk is IconChunk { } icon)
{
var iconId = (uint)icon.Icon;
return IconUtil.GfdFileView.TryGetEntry(iconId, out _) ? new MessageTemplate {PayloadType = WebPayloadType.Icon, IconId = iconId}: MessageTemplate.Empty;
}
if (chunk is TextChunk { } text)
{
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
{
var image = EmoteCache.GetEmote(emotePayload.Code);
if (image is { Failed: false })
return new MessageTemplate { PayloadType = WebPayloadType.CustomEmote, Color = 0, Content = emotePayload.Code };
}
var color = text.Foreground;
if (color == null && text.FallbackColour != null)
{
var type = text.FallbackColour.Value;
color = Plugin.Config.ChatColours.TryGetValue(type, out var col) ? col : type.DefaultColor();
}
color ??= 0;
var userContent = text.Content;
if (HostContext.Core.Plugin.ChatLogWindow.ScreenshotMode)
{
if (chunk.Link is PlayerPayload playerPayload)
userContent = HostContext.Core.Plugin.ChatLogWindow.HidePlayerInString(userContent, playerPayload.PlayerName, playerPayload.World.RowId);
else if (Plugin.PlayerState.IsLoaded)
userContent = HostContext.Core.Plugin.ChatLogWindow.HidePlayerInString(userContent, Plugin.PlayerState.CharacterName, Plugin.PlayerState.HomeWorld.RowId);
}
var isNotUrl = text.Link is not UriPayload;
return new MessageTemplate { PayloadType = isNotUrl ? WebPayloadType.RawText : WebPayloadType.CustomUri, Color = color.Value, Content = userContent };
}
return MessageTemplate.Empty;
}
public async Task<Messages> GetAllMessages()
{
var messages = await WebserverUtil.FrameworkWrapper(ReadMessageList);
return new Messages(messages);
}
public SwitchChannel GetCurrentChannel()
{
var channel = ReadChannelName(HostContext.Core.Plugin.ChatLogWindow.PreviousChannel);
return new SwitchChannel(channel);
}
public ChannelList GetValidChannels()
{
var channels = HostContext.Core.Plugin.ChatLogWindow.GetValidChannels();
return new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value));
}
public ChatTab GetCurrentTab()
{
var currentTab = HostContext.Core.Plugin.CurrentTab;
return new ChatTab(currentTab.Name, HostContext.Core.Plugin.LastTab, currentTab.Unread);
}
public ChatTabList GetAllTabs()
{
var tabs = Plugin.Config.Tabs.Select((tab, idx) => new ChatTab(tab.Name, idx, tab.Unread)).ToArray();
return new ChatTabList(tabs);
}
}
-287
View File
@@ -1,287 +0,0 @@
using System.Collections.Concurrent;
using System.Web;
using ChatTwo.Http.MessageProtocol;
using ChatTwo.Util;
using Lumina.Data.Files;
using Newtonsoft.Json;
using WatsonWebserver.Core;
using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs;
using HttpMethod = WatsonWebserver.Core.HttpMethod;
namespace ChatTwo.Http;
public class RouteController
{
private readonly HostContext HostContext;
private readonly string AuthTemplate;
private readonly string ChatBoxTemplate;
private readonly ConcurrentDictionary<string, long> RateLimit = [];
private readonly JsonSerializerSettings JsonSettings = new()
{
Error = delegate(object? _, ErrorEventArgs args) { args.ErrorContext.Handled = true; }
};
public RouteController(HostContext hostContext)
{
HostContext = hostContext;
AuthTemplate = File.ReadAllText(Path.Combine(HostContext.StaticDir, "index.html"));
ChatBoxTemplate = File.ReadAllText(Path.Combine(HostContext.StaticDir, "chat.html"));
// Pre Auth
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.POST, "/auth", AuthenticateClient, ExceptionRoute);
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/favicon.ico", GetFavicon, ExceptionRoute);
HostContext.Host.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
// Post Auth
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/channel", ReceiveChannelSwitch, ExceptionRoute);
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/tab", ReceiveTabSwitch, ExceptionRoute);
// Ship all other static files dynamically
HostContext.Host.Routes.PreAuthentication.Content.Add("/_app/", true, ExceptionRoute);
HostContext.Host.Routes.PreAuthentication.Content.Add("/static/", true, ExceptionRoute);
// Server-Sent Events Route
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/sse", NewSSEConnection, ExceptionRoute);
}
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
{
ctx.Response.StatusCode = 500;
await ctx.Response.Send("Internal Server Error, please try again");
}
private async Task AuthRoute(HttpContextBase ctx)
{
if (Plugin.Config.AuthStore.Count > 0)
{
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
if (cookies.TryGetValue("ChatTwo-token", out var value) && Plugin.Config.AuthStore.Contains(value))
{
await Redirect(ctx, "/chat");
return;
}
}
await ctx.Response.Send(AuthTemplate);
}
public void Dispose()
{
}
#region FileHandlerRoutes
private async Task GetTexData(HttpContextBase ctx)
{
var data = Plugin.DataManager.GetFile<TexFile>("common/font/fonticon_ps5.tex")!.Data;
await ctx.Response.Send(data);
}
private async Task GetGfdData(HttpContextBase ctx)
{
var data = Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
await ctx.Response.Send(data);
}
private async Task GetLodestoneFont(HttpContextBase ctx)
{
var data = HostContext.Core.Plugin.FontManager.GameSymFont;
await ctx.Response.Send(data);
}
private async Task GetFavicon(HttpContextBase ctx)
{
ctx.Response.StatusCode = 404;
await ctx.Response.Send();
}
private async Task GetEmote(HttpContextBase ctx)
{
var name = ctx.Request.Url.Parameters["name"] ?? "";
if (name == "" || !EmoteCache.Exists(name))
{
ctx.Response.StatusCode = 400;
await ctx.Response.Send("Malformed emote name.");
return;
}
var emote = EmoteCache.GetEmote(name);
if (emote is null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.Send("Emote not valid.");
return;
}
// Wait for the emote to be loaded a maximum of 5 times
var timeout = 5;
while (!emote.IsLoaded && timeout > 0)
{
timeout--;
await Task.Delay(25);
}
ctx.Response.Headers.Add("Cache-Control", "max-age=86400");
await ctx.Response.Send(emote.RawData);
}
#endregion
#region PreAuthRoutes
private async Task<bool> AuthenticateClient(HttpContextBase ctx)
{
var currentTick = Environment.TickCount64;
if (RateLimit.TryGetValue(ctx.Request.Source.IpAddress, out var timestamp) && timestamp > currentTick)
{
_ = ctx.Request.DataAsString; // Temp fix for Watson.Lite bug #155
return await Redirect(ctx, "/", ("message", "Rate limit active (10s)"));
}
// The next request will be rate limited for 10s
RateLimit[ctx.Request.Source.IpAddress] = currentTick + 10_000;
var authcode = HttpUtility.ParseQueryString(ctx.Request.DataAsString ?? "").Get("authcode");
if (authcode == null || authcode != Plugin.Config.WebinterfacePassword)
return await Redirect(ctx, "/", ("message", "Authentication failed"));
var token = WebinterfaceUtil.GenerateSimpleToken();
Plugin.Config.AuthStore.Add(token);
ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}");
return await Redirect(ctx, "/chat");
}
#endregion
#region PostAuthRoutes
private async Task ChatBoxRoute(HttpContextBase ctx)
{
await ctx.Response.Send(ChatBoxTemplate);
}
private async Task ReceiveMessage(HttpContextBase ctx)
{
if (!await EnforceMediaType(ctx, "application/json"))
return;
var content = JsonConvert.DeserializeObject<IncomingMessage>(ctx.Request.DataAsString, JsonSettings);
if (content.Message.Length is < 2 or > 500)
{
ctx.Response.StatusCode = 400;
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid message received.")));
return;
}
await Plugin.Framework.RunOnFrameworkThread(() =>
{
HostContext.Core.Plugin.ChatLogWindow.Chat = content.Message;
HostContext.Core.Plugin.ChatLogWindow.SendChatBox(HostContext.Core.Plugin.CurrentTab);
});
ctx.Response.StatusCode = 201;
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Message was send to the channel.")));
}
private async Task ReceiveChannelSwitch(HttpContextBase ctx)
{
if (!await EnforceMediaType(ctx, "application/json"))
return;
var channel = JsonConvert.DeserializeObject<IncomingChannel>(ctx.Request.DataAsString, JsonSettings);
if (!Enum.IsDefined(channel.Channel))
{
ctx.Response.StatusCode = 400;
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid channel received.")));
return;
}
await Plugin.Framework.RunOnFrameworkThread(() => { HostContext.Core.Plugin.ChatLogWindow.SetChannel(channel.Channel); });
ctx.Response.StatusCode = 201;
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Channel switch was initiated.")));
}
private async Task ReceiveTabSwitch(HttpContextBase ctx)
{
if (!await EnforceMediaType(ctx, "application/json"))
return;
var tab = JsonConvert.DeserializeObject<IncomingTab>(ctx.Request.DataAsString, JsonSettings);
if (tab.Index < 0 || tab.Index >= Plugin.Config.Tabs.Count)
{
ctx.Response.StatusCode = 400;
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid tab received.")));
return;
}
await Plugin.Framework.RunOnFrameworkThread(() => { HostContext.Core.Plugin.WantedTab = tab.Index; });
ctx.Response.StatusCode = 201;
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Tab switch was initiated.")));
}
private async Task NewSSEConnection(HttpContextBase ctx)
{
try
{
Plugin.Log.Debug($"Client connected: {ctx.Guid}");
var sse = new SSEConnection(HostContext.TokenSource.Token);
await HostContext.Core.PrepareNewClient(sse);
HostContext.EventConnections.Add(sse);
await sse.HandleEventLoop(ctx);
// It should always be done after return
if (sse.Done)
HostContext.EventConnections.Remove(sse);
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Failed to finish the server event function");
}
}
#endregion
#region RedirectHelper
public static async Task<bool> Redirect(HttpContextBase ctx, string location, params (string, string)[] parameter)
{
var query = HttpUtility.ParseQueryString(string.Empty);
foreach (var (key, value) in parameter)
query.Add(key, value);
ctx.Response.Headers.Add("Location", $"{location}?{query}");
ctx.Response.StatusCode = 303;
return await ctx.Response.Send();
}
#endregion
#region PreChecks
/// <summary>
/// Check that the request has the correct media type that the functions expects.
/// </summary>
/// <param name="ctx"></param>
/// <param name="requiredMediaType"></param>
/// <returns>True if media type is correct, otherwise handled and false</returns>
private async Task<bool> EnforceMediaType(HttpContextBase ctx, string requiredMediaType)
{
if (ctx.Request.ContentType == requiredMediaType)
return true;
ctx.Response.StatusCode = 415;
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Request contains wrong media type.")));
return false;
}
#endregion
}
-82
View File
@@ -1,82 +0,0 @@
using System.Collections.Concurrent;
using ChatTwo.Http.MessageProtocol;
using WatsonWebserver.Core;
namespace ChatTwo.Http;
public class SSEConnection
{
private bool Stopping;
private readonly CancellationToken Token;
public bool Done;
public readonly ConcurrentQueue<BaseEvent> OutboundQueue = new();
public SSEConnection(CancellationToken token)
{
Token = token;
}
public async Task HandleEventLoop(HttpContextBase ctx)
{
try
{
ctx.Response.Headers.Add("Content-Type", "text/event-stream");
ctx.Response.Headers.Add("Cache-Control", "no-cache");
ctx.Response.Headers.Add("Connection", "keep-alive");
ctx.Response.ChunkedTransfer = true;
while (!Token.IsCancellationRequested && !Stopping)
{
await Task.Delay(10, Token);
if (Token.IsCancellationRequested)
return;
if (!OutboundQueue.TryDequeue(out var outgoingEvent))
continue;
if (!await ctx.Response.SendChunk(outgoingEvent.Build(), false, Token))
{
Plugin.Log.Debug("SSE connection was unable to send new data");
Plugin.Log.Debug($"Client disconnected: {ctx.Guid}");
return;
}
}
}
catch (TaskCanceledException)
{
// Ignore
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "SSE handler failed.");
}
finally
{
// "No Content" (204) didn't work for Firefox, so manually closing the connection on client side
await ctx.Response.SendChunk(new CloseEvent().Build(), true, Token);
// Manually confirm that we have finished our connection, even if the final response failed
// This can happen if the client disconnects before the server does
ctx.Response.ResponseSent = true;
Done = true;
}
}
public async ValueTask DisposeAsync()
{
Stopping = true;
var timeout = 1000; // 1000ms
while (timeout > 0)
{
if (Done)
break;
timeout -= 100;
await Task.Delay(100);
Plugin.Log.Debug("Sleeping because EventServer still alive");
}
}
}
-190
View File
@@ -1,190 +0,0 @@
using ChatTwo.Http.MessageProtocol;
using Dalamud.Plugin.Services;
namespace ChatTwo.Http;
public class ServerCore : IAsyncDisposable
{
public readonly Plugin Plugin;
private readonly HostContext HostContext;
public ServerCore(Plugin plugin)
{
Plugin = plugin;
HostContext = new HostContext(this);
Plugin.Framework.Update += FrameworkUpdate;
}
public async ValueTask DisposeAsync()
{
Plugin.Framework.Update -= FrameworkUpdate;
await HostContext.DisposeAsync();
}
private void FrameworkUpdate(IFramework _)
{
foreach (var (idx, tab) in Plugin.Config.Tabs.Index())
{
if (tab.Unread == tab.LastSendUnread)
continue;
tab.LastSendUnread = tab.Unread;
foreach (var eventServer in HostContext.EventConnections)
eventServer.OutboundQueue.Enqueue(new ChatTabUnreadStateEvent(new ChatTabUnreadState(idx, tab.Unread)));
}
}
#region SSE Helper
internal async Task PrepareNewClient(SSEConnection sse)
{
// This takes long, so keep it outside the next frame
var messages = await HostContext.Processing.GetAllMessages();
// Using the bulk message event to clear everything on the client side that may still exist
await Plugin.Framework.RunOnTick(() =>
{
sse.OutboundQueue.Enqueue(new BulkMessagesEvent(messages));
sse.OutboundQueue.Enqueue(new SwitchChannelEvent(HostContext.Processing.GetCurrentChannel()));
sse.OutboundQueue.Enqueue(new ChannelListEvent(HostContext.Processing.GetValidChannels()));
sse.OutboundQueue.Enqueue(new ChatTabSwitchedEvent(HostContext.Processing.GetCurrentTab()));
sse.OutboundQueue.Enqueue(new ChatTabListEvent(HostContext.Processing.GetAllTabs()));
});
}
internal void SendNewMessage(Message message)
{
if (!HostContext.IsActive)
return;
try
{
Plugin.Framework.RunOnTick(() =>
{
var bundledResponse = new NewMessageEvent(HostContext.Processing.ReadMessageContent(message));
foreach (var eventServer in HostContext.EventConnections)
eventServer.OutboundQueue.Enqueue(bundledResponse);
});
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Sending message over SSE failed.");
}
}
internal void SendBulkMessageList()
{
if (!HostContext.IsActive)
return;
try
{
Plugin.Framework.RunOnTick(() =>
{
foreach (var eventServer in HostContext.EventConnections)
eventServer.OutboundQueue.Enqueue(new BulkMessagesEvent(new Messages(HostContext.Processing.ReadMessageList().Result)));
});
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
}
}
internal void SendChannelSwitch(Chunk[] channelName)
{
if (!HostContext.IsActive)
return;
try
{
Plugin.Framework.RunOnTick(() =>
{
var bundledResponse = new SwitchChannelEvent(new SwitchChannel(HostContext.Processing.ReadChannelName(channelName)));
foreach (var eventServer in HostContext.EventConnections)
eventServer.OutboundQueue.Enqueue(bundledResponse);
});
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
}
}
internal void SendChannelList()
{
if (!HostContext.IsActive)
return;
try
{
Plugin.Framework.RunOnTick(() =>
{
var bundledResponse = new ChannelListEvent(HostContext.Processing.GetValidChannels());
foreach (var eventServer in HostContext.EventConnections)
eventServer.OutboundQueue.Enqueue(bundledResponse);
});
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
}
}
internal void SendNewLogin()
{
if (!HostContext.IsActive)
return;
try
{
Plugin.Framework.RunOnTick(async () =>
{
foreach (var eventServer in HostContext.EventConnections)
await HostContext.Core.PrepareNewClient(eventServer);
});
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Preparing all clients after login failed.");
}
}
#endregion
public void InvalidateSessions()
{
if (!HostContext.IsActive)
return;
Plugin.Config.AuthStore.Clear();
Plugin.SaveConfig();
}
public bool IsActive()
{
return HostContext is { IsActive: true, Host.IsListening: true };
}
public bool IsStopping()
{
return HostContext is { IsActive: false, IsStopping: true };
}
public bool Start()
{
return HostContext.Start();
}
public void Run()
{
HostContext.Run();
}
public async ValueTask<bool> Stop()
{
return await HostContext.Stop();
}
}
-33
View File
@@ -1,33 +0,0 @@
namespace ChatTwo.Http;
public static class WebserverUtil
{
public static async Task<T> FrameworkWrapper<T>(Func<Task<T>> func)
{
return await Plugin.Framework.RunOnTick(func).ConfigureAwait(false);
}
// From: https://github.com/NancyFx/Nancy/blob/master/src/Nancy/Request.cs#L176
/// <summary>
/// Gets the cookie data from the provided string if it exists
/// </summary>
/// <param name="cookieHeader">The string containing cookie data</param>
/// <returns>Cookies dictionary</returns>
public static Dictionary<string, string> GetCookieData(string cookieHeader)
{
var cookieDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (cookieHeader.Length == 0)
return cookieDictionary;
var values = cookieHeader.TrimEnd(';').Split(';');
foreach (var parts in values.Select(c => c.Split(['='], 2)))
{
var cookieName = parts[0].Trim();
var cookieValue = parts.Length == 1 ? string.Empty : parts[1]; //Cookie attribute
cookieDictionary[cookieName] = cookieValue;
}
return cookieDictionary;
}
}
-6
View File
@@ -258,19 +258,13 @@ internal class MessageManager : IAsyncDisposable
if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
Store.UpsertMessage(message);
var currentTabId = Plugin.CurrentTab.Identifier;
var currentMatches = Plugin.CurrentTab.Matches(message);
foreach (var tab in Plugin.Config.Tabs)
{
var unread = !(tab.UnreadMode == UnreadMode.Unseen && Plugin.CurrentTab != tab && currentMatches);
if (tab.Matches(message))
{
tab.AddMessage(message, unread);
if (tab.Identifier == currentTabId)
Plugin.ServerCore.SendNewMessage(message);
}
}
}
+1 -1
View File
@@ -724,7 +724,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset) after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset) before).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page);
cmd.Parameters.AddWithValue("OffsetCount", DbViewer.RowPerPage);
cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage);
return new MessageEnumerator(cmd.ExecuteReader());
}
+1 -3
View File
@@ -150,9 +150,7 @@ public sealed class PayloadHandler
return;
}
// ScreenshotMode changed, so we inform the webinterface about the new message format
if (ImGui.Checkbox(Language.Context_ScreenshotMode, ref LogWindow.ScreenshotMode))
LogWindow.Plugin.ServerCore.SendBulkMessageList();
ImGui.Checkbox(Language.Context_ScreenshotMode, ref LogWindow.ScreenshotMode);
if (ImGui.Selectable(Language.Context_HideChat))
LogWindow.UserHide();
+43 -17
View File
@@ -1,7 +1,6 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using ChatTwo.Http;
using ChatTwo.Ipc;
using ChatTwo.Resources;
using ChatTwo.Ui;
@@ -63,10 +62,17 @@ public sealed class Plugin : IDalamudPlugin
internal TypingIpc TypingIpc { get; }
internal FontManager FontManager { get; }
public readonly ServerCore ServerCore;
internal int DeferredSaveFrames = -1;
// Serialises retention sweeps. The 24h auto-sweep on plugin load and
// the manual button in the Privacy tab both run on background threads;
// without this gate, hitting the manual button moments after a fresh
// plugin start would launch two sweeps in parallel and the second one
// would just re-do work the first one already finished. The lock guards
// the flag — the flag check itself bails before we touch the database.
internal readonly object RetentionSweepLock = new();
internal bool RetentionSweepRunning;
internal DateTime GameStarted { get; }
// Tab management needs to happen outside the chatlog window class for access reasons
@@ -142,6 +148,25 @@ public sealed class Plugin : IDalamudPlugin
});
}
// Hellion Chat v7→v8: webinterface removed in 0.2.0. Old config
// entries (WebinterfacePassword, AuthStore, etc.) get dropped on
// the next save because their properties no longer exist on the
// Configuration class. The bump is recorded so the notification
// only fires once.
if (Config.Version <= 7)
{
Config.Version = 8;
SaveConfig();
Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification
{
Title = HellionStrings.Migration_Webinterface_Removed_Title,
Content = HellionStrings.Migration_Webinterface_Removed_Content,
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info,
InitialDuration = TimeSpan.FromSeconds(20),
});
}
if (Config.Tabs.Count == 0)
Config.Tabs.Add(TabsUtil.VanillaGeneral);
@@ -150,9 +175,6 @@ public sealed class Plugin : IDalamudPlugin
FileDialogManager = new FileDialogManager();
// Function call this in its ctor if the player is already logged in
ServerCore = new ServerCore(this);
Commands = new Commands();
Functions = new GameFunctions.GameFunctions(this);
Ipc = new IpcManager();
@@ -219,16 +241,6 @@ public sealed class Plugin : IDalamudPlugin
// profiling difficult.
AutoTranslate.PreloadCache();
#endif
// Automatically start the webserver if requested
if (Config.WebinterfaceAutoStart)
{
Task.Run(() =>
{
ServerCore.Start();
ServerCore.Run();
});
}
}
catch (Exception ex)
{
@@ -267,7 +279,6 @@ public sealed class Plugin : IDalamudPlugin
Commands?.Dispose();
EmoteCache.Dispose();
ServerCore?.DisposeAsync().AsTask().Wait();
}
private static void MigrateFromChatTwoLayout()
@@ -403,6 +414,16 @@ public sealed class Plugin : IDalamudPlugin
new Thread(() =>
{
// Bail out cheaply if a manual sweep is already in flight; the
// lock around the actual work would queue us up otherwise and
// we would just re-do whatever the manual run already did.
lock (RetentionSweepLock)
{
if (RetentionSweepRunning)
return;
RetentionSweepRunning = true;
}
try
{
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
@@ -427,6 +448,11 @@ public sealed class Plugin : IDalamudPlugin
{
Log.Error(e, "Retention sweep failed");
}
finally
{
lock (RetentionSweepLock)
RetentionSweepRunning = false;
}
}) { IsBackground = true }.Start();
}
+26
View File
@@ -44,6 +44,7 @@ internal class HellionStrings
internal static string Privacy_Tab_Title => Get(nameof(Privacy_Tab_Title));
internal static string Privacy_FilterEnabled_Name => Get(nameof(Privacy_FilterEnabled_Name));
internal static string Privacy_FilterEnabled_Description => Get(nameof(Privacy_FilterEnabled_Description));
internal static string Privacy_FilterEnabled_StorageOnly_Help => Get(nameof(Privacy_FilterEnabled_StorageOnly_Help));
internal static string Privacy_Whitelist_Help => Get(nameof(Privacy_Whitelist_Help));
internal static string Privacy_Preset_PrivacyFirst => Get(nameof(Privacy_Preset_PrivacyFirst));
internal static string Privacy_Preset_ClearAll => Get(nameof(Privacy_Preset_ClearAll));
@@ -99,6 +100,8 @@ internal class HellionStrings
internal static string Migration_Notification_Title => Get(nameof(Migration_Notification_Title));
internal static string Migration_Notification_Content => Get(nameof(Migration_Notification_Content));
internal static string Migration_Webinterface_Removed_Title => Get(nameof(Migration_Webinterface_Removed_Title));
internal static string Migration_Webinterface_Removed_Content => Get(nameof(Migration_Webinterface_Removed_Content));
internal static string Wizard_Title => Get(nameof(Wizard_Title));
internal static string Wizard_Intro => Get(nameof(Wizard_Intro));
@@ -138,4 +141,27 @@ internal class HellionStrings
internal static string Theme_WindowOpacity_Help => Get(nameof(Theme_WindowOpacity_Help));
internal static string Theme_UseHellionFont_Name => Get(nameof(Theme_UseHellionFont_Name));
internal static string Theme_UseHellionFont_Description => Get(nameof(Theme_UseHellionFont_Description));
internal static string About_Maintainer_Heading => Get(nameof(About_Maintainer_Heading));
internal static string About_Maintainer_Body => Get(nameof(About_Maintainer_Body));
internal static string About_Maintainer_Website_Label => Get(nameof(About_Maintainer_Website_Label));
internal static string About_Mission_Heading => Get(nameof(About_Mission_Heading));
internal static string About_Mission_P1 => Get(nameof(About_Mission_P1));
internal static string About_Mission_P2 => Get(nameof(About_Mission_P2));
internal static string About_Mission_P3 => Get(nameof(About_Mission_P3));
internal static string About_BuiltOn_Heading => Get(nameof(About_BuiltOn_Heading));
internal static string About_BuiltOn_P1 => Get(nameof(About_BuiltOn_P1));
internal static string About_BuiltOn_P2 => Get(nameof(About_BuiltOn_P2));
internal static string About_BuiltOn_Upstream_Label => Get(nameof(About_BuiltOn_Upstream_Label));
internal static string About_License_Heading => Get(nameof(About_License_Heading));
internal static string About_License_P1 => Get(nameof(About_License_P1));
internal static string About_License_P2 => Get(nameof(About_License_P2));
internal static string About_License_P3 => Get(nameof(About_License_P3));
internal static string About_SE_Heading => Get(nameof(About_SE_Heading));
internal static string About_SE_P1 => Get(nameof(About_SE_P1));
internal static string About_SE_P2 => Get(nameof(About_SE_P2));
internal static string About_Localization_Heading => Get(nameof(About_Localization_Heading));
internal static string About_Localization_P1 => Get(nameof(About_Localization_P1));
internal static string About_Localization_P2 => Get(nameof(About_Localization_P2));
internal static string About_Translators_TreeNode => Get(nameof(About_Translators_TreeNode));
}
+82 -1
View File
@@ -21,6 +21,9 @@
<data name="Privacy_FilterEnabled_Description" xml:space="preserve">
<value>Wenn aktiviert, werden nur Nachrichten aus den erlaubten Kanälen in die Datenbank gespeichert. Beim Deaktivieren gilt wieder das Standard-Verhalten von ChatTwo, also alles außer Battle-Logs wird gespeichert.</value>
</data>
<data name="Privacy_FilterEnabled_StorageOnly_Help" xml:space="preserve">
<value>Der Filter steuert nur, was in die lokale Datenbank geschrieben wird. Im Chat-Log siehst du weiterhin jede Nachricht live, ausgeschlossene Kanäle werden nur nicht mehr gespeichert. Wenn du Kanäle auch aus der sichtbaren Anzeige entfernen willst, nutze die normalen Chat-Tab-Filter im Spiel.</value>
</data>
<data name="Privacy_Whitelist_Help" xml:space="preserve">
<value>Wähle aus, welche Kanäle in die lokale Datenbank gespeichert werden. Standard nach Datensparsamkeit: nur deine eigenen Konversationen. Über die Buttons unten kannst du eine Voreinstellung anwenden.</value>
</data>
@@ -177,6 +180,12 @@
<data name="Migration_Notification_Content" xml:space="preserve">
<value>Datenschutz-Filter ist standardmäßig aktiviert. Einstellungen → Datenschutz zum Anpassen.</value>
</data>
<data name="Migration_Webinterface_Removed_Title" xml:space="preserve">
<value>Hellion Chat 0.2.0</value>
</data>
<data name="Migration_Webinterface_Removed_Content" xml:space="preserve">
<value>Das Webinterface wurde in dieser Version entfernt, weil es nicht auf das Datenschutz-Niveau gehärtet werden konnte das Hellion Chat standardmäßig zusichert. Falls du es genutzt hast, schau bitte in die README für Hintergründe.</value>
</data>
<data name="Wizard_Title" xml:space="preserve">
<value>Hellion Chat — Willkommen</value>
</data>
@@ -271,7 +280,7 @@
<value>Hellion-Theme für alle Plugin-Fenster verwenden</value>
</data>
<data name="Theme_Enabled_Description" xml:space="preserve">
<value>Industrielle HUD-Palette mit cyan-blauen Aktionsfarben, schiefer-violetten Tabs und Bernstein-Akzenten für aktive Zustände, global angewendet auf Chat-Fenster, Einstellungen, Viewer und Wizard. Deaktivieren, um das Standard-Dalamud-Erscheinungsbild zu nutzen.</value>
<value>Hellion-Online-Media-Palette aus Arctic Cyan und Ember Orange, angewendet auf Chat-Fenster, Einstellungen, Viewer und Wizard. Deaktivieren, um das Standard-Dalamud-Erscheinungsbild zu nutzen.</value>
</data>
<data name="Theme_WindowOpacity_Label" xml:space="preserve">
<value>Fenster-Deckkraft</value>
@@ -285,4 +294,76 @@
<data name="Theme_UseHellionFont_Description" xml:space="preserve">
<value>Rendert Chat und UI in Exo 2 (SIL Open Font License 1.1), die mit dem Plugin ausgeliefert wird. Deaktivieren, um auf die unter Einstellungen → Schrift gewählte Schriftart zurückzufallen.</value>
</data>
<data name="About_Maintainer_Heading" xml:space="preserve">
<value>Maintainer</value>
</data>
<data name="About_Maintainer_Body" xml:space="preserve">
<value>Ich pflege Hellion Chat über Hellion Online Media. Auf der Website findest du die Kontaktdaten für lizenzrechtliche, rechtliche oder geschäftliche Fragen.</value>
</data>
<data name="About_Maintainer_Website_Label" xml:space="preserve">
<value>Website:</value>
</data>
<data name="About_Mission_Heading" xml:space="preserve">
<value>Warum es diesen Fork gibt</value>
</data>
<data name="About_Mission_P1" xml:space="preserve">
<value>Hellion Chat soll Chat 2 nicht ersetzen. Chat 2 liefert ein vollständiges Chat-Erlebnis mit kompletter Historie, die für Filter, Suche und Replay zur Verfügung steht. Dieser Default ist für die meisten Nutzer der richtige. Dieser Fork wählt einen anderen Ansatz: einen kleineren Default-Footprint, mit zusätzlichen Stellschrauben für Nutzer, die weniger fremden Chat auf der Festplatte behalten möchten.</value>
</data>
<data name="About_Mission_P2" xml:space="preserve">
<value>Der Wunsch nach diesem engeren Default war persönlich. Nach zwei Jahren mit Chat 2 lag meine Datenbank bei über zwei Millionen Nachrichten, der Großteil davon /say, /shout und /yell von Fremden in Limsa. Genau diese Daten machen Chat 2's Voll-Historie nützlich, und die meisten Nutzer behalten sie gerne. Mein eigener Geschmack wollte einen kleineren Default. Also habe ich diesen Fork gebaut.</value>
</data>
<data name="About_Mission_P3" xml:space="preserve">
<value>Ich strebe keine große Zielgruppe an, und der Fork steht nicht in Konkurrenz zu Chat 2. Der Code liegt offen unter derselben EUPL-1.2-Lizenz wie das Original. Infi, Anna oder sonst jemand dürfen reinschauen, Ideen mitnehmen, Fragen stellen oder das Projekt einfach ignorieren. Alles drei ist für mich in Ordnung.</value>
</data>
<data name="About_BuiltOn_Heading" xml:space="preserve">
<value>Aufbauend auf Chat 2</value>
</data>
<data name="About_BuiltOn_P1" xml:space="preserve">
<value>Hellion Chat ist ein Fork von Chat 2 von Infi und Anna (ascclemens). Das Chat-Replacement-Fenster, die IPC-Integration, die Render-Engine und der komplette Storage-Kern stammen aus dem Original.</value>
</data>
<data name="About_BuiltOn_P2" xml:space="preserve">
<value>Das Webinterface ist das einzige größere Teil, das ich entfernt habe. Es ist für den Remote-Zugriff auf den Chat von einem zweiten Gerät gebaut, also für einen anderen Fokus als der kleinere Default-Footprint, den dieser Fork verfolgt. Es an diese Defaults anzupassen hätte einen erheblichen Umbau bedeutet, also war die Entfernung der saubere Weg für genau diesen Fork.</value>
</data>
<data name="About_BuiltOn_Upstream_Label" xml:space="preserve">
<value>Upstream-Repository:</value>
</data>
<data name="About_License_Heading" xml:space="preserve">
<value>Lizenz</value>
</data>
<data name="About_License_P1" xml:space="preserve">
<value>Hellion Chat und Chat 2 stehen beide unter der European Union Public Licence v1.2 (EUPL-1.2).</value>
</data>
<data name="About_License_P2" xml:space="preserve">
<value>© 2023 bis 2026, die Chat-2-Autoren (Infi, Anna und die Upstream-Mitwirkenden).</value>
</data>
<data name="About_License_P3" xml:space="preserve">
<value>© 2026 Hellion Online Media für die Erweiterungen in diesem Fork.</value>
</data>
<data name="About_SE_Heading" xml:space="preserve">
<value>FINAL FANTASY XIV-Hinweis</value>
</data>
<data name="About_SE_P1" xml:space="preserve">
<value>FINAL FANTASY XIV © SQUARE ENIX CO., LTD. Alle Rechte vorbehalten.</value>
</data>
<data name="About_SE_P2" xml:space="preserve">
<value>Hellion Chat ist ein inoffizielles Fan-Plugin. Es steht in keiner Verbindung zu Square Enix und wird von ihnen weder unterstützt, gesponsert noch genehmigt.</value>
</data>
<data name="About_Localization_Heading" xml:space="preserve">
<value>Lokalisierung</value>
</data>
<data name="About_Localization_P1" xml:space="preserve">
<value>Die deutschen Übersetzungen der Hellion-spezifischen Strings stammen von mir. Weitere Sprachen sind aktuell nicht verfügbar.</value>
</data>
<data name="About_Localization_P2" xml:space="preserve">
<value>Die Übersetzerliste weiter unten gehört zu den Chat-2-Strings auf Crowdin. Diese Freiwilligen haben Chat 2 übersetzt, nicht die Hellion-Erweiterungen.</value>
</data>
<data name="About_Translators_TreeNode" xml:space="preserve">
<value>Chat-2-Community-Übersetzer (Upstream)</value>
</data>
</root>
+83 -2
View File
@@ -21,6 +21,9 @@
<data name="Privacy_FilterEnabled_Description" xml:space="preserve">
<value>When enabled, only messages from whitelisted channels are persisted to the database. Disabling restores upstream ChatTwo behavior (everything except battle messages is stored).</value>
</data>
<data name="Privacy_FilterEnabled_StorageOnly_Help" xml:space="preserve">
<value>The filter only controls what is written to the local database. The chat log itself keeps showing every message live, disallowed channels just stop being saved. Use the channel hide options in your in-game chat tabs if you want to remove channels from the visible chat.</value>
</data>
<data name="Privacy_Whitelist_Help" xml:space="preserve">
<value>Pick which channels are stored in the local database. Privacy-First default: only your own conversations. Use the buttons below to apply a preset.</value>
</data>
@@ -121,7 +124,7 @@
<value>Auto-delete messages after a per-channel retention window</value>
</data>
<data name="Retention_Enabled_Description" xml:space="preserve">
<value>When enabled, messages older than the configured window are deleted on every plugin start (at most once per 24 hours). Off by default — the plugin never deletes history without your explicit consent.</value>
<value>When enabled, messages older than the configured window are deleted on every plugin start (at most once per 24 hours). Off by default. The plugin never deletes history without your explicit consent.</value>
</data>
<data name="Retention_Default_Label" xml:space="preserve">
<value>Default retention (days, 0 = never)</value>
@@ -177,6 +180,12 @@
<data name="Migration_Notification_Content" xml:space="preserve">
<value>Privacy filter activated by default. Settings → Privacy to adjust.</value>
</data>
<data name="Migration_Webinterface_Removed_Title" xml:space="preserve">
<value>Hellion Chat 0.2.0</value>
</data>
<data name="Migration_Webinterface_Removed_Content" xml:space="preserve">
<value>The webinterface has been removed in this version because it could not be hardened to the privacy guarantees Hellion Chat makes by default. If you used it, please consult the README for context.</value>
</data>
<data name="Wizard_Title" xml:space="preserve">
<value>Hellion Chat — Welcome</value>
</data>
@@ -271,7 +280,7 @@
<value>Use the Hellion theme across all plugin windows</value>
</data>
<data name="Theme_Enabled_Description" xml:space="preserve">
<value>Industrial HUD palette with cyan-teal action accents, slate-violet tabs and amber active highlights, applied globally to chat log, settings, viewers and the wizard. Disable to fall back to the default Dalamud look.</value>
<value>Hellion Online Media palette of Arctic Cyan plus Ember Orange, applied across the chat log, settings, viewers and the wizard. Disable to fall back to the default Dalamud look.</value>
</data>
<data name="Theme_WindowOpacity_Label" xml:space="preserve">
<value>Window opacity</value>
@@ -285,4 +294,76 @@
<data name="Theme_UseHellionFont_Description" xml:space="preserve">
<value>Renders chat and UI in Exo 2 (SIL Open Font License 1.1) which ships with the plugin. Disable to fall back to whatever font you picked under Settings → Fonts.</value>
</data>
<data name="About_Maintainer_Heading" xml:space="preserve">
<value>Maintainer</value>
</data>
<data name="About_Maintainer_Body" xml:space="preserve">
<value>I maintain Hellion Chat through Hellion Online Media. The website has the contact details for licensing, legal or business questions.</value>
</data>
<data name="About_Maintainer_Website_Label" xml:space="preserve">
<value>Website:</value>
</data>
<data name="About_Mission_Heading" xml:space="preserve">
<value>Why this fork exists</value>
</data>
<data name="About_Mission_P1" xml:space="preserve">
<value>Hellion Chat is not trying to replace Chat 2. Chat 2 ships a complete chat experience with full history available for filtering, search and replay. That default is the right one for most users. This fork takes a different stance: a smaller default footprint, with extra knobs for users who want to keep less third-party chat on disk.</value>
</data>
<data name="About_Mission_P2" xml:space="preserve">
<value>The reason I wanted that narrower default was personal. After two years on Chat 2 my database had grown past two million messages, most of them /say, /shout and /yell from strangers in Limsa. That data is exactly what makes Chat 2's full-history view powerful and most users are happy to keep it. For my own taste I wanted a smaller default. So I built this fork.</value>
</data>
<data name="About_Mission_P3" xml:space="preserve">
<value>I am not chasing a big audience and the fork is not in competition with Chat 2. The code is open under the same EUPL-1.2 licence as the upstream plugin. Infi, Anna or anyone else are welcome to read it, borrow ideas, ask questions, or ignore the project. All three are fine by me.</value>
</data>
<data name="About_BuiltOn_Heading" xml:space="preserve">
<value>Built on Chat 2</value>
</data>
<data name="About_BuiltOn_P1" xml:space="preserve">
<value>Hellion Chat is a fork of Chat 2 by Infi and Anna (ascclemens). The chat replacement window, the IPC integration, the rendering engine and the entire storage core come from upstream Chat 2.</value>
</data>
<data name="About_BuiltOn_P2" xml:space="preserve">
<value>The webinterface is the only major piece I removed. It is built for remote access to chat from a second device, which is a different focus than the smaller default footprint this fork is built around. Aligning it with these defaults would have meant a substantial rebuild, so removing it was the cleaner path for this particular fork.</value>
</data>
<data name="About_BuiltOn_Upstream_Label" xml:space="preserve">
<value>Upstream repository:</value>
</data>
<data name="About_License_Heading" xml:space="preserve">
<value>License</value>
</data>
<data name="About_License_P1" xml:space="preserve">
<value>Hellion Chat and Chat 2 both ship under the European Union Public Licence v1.2 (EUPL-1.2).</value>
</data>
<data name="About_License_P2" xml:space="preserve">
<value>© 2023 to 2026, the Chat 2 authors (Infi, Anna and the upstream contributors).</value>
</data>
<data name="About_License_P3" xml:space="preserve">
<value>© 2026 Hellion Online Media for the additions made in this fork.</value>
</data>
<data name="About_SE_Heading" xml:space="preserve">
<value>FINAL FANTASY XIV disclaimer</value>
</data>
<data name="About_SE_P1" xml:space="preserve">
<value>FINAL FANTASY XIV © SQUARE ENIX CO., LTD. All rights reserved.</value>
</data>
<data name="About_SE_P2" xml:space="preserve">
<value>Hellion Chat is an unofficial, fan-made plugin. It has no affiliation with Square Enix and is not endorsed, sponsored or approved by them.</value>
</data>
<data name="About_Localization_Heading" xml:space="preserve">
<value>Localization</value>
</data>
<data name="About_Localization_P1" xml:space="preserve">
<value>The German translations of the Hellion-specific strings come from me. Other languages are not provided yet.</value>
</data>
<data name="About_Localization_P2" xml:space="preserve">
<value>The translator list below covers the upstream Chat 2 strings on Crowdin. Those volunteers translated Chat 2, not the Hellion additions.</value>
</data>
<data name="About_Translators_TreeNode" xml:space="preserve">
<value>Chat 2 community translators (upstream)</value>
</data>
</root>
+4 -11
View File
@@ -99,8 +99,8 @@ public sealed class ChatLogWindow : Window
SetUpTextCommandChannels();
SetUpAllCommands();
Plugin.Commands.Register("/clearlog2", "Clear the Chat 2 chat log").Execute += ClearLog;
Plugin.Commands.Register("/chat2").Execute += ToggleChat;
Plugin.Commands.Register("/clearhellion", "Clear the Hellion Chat log").Execute += ClearLog;
Plugin.Commands.Register("/hellion").Execute += ToggleChat;
Plugin.ClientState.Login += Login;
Plugin.ClientState.Logout += Logout;
@@ -115,8 +115,8 @@ public sealed class ChatLogWindow : Window
Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "ActionDetail", PayloadHandler.MoveTooltip);
Plugin.ClientState.Logout -= Logout;
Plugin.ClientState.Login -= Login;
Plugin.Commands.Register("/chat2").Execute -= ToggleChat;
Plugin.Commands.Register("/clearlog2").Execute -= ClearLog;
Plugin.Commands.Register("/hellion").Execute -= ToggleChat;
Plugin.Commands.Register("/clearhellion").Execute -= ClearLog;
}
private void Logout(int _, int __)
@@ -375,10 +375,6 @@ public sealed class ChatLogWindow : Window
newTab.CurrentChannel = previousTab.CurrentChannel;
SetChannel(newTab.CurrentChannel.Channel);
// Inform the webinterface about tab switch
// TODO implement tabs in the webinterface
Plugin.ServerCore.SendNewLogin();
}
private enum HideState
@@ -772,10 +768,7 @@ public sealed class ChatLogWindow : Window
{
var currentChannel = ReadChannelName(activeTab);
if (!currentChannel.SequenceEqual(PreviousChannel))
{
PreviousChannel = currentChannel;
Plugin.ServerCore.SendChannelSwitch(currentChannel);
}
DrawChunks(currentChannel);
}
+10 -160
View File
@@ -3,7 +3,6 @@ using System.Globalization;
using System.Numerics;
using System.Text;
using ChatTwo.Code;
using ChatTwo.Http.MessageProtocol;
using ChatTwo.Resources;
using ChatTwo.Util;
using Dalamud.Interface;
@@ -18,13 +17,12 @@ using Dalamud.Interface.ImGuiNotification;
using Lumina.Data.Files;
using Lumina.Text.ReadOnly;
using MoreLinq;
using Newtonsoft.Json;
namespace ChatTwo.Ui;
public class DbViewer : Window
{
public const float RowPerPage = 1000.0f;
public const int RowPerPage = 1000;
private readonly Plugin Plugin;
@@ -78,19 +76,19 @@ public class DbViewer : Window
RespectCloseHotkey = false;
DisableWindowSounds = true;
Plugin.Commands.Register("/chat2Viewer", "Get access to your message history, with simple filter options.", true).Execute += Toggle;
Plugin.Commands.Register("/hellionView", "Get access to your message history, with simple filter options.", true).Execute += Toggle;
}
public void Dispose()
{
Plugin.Commands.Register("/chat2Viewer", "Get access to your message history, with simple filter options.", true).Execute -= Toggle;
Plugin.Commands.Register("/hellionView", "Get access to your message history, with simple filter options.", true).Execute -= Toggle;
}
private void Toggle(string _, string __) => Toggle();
public override void Draw()
{
var totalPages = (int)Math.Ceiling(Count / RowPerPage);
var totalPages = (int)Math.Ceiling((double)Count / RowPerPage);
if (totalPages < 1)
totalPages = 1;
@@ -167,28 +165,12 @@ public class DbViewer : Window
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(Language.Export_Txt_Tooltip);
ImGui.SameLine(0, spacing);
using (ImRaii.Disabled(InputPath.Length == 0 || IsExporting))
{
if (ImGuiUtil.IconButton(FontAwesomeIcon.FileExport))
{
Notification = Plugin.Notification.AddNotification(
new Notification
{
Title = "Chat2 Json Export",
Content = Language.ChatExport_Initial,
Type = NotificationType.Info,
Minimized = false,
UserDismissable = false,
InitialDuration = TimeSpan.FromSeconds(10000),
Progress = 0.0f,
});
CreateTempJsonFile();
}
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(Language.Export_Json_Tooltip);
// Hellion Chat: the JSON export button used to dump the database in
// the upstream webinterface's wire format. With the webinterface
// removed there is no consumer for that format any more, so the
// button is dropped. The Privacy tab's MessageExporter covers the
// same ground (Markdown / JSON / CSV) with channel and date filters
// and is the supported way to get history out of the plugin.
var width = 350 * ImGuiHelpers.GlobalScale;
var loadingIndicator = IsProcessing && ProcessingStart < Environment.TickCount64;
@@ -462,136 +444,4 @@ public class DbViewer : Window
});
}
private void CreateTempJsonFile()
{
IsExporting = true;
Task.Run(async () =>
{
try
{
var channels = SelectedChannels.Select(pair => (byte)pair.Key).ToArray();
var rangeMessageEnumerator = Plugin.MessageManager.Store.GetDateRange(AfterDate, BeforeDate, channels);
var messageHistory = rangeMessageEnumerator.ToArray();
await rangeMessageEnumerator.DisposeAsync();
var filteredHistory = Filter(messageHistory);
await using var stream = new StreamWriter(Path.Join(InputPath, $"Chat2_{DateTime.Now:yyyy_dd_M__HH_mm_ss}.json"));
var batch = 0;
var messageContainer = new Messages();
List<MessageResponse> templates = [];
foreach (var messages in filteredHistory.Batch(5000))
{
foreach (var message in messages)
{
templates.Add(ReadMessageContent(message));
batch++;
}
Notification.Progress = (float)batch / filteredHistory.Count;
Notification.Content = $"Exported {batch} of {filteredHistory.Count} messages";
await Task.Delay(100);
}
messageContainer.Set = templates.ToArray();
await stream.WriteAsync(JsonConvert.SerializeObject(messageContainer));
templates.Clear();
await using (var fileStream = File.Open(Path.Join(InputPath, "gfdata.gfd"), FileMode.OpenOrCreate))
{
await using var byteWriter = new BinaryWriter(fileStream);
byteWriter.Write(Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data);
}
await using (var fileStream = File.Open(Path.Join(InputPath, "fonticon_ps5.tex"), FileMode.OpenOrCreate))
{
await using var byteWriter = new BinaryWriter(fileStream);
byteWriter.Write(Plugin.DataManager.GetFile<TexFile>("common/font/fonticon_ps5.tex")!.Data);
}
await using (var fileStream = File.Open(Path.Join(InputPath, "FFXIV_Lodestone_SSF.ttf"), FileMode.OpenOrCreate))
{
await using var byteWriter = new BinaryWriter(fileStream);
byteWriter.Write(Plugin.FontManager.GameSymFont);
}
Notification.Progress = 1.0f;
Notification.Content = "Done!!!";
Notification.Type = NotificationType.Success;
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Failed creating txt backup");
Notification.Content = "Error ...";
Notification.Type = NotificationType.Error;
}
finally
{
IsExporting = false;
Notification.UserDismissable = true;
}
});
}
private MessageResponse ReadMessageContent(Message message)
{
var response = new MessageResponse
{
Id = message.Id,
Timestamp = message.Date.ToLocalTime().ToString("t", !Plugin.Config.Use24HourClock ? null : CultureInfo.CreateSpecificCulture("es-ES"))
};
var sender = message.Sender.Select(ProcessChunk);
var content = message.Content.Select(ProcessChunk);
response.Templates = sender.Concat(content).ToArray();
return response;
}
private MessageTemplate ProcessChunk(Chunk chunk)
{
if (chunk is IconChunk { } icon)
{
var iconId = (uint)icon.Icon;
return IconUtil.GfdFileView.TryGetEntry(iconId, out _) ? new MessageTemplate {PayloadType = WebPayloadType.Icon, IconId = iconId}: MessageTemplate.Empty;
}
if (chunk is TextChunk { } text)
{
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
{
var image = EmoteCache.GetEmote(emotePayload.Code);
if (image is { Failed: false })
return new MessageTemplate { PayloadType = WebPayloadType.CustomEmote, Color = 0, Content = emotePayload.Code };
}
var color = text.Foreground;
if (color == null && text.FallbackColour != null)
{
var type = text.FallbackColour.Value;
color = Plugin.Config.ChatColours.TryGetValue(type, out var col) ? col : type.DefaultColor();
}
color ??= 0;
var userContent = text.Content;
if (Plugin.ChatLogWindow.ScreenshotMode)
{
if (chunk.Link is PlayerPayload playerPayload)
userContent = Plugin.ChatLogWindow.HidePlayerInString(userContent, playerPayload.PlayerName, playerPayload.World.RowId);
else if (Plugin.PlayerState.IsLoaded)
userContent = Plugin.ChatLogWindow.HidePlayerInString(userContent, Plugin.PlayerState.CharacterName, Plugin.PlayerState.HomeWorld.RowId);
}
var isNotUrl = text.Link is not UriPayload;
return new MessageTemplate { PayloadType = isNotUrl ? WebPayloadType.RawText : WebPayloadType.CustomUri, Color = color.Value, Content = userContent };
}
return MessageTemplate.Empty;
}
}
+2 -2
View File
@@ -28,12 +28,12 @@ public class DebuggerWindow : Window
RespectCloseHotkey = false;
DisableWindowSounds = true;
Plugin.Commands.Register("/chat2Debugger", showInHelp: false).Execute += Toggle;
Plugin.Commands.Register("/hellionDebugger", showInHelp: false).Execute += Toggle;
}
public void Dispose()
{
Plugin.Commands.Register("/chat2Debugger", showInHelp: false).Execute -= Toggle;
Plugin.Commands.Register("/hellionDebugger", showInHelp: false).Execute -= Toggle;
}
private void Toggle(string _, string __) => Toggle();
+63 -47
View File
@@ -21,63 +21,79 @@ internal static class HellionStyle
{
// Encoded as 0xRRGGBBAA, matching ChatTwo convention (see Settings.cs
// Ko-fi buttons). RgbaToAbgr handles the byte swap to the format ImGui
// expects.
// expects. Hex values are sourced from the Hellion Online Media brand
// guide ("Arctic Cyan + Ember Glow", BRANDING.md in the website repo).
// Primary — cyan-teal for actionable controls (buttons, checks, sliders).
private const uint PrimaryRgba = 0x00B8D4FF;
private const uint PrimaryHoverRgba = 0x26C6DAFF;
private const uint PrimaryActiveRgba = 0x00838FFF;
// Primary — Arctic Cyan, used for every interactive control (buttons,
// checks, sliders, separators when hovered). Three brand stages plus a
// hover that lifts to brand-color-light and a press that drops to
// brand-color-dark.
private const uint PrimaryRgba = 0x00BED2FF; // brand-color
private const uint PrimaryHoverRgba = 0x4DD9E8FF; // brand-color-light
private const uint PrimaryActiveRgba = 0x0097A7FF; // brand-color-dark
// Secondary — industrial amber, used as a warm highlight for active
// states (tab borders, resize grips, scrollbar grabs).
private const uint SecondaryRgba = 0xFFB300FF;
private const uint SecondaryHoverRgba = 0xFFC940FF;
private const uint SecondaryActiveRgba = 0xC68400FF;
// Identity — brand-color-dark teal for window title bars and the
// active tab. Sits visibly below the primary cyan on buttons so the
// user sees "where am I" (deep teal) versus "what can I click"
// (brand cyan) without leaving the cyan family.
private const uint IdentityRgba = 0x0097A7FF; // brand-color-dark
private const uint IdentityHoverRgba = 0x4DD9E8FF; // brand-color-light
private const uint IdentityDeepRgba = 0x005670FF; // dimmer teal for unfocused-active tab
// Tertiary — slate violet, reserved for title bars and the active tab
// background so identity beats out the cyan accent without competing
// with it on action controls.
private const uint TertiaryRgba = 0x7B61FFFF;
private const uint TertiaryHoverRgba = 0x9580FFFF;
private const uint TertiaryActiveRgba = 0x5E45D9FF;
// Accent — Ember Orange for warm highlights on grips and scrollbar
// pulls. Replaces the previous industrial amber so the plugin matches
// the website's CTA palette. AccentActive is reserved for any future
// pressed-state on accent surfaces; the current slots only need
// AccentRgba and AccentHoverRgba.
private const uint AccentRgba = 0xF97316FF; // accent-color
private const uint AccentHoverRgba = 0xFB923CFF; // accent-color-light
// Surfaces — deep slate window/frame backgrounds, steel borders.
private const uint WindowBgRgba = 0x0E1A20FF;
private const uint ChildBgRgba = 0x102027FF;
private const uint PopupBgRgba = 0x102027FF;
private const uint FrameBgRgba = 0x162831FF;
private const uint FrameBgHoverRgba = 0x1F3540FF;
private const uint FrameBgActiveRgba = 0x274250FF;
private const uint BorderRgba = 0x37474FFF;
// Surfaces — Hellion brand background ladder. Window darkest, frame
// hover ladder climbs into surface tones. Matches the website's
// background / background-medium / background-light / surface vars.
private const uint WindowBgRgba = 0x070B12FF; // background
private const uint ChildBgRgba = 0x0C1220FF; // background-medium
private const uint PopupBgRgba = 0x0C1220FF; // background-medium
private const uint FrameBgRgba = 0x141E30FF; // background-light
private const uint FrameBgHoverRgba = 0x1A2538FF; // surface
private const uint FrameBgActiveRgba = 0x22303FFF; // surface-hover
// Cyan-tinted border — matches website --border-brand (cyan @ 40% α).
private const uint BorderRgba = 0x00BED266;
private const uint BorderShadowRgba = 0x00000000;
// Headers / collapsing-headers / tree nodes / selectables.
private const uint HeaderRgba = 0x1B2C36FF;
private const uint HeaderHoverRgba = 0x263A45FF;
private const uint HeaderActiveRgba = 0x324A57FF;
// Headers / collapsing-headers / tree nodes / selectables — same
// surface ladder as frames so panels feel consistent.
private const uint HeaderRgba = 0x141E30FF;
private const uint HeaderHoverRgba = 0x1A2538FF;
private const uint HeaderActiveRgba = 0x22303FFF;
// Title bars — tertiary identity for the active state.
private const uint TitleBgRgba = 0x0E1A20FF;
private const uint TitleBgActiveRgba = 0x5E45D9FF;
private const uint TitleBgCollapsedRgba = 0x0A1318FF;
// Title bars — Identity teal on active so the focused window reads
// as "yours" without using accent or primary slots.
private const uint TitleBgRgba = 0x070B12FF;
private const uint TitleBgActiveRgba = IdentityRgba;
private const uint TitleBgCollapsedRgba = 0x05080EFF;
// Tabs — tertiary tint, secondary highlight while hovered/unfocused.
private const uint TabRgba = 0x162831FF;
private const uint TabHoveredRgba = 0x9580FFFF;
private const uint TabActiveRgba = 0x7B61FFFF;
private const uint TabUnfocusedRgba = 0x12222AFF;
private const uint TabUnfocusedActiveRgba = 0x5E45D9FF;
// Tabs — neutral inactive, Identity-light on hover, Identity teal on
// active. Unfocused-active uses the deeper Identity stage so an
// unfocused window's active tab still reads but does not pull focus.
private const uint TabRgba = 0x141E30FF;
private const uint TabHoveredRgba = IdentityHoverRgba;
private const uint TabActiveRgba = IdentityRgba;
private const uint TabUnfocusedRgba = 0x0C1220FF;
private const uint TabUnfocusedActiveRgba = IdentityDeepRgba;
// Scrollbar — slate base, secondary amber on grab.
private const uint ScrollbarBgRgba = 0x0E1A20FF;
private const uint ScrollbarGrabRgba = 0x37474FFF;
private const uint ScrollbarGrabHoveredRgba = 0xFFC940FF;
private const uint ScrollbarGrabActiveRgba = 0xFFB300FF;
// Scrollbar — Ember on grab so the pull stands out without competing
// with the cyan action buttons. Idle grab is a subtle surface tone,
// hover/active climb into accent.
private const uint ScrollbarBgRgba = 0x070B12FF;
private const uint ScrollbarGrabRgba = 0x22303FFF; // surface-hover
private const uint ScrollbarGrabHoveredRgba = AccentHoverRgba;
private const uint ScrollbarGrabActiveRgba = AccentRgba;
// Resize grip — secondary amber for the active corner pull.
private const uint ResizeGripRgba = 0x37474FFF;
private const uint ResizeGripHoveredRgba = 0xFFC940FF;
private const uint ResizeGripActiveRgba = 0xFFB300FF;
// Resize grip — same Ember treatment as the scrollbar.
private const uint ResizeGripRgba = 0x141E30FF;
private const uint ResizeGripHoveredRgba = AccentHoverRgba;
private const uint ResizeGripActiveRgba = AccentRgba;
// Separator and check mark / slider follow the primary cyan.
+2 -2
View File
@@ -30,14 +30,14 @@ public class SeStringDebugger : Window
DisableWindowSounds = true;
#if DEBUG
Plugin.Commands.Register("/chat2SeString", showInHelp: false).Execute += Toggle;
Plugin.Commands.Register("/hellionSeString", showInHelp: false).Execute += Toggle;
#endif
}
public void Dispose()
{
#if DEBUG
Plugin.Commands.Register("/chat2SeString", showInHelp: false).Execute -= Toggle;
Plugin.Commands.Register("/hellionSeString", showInHelp: false).Execute -= Toggle;
#endif
}
+2 -3
View File
@@ -42,7 +42,6 @@ public sealed class SettingsWindow : Window
new Tabs(Plugin, Mutable),
new SettingsTabs.Privacy(Plugin, Mutable),
new Database(Plugin, Mutable),
new Webinterface(Plugin, Mutable),
new Miscellaneous(Mutable),
new Changelog(Mutable),
new About()
@@ -53,14 +52,14 @@ public sealed class SettingsWindow : Window
Initialise();
Plugin.Commands.Register("/chat2", "Perform various actions with Chat 2.").Execute += Command;
Plugin.Commands.Register("/hellion", "Perform various actions with Hellion Chat.").Execute += Command;
Plugin.Interface.UiBuilder.OpenConfigUi += Toggle;
}
public void Dispose()
{
Plugin.Interface.UiBuilder.OpenConfigUi -= Toggle;
Plugin.Commands.Register("/chat2").Execute -= Command;
Plugin.Commands.Register("/hellion").Execute -= Command;
}
private void Command(string command, string args)
+35 -33
View File
@@ -60,67 +60,69 @@ internal sealed class About : ISettingsTab
ImGuiHelpers.ScaledDummy(10.0f);
// Hellion-specific maintainer / attribution / license / SE-
// disclaimer block. Hand-rolled in English here rather than via
// HellionStrings — the legal-ish copy stays close to the EUPL-1.2
// wording and the SE disclaimer is the same in every locale.
ImGui.TextColored(ImGuiColors.ParsedGold, "Maintainer");
ImGui.TextUnformatted("Hellion Chat is maintained by Hellion Online Media (Florian Wathling).");
ImGui.TextUnformatted("Website:");
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_Maintainer_Heading);
ImGui.TextUnformatted(HellionStrings.About_Maintainer_Body);
ImGui.TextUnformatted(HellionStrings.About_Maintainer_Website_Label);
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "hellionMedia"))
Dalamud.Utility.Util.OpenLink("https://hellion-media.de");
ImGui.TextUnformatted("For licensing, legal or contact inquiries please reach out via the website above.");
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextColored(ImGuiColors.ParsedGold, "Built on Chat 2");
ImGui.TextUnformatted("Hellion Chat is a fork of Chat 2 by Infi and Anna (ascclemens).");
ImGui.TextUnformatted("Every chat replacement feature, the IPC integration, the rendering engine and the storage core come from upstream Chat 2.");
ImGui.TextUnformatted("Upstream repository:");
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_Mission_Heading);
ImGui.TextUnformatted(HellionStrings.About_Mission_P1);
ImGui.Spacing();
ImGui.TextUnformatted(HellionStrings.About_Mission_P2);
ImGui.Spacing();
ImGui.TextUnformatted(HellionStrings.About_Mission_P3);
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_BuiltOn_Heading);
ImGui.TextUnformatted(HellionStrings.About_BuiltOn_P1);
ImGui.Spacing();
ImGui.TextUnformatted(HellionStrings.About_BuiltOn_P2);
ImGui.Spacing();
ImGui.TextUnformatted(HellionStrings.About_BuiltOn_Upstream_Label);
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "chatTwoUpstream"))
Dalamud.Utility.Util.OpenLink("https://github.com/Infiziert90/ChatTwo");
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextColored(ImGuiColors.ParsedGold, "License");
ImGui.TextUnformatted("Hellion Chat and Chat 2 are licensed under the European Union Public License v1.2 (EUPL-1.2).");
ImGui.TextUnformatted("© 20232026 the Chat 2 authors (Infi, Anna and the upstream contributors).");
ImGui.TextUnformatted("© 2026 Hellion Online Media — for the Hellion Chat additions.");
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_License_Heading);
ImGui.TextUnformatted(HellionStrings.About_License_P1);
ImGui.TextUnformatted(HellionStrings.About_License_P2);
ImGui.TextUnformatted(HellionStrings.About_License_P3);
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextColored(ImGuiColors.DalamudOrange, "FINAL FANTASY XIV disclaimer");
ImGui.TextUnformatted("FINAL FANTASY XIV © SQUARE ENIX CO., LTD. All rights reserved.");
ImGui.TextUnformatted("Hellion Chat is an unofficial, fan-made plugin and is not affiliated with, endorsed, sponsored or approved by Square Enix.");
ImGui.TextColored(ImGuiColors.DalamudOrange, HellionStrings.About_SE_Heading);
ImGui.TextUnformatted(HellionStrings.About_SE_P1);
ImGui.TextUnformatted(HellionStrings.About_SE_P2);
ImGui.Spacing();
ImGui.TextColored(ImGuiColors.ParsedGold, "Localization");
ImGui.TextUnformatted("German translations of Hellion-specific UI strings (HellionStrings.de.resx) are written by the Hellion Online Media maintainer.");
ImGui.TextUnformatted("All other locales for Hellion-specific strings are not currently provided.");
ImGui.TextUnformatted("The translator list below covers the upstream Chat 2 community translators on Crowdin — their work covers the inherited Chat 2 strings, not the Hellion additions.");
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_Localization_Heading);
ImGui.TextUnformatted(HellionStrings.About_Localization_P1);
ImGui.TextUnformatted(HellionStrings.About_Localization_P2);
ImGui.Spacing();
var height = ImGui.GetContentRegionAvail().Y - ImGui.CalcTextSize("A").Y - ImGui.GetStyle().ItemSpacing.Y * 2;
using (var aboutChild = ImRaii.Child("about", new Vector2(-1, height)))
// The translator list lives at the bottom of the About tab. Render
// it directly inside the parent scroll container instead of a
// fixed-height child — the previous "remaining space" calculation
// shrank to zero (or below) once the About copy grew, which made
// the section unreachable on smaller settings windows.
using (var treeNode = ImRaii.TreeNode(HellionStrings.About_Translators_TreeNode))
{
if (aboutChild)
{
using var treeNode = ImRaii.TreeNode("Chat 2 community translators (upstream)");
if (treeNode)
{
using var translatorChild = ImRaii.Child("translators");
if (translatorChild)
{
using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false);
foreach (var translator in Translators)
ImGui.TextUnformatted(translator);
}
}
}
}
ImGui.Spacing();
}
}
+17 -4
View File
@@ -55,7 +55,10 @@ internal sealed class Privacy : ISettingsTab
private long CleanupDeleteCount;
private bool CleanupRunning;
private bool RetentionRunning;
// The retention-running state lives on Plugin so the auto-sweep and
// this manual button see the same flag. UI reads stay lock-free
// because ImGui is single-threaded and bool reads are atomic in .NET.
private bool RetentionRunning => Plugin.RetentionSweepRunning;
// Export form state
private int ExportRangeDays = 30;
@@ -104,6 +107,8 @@ internal sealed class Privacy : ISettingsTab
HellionStrings.Privacy_FilterEnabled_Name,
HellionStrings.Privacy_FilterEnabled_Description);
ImGuiUtil.HelpText(HellionStrings.Privacy_FilterEnabled_StorageOnly_Help);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
@@ -408,10 +413,17 @@ internal sealed class Privacy : ISettingsTab
private void StartRetentionRun()
{
if (RetentionRunning)
// Take the shared retention lock so we cannot fight the auto-sweep
// for the database connection. If the auto-sweep is already in
// flight we just bail — the user can press the button again once
// it finishes.
lock (Plugin.RetentionSweepLock)
{
if (Plugin.RetentionSweepRunning)
return;
Plugin.RetentionSweepRunning = true;
}
RetentionRunning = true;
var policy = Plugin.Config.RetentionPerChannelDays.ToDictionary(p => (int)(ushort)p.Key, p => p.Value);
var defaultDays = Plugin.Config.RetentionDefaultDays;
@@ -443,7 +455,8 @@ internal sealed class Privacy : ISettingsTab
}
finally
{
RetentionRunning = false;
lock (Plugin.RetentionSweepLock)
Plugin.RetentionSweepRunning = false;
}
}) { IsBackground = true }.Start();
}
-154
View File
@@ -1,154 +0,0 @@
using ChatTwo.Resources;
using ChatTwo.Util;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
namespace ChatTwo.Ui.SettingsTabs;
internal sealed class Webinterface(Plugin plugin, Configuration mutable) : ISettingsTab
{
private Plugin Plugin { get; } = plugin;
private Configuration Mutable { get; } = mutable;
public string Name => Language.Options_Webinterface_Tab + "###tabs-Webinterface";
public void Draw(bool changed)
{
if (ImGui.CollapsingHeader(Language.Webinterface_UsageNotice, ImGuiTreeNodeFlags.DefaultOpen))
{
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudWhite, Language.Options_Webinterface_Warning_Header);
ImGui.Spacing();
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Options_Webinterface_Warning_Reason);
ImGui.Spacing();
ImGui.Spacing();
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_Warning_DoNot);
using (ImRaii.PushIndent(15.0f))
{
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_DoNot_Port);
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_DoNot_Share);
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_DoNot_Multibox);
}
ImGui.Spacing();
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Options_Webinterface_Warning_Support);
ImGui.Spacing();
}
ImGui.Separator();
ImGui.Spacing();
ImGuiUtil.OptionCheckbox(ref Mutable.WebinterfaceEnabled, Language.Options_WebinterfaceEnable_Name, Language.Options_WebinterfaceEnable_Description);
ImGui.Spacing();
if (!Mutable.WebinterfaceEnabled)
return;
ImGui.Spacing();
ImGuiUtil.OptionCheckbox(ref Mutable.WebinterfaceAutoStart, Language.Options_WebinterfaceAutoStart_Name, Language.Options_WebinterfaceAutoStart_Description);
ImGui.Spacing();
if (ImGuiUtil.InputIntVertical(Language.Webinterface_Option_Port_Name, Language.Webinterface_Option_Port_Description, ref Mutable.WebinterfacePort))
Mutable.WebinterfacePort = Math.Clamp(Mutable.WebinterfacePort, 1024, 49151);
ImGui.Spacing();
if (ImGuiUtil.InputIntVertical(Language.Options_WebinterfaceMaxLinesToSend_Name, Language.Options_WebinterfaceMaxLinesToSend_Description, ref Mutable.WebinterfaceMaxLinesToSend))
Mutable.WebinterfaceMaxLinesToSend = Math.Clamp(Mutable.WebinterfaceMaxLinesToSend, 1, 10_000);
ImGui.Spacing();
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Webinterface_CurrentPassword);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(Mutable.WebinterfacePassword);
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle, tooltip: Language.Webinterface_PasswordReset_Tooltip))
{
Mutable.WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
Plugin.ServerCore.InvalidateSessions();
}
ImGui.TextUnformatted(Language.Webinterface_Controls);
using (ImRaii.PushIndent(10.0f))
{
var isActive = Plugin.ServerCore.IsActive();
using (ImRaii.Disabled(isActive || Plugin.ServerCore.IsStopping()))
{
if (ImGui.Button(Language.Webinterface_Button_Start))
{
Task.Run(() =>
{
var ok = Plugin.ServerCore.Start();
if (ok)
{
Plugin.ServerCore.Run();
WrapperUtil.AddNotification(Language.Webinterface_Start_Success, NotificationType.Success);
}
else
{
WrapperUtil.AddNotification(Language.Webinterface_Start_Failed, NotificationType.Error);
}
});
}
}
ImGui.SameLine();
using (ImRaii.Disabled(!isActive || Plugin.ServerCore.IsStopping()))
{
if (ImGui.Button(Language.Webinterface_Button_Stop))
{
Task.Run(async () =>
{
var ok = await Plugin.ServerCore.Stop();
if (ok)
WrapperUtil.AddNotification(Language.Webinterface_Stop_Success, NotificationType.Success);
else
WrapperUtil.AddNotification(Language.Webinterface_Stop_Failed, NotificationType.Error);
});
}
}
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(Language.Webinterface_Controls_Active);
ImGui.SameLine();
using (Plugin.FontManager.FontAwesome.Push())
using (ImRaii.PushColor(ImGuiCol.Text, isActive ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed))
{
ImGui.TextUnformatted(isActive ? FontAwesomeIcon.Check.ToIconString() : FontAwesomeIcon.Times.ToIconString());
}
Uri? uri;
try {
uri = new Uri($"http://{System.Net.Dns.GetHostName()}:{Mutable.WebinterfacePort}/");
}
catch(Exception)
{
uri = null;
}
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(Language.Webinterface_Controls_Url);
ImGui.SameLine();
if (uri is not null)
{
var clicked = false;
clicked |= ImGui.Selectable(uri.AbsoluteUri);
ImGui.SameLine();
clicked |= ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "urlOpen");
if (clicked)
WrapperUtil.TryOpenUri(uri);
}
else
{
ImGui.TextUnformatted(Language.Options_Webinterface_Hostname_Fail);
}
ImGuiUtil.WrappedTextWithColor(ImGuiColors.HealerGreen, Language.Options_Webinterface_Note);
}
ImGui.Spacing();
ImGui.Spacing();
}
}
-19
View File
@@ -1,19 +0,0 @@
namespace ChatTwo.Util;
public static class WebinterfaceUtil
{
private static readonly Random Rng = new();
public static string GenerateSimpleAuthCode()
{
return (100000 + Rng.Next() % 100000).ToString()[1..];
}
public static string GenerateSimpleToken()
{
var buffer = new byte[15];
Rng.NextBytes(buffer);
return Convert.ToHexString(buffer);
}
}
-46
View File
@@ -54,26 +54,6 @@
"resolved": "3.1.12",
"contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A=="
},
"Watson.Lite": {
"type": "Direct",
"requested": "[6.3.9, )",
"resolved": "6.3.9",
"contentHash": "sDigTY8D8V7W38lfzJGiigf7xZEfp3Kw7XE7VJyeNO9mxOkv+w8HcmCsmORMDhsipDqGU0gMEsPOqORmZzRaWg==",
"dependencies": {
"CavemanTcp": "2.0.9",
"Watson.Core": "6.3.9"
}
},
"CavemanTcp": {
"type": "Transitive",
"resolved": "2.0.9",
"contentHash": "KgIwYhPhGkBTm+wwVAmWonkKPw4xYVnutzzlIeqOLcX1fti+8d+MEGTvbern1smf3S/UpjFjihkf6XRziTddzQ=="
},
"IpMatcher": {
"type": "Transitive",
"resolved": "1.0.5",
"contentHash": "WXNlWERj+0GN699AnMNsuJ7PfUAbU4xhOHP3nrNXLHqbOaBxybu25luSYywX1133NSlitA4YkSNmJuyPvea4sw=="
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "3.1.4",
@@ -97,11 +77,6 @@
"resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
},
"RegexMatcher": {
"type": "Transitive",
"resolved": "1.0.9",
"contentHash": "RkQGXIrqHjD5h1mqefhgCbkaSdRYNRG5rrbzyw5zeLWiS0K1wq9xR3cNhQdzYR2MsKZ3GN523yRUsEQIMPxh3Q=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.10",
@@ -128,27 +103,6 @@
"dependencies": {
"SQLitePCLRaw.core": "2.1.10"
}
},
"Timestamps": {
"type": "Transitive",
"resolved": "1.0.11",
"contentHash": "SnWhXm3FkEStQGgUTfWMh9mKItNW032o/v8eAtFrOGqG0/ejvPPA1LdLZx0N/qqoY0TH3x11+dO00jeVcM8xNQ=="
},
"UrlMatcher": {
"type": "Transitive",
"resolved": "3.0.1",
"contentHash": "hHBZVzFSfikrx4XsRsnCIwmGLgbNKtntnlqf4z+ygcNA6Y/L/J0x5GiZZWfXdTfpxhy5v7mlt2zrZs/L9SvbOA=="
},
"Watson.Core": {
"type": "Transitive",
"resolved": "6.3.9",
"contentHash": "hGoadE4SLbko8yxhx5+nxGV8lEVgEquNli87lN6/eOTQEJNpK/Cs+OF0etTgFKZ4p0u5ivetoDxl82Lg6oHZEg==",
"dependencies": {
"IpMatcher": "1.0.5",
"RegexMatcher": "1.0.9",
"Timestamps": "1.0.11",
"UrlMatcher": "3.0.1"
}
}
}
}
Binary file not shown.
+12 -3
View File
@@ -1,6 +1,6 @@
# Hellion Chat
**Version 0.1.2** — DSGVO-bewusste Erweiterung von [Chat 2](https://github.com/Infiziert90/ChatTwo) für FINAL FANTASY XIV / Dalamud.
**Version 0.3.0** — DSGVO-bewusste Erweiterung von [Chat 2](https://github.com/Infiziert90/ChatTwo) für FINAL FANTASY XIV / Dalamud.
Hellion Chat baut auf Chat 2 auf und ergänzt es um Datenschutz- und Daten-Handling-Kontrollen, die mit den Datenschutz-Regeln in der EU, den USA und Japan im Einklang sind. Alle Chat-2-Funktionen, Befehle und Tastenkürzel funktionieren unverändert. Eigenständiger Plugin-Slot, eigene Konfiguration, eigene Datenbank.
@@ -54,6 +54,10 @@ Privates Repository, EUPL-1.2-lizenziert. Distribution über Custom-Repo währen
- Font-Atlas-Build-Fallback bei nicht-installierten System-Fonts.
- Defensive Wrapping aller Migrations-Operationen.
### Was gegenüber Chat 2 fehlt
- **Webinterface** wurde in Hellion Chat 0.2.0 entfernt. Es bedient einen anderen Anwendungsfall als der Fokus dieses Forks, nämlich Remote-Zugriff auf den Chat von einem zweiten Gerät. An die kleineren Defaults dieses Forks anzupassen hätte einen erheblichen Umbau bedeutet, also ist es ersatzlos entfernt worden. Wer den vollen Funktionsumfang von Chat 2 möchte, ist mit dem Upstream-Plugin besser bedient. Hellion Chat fokussiert sich auf einen schmaleren Datenbestand und verzichtet bewusst auf Remote-Zugriffs-Features.
---
## Architektur
@@ -227,7 +231,7 @@ Konflikte in Upstream-Sprach-Ressourcen (`Language.<lang>.resx`) kommen häufig
## Projektstatus
**Version 0.1.2** | Stand: Mai 2026
**Version 0.3.0** | Stand: Mai 2026
Alle Bootstrap-Phasen abgeschlossen:
@@ -239,8 +243,13 @@ Alle Bootstrap-Phasen abgeschlossen:
- [x] Custom-Repo-Pipeline mit GitHub-Release-Distribution
- [x] About-Tab im Hellion-Branding mit License + Disclaimer
- [x] AI-Disclosure dokumentiert (Pair-Klassifikation)
- [x] Webinterface entfernt (Phase 1.5, Audit-Konsequenz aus 2026-05-02)
- [x] Audit-Hardening Phase 2 (Path-Traversal, Retention-Race, DbViewer-Konsistenz, Privacy-Filter-Help-Text)
- [x] Slash-Commands auf `/hellion`-Familie umbenannt
- [x] Theme auf Hellion-Online-Media-Brand-Palette aligned (Arctic Cyan + Ember Orange)
- [x] About-Tab vollständig lokalisiert (EN + DE) mit Mission-Statement und neutraler Tonart
Phase 2 (offen, kein festes Datum):
Phase 3 (offen, kein festes Datum):
- [ ] MySQL/MariaDB-Backend mit Drei-Stufen-Bestätigung
- [ ] PostgreSQL-Backend
+7 -7
View File
File diff suppressed because one or more lines are too long