Compare commits

...
Sign in to create a new pull request.

191 commits
dev ... main

Author SHA1 Message Date
MiTHRAL
1e3f165857 v1.0.7
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m55s
2026-05-05 20:50:53 -04:00
MiTHRAL
8a9d621456 v1.0.6
Some checks failed
Build and Release Sanctum / Build App (push) Has been cancelled
2026-05-05 20:48:42 -04:00
MiTHRAL
3055e283a1 v1.0.5
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m55s
2026-05-05 20:40:57 -04:00
MiTHRAL
44ee4970f9 fix: remove custom headliner to resolve title overlap and bump to 1.0.4
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m48s
2026-04-24 16:11:13 -04:00
MiTHRAL
90797d6dd9 chore: bump version to 1.0.3 and clarify tagging mandate
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m50s
2026-04-24 15:36:44 -04:00
MiTHRAL
194199daed chore: bump version to 1.0.2 and add agent mandates
Some checks failed
Build and Release Sanctum / Build App (push) Has been cancelled
2026-04-24 15:35:21 -04:00
MiTHRAL
19a1b41e6d chore: bump version to 1.0.1 and update headliner
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m45s
2026-04-24 15:33:10 -04:00
MiTHRAL
c5e8c49bd9 fix: resolve titlebar text overlap and update branding in headliner
Some checks failed
Build and Release Sanctum / Build App (push) Has been cancelled
2026-04-24 15:27:38 -04:00
MiTHRAL
398451d7c7 chore: replace tray icons
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m52s
2026-04-22 23:46:41 -04:00
MiTHRAL
2ca7e2e0d2 refactor: update build workflow with jq and asset conflict handling
Some checks failed
Build and Release Sanctum / Build App (push) Has been cancelled
2026-04-22 18:18:35 -04:00
MiTHRAL
95bb71b60f ci: fix release asset upload to use multipart form, handle existing releases
Some checks failed
/ Build App (push) Failing after 1m37s
2026-04-22 18:09:04 -04:00
MiTHRAL
692c4834cd ci: apt-get update before installing zip
Some checks failed
/ Build App (push) Failing after 1m37s
2026-04-22 18:01:55 -04:00
MiTHRAL
3ca0b7b395 ci: install zip before making distributables
Some checks failed
/ Build App (push) Failing after 22s
2026-04-22 18:00:52 -04:00
MiTHRAL
a47440a3ef ci: build linux + windows zips and publish to Gitea release on tag
Some checks failed
/ Build App (push) Failing after 39s
2026-04-22 17:58:44 -04:00
MiTHRAL
c8f8212d7a ci: replace mise action with direct pnpm install
All checks were successful
/ Build App (push) Successful in 37s
2026-04-22 17:52:37 -04:00
MiTHRAL
657bf6d0d0 ci: use docker runner label to match self-hosted runner
Some checks failed
/ Build App (push) Failing after 3s
2026-04-22 17:50:18 -04:00
MiTHRAL
9d361c35cc ci: use media-server runner for all workflows
Some checks are pending
/ Build App (push) Waiting to run
2026-04-22 17:47:04 -04:00
MiTHRAL
93d2558324 ci: use media-server self-hosted runner for builds 2026-04-22 17:46:39 -04:00
MiTHRAL
4f5cbbb3c2 chore: rebrand UI to Sanctum, bump to v1.0.0
Some checks are pending
/ Build App (push) Waiting to run
Replace all user-visible "Avia"/"AviaClient" strings with "Sanctum" equivalents
across all UI components. Update version to 1.0.0 in package.json and add release
notes to metainfo.xml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:42:21 -04:00
MiTHRAL
cc8ba75694 chore: replace update-electron-app with Gitea-backed updater 2026-04-22 17:30:21 -04:00
MiTHRAL
225c623ecb chore: point to self-hosted instance at mithraic.space 2026-04-22 17:27:58 -04:00
MiTHRAL
1eb09a589a chore: rebrand to Sanctum for self-hosted instance 2026-04-22 17:25:22 -04:00
AvaLilac
821ff30d40
Add files via upload
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-07 09:55:15 -04:00
AvaLilac
d98d6d5441
Delete avia_assets/icon.png
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-07 09:54:30 -04:00
AvaLilac
ac7f85f679
Merge pull request #2 from AvaLilac/dev
Dev
2026-04-06 22:35:03 -04:00
AvaLilac
222d796843
Merge branch 'stoatchat:main' into dev 2026-04-06 22:27:08 -04:00
Amelia
0abe72a3c6
Add Aurora to the README
Signed-off-by: Amelia <afrosty.skye@gmail.com>
2026-04-06 18:48:13 -07:00
Amelia Frost
4b05dd4fbe
Our kitty is named Aurora. 2026-04-06 16:59:03 -07:00
Amelia Frost
0e40c6c6ec
Remove 16x16 and 24x24 2026-04-06 16:44:21 -07:00
Amelia Frost
4f13d893cd
New Icon 2026-04-06 16:33:57 -07:00
AvaLilac
593271c2b9
Merge MonacoCSS Plugin directly into QuickCSS as the native panel
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-06 18:44:10 -04:00
AvaLilac
dec110d795
Merge MonacoCSS Plugin directly into Themes as the native panel
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-06 18:43:09 -04:00
AvaLilac
8b0e6588fd
Change Discord RPC to aviaclients
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-06 14:52:47 -04:00
Amelia Frost
0c2c081ab0
Focus on the main window when the about window is closed 2026-04-06 00:14:31 -07:00
Amelia Frost
35ee57483a
Allow closing the about window by pressing Escape 2026-04-05 23:15:07 -07:00
Amelia Frost
a632ecde33
Don't show more than a single about window 2026-04-05 23:14:39 -07:00
Amelia Frost
4f845f58a9
Add our about window to the tray icon 2026-04-05 21:55:23 -07:00
Amelia Frost
d165a77875
Add the stoat version we're based on 2026-04-05 21:47:52 -07:00
Amelia Frost
7ca75e859e
Lower the width of the about window 2026-04-05 21:14:43 -07:00
Amelia Frost
c330333ee6
Replace version with the version from package.json 2026-04-05 21:14:08 -07:00
Amelia Frost
c15d64164b
Format about.html 2026-04-05 20:26:13 -07:00
Amelia Frost
4208ee0ec2
do not allow minimizing, and set the window handle to null on close 2026-04-05 20:17:06 -07:00
Amelia Frost
50b05be8fc
Get rid of the menu on our about window 2026-04-05 20:16:25 -07:00
AvaLilac
a81c7b092b
get the window to display
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-05 22:59:17 -04:00
Amelia Frost
5d1a5e3ecb
Partial about window implementation 2026-04-05 19:36:13 -07:00
AvaLilac
b3c4959c7c
Add a about page inside avia client. step 1
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-05 20:48:39 -04:00
Amelia Frost
841a9be2cf
Reapply "Prevent the page keydown/keyup events and menu shortcuts"
This reverts commit 60fb61a1db.
2026-04-05 16:59:30 -07:00
Amelia Frost
60fb61a1db
Revert "Prevent the page keydown/keyup events and menu shortcuts"
This reverts commit 24c9cc51f4.
2026-04-05 15:26:04 -07:00
Amelia Frost
24c9cc51f4
Prevent the page keydown/keyup events and menu shortcuts 2026-04-05 14:27:48 -07:00
Amelia Frost
da383611c0
on macOS, open/close Settings with Cmd+, 2026-04-04 20:54:16 -07:00
Amelia Frost
741102d6eb
Show AviaClient's version in the about window 2026-04-04 16:23:39 -07:00
Amelia Frost
22785a9860
Use our exposed versions 2026-04-04 15:43:57 -07:00
Amelia Frost
e56455fbaf
Show AviaClient version is tray, and expose it to world 2026-04-04 15:26:59 -07:00
Amelia Frost
0c2d23d029
Make it a toggle 2026-04-04 12:35:06 -07:00
AvaLilac
363f9f675b
Make it possible for users to open devtools via F12
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-04 15:27:29 -04:00
AvaLilac
c386cb6cba
Update README.md
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-04 15:00:59 -04:00
AvaLilac
b5f8edeb4d
Update README.md
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-04 14:58:54 -04:00
Amelia Frost
b9a3d37c9a
partial fix for badges being displayed 2026-04-02 16:52:56 -07:00
AvaLilac
962e4b188b
Update README.md
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-02 12:57:03 -04:00
Amelia Frost
6fbd8a18f6
Load Token Login internal plugin 2026-04-01 15:19:23 -07:00
AvaLilac
b22130645a
adds the ability to login to stoat desktop with your session token
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-04-01 18:08:46 -04:00
Amelia Frost
7d07e3fcc8
refactor: prefer using app.whenReady() to avoid a race condition 2026-04-01 00:40:26 -07:00
Amelia Frost
b59ab85e7a
Update pnpm to 10.33.0 2026-03-31 23:47:36 -07:00
Amelia Frost
5a3c1458ac
New package lock 2026-03-31 01:24:55 -07:00
Amelia Frost
262c74fefe
Update update-electron-app 2026-03-31 01:24:39 -07:00
Amelia Frost
da075829dc
Install electron-vite to fix types warning 2026-03-31 01:24:10 -07:00
Amelia Frost
60dbbde2af
fix formatting and linter warnings 2026-03-31 01:22:15 -07:00
AvaLilac
c0eb4129ac
added full codeberg support
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-30 19:46:15 -04:00
Amelia Frost
2c7a864815
ESLint should ignore assets 2026-03-29 17:36:31 -07:00
Amelia Frost
590729ccff
Remove unused import 2026-03-29 17:34:57 -07:00
Amelia Frost
c407a89142
only include TypeScript & JavaScript in compilation 2026-03-29 17:18:06 -07:00
AvaLilac
fa6cfb86f3
Update main.ts
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-29 17:58:17 -04:00
AvaLilac
b292308cd5
Allows you to completely backup avia client and restore it
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-29 23:56:59 +02:00
Amelia Frost
a60a60ab4b
very minor refactor 2026-03-29 00:45:05 -07:00
Amelia Frost
2f2b4474b4
Add option to disable showing/hiding on clicking the tray icon 2026-03-28 19:55:40 -07:00
Amelia Frost
0e1c696a68
Replace macOS placeholder icon with an .icon from Icon Composer 2026-03-28 18:19:25 -07:00
Amelia Frost
f31c2ca067
Revert "Changed tray icon behaviour to not show/hide on click (as we have a Show App/Hide App sub-menu)"
This reverts commit 85aaf5946d.
2026-03-28 18:16:29 -07:00
Amelia Frost
fbf53123fa
Add a seperator before Restart/Quit 2026-03-28 18:13:17 -07:00
Amelia Frost
85aaf5946d
Changed tray icon behaviour to not show/hide on click (as we have a Show App/Hide App sub-menu) 2026-03-28 18:12:35 -07:00
Amelia Frost
cec88cfe3f
fix: compile 2026-03-28 18:10:14 -07:00
AvaLilac
0e903e71df
Added the Ability to restart the entire client
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-28 21:06:46 -04:00
Amelia Frost
f95cc126ce
fix: missing semi-colons 2026-03-28 17:33:34 -07:00
Amelia Frost
09662fc37e
Add the system's native menu to the titlebar, configurable 2026-03-28 17:22:40 -07:00
AvaLilac
efbba2a65f
change 1.5.0 to 1.6.0
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-28 19:00:19 -04:00
AvaLilac
149930460b
Update main.tsadded aviaclientdesktop
and fixed a issue that made it not compile

Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-28 18:46:20 -04:00
AvaLilac
05decfbae4
changes the stoat desktop button in desktop settings to make it avia
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-28 18:45:02 -04:00
AvaLilac
727ba0f1df
intergrated headliner
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-28 18:23:54 -04:00
AvaLilac
a7115dc1d4
we want the community to be involved in making builds
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-28 17:12:19 -04:00
AvaLilac
5e0b70056e
allows you to change stoats text
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-28 17:06:04 -04:00
Amelia Frost
a63ce3c41f
Fix missing comma 2026-03-28 13:33:38 -07:00
AvaLilac
0dd42efcaa
Update main.ts
Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-28 16:19:39 -04:00
AvaLilac
ac1b93677c
Fix's toolbar button injection
This Plugin is integrated as it fix's the bug where the buttons inside the tooltip do not hide when you look in a channel where you cant talk in. making the buttons push onto the other side till you reload the chat bar. futureproof. 

This plugin is made by 0simp#2291
Full credits to them for making this possible

Signed-off-by: AvaLilac <257690424+AvaLilac@users.noreply.github.com>
2026-03-28 16:19:08 -04:00
AvaLilac
73af6e3062
Update forge.config.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-28 15:05:51 -04:00
AvaLilac
47e524f0d2
Update README.md
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-28 11:31:07 -04:00
AvaLilac
191e23b61f
Update README.md
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-27 09:42:13 -04:00
Amelia Frost
94610845be
macOS placeholder icon 2026-03-26 15:41:26 -07:00
AvaLilac
34fd294be9
Added Drag and drop and a export feature
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-26 17:56:26 -04:00
Amelia Frost
7110c2202f
Automate building of avia_core files 2026-03-25 16:37:26 -07:00
AvaLilac
e5731054a7
Update main.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-25 18:18:24 -04:00
AvaLilac
1e8310874c
Update forge.config.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-25 18:18:00 -04:00
AvaLilac
95715a4fcd
Add files via upload
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-25 18:17:22 -04:00
AvaLilac
a61fbb695a
Add Import button
This commit adds the ability to import plugins without having to create a plugin and copy and paste code

you can

Drag And Drop into the panel to add plugins
Click Import and either import 1 .js plugin or as many as you want

Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-25 13:17:00 -04:00
AvaLilac
b2bb58b9f0
Add the exe icon
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:40:48 -04:00
Amelia Frost
97662d2e15
Use new icon path 2026-03-24 16:35:38 -07:00
AvaLilac
a9f67aa099
Delete src/themes.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:16:54 -04:00
AvaLilac
0b849eb62b
Delete src/pluginsupport.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:16:47 -04:00
AvaLilac
ff169347df
Delete src/inject.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:16:40 -04:00
AvaLilac
b6cd8524dc
Delete src/aviaversion.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:16:32 -04:00
AvaLilac
a8af809134
Delete src/aviafavsystem.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:16:20 -04:00
AvaLilac
ef57607bc6
Delete src/aviaclientcategory.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:16:12 -04:00
AvaLilac
63d735f265
Delete src/LocalPlugins.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:16:04 -04:00
AvaLilac
5a0959d721
Have It compile from the new folder
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:14:03 -04:00
AvaLilac
82c81a6424
Seperating All of avia client's stuff out of SRC and into its own folder
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 19:12:47 -04:00
Amelia Frost
4bcbd24d99
Merge branch 'main' into dev 2026-03-24 16:11:50 -07:00
Amelia Frost
671e67cde4
Fix file indention 2026-03-24 16:05:21 -07:00
AvaLilac
0e8441e709
Change icon to Placeholder
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 18:44:10 -04:00
AvaLilac
8d32b73d98
Delete avia_assets/icon.png
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 18:43:39 -04:00
AvaLilac
02003514df
Add files via upload
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 18:43:11 -04:00
AvaLilac
a1463ea803
Delete avia_assets/icon.png
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 18:42:43 -04:00
AvaLilac
457f2e5b21
Merge branch 'stoatchat:main' into dev 2026-03-24 18:36:23 -04:00
AvaLilac
23241d8777
Update README.md
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 16:04:11 -04:00
AvaLilac
fe3a7c479b
Update README.md
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-24 15:57:40 -04:00
Amelia Frost
02185d005a
use avia_assets as the assets folder 2026-03-24 12:15:18 -07:00
AvaLilac
542023c6a5 Add support for mac os tray 2026-03-24 14:40:27 -04:00
AvaLilac
cbec0b1804 mac os 2026-03-24 14:38:55 -04:00
AvaLilac
982088c96d Update src/native/tray.ts 2026-03-24 11:35:03 -04:00
AvaLilac
77c2337b84 Update src/native/window.ts 2026-03-24 11:34:31 -04:00
AvaLilac
901e416b89 add folder to help change stoats icon 2026-03-24 11:33:52 -04:00
AvaLilac
9db9cd9373 Change the windows title from Stoat to AviaClient To match upcoming updates 2026-03-24 05:33:31 -04:00
AvaLilac
02f1eb08ff Update Stoat for desktop to AviaClient To desktop in hopes of changing the tray's name 2026-03-24 05:22:46 -04:00
AvaLilac
910b356071 Delete test.txt 2026-03-23 20:34:35 -04:00
AvaLilac
63d5e94e6c Add test.txt 2026-03-23 20:34:07 -04:00
Relux
a5327b96cc Delete test file.txt 2026-03-23 20:27:34 -04:00
Relux
bba0bb2c0e This is a test on my local repo 2026-03-23 20:26:28 -04:00
AvaLilac
a926345013
Replace Stoats screenshot.png with one took on aviaclient
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-23 18:05:57 -04:00
AvaLilac
5ac04dff7e
Delete screenshot.png
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-23 18:05:27 -04:00
Amelia Frost
dad8a675e2
Simplify compiling internal plugins 2026-03-23 13:45:12 -07:00
AvaLilac
db0e1b9647
Change Stoat from desktop to AviaClient to desktop
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-23 16:44:08 -04:00
AvaLilac
581cf4fce5
This makes it so AviaClient compiles into a folder named aviaclient. and the exe is also named aviaclient-fordesktop. instead of stoats
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-23 15:39:05 -04:00
Amelia Frost
3d738bfe0e
Simplify loading internal plugins 2026-03-22 17:24:26 -07:00
AvaLilac
dd90d70560
Update LocalPlugins.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-22 18:57:17 -04:00
AvaLilac
8b81078429
Update main.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-22 18:52:44 -04:00
AvaLilac
76bbebec1b
Update forge.config.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-22 18:50:40 -04:00
AvaLilac
d0196cd675
Add files via upload
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-22 18:49:36 -04:00
AvaLilac
5ad6819747
Update themes.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-22 18:43:27 -04:00
AvaLilac
449c819214
Update pluginsupport.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-22 18:10:21 -04:00
AvaLilac
e9b04f7dc5
Added Plugins own icon as Extension. To make it more professional
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-22 13:09:29 -04:00
AvaLilac
92ec5e1a9f
Update release-webhook.yml
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 13:12:23 -04:00
AvaLilac
c9165c682e
Update release-please.yml
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 13:11:23 -04:00
AvaLilac
f9c3108590
Update release-please.yml
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 13:07:46 -04:00
AvaLilac
f9a01a3a1a
Update release-please.yml
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 13:05:08 -04:00
AvaLilac
356964d1ee
Update release-please.yml
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 13:01:56 -04:00
AvaLilac
bb93c62e92
Enhance release workflow with Stoat notification
Added notification step to inform Stoat about new releases.

Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 12:58:31 -04:00
AvaLilac
994381411d
Update release-webhook.yml
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 12:46:17 -04:00
AvaLilac
638df6aafd
Inject category script into main window
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 12:01:04 -04:00
AvaLilac
eff6fe57f9
Update pluginsupport.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 12:00:36 -04:00
AvaLilac
45b96855eb
Update themes.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 12:00:22 -04:00
AvaLilac
b2afc5d363
Update inject.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 12:00:00 -04:00
AvaLilac
84ecbc5233
Add files via upload
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 11:59:17 -04:00
AvaLilac
133576411f
Update forge.config.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-18 11:58:56 -04:00
AvaLilac
d6932130c4
Add files via upload
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-11 18:44:17 -04:00
AvaLilac
a848275ec8
Delete src/userbadges.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-11 18:43:48 -04:00
AvaLilac
175fb81bcc
Update main.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-11 18:42:52 -04:00
AvaLilac
e20818fb5b
Update forge.config.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-11 18:41:53 -04:00
AvaLilac
d94e02e42c
Update userbadges.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-11 07:50:51 -04:00
AvaLilac
61d3ca0fbe
the intergrated code
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-06 11:42:24 -05:00
AvaLilac
8e6ed4bbb2
Had the new themes and Userbadges load into avia
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-06 11:41:46 -05:00
AvaLilac
cbd5f599d9
Added New Themes and intergrated Userbadges
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-03-06 11:41:09 -05:00
AvaLilac
5696875641
Add files via upload
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 19:31:44 -05:00
AvaLilac
a6cd720bc1
Update forge.config.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 19:30:36 -05:00
AvaLilac
9712f52e63
Update main.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 19:30:18 -05:00
AvaLilac
5c3b0073e2
Fix
Fixed it so it says copied to clipboard when you click a image/link

Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 08:41:26 -05:00
AvaLilac
58088aa094
fix readme to stop confusion
I thought i broke my source code because i didnt see this option. so i moved it up

Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 08:10:34 -05:00
AvaLilac
b18aacba42
Update forge.config.ts Fix
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 08:06:49 -05:00
AvaLilac
f535e1249c
Update main.ts (fixed
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 08:06:03 -05:00
AvaLilac
cfeb0637e6
FX
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 07:27:05 -05:00
AvaLilac
3256318e1b
PLEase fix
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 07:26:49 -05:00
AvaLilac
fb6a811b63
Update forge.config.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 07:17:36 -05:00
AvaLilac
db426339ea
Update main.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 07:13:46 -05:00
AvaLilac
4f4f475ee7
Update forge.config.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 07:12:48 -05:00
AvaLilac
c4d24aa88b
Adds Faviorite Gifs/Links/videos support to Stoat
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 06:48:01 -05:00
AvaLilac
d57e155e63
Update main.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 06:47:00 -05:00
AvaLilac
ccf04bfce9
Update forge.config.ts
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-26 06:46:19 -05:00
AvaLilac
21833d3acf
Update user instructions
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-24 15:38:40 -05:00
AvaLilac
4e00fe9399
ADDED THE CLIENT
Makes sure the client even exists. idk why i forgot to add the MOD ITSELF

Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-24 12:16:54 -05:00
AvaLilac
c973707d99
Update README.md
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-24 12:05:33 -05:00
AvaLilac
9a6fbbf8f0
Update README.md
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-24 12:05:02 -05:00
AvaLilac
1ab4b95570
Update README.md
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-24 12:03:48 -05:00
AvaLilac
8dfb49c8fe
Update README.md
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-24 12:03:08 -05:00
AvaLilac
d32fd3b7df
Put inject.js into the same folder as main.js
This is important as without this. Avia client will not load 

Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-24 10:57:40 -05:00
AvaLilac
fa3bdc7019
Inject Avia client via inject.js
Signed-off-by: AvaLilac <amyshimplays@gmail.com>
2026-02-24 10:56:52 -05:00
50 changed files with 7881 additions and 291 deletions

View file

@ -1,13 +1,20 @@
name: Build and Release Sanctum
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
permissions:
contents: write
jobs:
build:
name: Build App
runs-on: ubuntu-latest
runs-on: docker
steps:
- name: Checkout
@ -16,13 +23,80 @@ jobs:
- name: Checkout assets
run: git -c submodule."assets".update=checkout submodule update --init assets
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Setup Node
uses: actions/setup-node@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
node-version: 20
- name: Install dependencies
- name: Install pnpm
run: npm install -g pnpm@10.18.1
- name: Install System Dependencies
run: |
apt-get update
apt-get install -y zip jq curl
- name: Install Project Dependencies
run: pnpm install
- name: Build
run: pnpm run package
- name: Build Linux & Windows
# We run both; if one fails, the whole job stops before reaching release logic
run: |
pnpm exec electron-forge make --platform linux --arch x64 --targets @electron-forge/maker-zip
pnpm exec electron-forge make --platform win32 --arch x64 --targets @electron-forge/maker-zip
- name: Create or Update Release
if: startsWith(github.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
BASE_URL: "https://git.mithraic.cloud/api/v1/repos/${{ github.repository }}"
run: |
echo "Processing release for $TAG..."
# 1. Try to get existing release ID
RELEASE_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$BASE_URL/releases/tags/$TAG" | jq -r '.id // empty')
# 2. Create release if it doesn't exist
if [ -z "$RELEASE_ID" ]; then
echo "Creating new release..."
RELEASE_ID=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Sanctum $TAG\",\"draft\":false,\"prerelease\":false}" | jq -r '.id')
fi
if [ "$RELEASE_ID" == "null" ] || [ -z "$RELEASE_ID" ]; then
echo "::error::Could not determine Release ID"
exit 1
fi
echo "Release ID: $RELEASE_ID"
# 3. Upload loop with conflict handling
find out/make -type f -name "*.zip" | while read FILE; do
NAME=$(basename "$FILE")
echo "Targeting asset: $NAME"
# Check for existing asset with same name
EXISTING_ASSET_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$BASE_URL/releases/$RELEASE_ID/assets" | \
jq -r ".[] | select(.name==\"$NAME\") | .id // empty")
if [ ! -z "$EXISTING_ASSET_ID" ]; then
echo "Asset already exists (ID: $EXISTING_ASSET_ID). Deleting to avoid conflict..."
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID"
fi
echo "Uploading $NAME..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$FILE")
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
echo "✅ Successfully uploaded $NAME"
else
echo "❌ Failed to upload $NAME (HTTP $HTTP_CODE)"
exit 1
fi
done

View file

@ -1,88 +0,0 @@
name: Release Please
on:
push:
branches: [main] # updates/opens the release PR when commits land on main
workflow_dispatch:
permissions:
contents: write
pull-requests: write
id-token: write
concurrency:
group: release-please
cancel-in-progress: true
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.rp.outputs.release_created }}
steps:
- id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_STOAT_RELEASE_APP_ID }}
private-key: ${{ secrets.GH_STOAT_RELEASE_APP_PRIVATE_KEY }}
- id: rp
uses: googleapis/release-please-action@v4
with:
token: ${{ steps.app-token.outputs.token }}
config-file: release-please-config.json
publish-release:
name: Publish App
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ${{ matrix.os }}
permissions:
contents: write
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout assets
run: git -c submodule."assets".update=checkout submodule update --init assets
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Publish
run: |
pnpm run publish
env:
PLATFORM: ${{ matrix.os }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish macOS x64
if: matrix.os == 'macos-latest'
run: pnpm run publish --arch=x64
env:
PLATFORM: ${{ matrix.os }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Linux arm64
if: matrix.os == 'ubuntu-latest'
run: pnpm run publish --arch=arm64
env:
PLATFORM: ${{ matrix.os }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -11,7 +11,7 @@ on:
jobs:
release-webhook:
name: Send Release Webhook
runs-on: ubuntu-latest
runs-on: docker
steps:
- name: Send release notification webhook
@ -23,4 +23,4 @@ jobs:
RELEASE_URL="https://github.com/${REPOSITORY}/releases/tag/${TAG_NAME}"
curl -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"content\": \"$RELEASE_URL\"}"
-d "{\"content\": \"$RELEASE_URL\"}"

View file

@ -14,7 +14,7 @@ on:
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
runs-on: docker
permissions:
pull-requests: read
steps:

12
GEMINI.md Normal file
View file

@ -0,0 +1,12 @@
# Agent Mandates
## Versioning and Release Workflow
Before every `git push` that includes code changes, you MUST perform the following steps:
1. **Bump Version:** Increment the version in `package.json` (both `version` and `aviaVersion`).
2. **Update Branding:** If a version string is hardcoded in UI plugins, update it to match the new version.
3. **Migration Logic:** Update any migration logic in plugins to ensure users on the previous version are automatically updated to the new default.
4. **Tagging:** Create the git tag corresponding to the new version with a 'v' prefix (e.g., `git tag v1.0.x`).
5. **Push:** Push both the branch and the tags to the remote repository (`git push origin main --tags`).
This ensures the internal app state matches the release tag and prevents auto-updater loops.

View file

@ -1,15 +1,10 @@
<div align="center">
<h1>
Stoat for Desktop
[![Stars](https://img.shields.io/github/stars/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/stargazers)
[![Forks](https://img.shields.io/github/forks/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/network/members)
[![Pull Requests](https://img.shields.io/github/issues-pr/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/pulls)
[![Issues](https://img.shields.io/github/issues/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/issues)
[![Contributors](https://img.shields.io/github/contributors/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/graphs/contributors)
[![License](https://img.shields.io/github/license/stoatchat/for-desktop?style=flat-square&logoColor=white)](https://github.com/stoatchat/for-desktop/blob/main/LICENSE)
Avia Client for Desktop
"stoat desktop"
</h1>
Application for Windows, macOS, and Linux.
<img width="256" height="256" alt="aurora" src="https://github.com/user-attachments/assets/dc3adfa3-ce3b-41ef-bdfd-9ca66d333e24" /><br />
Application for Windows, macOS, and Linux. now with avia client injected
</div>
<br/>
@ -19,7 +14,8 @@ Application for Windows, macOS, and Linux.
<img src="https://repology.org/badge/vertical-allrepos/stoat-desktop.svg" alt="Packaging status" align="right">
</a>
- All downloads and instructions for Stoat can be found on our [Website](https://stoat.chat/download).
- If you use the Browser you can find FireFox/Chrome/Userscript Builds at [BrowserBuilds](https://github.com/AvaLilac/Ava-Client).
- Though I reccomend you use Userscript if on Chrome Based Browsers. As Plugins do not exist due to browser limits in Extensions. Userscript fine though
## Development Guide
@ -37,18 +33,22 @@ Then proceed to setup:
```bash
# clone the repository
git clone --recursive https://github.com/stoatchat/for-desktop stoat-for-desktop
cd stoat-for-desktop
git clone --recursive https://github.com/AvaLilac/for-desktop aviaclient-for-desktop
# clone the repository (If you are building from developer branch. Which is not always stable)
git clone -b dev --recursive https://github.com/AvaLilac/for-desktop aviaclient-for-desktop
# CD into the directory
cd aviaclient-for-desktop
# install all packages
pnpm i --frozen-lockfile
# start the application
pnpm start
# ... or build the bundle
# update the assets. if you are using stoat's
git -c submodule."assets".update=checkout submodule update --init assets
# build the bundle
pnpm package
# ... or build all distributables
pnpm make
```
Various useful commands for development testing:
@ -72,13 +72,4 @@ pnpm run:nix --force-server=http://localhost:5173
# Electron Forge where system Electron is
```
### Pulling in Stoat's assets
If you want to pull in Stoat brand assets after pulling, run the following:
```bash
# update the assets
git -c submodule."assets".update=checkout submodule update --init assets
```
Currently, this is required to build, any forks are expected to provide their own assets.
`VCSounds.js` ships as a built-in local plugin now, so it is seeded automatically on launch and cannot be accidentally removed from the release install.

611
about.html Normal file
View file

@ -0,0 +1,611 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>About Aviaclient</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500&family=Google+Sans+Display:wght@400;500&family=Roboto:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--md-primary: #adc6ff;
--md-primary-container: #003e9c;
--md-on-primary-container: #d8e2ff;
--md-surface: #131318;
--md-surface-container: #1e1e24;
--md-surface-container-high: #28282e;
--md-outline-variant: #46464f;
--md-on-surface: #e4e1e9;
--md-on-surface-variant: #c7c5d0;
--md-secondary: #bec6dc;
--md-secondary-container: #3f4759;
--md-on-secondary-container: #dbe2f9;
--md-tertiary-container: #5c3349;
--md-tertiary: #efb8c8;
}
html,
body {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
background: var(--md-surface);
color: var(--md-on-surface);
font-family: "Roboto", sans-serif;
font-size: 14px;
display: flex;
flex-direction: column;
}
.topbar {
padding: 14px 24px 10px;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.topbar-title {
font-family: "Google Sans", sans-serif;
font-size: 18px;
font-weight: 400;
color: var(--md-on-surface);
}
.layout {
flex: 1 1 0;
min-height: 0;
display: flex;
overflow: hidden;
}
.sidebar {
width: clamp(160px, 26%, 240px);
flex-shrink: 0;
background: var(--md-surface-container);
border-right: 1px solid rgba(255, 255, 255, 0.04);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 20px;
gap: 14px;
overflow-y: auto;
overflow-x: hidden;
animation: fadeIn 0.25s ease both;
}
.sidebar::-webkit-scrollbar {
width: 4px;
}
.sidebar::-webkit-scrollbar-track {
background: transparent;
}
.sidebar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
}
.sidebar-text {
text-align: center;
min-width: 0;
width: 100%;
}
.sidebar-text h1 {
font-family: "Google Sans Display", "Google Sans", sans-serif;
font-size: clamp(15px, 2.2vw, 21px);
font-weight: 400;
color: var(--md-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-text p {
margin-top: 8px;
font-size: clamp(11px, 1.3vw, 13px);
color: var(--md-on-surface-variant);
line-height: 1.55;
overflow-wrap: break-word;
}
#stoatVersion {
position: absolute;
bottom: 15px;
margin-left: 45px;
font-size: 12px;
color: #888;
}
.sidebar #logo {
position: absolute;
width: 200px;
left: 25px;
top: 75px;
}
.version-chip {
display: inline-flex;
align-items: center;
margin-top: 8px;
padding: 3px 12px;
background: var(--md-secondary-container);
color: var(--md-on-secondary-container);
border-radius: 999px;
font-family: "Google Sans", sans-serif;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.main {
flex: 1 1 0;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
}
.main::-webkit-scrollbar {
width: 6px;
}
.main::-webkit-scrollbar-track {
background: transparent;
}
.main::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.main::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.18);
}
.section-label {
font-family: "Google Sans", sans-serif;
font-size: 11px;
font-weight: 500;
color: var(--md-primary);
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 12px 4px 6px;
flex-shrink: 0;
}
.section-label:first-child {
padding-top: 4px;
}
.card {
background: var(--md-surface-container);
border-radius: 16px;
margin-bottom: 8px;
flex-shrink: 0;
}
.card {
position: relative;
}
.list-item:first-child {
border-radius: 16px 16px 0 0;
}
.list-item:last-child {
border-radius: 0 0 16px 16px;
}
.list-item:only-child {
border-radius: 16px;
}
.license-icon-row {
border-radius: 16px 16px 0 0;
}
.license-body {
border-radius: 0 0 16px 16px;
}
.list-item {
display: flex;
align-items: center;
padding: 11px 14px;
gap: 12px;
cursor: pointer;
transition: background 0.15s;
position: relative;
text-decoration: none;
color: inherit;
min-width: 0;
}
.list-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.list-item:active {
background: rgba(255, 255, 255, 0.09);
}
.list-item + .list-item::before {
content: "";
position: absolute;
top: 0;
left: 58px;
right: 0;
height: 1px;
background: var(--md-outline-variant);
opacity: 0.35;
}
.item-icon {
width: 36px;
height: 36px;
border-radius: 999px;
background: var(--md-primary-container);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.item-icon svg {
width: 18px;
height: 18px;
fill: var(--md-primary);
}
.item-icon.secondary {
background: var(--md-secondary-container);
}
.item-icon.secondary svg {
fill: var(--md-secondary);
}
.item-icon.tertiary {
background: var(--md-tertiary-container);
}
.item-icon.tertiary svg {
fill: var(--md-tertiary);
}
.item-text {
flex: 1;
min-width: 0;
}
.item-title {
font-family: "Google Sans", sans-serif;
font-size: 14px;
font-weight: 400;
color: var(--md-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-sub {
font-size: 12px;
color: var(--md-on-surface-variant);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-trail {
flex-shrink: 0;
}
.item-trail svg {
width: 16px;
height: 16px;
fill: var(--md-on-surface-variant);
opacity: 0.45;
display: block;
}
.item-badge {
font-family: "Google Sans", sans-serif;
font-size: 11px;
font-weight: 500;
color: var(--md-on-surface-variant);
background: var(--md-surface-container-high);
border-radius: 999px;
padding: 2px 10px;
white-space: nowrap;
}
.license-icon-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.license-body {
padding: 10px 14px 14px;
}
.license-body p {
font-size: 13px;
color: var(--md-on-surface-variant);
line-height: 1.65;
overflow-wrap: break-word;
}
.license-body a {
color: var(--md-primary);
text-decoration: none;
font-weight: 500;
}
.license-body a:hover {
text-decoration: underline;
}
@media (max-width: 460px) {
.layout {
flex-direction: column;
overflow-y: auto;
}
.sidebar {
width: 100%;
flex-direction: row;
justify-content: flex-start;
padding: 14px 16px;
gap: 14px;
border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
overflow: visible;
}
.sidebar-text {
text-align: left;
}
.sidebar-text p {
display: none;
}
.main {
overflow-y: visible;
}
}
.sidebar {
animation: fadeIn 0.25s ease both;
}
.main > * {
animation: slideUp 0.25s ease both;
}
.main > *:nth-child(1) {
animation-delay: 0.06s;
}
.main > *:nth-child(2) {
animation-delay: 0.1s;
}
.main > *:nth-child(3) {
animation-delay: 0.13s;
}
.main > *:nth-child(4) {
animation-delay: 0.16s;
}
.main > *:nth-child(5) {
animation-delay: 0.19s;
}
.main > *:nth-child(6) {
animation-delay: 0.22s;
}
.main > *:nth-child(7) {
animation-delay: 0.25s;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
</head>
<body>
<div class="topbar">
<span class="topbar-title">About</span>
</div>
<div class="layout">
<div class="sidebar">
<img id="logo" title="Aurora" src="avia_assets/icon.png" />
<div class="sidebar-text">
<h1>Aviaclient</h1>
<div id="aviaVersion" class="version-chip">TBD</div>
<p>
A custom desktop client with enhancements and additional features.
</p>
<div id="stoatVersion">Based on Stoat</div>
</div>
</div>
<div class="main">
<div class="section-label">Links</div>
<div class="card">
<a
class="list-item"
href="https://github.com/AvaLilac/for-desktop"
target="_blank"
>
<div class="item-icon">
<svg viewBox="0 0 24 24">
<path
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2z"
/>
</svg>
</div>
<div class="item-text">
<div class="item-title">Source code</div>
<div class="item-sub">github.com/AvaLilac/for-desktop</div>
</div>
<div class="item-trail">
<svg viewBox="0 0 24 24">
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z" />
</svg>
</div>
</a>
<a
class="list-item"
href="https://github.com/AvaLilac/for-desktop/issues"
target="_blank"
>
<div class="item-icon secondary">
<svg viewBox="0 0 24 24">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
/>
</svg>
</div>
<div class="item-text">
<div class="item-title">Issues</div>
<div class="item-sub">Report bugs or request features</div>
</div>
<div class="item-trail">
<svg viewBox="0 0 24 24">
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z" />
</svg>
</div>
</a>
<a
class="list-item"
href="https://github.com/AvaLilac/for-desktop/tree/dev"
target="_blank"
>
<div class="item-icon tertiary">
<svg viewBox="0 0 24 24">
<path
d="M17 12h-5v5h5v-5zM16 1v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-1V1h-2zm3 18H5V8h14v11z"
/>
</svg>
</div>
<div class="item-text">
<div class="item-title">Dev branch</div>
<div class="item-sub">Latest development changes</div>
</div>
<div class="item-trail">
<svg viewBox="0 0 24 24">
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z" />
</svg>
</div>
</a>
</div>
<div class="section-label">License</div>
<div class="card">
<div class="license-icon-row">
<div class="item-icon tertiary">
<svg viewBox="0 0 24 24">
<path
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"
/>
</svg>
</div>
<div class="item-text">
<div class="item-title">GNU AGPL v3.0</div>
</div>
</div>
<div class="license-body">
<p>
Licensed under the
<a
href="https://www.gnu.org/licenses/agpl-3.0.txt"
target="_blank"
>GNU Affero General Public License v3.0</a
>. You are free to use, modify, and distribute this software under
the same license.
</p>
</div>
</div>
<div class="section-label">Open source</div>
<div class="card">
<a
class="list-item"
href="https://github.com/electron/electron"
target="_blank"
>
<div class="item-icon">
<svg viewBox="0 0 24 24">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"
/>
</svg>
</div>
<div class="item-text">
<div class="item-title">Electron</div>
<div class="item-sub">Desktop runtime framework</div>
</div>
<div class="item-trail">
<span class="item-badge">runtime</span>
</div>
</a>
<a
class="list-item"
href="https://github.com/electron-userland/electron-builder"
target="_blank"
>
<div class="item-icon secondary">
<svg viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
<div class="item-text">
<div class="item-title">Electron Forge</div>
<div class="item-sub">Packaging and distribution</div>
</div>
<div class="item-trail">
<span class="item-badge">packaging</span>
</div>
</a>
<a
class="list-item"
href="https://github.com/discordjs/RPC"
target="_blank"
>
<div class="item-icon tertiary">
<svg viewBox="0 0 24 24">
<path
d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"
/>
</svg>
</div>
<div class="item-text">
<div class="item-title">discord-rpc</div>
<div class="item-sub">Discord Rich Presence integration</div>
</div>
<div class="item-trail"><span class="item-badge">rpc</span></div>
</a>
</div>
</div>
</div>
<script>
window.addEventListener("load", () => {
const aviaElem = document.getElementById("aviaVersion");
if (aviaElem) {
aviaElem.textContent = window.native.versions.aviaClient();
}
const stoatElem = document.getElementById("stoatVersion");
if (stoatElem) {
stoatElem.textContent = `Based on Stoat ${window.native.versions.desktop()}`;
}
});
</script>
</body>
</html>

BIN
avia_assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

View file

@ -0,0 +1,75 @@
{
"fill-specializations" : [
{
"value" : {
"solid" : "display-p3:0.33912,0.12908,0.93430,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "display-p3:0.15333,0.05837,0.42244,1.00000",
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
}
}
],
"groups" : [
{
"layers" : [
{
"blend-mode-specializations" : [
{
"value" : "normal"
},
{
"appearance" : "tinted",
"value" : "normal"
}
],
"fill-specializations" : [
{
"value" : "automatic"
},
{
"appearance" : "tinted",
"value" : "none"
}
],
"glass" : false,
"image-name" : "IMG_0248.PNG",
"name" : "IMG_0248",
"position" : {
"scale" : 0.25,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

BIN
avia_assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

29
avia_core/ButtonFix.js Normal file
View file

@ -0,0 +1,29 @@
(function () {
if (window.__BUTTON_FIX__) return;
window.__BUTTON_FIX__ = true;
function uninjectButton(button){
if(button){
button.parentElement.removeChild(button)
}
}
const observer = new MutationObserver(()=>{
let balls = [];
document.querySelectorAll('div[class=\'flex-sh_0 d_flex ai_end jc_center w_42px\']').forEach(element=>{
if(element.id?.includes('avia')){
balls.push(element)
}
})
const gifSpan = [...document.querySelectorAll("span.material-symbols-outlined")]
.find(s => s.textContent.trim() === "gif");
if(!gifSpan){
balls.forEach(element=>{
uninjectButton(element)
})
}
});
observer.observe(document.documentElement, {childList: true, subtree: true })
})();

688
avia_core/LocalPlugins.js Normal file
View file

@ -0,0 +1,688 @@
(function () {
if (window.__AVIA_LOCAL_PLUGINS_LOADED__) return;
window.__AVIA_LOCAL_PLUGINS_LOADED__ = true;
const STORAGE_KEY = "avia_local_plugins";
const BUILTIN_SEED = Array.isArray(window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__)
? window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__
: [];
const runningLocalPlugins = {};
const localPluginErrors = {};
const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
function upsertBuiltinLocalPlugins() {
if (!BUILTIN_SEED.length) return;
const plugins = getLocalPlugins();
let dirty = false;
for (const builtin of BUILTIN_SEED) {
const next = {
id: builtin.id,
name: builtin.name,
code: builtin.code || "",
enabled: true,
locked: true,
builtin: true,
};
const existingIndex = plugins.findIndex((plugin) =>
plugin.id === next.id || plugin.name === next.name
);
if (existingIndex >= 0) {
const current = plugins[existingIndex];
const merged = {
...current,
...next,
enabled: true,
locked: true,
builtin: true,
};
if (
JSON.stringify(current) !== JSON.stringify(merged)
) {
plugins[existingIndex] = merged;
dirty = true;
}
} else {
plugins.push(next);
dirty = true;
}
}
if (dirty) setLocalPlugins(plugins);
}
function preloadMonaco() {
return new Promise(resolve => {
if (window.monaco) return resolve();
const loader = document.createElement("script");
loader.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js";
loader.onload = function () {
require.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs" } });
require(["vs/editor/editor.main"], () => resolve());
};
document.head.appendChild(loader);
});
}
function runLocalPlugin(plugin) {
stopLocalPlugin(plugin);
try {
const script = document.createElement("script");
script.textContent = plugin.code || "";
script.dataset.localPluginId = plugin.id;
document.body.appendChild(script);
runningLocalPlugins[plugin.id] = script;
delete localPluginErrors[plugin.id];
} catch (e) {
localPluginErrors[plugin.id] = true;
}
renderLocalPanel();
}
function stopLocalPlugin(plugin) {
const script = runningLocalPlugins[plugin.id];
if (!script) return;
script.remove();
delete runningLocalPlugins[plugin.id];
delete localPluginErrors[plugin.id];
renderLocalPanel();
}
async function openEditorPanel(plugin, onSave) {
await preloadMonaco();
const existing = document.getElementById("avia-local-editor-panel");
if (existing) existing.remove();
const panel = document.createElement("div");
panel.id = "avia-local-editor-panel";
Object.assign(panel.style, {
position: "fixed",
bottom: "24px",
left: "24px",
width: "680px",
height: "460px",
background: "var(--md-sys-color-surface, #1e1e1e)",
borderRadius: "16px",
boxShadow: "0 8px 28px rgba(0,0,0,0.35)",
zIndex: "9999999",
display: "flex",
flexDirection: "column",
overflow: "hidden",
border: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(12px)"
});
const header = document.createElement("div");
header.textContent = `Editing: ${plugin.name}`;
Object.assign(header.style, {
padding: "14px 16px",
fontWeight: "600",
fontSize: "14px",
background: "var(--md-sys-color-surface-container, rgba(255,255,255,0.04))",
borderBottom: "1px solid rgba(255,255,255,0.08)",
cursor: "move",
color: "#fff",
flex: "0 0 auto"
});
const closeBtn = document.createElement("div");
closeBtn.textContent = "✕";
Object.assign(closeBtn.style, {
position: "absolute",
top: "12px",
right: "16px",
cursor: "pointer",
opacity: "0.7",
color: "#fff",
zIndex: "1"
});
closeBtn.onmouseenter = () => closeBtn.style.opacity = "1";
closeBtn.onmouseleave = () => closeBtn.style.opacity = "0.7";
closeBtn.onclick = () => panel.remove();
const toolbar = document.createElement("div");
Object.assign(toolbar.style, {
padding: "8px 16px",
display: "flex",
gap: "8px",
borderBottom: "1px solid rgba(255,255,255,0.08)",
flex: "0 0 auto"
});
const saveBtn = document.createElement("button");
saveBtn.textContent = "💾 Save";
styleEditorBtn(saveBtn, "#2d6a4f");
const saveRunBtn = document.createElement("button");
saveRunBtn.textContent = "▶ Save & Run";
styleEditorBtn(saveRunBtn, "#1b4332");
toolbar.appendChild(saveBtn);
toolbar.appendChild(saveRunBtn);
const editorContainer = document.createElement("div");
editorContainer.style.flex = "1";
panel.appendChild(header);
panel.appendChild(closeBtn);
panel.appendChild(toolbar);
panel.appendChild(editorContainer);
document.body.appendChild(panel);
const editor = monaco.editor.create(editorContainer, {
value: plugin.code || "// Write your plugin code here\n",
language: "javascript",
theme: "vs-dark",
automaticLayout: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
wordWrap: "on"
});
saveBtn.onclick = () => {
onSave(editor.getValue(), false);
saveBtn.textContent = "✓ Saved";
setTimeout(() => saveBtn.textContent = "💾 Save", 1200);
};
saveRunBtn.onclick = () => {
onSave(editor.getValue(), true);
saveRunBtn.textContent = "✓ Ran!";
setTimeout(() => saveRunBtn.textContent = "▶ Save & Run", 1200);
};
enableEditorDrag(panel, header);
}
function styleEditorBtn(btn, bg) {
Object.assign(btn.style, {
padding: "5px 14px",
borderRadius: "8px",
border: "none",
background: bg || "rgba(255,255,255,0.1)",
color: "#fff",
cursor: "pointer",
fontSize: "12px",
fontWeight: "500"
});
btn.onmouseenter = () => btn.style.opacity = "0.8";
btn.onmouseleave = () => btn.style.opacity = "1";
}
function enableEditorDrag(panel, handle) {
let isDragging = false, offsetX, offsetY;
handle.addEventListener("mousedown", e => {
isDragging = true;
offsetX = e.clientX - panel.offsetLeft;
offsetY = e.clientY - panel.offsetTop;
document.body.style.userSelect = "none";
});
document.addEventListener("mouseup", () => {
isDragging = false;
document.body.style.userSelect = "";
});
document.addEventListener("mousemove", e => {
if (!isDragging) return;
panel.style.left = (e.clientX - offsetX) + "px";
panel.style.top = (e.clientY - offsetY) + "px";
panel.style.right = "auto";
panel.style.bottom = "auto";
});
}
function toggleLocalPanel() {
let panel = document.getElementById("avia-local-plugins-panel");
if (panel) {
panel.style.display = panel.style.display === "none" ? "flex" : "none";
return;
}
panel = document.createElement("div");
panel.id = "avia-local-plugins-panel";
Object.assign(panel.style, {
position: "fixed",
bottom: "24px",
right: "560px",
width: "520px",
height: "460px",
background: "var(--md-sys-color-surface, #1e1e1e)",
color: "var(--md-sys-color-on-surface, #fff)",
borderRadius: "16px",
boxShadow: "0 8px 28px rgba(0,0,0,0.35)",
zIndex: "999999",
display: "flex",
flexDirection: "column",
overflow: "hidden",
border: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(12px)"
});
const header = document.createElement("div");
header.textContent = "Local Plugins";
Object.assign(header.style, {
padding: "14px 16px",
fontWeight: "600",
fontSize: "14px",
background: "var(--md-sys-color-surface-container, rgba(255,255,255,0.04))",
borderBottom: "1px solid rgba(255,255,255,0.08)",
cursor: "move"
});
const closeBtn = document.createElement("div");
closeBtn.textContent = "✕";
Object.assign(closeBtn.style, {
position: "absolute",
top: "12px",
right: "16px",
cursor: "pointer",
opacity: "0.7"
});
closeBtn.onclick = () => panel.style.display = "none";
const controlsBar = document.createElement("div");
Object.assign(controlsBar.style, {
padding: "12px 16px",
display: "flex",
gap: "8px",
alignItems: "center",
borderBottom: "1px solid rgba(255,255,255,0.08)",
flex: "0 0 auto"
});
const nameInput = document.createElement("input");
nameInput.placeholder = "Plugin name";
styleLocalInput(nameInput);
nameInput.style.flex = "1";
const addBtn = document.createElement("button");
addBtn.textContent = "+ New";
styleLocalBtn(addBtn);
addBtn.onclick = () => {
const name = nameInput.value.trim();
if (!name) return;
const plugins = getLocalPlugins();
const newPlugin = {
id: "local_" + Date.now(),
name,
code: "// " + name + "\n",
enabled: false
};
plugins.push(newPlugin);
setLocalPlugins(plugins);
nameInput.value = "";
renderLocalPanel();
};
const importBtn = document.createElement("button");
importBtn.textContent = "Import";
styleLocalBtn(importBtn, "#2d6a4f");
importBtn.onmouseenter = () => importBtn.style.opacity = "0.75";
importBtn.onmouseleave = () => importBtn.style.opacity = "1";
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".js";
fileInput.multiple = true;
fileInput.style.display = "none";
importBtn.onclick = () => fileInput.click();
fileInput.onchange = async () => {
const files = [...fileInput.files];
if (!files.length) return;
const plugins = getLocalPlugins();
for (const file of files) {
const text = await file.text();
const name = file.name.replace(/\.js$/i, "");
plugins.push({
id: "local_" + Date.now() + "_" + Math.random(),
name,
code: text,
enabled: false
});
}
setLocalPlugins(plugins);
fileInput.value = "";
renderLocalPanel();
};
controlsBar.appendChild(nameInput);
controlsBar.appendChild(addBtn);
controlsBar.appendChild(importBtn);
controlsBar.appendChild(fileInput);
const content = document.createElement("div");
content.id = "avia-local-plugins-content";
Object.assign(content.style, {
flex: "1",
overflow: "auto",
padding: "16px"
});
panel.appendChild(header);
panel.appendChild(closeBtn);
panel.appendChild(controlsBar);
panel.appendChild(content);
document.body.appendChild(panel);
const dropOverlay = document.createElement("div");
dropOverlay.textContent = "Import JS files";
Object.assign(dropOverlay.style, {
position: "absolute",
inset: "0",
background: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "18px",
fontWeight: "600",
color: "#fff",
opacity: "0",
pointerEvents: "none",
transition: "opacity 0.15s ease",
borderRadius: "16px"
});
panel.appendChild(dropOverlay);
let dragDepth = 0;
panel.addEventListener("dragenter", e => {
e.preventDefault();
e.stopPropagation();
dragDepth++;
dropOverlay.style.opacity = "1";
panel.style.border = "1px dashed rgba(255,255,255,0.4)";
});
panel.addEventListener("dragover", e => {
e.preventDefault();
e.stopPropagation();
});
panel.addEventListener("dragleave", e => {
e.preventDefault();
e.stopPropagation();
dragDepth--;
if (dragDepth <= 0) {
dropOverlay.style.opacity = "0";
panel.style.border = "1px solid rgba(255,255,255,0.08)";
dragDepth = 0;
}
});
panel.addEventListener("drop", async e => {
e.preventDefault();
e.stopPropagation();
dropOverlay.style.opacity = "0";
panel.style.border = "1px solid rgba(255,255,255,0.08)";
dragDepth = 0;
const files = [...e.dataTransfer.files].filter(f => f.name.endsWith(".js"));
if (!files.length) return;
const plugins = getLocalPlugins();
for (const file of files) {
const text = await file.text();
const name = file.name.replace(/\.js$/i, "");
plugins.push({
id: "local_" + Date.now() + "_" + Math.random(),
name,
code: text,
enabled: false
});
}
setLocalPlugins(plugins);
renderLocalPanel();
});
let isDragging = false, offsetX, offsetY;
header.addEventListener("mousedown", e => {
isDragging = true;
offsetX = e.clientX - panel.offsetLeft;
offsetY = e.clientY - panel.offsetTop;
});
document.addEventListener("mouseup", () => isDragging = false);
document.addEventListener("mousemove", e => {
if (!isDragging) return;
panel.style.left = (e.clientX - offsetX) + "px";
panel.style.top = (e.clientY - offsetY) + "px";
panel.style.right = "auto";
panel.style.bottom = "auto";
});
renderLocalPanel();
}
function renderLocalPanel() {
const content = document.getElementById("avia-local-plugins-content");
if (!content) return;
content.innerHTML = "";
const plugins = getLocalPlugins();
if (plugins.length === 0) {
const empty = document.createElement("div");
empty.textContent = "No local plugins yet. Add one above.";
empty.style.opacity = "0.4";
empty.style.fontSize = "13px";
content.appendChild(empty);
return;
}
plugins.forEach((plugin, index) => {
const isRunning = !!runningLocalPlugins[plugin.id];
const hasError = !!localPluginErrors[plugin.id];
const isBuiltin = !!plugin.locked || !!plugin.builtin;
const row = document.createElement("div");
Object.assign(row.style, {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "12px",
padding: "10px 12px",
borderRadius: "10px",
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.06)"
});
const left = document.createElement("div");
Object.assign(left.style, { display: "flex", alignItems: "center", gap: "10px" });
const statusDot = document.createElement("div");
Object.assign(statusDot.style, { width: "10px", height: "10px", borderRadius: "50%", flexShrink: "0" });
if (hasError) {
statusDot.style.background = "#ff4d4d";
statusDot.style.boxShadow = "0 0 6px #ff4d4d";
} else if (isRunning) {
statusDot.style.background = "#4dff88";
statusDot.style.boxShadow = "0 0 6px #4dff88";
} else {
statusDot.style.background = "#777";
}
const name = document.createElement("div");
name.textContent = plugin.name;
name.style.fontSize = "13px";
left.appendChild(statusDot);
left.appendChild(name);
if (isBuiltin) {
const badge = document.createElement("div");
badge.textContent = "Built-in";
Object.assign(badge.style, {
fontSize: "10px",
padding: "2px 7px",
borderRadius: "999px",
background: "rgba(120,170,255,0.16)",
color: "#a9c4ff",
border: "1px solid rgba(120,170,255,0.28)",
marginLeft: "2px",
textTransform: "uppercase",
letterSpacing: "0.06em",
});
left.appendChild(badge);
}
const controls = document.createElement("div");
Object.assign(controls.style, { display: "flex", gap: "6px" });
if (!isBuiltin) {
const editBtn = document.createElement("button");
editBtn.textContent = "✏ Edit";
styleLocalBtn(editBtn, "rgba(100,140,255,0.2)");
editBtn.onclick = () => {
openEditorPanel(plugin, (newCode, andRun) => {
const all = getLocalPlugins();
const target = all.find(p => p.id === plugin.id);
if (target) {
target.code = newCode;
plugin.code = newCode;
setLocalPlugins(all);
}
if (andRun) {
plugin.enabled = true;
if (target) target.enabled = true;
setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p));
runLocalPlugin(plugin);
}
renderLocalPanel();
});
};
const toggleBtn = document.createElement("button");
toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable";
styleLocalBtn(toggleBtn);
toggleBtn.onclick = () => {
const all = getLocalPlugins();
const target = all.find(p => p.id === plugin.id);
if (!target) return;
target.enabled = !target.enabled;
plugin.enabled = target.enabled;
setLocalPlugins(all);
if (target.enabled) runLocalPlugin(plugin);
else stopLocalPlugin(plugin);
renderLocalPanel();
};
const removeBtn = document.createElement("button");
removeBtn.textContent = "✕";
styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)");
removeBtn.onclick = () => {
stopLocalPlugin(plugin);
const editorPanel = document.getElementById("avia-local-editor-panel");
if (editorPanel) editorPanel.remove();
const all = getLocalPlugins();
all.splice(all.findIndex(p => p.id === plugin.id), 1);
setLocalPlugins(all);
renderLocalPanel();
};
controls.appendChild(editBtn);
controls.appendChild(toggleBtn);
controls.appendChild(removeBtn);
}
row.appendChild(left);
row.appendChild(controls);
content.appendChild(row);
});
}
function styleLocalInput(input) {
Object.assign(input.style, {
padding: "6px 8px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.1)",
background: "rgba(255,255,255,0.05)",
color: "#fff",
fontSize: "13px"
});
}
function styleLocalBtn(btn, bg) {
Object.assign(btn.style, {
padding: "5px 12px",
borderRadius: "8px",
border: "none",
background: bg || "rgba(255,255,255,0.08)",
color: "#fff",
cursor: "pointer",
fontSize: "12px",
whiteSpace: "nowrap"
});
btn.onmouseenter = () => btn.style.opacity = "0.75";
btn.onmouseleave = () => btn.style.opacity = "1";
}
function injectLocalButton() {
if (document.getElementById("avia-local-plugins-btn")) return;
const appearanceBtn = [...document.querySelectorAll("a")]
.find(a => a.textContent.trim() === "Appearance");
if (!appearanceBtn) return;
const aviaPluginsBtn = document.getElementById("stoat-fake-plugins");
if (!aviaPluginsBtn) return;
const localBtn = appearanceBtn.cloneNode(true);
localBtn.id = "avia-local-plugins-btn";
const textNode = [...localBtn.querySelectorAll("div")]
.find(d => d.children.length === 0 && d.textContent.trim() === "Appearance");
if (textNode) textNode.textContent = "(Sanctum) Local Plugins";
const oldSvg = localBtn.querySelector("svg");
if (oldSvg) oldSvg.remove();
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", "20");
svg.setAttribute("height", "20");
svg.setAttribute("fill", "currentColor");
svg.style.marginRight = "8px";
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", "M20.5 11H19V7a2 2 0 00-2-2h-4V3.5a2.5 2.5 0 00-5 0V5H4a2 2 0 00-2 2v3.8h1.5c1.5 0 2.7 1.2 2.7 2.7S5 16.2 3.5 16.2H2V20a2 2 0 002 2h3.8v-1.5c0-1.5 1.2-2.7 2.7-2.7s2.7 1.2 2.7 2.7V22H17a2 2 0 002-2v-4h1.5a2.5 2.5 0 000-5z");
svg.appendChild(path);
localBtn.insertBefore(svg, localBtn.firstChild);
localBtn.addEventListener("click", toggleLocalPanel);
aviaPluginsBtn.parentElement.insertBefore(localBtn, aviaPluginsBtn.nextSibling);
}
function waitForBody(callback) {
if (document.body) callback();
else new MutationObserver((obs) => {
if (document.body) { obs.disconnect(); callback(); }
}).observe(document.documentElement, { childList: true });
}
waitForBody(() => {
const observer = new MutationObserver(() => injectLocalButton());
observer.observe(document.body, { childList: true, subtree: true });
injectLocalButton();
});
upsertBuiltinLocalPlugins();
getLocalPlugins().forEach(plugin => {
if (plugin.enabled) runLocalPlugin(plugin);
});
preloadMonaco();
})();

139
avia_core/LoginWithToken.js Normal file
View file

@ -0,0 +1,139 @@
(function () {
if (window.__LOGIN_WITH_TOKEN__) return;
window.__LOGIN_WITH_TOKEN__ = true;
async function loginWithToken(token) {
const res = await fetch('https://stoat.chat/api/users/@me', {
headers: { 'x-session-token': token }
});
if (!res.ok) throw new Error('Invalid token');
const user = await res.json();
const db = await new Promise((resolve, reject) => {
const r = indexedDB.open('localforage');
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
const tx = db.transaction('keyvaluepairs', 'readwrite');
await new Promise((resolve, reject) => {
const r = tx.objectStore('keyvaluepairs').put({
session: {
_id: user._id,
token: token,
userId: user._id,
valid: true
}
}, 'auth');
r.onsuccess = () => resolve();
r.onerror = () => reject(r.error);
});
location.reload();
}
function openTokenDialog() {
const backdrop = document.createElement('div');
backdrop.className = 'top_0 left_0 right_0 bottom_0 pos_fixed z_100 max-h_100% d_grid us_none place-items_center pointer-events_all anim-n_scrimFadeIn anim-dur_0.1s anim-fm_forwards trs_var(--transitions-medium)_all p_80px ov-y_auto';
backdrop.style.cssText = '--background: rgba(0, 0, 0, 0.6);';
backdrop.innerHTML = `
<div style="opacity: 1; --motion-translateY: 0px; transform: translateY(var(--motion-translateY));">
<div class="p_24px min-w_280px max-w_560px bdr_28px d_flex flex-d_column c_var(--md-sys-color-on-surface) bg_var(--md-sys-color-surface-container-high)">
<span class="lh_2rem fs_1.5rem ls_0 fw_400 mbe_16px">Login With Token</span>
<div class="c_var(--md-sys-color-on-surface-variant) lh_1.25rem fs_0.875rem ls_0.015625rem fw_400">
<div class="d_flex flex-d_column flex-g_initial m_0 ai_initial jc_initial gap_var(--gap-md)">
<mdui-text-field id="lwt-token-input" variant="filled" type="password" name="token" required label="Session Token"></mdui-text-field>
</div>
</div>
<div class="gap_8px d_flex jc_end mbs_24px">
<button id="lwt-close-btn" type="button" class="lh_1.25rem fs_0.875rem ls_0.015625rem fw_400 pos_relative px_16px flex-sh_0 d_flex ai_center jc_center ff_inherit cursor_pointer bd_none trs_var(--transitions-medium)_all c_var(--color) fill_var(--color) h_40px bdr_var(--borderRadius-full) --color_var(--md-sys-color-primary)">
<md-ripple aria-hidden="true"></md-ripple>Close
</button>
<button id="lwt-login-btn" type="button" class="lh_1.25rem fs_0.875rem ls_0.015625rem fw_400 pos_relative px_16px flex-sh_0 d_flex ai_center jc_center ff_inherit cursor_pointer bd_none trs_var(--transitions-medium)_all c_var(--color) fill_var(--color) h_40px bdr_var(--borderRadius-full) --color_var(--md-sys-color-on-primary) bg_var(--md-sys-color-primary)">
<md-ripple aria-hidden="true"></md-ripple>Login
</button>
</div>
</div>
</div>
`;
document.body.appendChild(backdrop);
const closeBtn = backdrop.querySelector('#lwt-close-btn');
const loginBtn = backdrop.querySelector('#lwt-login-btn');
const tokenInput = backdrop.querySelector('#lwt-token-input');
function close() { backdrop.remove(); }
function setLoading(loading) {
loginBtn.disabled = loading;
loginBtn.style.cursor = loading ? 'not-allowed' : 'pointer';
const ripple = loginBtn.querySelector('md-ripple');
loginBtn.textContent = loading ? 'Logging in…' : 'Login';
if (ripple) loginBtn.prepend(ripple);
}
function setError(msg) {
loginBtn.disabled = false;
loginBtn.style.cursor = 'pointer';
const ripple = loginBtn.querySelector('md-ripple');
loginBtn.textContent = msg;
if (ripple) loginBtn.prepend(ripple);
setTimeout(() => {
loginBtn.textContent = 'Login';
if (ripple) loginBtn.prepend(ripple);
}, 2000);
}
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
closeBtn.addEventListener('click', close);
loginBtn.addEventListener('click', async () => {
const token = tokenInput.value?.trim();
if (!token) {
setError('Enter a token!');
return;
}
setLoading(true);
try {
await loginWithToken(token);
} catch (err) {
setError('Invalid token!');
}
});
}
function injectLoginButton() {
const signUpBtn = [...document.querySelectorAll('button')]
.find(b => b.textContent.trim() === 'Sign Up');
if (!signUpBtn) return;
const parent = signUpBtn.parentElement;
if (parent.querySelector('[data-lwt-btn]')) return;
const clone = signUpBtn.cloneNode(false);
clone.dataset.lwtBtn = 'true';
clone.textContent = 'Login With Token';
const ripple = document.createElement('md-ripple');
ripple.setAttribute('aria-hidden', 'true');
clone.prepend(ripple);
clone.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openTokenDialog();
});
signUpBtn.insertAdjacentElement('afterend', clone);
}
let debounceTimer = null;
new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(injectLoginButton, 150);
}).observe(document.body, { childList: true, subtree: true });
injectLoginButton();
})();

605
avia_core/VCSounds.js Normal file
View file

@ -0,0 +1,605 @@
(function () {
if (window.__VC_SOUNDS__) return;
window.__VC_SOUNDS__ = true;
const ctx = new (window.AudioContext || window.webkitAudioContext)();
document.addEventListener(
"click",
() => {
if (ctx.state === "suspended") ctx.resume();
},
{ once: true },
);
function playNote(freq, startTime, duration, volume) {
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.type = "sine";
osc1.frequency.value = freq;
osc1.connect(gain1);
gain1.connect(ctx.destination);
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.type = "triangle";
osc2.frequency.value = freq / 2;
osc2.connect(gain2);
gain2.connect(ctx.destination);
gain1.gain.setValueAtTime(0, startTime);
gain1.gain.linearRampToValueAtTime(volume, startTime + 0.03);
gain1.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
gain2.gain.setValueAtTime(0, startTime);
gain2.gain.linearRampToValueAtTime(volume * 0.4, startTime + 0.03);
gain2.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc1.start(startTime);
osc1.stop(startTime + duration + 0.05);
osc2.start(startTime);
osc2.stop(startTime + duration + 0.05);
}
function playJoin() {
if (ctx.state === "suspended") ctx.resume();
const t = ctx.currentTime + 0.01;
playNote(294, t, 0.35, 0.14);
playNote(370, t + 0.28, 0.45, 0.11);
}
function playLeave() {
if (ctx.state === "suspended") ctx.resume();
const t = ctx.currentTime + 0.01;
playNote(370, t, 0.35, 0.14);
playNote(294, t + 0.28, 0.45, 0.11);
}
let inVoice = false;
let initialising = false;
let initTimer = null;
let globalObserver = null;
let refreshTimer = null;
let lastVoiceState = null;
let lastMemberIdentityKey = "";
let leaveWatchdog = null;
const recentRowActivity = new Map();
function onSelfJoined() {
if (inVoice) return;
inVoice = true;
initialising = true;
playJoin();
console.debug("[VCSounds] self joined");
clearTimeout(initTimer);
initTimer = setTimeout(() => {
initialising = false;
}, 1500);
clearTimeout(leaveWatchdog);
leaveWatchdog = null;
publishVoiceState();
}
function onSelfLeft() {
if (!inVoice) return;
inVoice = false;
initialising = false;
clearTimeout(initTimer);
clearTimeout(leaveWatchdog);
leaveWatchdog = null;
recentRowActivity.clear();
lastVoiceState = null;
lastMemberIdentityKey = "";
playLeave();
console.debug("[VCSounds] self left");
const overlayApi = window.native?.overlay;
if (overlayApi && typeof overlayApi.setVoiceState === "function") {
overlayApi.setVoiceState(null);
}
}
function isParticipantEntry(el) {
if (el.nodeType !== 1) return false;
const c = String(el.className || "");
if (!el.isConnected) return false;
const rect = el.getBoundingClientRect();
if (rect.width < 24 || rect.height < 24) return false;
const style = window.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity || "1") <= 0.05) return false;
return (
c.includes("p_var(--gap-sm)") &&
c.includes("pos_relative") &&
c.includes("d_flex") &&
c.includes("ai_center")
);
}
function findParticipantEntry(node) {
let current = node && node.nodeType === 1 ? node : node?.parentElement || null;
while (current) {
if (isParticipantEntry(current)) return current;
current = current.parentElement;
}
return null;
}
function markRowActivity(node) {
const entry = findParticipantEntry(node);
if (!entry) return;
recentRowActivity.set(entry, Date.now());
}
function pruneRowActivity() {
const cutoff = Date.now() - 15000;
for (const [entry, at] of recentRowActivity.entries()) {
if (typeof at !== "number" || at < cutoff || !entry.isConnected) {
recentRowActivity.delete(entry);
}
}
}
function pseudoStyleIsVisible(style) {
if (!style) return false;
const content = String(style.content || "").toLowerCase();
if (content === "none") return false;
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
parseFloat(style.opacity || "1") > 0.05
);
}
function pseudoLooksActive(style) {
if (!pseudoStyleIsVisible(style)) return false;
const boxShadow = String(style.boxShadow || "").toLowerCase();
const outlineWidth = parseFloat(style.outlineWidth || "0");
const borderWidth = parseFloat(style.borderWidth || "0");
const filter = String(style.filter || "").toLowerCase();
const transform = String(style.transform || "").toLowerCase();
const background = String(style.backgroundColor || "").toLowerCase();
const borderColor = String(style.borderColor || "").toLowerCase();
const borderRadius = String(style.borderRadius || "").toLowerCase();
const size = Math.max(parseFloat(style.width || "0"), parseFloat(style.height || "0"));
const ringish =
boxShadow !== "none" ||
outlineWidth > 0 ||
borderWidth > 0 ||
filter !== "none" ||
transform !== "none";
const accentish =
background.includes("rgba") ||
background.includes("rgb(") ||
borderColor.includes("rgba") ||
borderColor.includes("rgb(");
const circular = borderRadius.includes("50%") || borderRadius.includes("999");
return Boolean(size <= 80 && circular && (ringish || accentish));
}
function looksLikeVoiceActivityIndicator(node) {
if (!node || node.nodeType !== 1) return false;
const rect = node.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) return false;
const style = window.getComputedStyle(node);
if (!style || style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity || "1") <= 0.05) {
return false;
}
const size = Math.min(rect.width, rect.height);
const borderRadius = String(style.borderRadius || "").toLowerCase();
const boxShadow = String(style.boxShadow || "").toLowerCase();
const outlineWidth = parseFloat(style.outlineWidth || "0");
const borderWidth = parseFloat(style.borderWidth || "0");
const filter = String(style.filter || "").toLowerCase();
const transform = String(style.transform || "").toLowerCase();
const animation = String(style.animationName || "").toLowerCase();
const transition = String(style.transitionProperty || "").toLowerCase();
const background = String(style.backgroundColor || "").toLowerCase();
const borderColor = String(style.borderColor || "").toLowerCase();
const circular = borderRadius.includes("50%") || borderRadius.includes("999");
const ringish =
boxShadow !== "none" ||
outlineWidth > 0 ||
borderWidth > 0 ||
filter !== "none" ||
transform !== "none" ||
animation !== "none" ||
transition !== "none";
const accentish =
background.includes("rgba") ||
background.includes("rgb(") ||
borderColor.includes("rgba") ||
borderColor.includes("rgb(");
if (size <= 80 && circular && (ringish || accentish)) return true;
const before = window.getComputedStyle(node, "::before");
const after = window.getComputedStyle(node, "::after");
if (pseudoLooksActive(before) || pseudoLooksActive(after)) return true;
return false;
}
function startObserver() {
if (globalObserver) return;
globalObserver = new MutationObserver((mutations) => {
if (!inVoice || initialising) return;
for (const m of mutations) {
markRowActivity(m.target);
for (const node of m.addedNodes) {
markRowActivity(node);
if (isParticipantEntry(node)) {
console.debug("[VCSounds] participant joined");
playJoin();
}
}
for (const node of m.removedNodes) {
markRowActivity(node);
if (isParticipantEntry(node)) {
console.debug("[VCSounds] participant left");
playLeave();
}
}
}
publishVoiceState();
});
globalObserver.observe(document.body, { childList: true, subtree: true });
globalObserver.observe(document.body, { attributes: true, subtree: true, attributeFilter: ["class", "aria-label", "style", "data-speaking", "data-active", "data-state", "title"] });
if (!refreshTimer) {
refreshTimer = setInterval(() => {
if (inVoice && !initialising) publishVoiceState();
}, 700);
}
}
function getParticipantName(entry) {
const text = (entry.textContent || "").replace(/\s+/g, " ").trim();
if (!text) return "Unknown";
return text.length > 40 ? text.slice(0, 40) : text;
}
function isSpeakingEntry(entry) {
const recentActivityAt = recentRowActivity.get(entry);
const recentActivity = typeof recentActivityAt === "number" && Date.now() - recentActivityAt < 1200;
const nodes = [entry, ...Array.from(entry.querySelectorAll("*"))];
for (const node of nodes) {
const className = String(node.className || "").toLowerCase();
const label = String(node.getAttribute?.("aria-label") || "").toLowerCase();
const title = String(node.getAttribute?.("title") || "").toLowerCase();
const text = String(node.textContent || "").toLowerCase();
const state = String(node.getAttribute?.("data-state") || "").toLowerCase();
const active = String(node.getAttribute?.("data-active") || "").toLowerCase();
const speaking = String(node.getAttribute?.("data-speaking") || "").toLowerCase();
const style = String(node.getAttribute?.("style") || "").toLowerCase();
const attrs = typeof node.getAttributeNames === "function" ? node.getAttributeNames().map((name) => name.toLowerCase()) : [];
if (
className.includes("speaking") ||
className.includes("voice-activity") ||
className.includes("active-speaker") ||
className.includes("active") ||
label.includes("speaking") ||
label.includes("active") ||
label.includes("voice activity") ||
title.includes("speaking") ||
title.includes("active") ||
text.includes("speaking") ||
text.includes("voice activity") ||
state === "speaking" ||
active === "true" ||
speaking === "true" ||
style.includes("speaking") ||
attrs.includes("data-speaking") ||
attrs.includes("data-active") ||
attrs.includes("data-state")
) {
return true;
}
}
if (recentActivity && nodes.some(looksLikeVoiceActivityIndicator)) return true;
if (nodes.some(looksLikeVoiceActivityIndicator)) return true;
return !!entry.querySelector(
"[data-speaking='true'], [data-active='true'], [data-state='speaking'], [aria-label*='speaking'], [aria-label*='voice activity'], [title*='speaking'], [class*='speaking'], [class*='active-speaker'], [class*='voice-activity'], [class*='active']",
);
}
function extractAvatarUrl(entry) {
const candidates = Array.from(entry.querySelectorAll("img, source, [style*='background-image'], [style*='background']"));
for (const candidate of candidates) {
if (candidate.tagName === "IMG") {
const src = candidate.currentSrc || candidate.src || candidate.getAttribute("src") || "";
if (src) return src;
}
const style = String(candidate.getAttribute("style") || "");
const match = style.match(/url\(["']?([^"')]+)["']?\)/i);
if (match && match[1]) return match[1];
}
const img = entry.querySelector("img");
if (img) {
const src = img.currentSrc || img.src || img.getAttribute("src") || "";
if (src) return src;
}
return "";
}
function normalizeAvatarUrl(url) {
return String(url || "")
.replace(/\/original(?=$|[?#])/i, "")
.replace(/[?#].*$/, "")
.trim();
}
function isAvatarLikeSrc(src) {
const value = String(src || "").toLowerCase();
return (
value.includes("/avatars/") ||
value.includes("/default_avatar") ||
value.includes("/avatar") ||
value.includes("/icons/") ||
value.includes("avatar")
);
}
function isVisibleElement(el) {
if (!el || el.nodeType !== 1) return false;
if (!el.isConnected) return false;
const rect = el.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) return false;
const style = window.getComputedStyle(el);
if (!style) return false;
return style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity || "1") > 0.05;
}
function findAvatarRoot(img) {
let current = img && img.parentElement;
let depth = 0;
while (current && depth < 6) {
if (!isVisibleElement(current)) {
current = current.parentElement;
depth++;
continue;
}
const rect = current.getBoundingClientRect();
const style = window.getComputedStyle(current);
const radius = String(style.borderRadius || "").toLowerCase();
const clip = String(style.clipPath || "").toLowerCase();
const circular = radius.includes("50%") || radius.includes("999") || clip.includes("circle");
if (circular || (rect.width >= 24 && rect.height >= 24 && rect.width <= 240 && rect.height <= 240)) {
return current;
}
current = current.parentElement;
depth++;
}
return img?.parentElement || null;
}
function collectSpeakingAvatarUrls() {
const speaking = new Set();
const ringTiles = Array.from(document.querySelectorAll("*")).filter((el) => {
const cls = String(el.className || "");
return (
el.isConnected &&
cls.includes("vc_tile") &&
cls.includes("ring-c_var(--md-sys-color-primary)")
);
});
for (const tile of ringTiles) {
const imageNodes = Array.from(tile.querySelectorAll("img")).filter((img) => isVisibleElement(img));
for (const img of imageNodes) {
const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || "");
if (src && isAvatarLikeSrc(src)) {
speaking.add(src);
}
}
}
const images = Array.from(document.querySelectorAll("img")).filter((img) => {
if (!isVisibleElement(img)) return false;
const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || "");
return isAvatarLikeSrc(src);
});
for (const img of images) {
const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || "");
const root = findAvatarRoot(img);
if (!root) continue;
const nodes = [root, ...Array.from(root.querySelectorAll("*"))].slice(0, 40);
const speaks = nodes.some(looksLikeVoiceActivityIndicator) || nodes.some(isSpeakingMarkerNode);
if (speaks) speaking.add(src);
}
return speaking;
}
function isSpeakingMarkerNode(node) {
if (!node || node.nodeType !== 1) return false;
const className = String(node.className || "").toLowerCase();
const label = String(node.getAttribute?.("aria-label") || "").toLowerCase();
const title = String(node.getAttribute?.("title") || "").toLowerCase();
const text = String(node.textContent || "").toLowerCase();
const state = String(node.getAttribute?.("data-state") || "").toLowerCase();
const active = String(node.getAttribute?.("data-active") || "").toLowerCase();
const speaking = String(node.getAttribute?.("data-speaking") || "").toLowerCase();
return Boolean(
className.includes("speaking") ||
className.includes("active-speaker") ||
className.includes("voice-activity") ||
label.includes("speaking") ||
label.includes("voice activity") ||
title.includes("speaking") ||
title.includes("voice activity") ||
text.includes("speaking") ||
text.includes("voice activity") ||
state === "speaking" ||
active === "true" ||
speaking === "true"
);
}
function detectSelfFlags() {
const buttons = Array.from(document.querySelectorAll("button"));
const muteButton = buttons.find((button) =>
/unmute|mute/i.test(
button.getAttribute("aria-label") || button.title || button.textContent || "",
),
);
const deafenButton = buttons.find((button) =>
/undeafen|deafen/i.test(
button.getAttribute("aria-label") || button.title || button.textContent || "",
),
);
return {
selfMuted: muteButton
? /unmute/i.test(
muteButton.getAttribute("aria-label") ||
muteButton.title ||
muteButton.textContent ||
"",
)
: undefined,
selfDeafened: deafenButton
? /undeafen/i.test(
deafenButton.getAttribute("aria-label") ||
deafenButton.title ||
deafenButton.textContent ||
"",
)
: undefined,
};
}
function collectVoiceState() {
const speakingAvatars = collectSpeakingAvatarUrls();
const members = Array.from(document.querySelectorAll("*"))
.filter(isParticipantEntry)
.slice(0, 10)
.map((entry) => ({
name: getParticipantName(entry),
speaking: isSpeakingEntry(entry),
avatarUrl: normalizeAvatarUrl(extractAvatarUrl(entry)) || undefined,
}))
.map((member) => ({
...member,
speaking:
member.speaking ||
(member.avatarUrl ? speakingAvatars.has(normalizeAvatarUrl(member.avatarUrl)) : false),
}));
const selfFlags = detectSelfFlags();
return {
channelName: "Voice call",
// The join/leave hook is authoritative for whether the overlay should show.
// The DOM scan is only used to enrich the overlay with members while in call.
isInCall: inVoice,
members,
selfMuted: selfFlags.selfMuted,
selfDeafened: selfFlags.selfDeafened,
source: "voice DOM",
};
}
function memberIdentityKey(members) {
return members
.map((member) =>
[
String(member?.name || "").trim().toLowerCase(),
String(member?.avatarUrl || "").trim().toLowerCase(),
].join(":"),
)
.sort()
.join("|");
}
function publishVoiceState() {
const overlayApi = window.native?.overlay;
if (!overlayApi || typeof overlayApi.setVoiceState !== "function") return;
pruneRowActivity();
const next = collectVoiceState();
const voiceState = inVoice ? next : null;
const nextMemberKey = memberIdentityKey(next.members);
const nextKey = JSON.stringify(voiceState);
const memberChanged = nextMemberKey !== lastMemberIdentityKey;
if (memberChanged && inVoice && !initialising && lastMemberIdentityKey) {
const previousMembers = new Set(
lastMemberIdentityKey
.split("|")
.map((entry) => entry.trim())
.filter(Boolean),
);
const currentMembers = new Set(
nextMemberKey
.split("|")
.map((entry) => entry.trim())
.filter(Boolean),
);
const added = [...currentMembers].some((entry) => !previousMembers.has(entry));
const removed = [...previousMembers].some((entry) => !currentMembers.has(entry));
if (added) playJoin();
if (removed) playLeave();
}
lastMemberIdentityKey = nextMemberKey;
if (nextKey === lastVoiceState) return;
lastVoiceState = nextKey;
if (inVoice && next.members.length === 0) {
if (!leaveWatchdog) {
leaveWatchdog = setTimeout(() => {
leaveWatchdog = null;
if (!inVoice) return;
const stillEmpty = collectVoiceState().members.length === 0;
if (stillEmpty) {
overlayApi.setVoiceState(null);
onSelfLeft();
}
}, 2200);
}
} else {
clearTimeout(leaveWatchdog);
leaveWatchdog = null;
}
overlayApi.setVoiceState(voiceState);
}
const originalFetch = window.fetch;
window.fetch = async function (...args) {
const url = typeof args[0] === "string" ? args[0] : args[0]?.url ?? "";
const response = await originalFetch.apply(this, args);
if (url.includes("/join_call") && response.ok) {
setTimeout(onSelfJoined, 300);
}
if (/(leave_call|leave-?call|disconnect|close_call)/i.test(url) && response.ok) {
setTimeout(onSelfLeft, 150);
}
return response;
};
const OriginalWebSocket = window.WebSocket;
window.WebSocket = function (url, protocols) {
const ws = protocols
? new OriginalWebSocket(url, protocols)
: new OriginalWebSocket(url);
if (typeof url === "string" && url.includes("/livekit/rtc")) {
ws.addEventListener("close", () => onSelfLeft());
}
return ws;
};
Object.assign(window.WebSocket, OriginalWebSocket);
window.WebSocket.prototype = OriginalWebSocket.prototype;
startObserver();
publishVoiceState();
})();

View file

@ -0,0 +1,34 @@
(function(){
if(window.__AVIA_CATEGORY_SETTINGS__) return;
window.__AVIA_CATEGORY_SETTINGS__ = true;
function inject(){
if(document.getElementById('avia-cloned-settings')) return;
const spans = [...document.querySelectorAll('span')];
const target = spans.find(s => s.textContent.trim() === "User Settings");
if(!target) return;
const container = target.closest('.d_flex.flex-d_column');
if(!container) return;
const clone = container.cloneNode(true);
clone.id = "avia-cloned-settings";
const header = clone.querySelector('span');
if(header) header.textContent = "AVIA CLIENT SETTINGS";
const list = clone.querySelector('.d_flex.flex-d_column.gap_var\\(--gap-s\\)');
if(list) list.innerHTML = "";
container.parentNode.insertBefore(clone, container.nextSibling);
}
new MutationObserver(() => {
inject();
}).observe(document.body, { childList: true, subtree: true });
inject();
})();

View file

@ -0,0 +1,40 @@
(function () {
if (window.__AVIA_DESKTOP_PATCH__) return;
window.__AVIA_DESKTOP_PATCH__ = true;
function patchButton() {
document
.querySelectorAll("a.pos_relative.gap_16px.p_13px")
.forEach((el) => {
if (el.dataset.aviaPatched) return;
const textContainer = el.querySelector(
"div.d_flex.flex-g_1.flex-d_column",
);
if (!textContainer) return;
const nameDiv = textContainer.querySelector("div");
const versionSpan = textContainer.querySelector("span.lh_1rem");
if (!nameDiv || !versionSpan) return;
if (!nameDiv.textContent.includes("Stoat for Desktop")) return;
if (!versionSpan.textContent.includes("Version:")) return;
const aviaVersion = window.native.versions.aviaClient();
const stoatVersion = window.native.versions.desktop();
el.dataset.aviaPatched = "true";
nameDiv.textContent = "Sanctum Desktop";
versionSpan.textContent = `Version ${aviaVersion} (Based on Stoat ${stoatVersion})`;
textContainer.style.whiteSpace = "normal";
textContainer.style.overflow = "visible";
});
}
const observer = new MutationObserver(patchButton);
observer.observe(document.body, { childList: true, subtree: true });
patchButton();
})();

349
avia_core/aviafavsystem.js Normal file
View file

@ -0,0 +1,349 @@
(function () {
if (window.__AVIA_FAVORITES_LOADED__) return;
window.__AVIA_FAVORITES_LOADED__ = true;
const STORAGE_KEY = "avia_favorites";
const getFavorites = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
const setFavorites = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
function extractYouTubeID(url) {
const reg = /(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([^&?/]+)/;
const match = url.match(reg);
return match ? match[1] : null;
}
function toggleFavoritesPanel() {
let panel = document.getElementById("avia-favorites-panel");
if (panel) {
panel.style.display = panel.style.display === "none" ? "flex" : "none";
return;
}
panel = document.createElement("div");
panel.id = "avia-favorites-panel";
Object.assign(panel.style, {
position: "fixed",
bottom: "40px",
right: "40px",
width: "640px",
height: "580px",
background: "#1e1e1e",
color: "#fff",
borderRadius: "20px",
boxShadow: "0 12px 35px rgba(0,0,0,0.45)",
zIndex: 999999,
display: "flex",
flexDirection: "column",
overflow: "hidden",
border: "1px solid rgba(255,255,255,0.08)"
});
const header = document.createElement("div");
header.textContent = "Favorites";
Object.assign(header.style, {
padding: "18px",
fontWeight: "600",
fontSize: "16px",
background: "rgba(255,255,255,0.04)",
borderBottom: "1px solid rgba(255,255,255,0.08)",
cursor: "move",
position: "relative",
userSelect: "none"
});
const close = document.createElement("div");
close.textContent = "✕";
Object.assign(close.style, {
position: "absolute",
right: "18px",
top: "16px",
cursor: "pointer"
});
close.onclick = () => panel.style.display = "none";
header.appendChild(close);
const inputRow = document.createElement("div");
Object.assign(inputRow.style, {
display: "flex",
gap: "8px",
padding: "14px 18px"
});
const urlInput = document.createElement("input");
urlInput.placeholder = "Paste link...";
Object.assign(urlInput.style, {
flex: "2",
padding: "10px",
borderRadius: "10px",
border: "none",
outline: "none"
});
const titleInput = document.createElement("input");
titleInput.placeholder = "Optional title...";
Object.assign(titleInput.style, {
flex: "1",
padding: "10px",
borderRadius: "10px",
border: "none",
outline: "none"
});
const addBtn = document.createElement("button");
addBtn.textContent = "Add";
Object.assign(addBtn.style, {
padding: "10px 16px",
borderRadius: "10px",
border: "none",
cursor: "pointer"
});
inputRow.appendChild(urlInput);
inputRow.appendChild(titleInput);
inputRow.appendChild(addBtn);
const grid = document.createElement("div");
Object.assign(grid.style, {
flex: "1",
minHeight: "0",
overflowY: "auto",
padding: "18px",
display: "grid",
gridTemplateColumns: "repeat(auto-fill, 120px)",
gap: "14px",
alignContent: "start"
});
panel.appendChild(header);
panel.appendChild(inputRow);
panel.appendChild(grid);
document.body.appendChild(panel);
let isDragging = false, offsetX, offsetY;
header.addEventListener("mousedown", e => {
isDragging = true;
offsetX = e.clientX - panel.offsetLeft;
offsetY = e.clientY - panel.offsetTop;
});
document.addEventListener("mouseup", () => isDragging = false);
document.addEventListener("mousemove", e => {
if (!isDragging) return;
panel.style.left = (e.clientX - offsetX) + "px";
panel.style.top = (e.clientY - offsetY) + "px";
panel.style.right = "auto";
panel.style.bottom = "auto";
});
function showToast(card) {
const toast = document.createElement("div");
toast.textContent = "Copied to clipboard";
Object.assign(toast.style, {
position: "absolute",
bottom: "6px",
left: "50%",
transform: "translateX(-50%)",
background: "rgba(0,0,0,0.85)",
padding: "6px 10px",
borderRadius: "8px",
fontSize: "11px",
opacity: "0",
transition: "opacity 0.2s",
pointerEvents: "none"
});
card.appendChild(toast);
requestAnimationFrame(() => toast.style.opacity = "1");
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 200);
}, 2000);
}
function fallbackCopy(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try { document.execCommand("copy"); } catch {}
document.body.removeChild(textarea);
}
function render() {
grid.innerHTML = "";
const favorites = getFavorites();
favorites.forEach(item => {
const card = document.createElement("div");
Object.assign(card.style, {
position: "relative",
width: "120px",
height: "120px",
borderRadius: "14px",
overflow: "hidden",
background: "rgba(255,255,255,0.05)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center"
});
const remove = document.createElement("div");
remove.textContent = "✕";
Object.assign(remove.style, {
position: "absolute",
top: "6px",
right: "8px",
fontSize: "12px",
cursor: "pointer",
background: "rgba(0,0,0,0.6)",
padding: "2px 6px",
borderRadius: "6px",
zIndex: 2
});
remove.onclick = (e) => {
e.stopPropagation();
setFavorites(favorites.filter(f => f.url !== item.url));
render();
};
card.appendChild(remove);
let mediaAdded = false;
const ytID = extractYouTubeID(item.url);
if (ytID) {
const img = new Image();
img.src = `https://img.youtube.com/vi/${ytID}/hqdefault.jpg`;
Object.assign(img.style, { width:"100%", height:"100%", objectFit:"cover" });
card.appendChild(img);
mediaAdded = true;
}
if (!mediaAdded) {
const ext = item.url.split(".").pop().split("?")[0].toLowerCase();
const isVideo = ["mp4","webm","mov","gifv"].includes(ext);
if (isVideo) {
const video = document.createElement("video");
video.src = item.url.replace(".gifv",".mp4");
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
Object.assign(video.style, { width:"100%", height:"100%", objectFit:"cover" });
video.onerror = fallback;
card.appendChild(video);
} else {
const img = new Image();
img.src = item.url;
Object.assign(img.style, { width:"100%", height:"100%", objectFit:"cover" });
img.onerror = fallback;
card.appendChild(img);
}
}
function fallback() {
card.innerHTML = "";
card.appendChild(remove);
const text = document.createElement("div");
text.textContent = item.title || item.url;
Object.assign(text.style, {
padding:"8px",
fontSize:"11px",
textAlign:"center",
wordBreak:"break-word"
});
card.appendChild(text);
}
if (item.title) {
const titleOverlay = document.createElement("div");
titleOverlay.textContent = item.title;
Object.assign(titleOverlay.style, {
position:"absolute",
bottom:"0",
width:"100%",
background:"rgba(0,0,0,0.6)",
fontSize:"11px",
padding:"4px",
textAlign:"center",
whiteSpace:"nowrap",
overflow:"hidden",
textOverflow:"ellipsis"
});
card.appendChild(titleOverlay);
}
card.onclick = () => {
const doToast = () => showToast(card);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(item.url)
.then(doToast)
.catch(() => {
fallbackCopy(item.url);
doToast();
});
} else {
fallbackCopy(item.url);
doToast();
}
};
grid.appendChild(card);
});
}
addBtn.onclick = () => {
const url = urlInput.value.trim();
const title = titleInput.value.trim();
if (!url) return;
const favorites = getFavorites();
if (favorites.some(f => f.url === url)) return;
favorites.push({ url, title, addedAt: Date.now() });
setFavorites(favorites);
urlInput.value = "";
titleInput.value = "";
render();
};
render();
}
function injectButton() {
if (document.getElementById("avia-favorites-btn")) return;
const gifSpan = [...document.querySelectorAll("span.material-symbols-outlined")]
.find(s => s.textContent.trim() === "gif");
if (!gifSpan) return;
const wrapper = gifSpan.closest("div.flex-sh_0");
if (!wrapper) return;
const clone = wrapper.cloneNode(true);
clone.id = "avia-favorites-btn";
clone.querySelector("span.material-symbols-outlined").textContent = "star";
clone.querySelector("button").onclick = toggleFavoritesPanel;
wrapper.parentElement.insertBefore(clone, wrapper.nextSibling);
}
new MutationObserver(injectButton)
.observe(document.body, { childList: true, subtree: true });
injectButton();
})();

34
avia_core/aviaversion.js Normal file
View file

@ -0,0 +1,34 @@
(function () {
if (window.__AVIA_VERSION_PATCH__) return;
window.__AVIA_VERSION_PATCH__ = true;
function patchVersion() {
document
.querySelectorAll("span.lh_1rem.fs_0\\.75rem.ls_0\\.03125rem.fw_500")
.forEach((el) => {
if (el.dataset.aviaPatched) return;
if (!el.textContent.trim().startsWith("Stoat for Desktop")) return;
const stoatVersion = window.native.versions.desktop();
el.dataset.aviaPatched = "true";
el.innerHTML = `
Sanctum Desktop<br>
<span style="font-size:10px;opacity:0.7;">
Based on Stoat ${stoatVersion}
</span>
`;
});
}
const observer = new MutationObserver(patchVersion);
observer.observe(document.body, {
childList: true,
subtree: true,
});
patchVersion();
})();

175
avia_core/clientBackup.js Normal file
View file

@ -0,0 +1,175 @@
(function () {
if (window.__clientBackup) return;
window.__clientBackup = true;
const TARGET_TEXT = "Spellchecker";
const CLONE_KEY = "data-lsbackup-cloned";
function exportLS() {
const data = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
data[key] = localStorage.getItem(key);
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "localstorage-backup.json";
a.click();
URL.revokeObjectURL(url);
}
function importLS(file, onDone) {
const reader = new FileReader();
reader.onload = e => {
try {
const data = JSON.parse(e.target.result);
let count = 0;
for (const [key, value] of Object.entries(data)) {
localStorage.setItem(key, value);
count++;
}
onDone(null, count);
} catch (err) {
onDone(err);
}
};
reader.readAsText(file);
}
function buildPanel() {
const panel = document.createElement("div");
panel.style.cssText = `
display: none;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
background: var(--md-sys-color-surface-container-highest);
border: 1px solid var(--md-sys-color-outline-variant);
font-size: 12px;
color: var(--md-sys-color-on-surface);
`;
const btnStyle = `
padding: 5px 12px;
border-radius: 4px;
border: none;
font-size: 11px;
font-weight: 600;
cursor: pointer;
`;
const status = document.createElement("span");
status.style.cssText = "font-size: 11px; opacity: 0.7; min-height: 14px;";
const exportBtn = document.createElement("button");
exportBtn.textContent = "⬇ Export localStorage";
exportBtn.style.cssText = btnStyle + `
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
`;
exportBtn.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
exportLS();
status.textContent = `✓ Exported ${localStorage.length} keys`;
});
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json";
fileInput.style.cssText = "display: none;";
fileInput.addEventListener("change", e => {
const file = e.target.files[0];
if (!file) return;
importLS(file, (err, count) => {
if (err) {
status.textContent = "✗ Invalid JSON file";
} else {
status.textContent = `✓ Imported ${count} keys`;
}
fileInput.value = "";
});
});
const importBtn = document.createElement("button");
importBtn.textContent = "⬆ Import localStorage";
importBtn.style.cssText = btnStyle + `
background: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
border: 1px solid var(--md-sys-color-outline-variant);
`;
importBtn.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
fileInput.click();
});
panel.appendChild(exportBtn);
panel.appendChild(importBtn);
panel.appendChild(fileInput);
panel.appendChild(status);
return panel;
}
function tryInject() {
document.querySelectorAll("a.pos_relative").forEach(btn => {
if (
btn.hasAttribute(CLONE_KEY) ||
btn.hasAttribute("data-lsbackup-entry") ||
!btn.innerText.includes(TARGET_TEXT)
) return;
btn.setAttribute(CLONE_KEY, "true");
const clone = btn.cloneNode(true);
clone.removeAttribute(CLONE_KEY);
clone.setAttribute("data-lsbackup-entry", "true");
const title = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > div");
if (title) title.textContent = "Sanctum Backup";
const desc = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > span");
if (desc) desc.textContent = "Backup or Restore all client data";
const iconBtn = document.createElement("div");
iconBtn.title = "LocalStorage Backup";
iconBtn.style.cssText = "cursor: pointer; z-index: 10; flex-shrink: 0;";
iconBtn.innerHTML = `
<div class="fill_var(--md-sys-color-on-surface) bg_var(--md-sys-color-surface-dim) w_36px h_36px d_flex flex-sh_0 ai_center jc_center bdr_var(--borderRadius-full)">
<span aria-hidden="true" class="material-symbols-outlined fs_inherit fw_undefined!" style="display: block; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0;">database</span>
</div>
`;
const existingIcon = clone.querySelector("div.fill_var\\(--md-sys-color-on-surface\\)");
if (existingIcon) {
existingIcon.replaceWith(iconBtn);
} else {
clone.prepend(iconBtn);
}
clone.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
panel.style.display = panel.style.display === "flex" ? "none" : "flex";
});
const wrapper = document.createElement("div");
wrapper.style.cssText = "display: flex; flex-direction: column;";
const panel = buildPanel();
wrapper.appendChild(clone);
wrapper.appendChild(panel);
btn.parentNode.insertBefore(wrapper, btn.nextSibling);
});
}
tryInject();
const observer = new MutationObserver(() => tryInject());
observer.observe(document.body, { childList: true, subtree: true });
})();

View file

@ -0,0 +1,57 @@
(function () {
if (window.__customFrameNativeMenu) return;
window.__customFrameNativeMenu = true;
function toggleCheckbox(elem, toggle) {
const checkbox = elem.querySelector("mdui-checkbox");
if (!checkbox) return;
if (toggle) {
checkbox.setAttribute('checked', '');
checkbox.setAttribute('value', 'on');
} else {
checkbox.removeAttribute('checked');
checkbox.setAttribute('value', 'off');
}
}
function initCFNM() {
let elem = document.querySelector("#floating div:not(:empty) div.will-change_transform.flex_1_1_800px div:has(> a) > a:last-child");
if (!elem) { return; }
let title = elem.querySelector("div.flex-g_1 > div");
if (!title || title.textContent.trim() !== "Custom window frame") { return; }
let desc = elem.querySelector("div.flex-g_1 > span");
if (!desc || desc.textContent.trim() !== "Let Stoat use its own custom titlebar.") { return; }
var newElem = elem.cloneNode(true);
let nTitle = newElem.querySelector("div.flex-g_1 > div");
let nDesc = newElem.querySelector("div.flex-g_1 > span");
if (!nTitle || !nDesc) { newElem = null; return; }
nTitle.textContent = "Native menu on custom window frame";
nDesc.textContent = "Use the system's native menu on the custom window frame.";
let config = window.desktopConfig.get();
toggleCheckbox(newElem, config.customFrameNativeMenu);
newElem.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
let config = window.desktopConfig.get();
config.customFrameNativeMenu = !config.customFrameNativeMenu;
window.desktopConfig.set(config);
toggleCheckbox(newElem, config.customFrameNativeMenu);
});
elem.parentNode.appendChild(newElem);
}
initCFNM();
const observer = new MutationObserver(() => initCFNM());
observer.observe(document.body, { childList: true, subtree: true });
})();

View file

@ -0,0 +1,66 @@
(function () {
if (window.__disableTrayClick) return;
window.__disableTrayClick = true;
function toggleCheckbox(elem, value) {
const checkbox = elem.querySelector("mdui-checkbox");
if (!checkbox) return;
if (value) {
checkbox.setAttribute("checked", "");
checkbox.setAttribute("value", "on");
} else {
checkbox.removeAttribute("checked");
checkbox.setAttribute("value", "off");
}
}
function createButton(baseElem) {
const newElem = baseElem.cloneNode(true);
newElem.setAttribute("data-disable-tray-click", "true");
const title = newElem.querySelector("div.d_flex.flex-g_1 > div");
const desc = newElem.querySelector("div.d_flex.flex-g_1 > span");
const icon = newElem.querySelector("div.w_36px span.material-symbols-outlined");
if (title) title.textContent = "Disable Tray Icon Click";
if (desc) desc.textContent = "Prevents tray icon from toggling the app window.";
if (icon) icon.textContent = "block";
let config = window.desktopConfig.get();
toggleCheckbox(newElem, config.disableTrayClick);
newElem.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
let config = window.desktopConfig.get();
config.disableTrayClick = !config.disableTrayClick;
window.desktopConfig.set(config);
toggleCheckbox(newElem, config.disableTrayClick);
});
return newElem;
}
function injectButton() {
const base = Array.from(document.querySelectorAll("a")).find((e) => {
const t = e.querySelector("div.d_flex.flex-g_1 > div");
return t && t.textContent.trim() === "Discord RPC";
});
if (!base) return;
if (document.querySelector("[data-disable-tray-click]")) return;
const newButton = createButton(base);
base.parentNode.appendChild(newButton);
}
injectButton();
const observer = new MutationObserver(() => injectButton());
observer.observe(document.body, { childList: true, subtree: true });
})();

View file

@ -0,0 +1,397 @@
(function () {
if (window.__sanctumGamePresenceSettings) return;
window.__sanctumGamePresenceSettings = true;
const CLONE_ATTR = "data-sanctum-game-presence";
const PANEL_ATTR = "data-sanctum-game-presence-panel";
const POPULAR_GAMES = [
"Apex Legends",
"Among Us",
"Assassin's Creed Mirage",
"Assassin's Creed Valhalla",
"Armored Core VI: Fires of Rubicon",
"Baldur's Gate 3",
"Black Myth: Wukong",
"Brawlhalla",
"Call of Duty: Black Ops 6",
"Call of Duty: Modern Warfare III",
"Call of Duty: Warzone",
"Celeste",
"Cities: Skylines II",
"Civilization VI",
"Counter-Strike 2",
"Cuphead",
"Cyberpunk 2077",
"Dark Souls III",
"Dave the Diver",
"Days Gone",
"Dead by Daylight",
"Dead Cells",
"Deep Rock Galactic",
"Destiny 2",
"Diablo IV",
"Dota 2",
"Dragon's Dogma 2",
"Elden Ring",
"Enshrouded",
"Escape from Tarkov",
"Euro Truck Simulator 2",
"EVE Online",
"Fall Guys",
"Fallout 4",
"Fallout 76",
"Factorio",
"F1 24",
"Final Fantasy XIV",
"Forza Horizon 5",
"Fortnite",
"Genshin Impact",
"Ghost of Tsushima",
"God of War",
"Grand Theft Auto V",
"Grounded",
"Guild Wars 2",
"Hades",
"Hades II",
"Helldivers 2",
"Hogwarts Legacy",
"Hollow Knight",
"Honkai: Star Rail",
"Honkai Impact 3rd",
"Hunt: Showdown",
"It Takes Two",
"Kingdom Come: Deliverance",
"League of Legends",
"Lethal Company",
"Left 4 Dead 2",
"Last Epoch",
"Marvel Rivals",
"Minecraft",
"Monster Hunter: World",
"Monster Hunter Rise",
"Mortal Kombat 1",
"Metaphor: ReFantazio",
"No Man's Sky",
"Once Human",
"Overwatch 2",
"Palworld",
"Path of Exile",
"Path of Exile 2",
"Persona 5 Royal",
"Phasmophobia",
"PUBG: Battlegrounds",
"Paladins",
"Rainbow Six Siege",
"Red Dead Redemption 2",
"Resident Evil 4",
"Resident Evil Village",
"Rocket League",
"Rust",
"Satisfactory",
"Sea of Thieves",
"Skyrim Special Edition",
"Slay the Spire",
"Sons of the Forest",
"Spider-Man Remastered",
"Split Fiction",
"Star Citizen",
"Starfield",
"Stardew Valley",
"Street Fighter 6",
"Subnautica",
"Team Fortress 2",
"Tekken 8",
"Terraria",
"The Elder Scrolls Online",
"The Finals",
"The Last of Us Part I",
"The Witcher 3",
"Titanfall 2",
"VALORANT",
"V Rising",
"Valheim",
"Warframe",
"War Thunder",
"Wuthering Waves",
"World of Warcraft",
"World of Tanks",
"World of Warships",
"Zenless Zone Zero",
];
function toggleCheckbox(elem, value) {
const checkbox = elem.querySelector("mdui-checkbox");
if (!checkbox) return;
if (value) {
checkbox.setAttribute("checked", "");
checkbox.setAttribute("value", "on");
} else {
checkbox.removeAttribute("checked");
checkbox.setAttribute("value", "off");
}
}
function getConfig() {
return window.desktopConfig.get();
}
function setConfig(next) {
window.desktopConfig.set(next);
}
function buildPanel() {
const panel = document.createElement("div");
panel.setAttribute(PANEL_ATTR, "true");
panel.style.cssText = `
display: none;
flex-direction: column;
gap: 10px;
margin-top: 8px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
color: inherit;
`;
const note = document.createElement("div");
note.style.cssText = "font-size:12px; opacity:0.75; line-height:1.35;";
note.textContent =
"Sanctum only lights up for games in its built-in catalog or names you add below.";
panel.appendChild(note);
const allowLabel = document.createElement("div");
allowLabel.textContent = "Allowed games / windows";
allowLabel.style.cssText = "font-size:12px; font-weight:600;";
panel.appendChild(allowLabel);
const textarea = document.createElement("textarea");
textarea.value = getConfig().gamePresenceAllowList || "";
textarea.rows = 4;
textarea.placeholder = "Examples: Fortnite, Valorant, Counter-Strike 2, Baldur's Gate 3";
textarea.style.cssText = `
width: 100%;
resize: vertical;
min-height: 88px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.18);
color: inherit;
font: inherit;
line-height: 1.4;
`;
textarea.addEventListener("input", () => {
const config = getConfig();
config.gamePresenceAllowList = textarea.value;
setConfig(config);
});
panel.appendChild(textarea);
const pickerLabel = document.createElement("div");
pickerLabel.textContent = "Popular games";
pickerLabel.style.cssText = "font-size:12px; font-weight:600; margin-top:2px;";
panel.appendChild(pickerLabel);
const pickerHint = document.createElement("div");
pickerHint.textContent = "Search and add games from the built-in catalog.";
pickerHint.style.cssText = "font-size:11px; opacity:0.7; line-height:1.35;";
panel.appendChild(pickerHint);
const pickerRow = document.createElement("div");
pickerRow.style.cssText = "display:flex; gap:8px; align-items:center;";
const pickerSearch = document.createElement("input");
pickerSearch.type = "search";
pickerSearch.placeholder = "Search popular games";
pickerSearch.style.cssText = `
flex: 1;
min-width: 0;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.18);
color: inherit;
font: inherit;
`;
const pickerAdd = document.createElement("button");
pickerAdd.type = "button";
pickerAdd.textContent = "Add";
pickerAdd.style.cssText = `
flex-shrink: 0;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.08);
color: inherit;
font: inherit;
cursor: pointer;
`;
pickerRow.appendChild(pickerSearch);
pickerRow.appendChild(pickerAdd);
panel.appendChild(pickerRow);
const pickerList = document.createElement("div");
pickerList.style.cssText = `
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 6px;
max-height: 180px;
overflow: auto;
padding-right: 2px;
`;
panel.appendChild(pickerList);
function existingEntries() {
return textarea.value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function addGameToAllowList(name) {
const current = new Set(existingEntries().map((item) => item.toLowerCase()));
if (current.has(name.toLowerCase())) return;
const next = existingEntries();
next.push(name);
textarea.value = next.join("\n");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
function renderPicker() {
const query = pickerSearch.value.trim().toLowerCase();
const selected = new Set(existingEntries().map((item) => item.toLowerCase()));
pickerList.innerHTML = "";
const matches = POPULAR_GAMES.filter((game) => !query || game.toLowerCase().includes(query)).slice(0, 40);
for (const game of matches) {
const button = document.createElement("button");
button.type = "button";
button.textContent = selected.has(game.toLowerCase()) ? `${game}` : game;
button.title = selected.has(game.toLowerCase()) ? "Already added" : `Add ${game}`;
button.style.cssText = `
padding: 8px 10px;
border-radius: 10px;
border: 1px solid ${selected.has(game.toLowerCase()) ? "rgba(104, 126, 255, 0.55)" : "rgba(255,255,255,0.12)"};
background: ${selected.has(game.toLowerCase()) ? "rgba(104, 126, 255, 0.16)" : "rgba(255,255,255,0.05)"};
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
button.addEventListener("click", () => addGameToAllowList(game));
pickerList.appendChild(button);
}
}
pickerSearch.addEventListener("input", renderPicker);
pickerAdd.addEventListener("click", () => {
const query = pickerSearch.value.trim();
if (!query) return;
const exact = POPULAR_GAMES.find((game) => game.toLowerCase() === query.toLowerCase());
if (exact) {
addGameToAllowList(exact);
return;
}
addGameToAllowList(query);
});
textarea.addEventListener("input", renderPicker);
renderPicker();
return panel;
}
function createButton(baseElem) {
const row = baseElem.cloneNode(true);
row.setAttribute(CLONE_ATTR, "true");
const title = row.querySelector("div.d_flex.flex-g_1 > div");
const desc = row.querySelector("div.d_flex.flex-g_1 > span");
const icon = row.querySelector("div.w_36px span.material-symbols-outlined");
const existingIcon = row.querySelector("div.w_36px");
if (title) title.textContent = "Gameplay overlay";
if (desc) desc.textContent = "Shows the mini voice overlay while you are in a game.";
if (icon) icon.textContent = "sports_esports";
if (existingIcon) {
existingIcon.title = "Toggle gameplay sharing settings";
existingIcon.style.cursor = "pointer";
}
const settingsBtn = document.createElement("div");
settingsBtn.title = "Edit gameplay sharing";
settingsBtn.style.cssText = "cursor: pointer; z-index: 10; flex-shrink: 0; margin-left: 6px;";
settingsBtn.innerHTML = `
<div class="fill_var(--md-sys-color-on-surface) bg_var(--md-sys-color-surface-dim) w_36px h_36px d_flex flex-sh_0 ai_center jc_center bdr_var(--borderRadius-full)">
<span aria-hidden="true" class="material-symbols-outlined fs_inherit fw_undefined!" style="display:block;font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0;">settings</span>
</div>
`;
const iconSlot = row.querySelector(".d_flex.ai_center.jc_center, .w_36px");
if (iconSlot && iconSlot.parentNode) {
iconSlot.parentNode.appendChild(settingsBtn);
} else {
row.appendChild(settingsBtn);
}
const panel = buildPanel();
const wrapper = document.createElement("div");
wrapper.style.cssText = "display:flex; flex-direction:column;";
const applyState = () => {
const config = getConfig();
toggleCheckbox(row, config.gamePresenceEnabled);
if (config.gamePresenceEnabled) {
row.setAttribute("data-active", "true");
} else {
row.setAttribute("data-active", "false");
}
};
row.addEventListener("click", (e) => {
if (settingsBtn.contains(e.target)) return;
e.preventDefault();
e.stopPropagation();
const config = getConfig();
config.gamePresenceEnabled = !config.gamePresenceEnabled;
setConfig(config);
applyState();
});
settingsBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
panel.style.display = panel.style.display === "flex" ? "none" : "flex";
});
applyState();
wrapper.appendChild(row);
wrapper.appendChild(panel);
return wrapper;
}
function injectButton() {
const base = Array.from(document.querySelectorAll("a")).find((e) => {
const t = e.querySelector("div.d_flex.flex-g_1 > div");
return t && t.textContent.trim() === "Discord RPC";
});
if (!base) return;
if (document.querySelector(`[${CLONE_ATTR}]`)) return;
const newButton = createButton(base);
base.parentNode.appendChild(newButton);
}
injectButton();
const observer = new MutationObserver(() => injectButton());
observer.observe(document.body, { childList: true, subtree: true });
})();

244
avia_core/headliner.js Normal file
View file

@ -0,0 +1,244 @@
(function () {
if (window.__headliner) return;
window.__headliner = true;
window.__headlinerActive = localStorage.getItem("headlinerActive") === "true";
const TARGET_TEXT = "Spellchecker";
const CLONE_KEY = "data-headliner-cloned";
const STYLE_ID = "headliner-style";
const defaults = {
content: "Sanctum",
left: "32",
top: "56",
fontSize: "15",
fontWeight: "700"
};
function loadSettings() {
try {
let s = JSON.parse(localStorage.getItem("headlinerSettings"));
if (s && /^Sanctum V 1\.0\.[0-9]+$/.test(s.content)) {
s.content = defaults.content;
saveSettings(s);
}
return s || { ...defaults };
} catch {
return { ...defaults };
}
}
function saveSettings(settings) {
localStorage.setItem("headlinerSettings", JSON.stringify(settings));
}
function buildCSS(s) {
return `
svg path[d^="M466.17 254c-12.65"],
svg path[d^="M734.245 254c-15.377"] {
display: none !important;
}
.flex-sh_0.h_29px.us_none.d_flex.ai_center.fill_var\\(--md-sys-color-on-surface\\).c_var\\(--md-sys-color-outline\\).bg_var\\(--md-sys-color-surface-container-high\\) {
position: relative !important;
color: transparent !important;
}
.flex-sh_0.h_29px.us_none.d_flex.ai_center.fill_var\\(--md-sys-color-on-surface\\).c_var\\(--md-sys-color-outline\\).bg_var\\(--md-sys-color-surface-container-high\\)::before {
content: "${s.content}";
position: absolute;
left: ${s.left}px;
top: ${s.top}%;
transform: translateY(-50%);
font-size: ${s.fontSize}px;
font-weight: ${s.fontWeight};
color: var(--md-sys-color-on-surface) !important;
pointer-events: none;
}
`;
}
function applyCSS() {
const settings = loadSettings();
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement("style");
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = buildCSS(settings);
}
function removeCSS() {
const style = document.getElementById(STYLE_ID);
if (style) style.remove();
}
if (window.__headlinerActive) applyCSS();
function applyActiveStyle(clone) {
const desc = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > span");
const checkbox = clone.querySelector("mdui-checkbox");
if (window.__headlinerActive) {
clone.setAttribute("data-active", "true");
if (desc) desc.textContent = "Headliner is ON";
if (checkbox) checkbox.setAttribute("checked", "");
applyCSS();
} else {
clone.setAttribute("data-active", "false");
if (desc) desc.textContent = "Modify the Sanctum name in the titlebar to say anything you want";
if (checkbox) checkbox.removeAttribute("checked");
removeCSS();
}
}
function buildPanel() {
const s = loadSettings();
const panel = document.createElement("div");
panel.id = "headliner-panel";
panel.style.cssText = `
display: none;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 8px;
background: var(--md-sys-color-surface-container-highest);
border: 1px solid var(--md-sys-color-outline-variant);
font-size: 12px;
color: var(--md-sys-color-on-surface);
`;
const fields = [
{ label: "Content", key: "content", type: "text", value: s.content },
{ label: "Left (px)", key: "left", type: "number", value: s.left },
{ label: "Top (%)", key: "top", type: "number", value: s.top },
{ label: "Font Size", key: "fontSize", type: "number", value: s.fontSize },
{ label: "Font Weight", key: "fontWeight", type: "number", value: s.fontWeight }
];
fields.forEach(({ label, key, type, value }) => {
const row = document.createElement("div");
row.style.cssText = "display:flex; align-items:center; justify-content:space-between; gap:8px;";
const lbl = document.createElement("label");
lbl.textContent = label;
lbl.style.cssText = "flex-shrink:0; font-size:11px; opacity:0.8;";
const input = document.createElement("input");
input.type = type;
input.value = value;
input.dataset.key = key;
input.style.cssText = `
width: ${type === "text" ? "160px" : "60px"};
padding: 3px 6px;
border-radius: 4px;
border: 1px solid var(--md-sys-color-outline-variant);
background: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
font-size: 11px;
`;
row.appendChild(lbl);
row.appendChild(input);
panel.appendChild(row);
});
const saveBtn = document.createElement("button");
saveBtn.textContent = "Apply";
saveBtn.style.cssText = `
margin-top: 4px;
padding: 4px 10px;
border-radius: 4px;
border: none;
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
font-size: 11px;
font-weight: 600;
cursor: pointer;
align-self: flex-end;
`;
saveBtn.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
const newSettings = { ...defaults };
panel.querySelectorAll("input").forEach(input => {
newSettings[input.dataset.key] = input.value;
});
saveSettings(newSettings);
if (window.__headlinerActive) applyCSS();
});
panel.appendChild(saveBtn);
return panel;
}
function tryInject() {
document.querySelectorAll("a.pos_relative").forEach(btn => {
if (
btn.hasAttribute(CLONE_KEY) ||
btn.hasAttribute("data-headliner-entry") ||
!btn.innerText.includes(TARGET_TEXT)
) return;
btn.setAttribute(CLONE_KEY, "true");
const clone = btn.cloneNode(true);
clone.removeAttribute(CLONE_KEY);
clone.setAttribute("data-headliner-entry", "true");
clone.setAttribute("data-active", "false");
const title = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > div");
if (title) title.textContent = "Activate headliner";
const settingsBtn = document.createElement("div");
settingsBtn.title = "Edit headliner settings";
settingsBtn.style.cssText = "cursor: pointer; z-index: 10; flex-shrink: 0;";
settingsBtn.innerHTML = `
<div class="fill_var(--md-sys-color-on-surface) bg_var(--md-sys-color-surface-dim) w_36px h_36px d_flex flex-sh_0 ai_center jc_center bdr_var(--borderRadius-full)">
<span aria-hidden="true" class="material-symbols-outlined fs_inherit fw_undefined!" style="display: block; font-variation-settings: &quot;FILL&quot; 0, &quot;wght&quot; 400, &quot;GRAD&quot; 0;">settings</span>
</div>
`;
const existingIcon = clone.querySelector("div.fill_var\\(--md-sys-color-on-surface\\)");
if (existingIcon) {
existingIcon.replaceWith(settingsBtn);
} else {
clone.prepend(settingsBtn);
}
const wrapper = document.createElement("div");
wrapper.style.cssText = "display: flex; flex-direction: column;";
const panel = buildPanel();
settingsBtn.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
panel.style.display = panel.style.display === "flex" ? "none" : "flex";
});
clone.addEventListener("click", e => {
if (settingsBtn.contains(e.target)) return;
e.preventDefault();
e.stopPropagation();
window.__headlinerActive = !window.__headlinerActive;
localStorage.setItem("headlinerActive", window.__headlinerActive);
applyActiveStyle(clone);
});
applyActiveStyle(clone);
wrapper.appendChild(clone);
wrapper.appendChild(panel);
btn.parentNode.insertBefore(wrapper, btn.nextSibling);
});
}
tryInject();
const observer = new MutationObserver(() => tryInject());
observer.observe(document.body, { childList: true, subtree: true });
})();

361
avia_core/inject.js Normal file
View file

@ -0,0 +1,361 @@
(function () {
if (window.__AVIA_WEB_LOADED__) return;
window.__AVIA_WEB_LOADED__ = true;
const LINKTREE_URL = "https://linktr.ee/GermanAvaLilac";
const STOAT_SERVER_URL = "https://stt.gg/GvBhcejB";
function preloadMonaco() {
return new Promise(resolve => {
if (window.monaco) return resolve();
const loader = document.createElement("script");
loader.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js";
loader.onload = function () {
require.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs" } });
require(["vs/editor/editor.main"], () => resolve());
};
document.head.appendChild(loader);
});
}
async function toggleQuickCSSPanel() {
await preloadMonaco();
let panel = document.getElementById('avia-quickcss-panel');
if (panel) {
panel.style.display = panel.style.display === 'none' ? 'flex' : 'none';
return;
}
panel = document.createElement('div');
panel.id = 'avia-quickcss-panel';
Object.assign(panel.style, {
position: 'fixed',
bottom: '24px',
right: '24px',
width: '650px',
height: '420px',
background: 'var(--md-sys-color-surface, #1e1e1e)',
color: 'var(--md-sys-color-on-surface, #fff)',
borderRadius: '16px',
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
zIndex: '999999',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(12px)'
});
const header = document.createElement('div');
header.textContent = 'QuickCSS';
Object.assign(header.style, {
padding: '14px 16px',
fontWeight: '600',
fontSize: '14px',
letterSpacing: '0.3px',
background: 'var(--md-sys-color-surface-container, rgba(255,255,255,0.04))',
borderBottom: '1px solid rgba(255,255,255,0.08)',
cursor: 'move',
color: '#fff'
});
const closeBtn = document.createElement('div');
closeBtn.textContent = '✕';
Object.assign(closeBtn.style, {
position: 'absolute',
top: '12px',
right: '16px',
cursor: 'pointer',
opacity: '0.7',
color: '#fff'
});
closeBtn.onmouseenter = () => closeBtn.style.opacity = '1';
closeBtn.onmouseleave = () => closeBtn.style.opacity = '0.7';
closeBtn.onclick = () => panel.style.display = 'none';
const editorContainer = document.createElement('div');
editorContainer.style.flex = '1';
panel.appendChild(header);
panel.appendChild(closeBtn);
panel.appendChild(editorContainer);
document.body.appendChild(panel);
const editor = monaco.editor.create(editorContainer, {
value: localStorage.getItem('avia_quickcss') || '',
language: 'css',
theme: 'vs-dark',
automaticLayout: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
wordWrap: 'on'
});
editor.onDidChangeModelContent(() => {
const value = editor.getValue();
localStorage.setItem('avia_quickcss', value);
applyQuickCSS(value);
});
let isDragging = false, offsetX, offsetY;
header.addEventListener('mousedown', e => {
isDragging = true;
offsetX = e.clientX - panel.offsetLeft;
offsetY = e.clientY - panel.offsetTop;
document.body.style.userSelect = 'none';
});
document.addEventListener('mouseup', () => {
isDragging = false;
document.body.style.userSelect = '';
});
document.addEventListener('mousemove', e => {
if (!isDragging) return;
panel.style.left = (e.clientX - offsetX) + 'px';
panel.style.top = (e.clientY - offsetY) + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
});
}
function setIcon(button, type) {
const oldSvg = button.querySelector('svg');
if (oldSvg) oldSvg.remove();
const icons = {
monitor: "M3 4h18v12H3V4zm2 2v8h14V6H5zm3 12h8v2H8v-2z",
upload: "M5 20h14v-2H5v2zm7-18L5.33 9h3.84v4h4.66V9h3.84L12 2z",
refresh: "M17.65 6.35A7.95 7.95 0 0012 4V1L7 6l5 5V7a5 5 0 11-5 5H5a7 7 0 107.75-6.65z",
code: "M8.7 16.3L4.4 12l4.3-4.3 1.4 1.4L7.2 12l2.9 2.9-1.4 1.4zm6.6 0l-1.4-1.4L16.8 12l-2.9-2.9 1.4-1.4L19.6 12l-4.3 4.3z"
};
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", "20");
svg.setAttribute("height", "20");
svg.setAttribute("fill", "currentColor");
svg.style.marginRight = "8px";
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", icons[type]);
svg.appendChild(path);
button.insertBefore(svg, button.firstChild);
}
function showFontLoaderPopup() {
removeExistingPopup();
const popup = document.createElement('div');
popup.id = 'avia-font-loader-popup';
Object.assign(popup.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '16px',
background: '#1e1e1e',
color: '#fff',
borderRadius: '12px',
boxShadow: '0 8px 20px rgba(0,0,0,0.5)',
zIndex: 999999,
minWidth: '320px'
});
popup.innerHTML = `
<div style="margin-bottom:8px;">Paste font URL (.ttf, .woff, etc.)</div>
<input id="avia-font-url" type="text" style="width:100%; padding:6px; margin-bottom:8px; border-radius:6px; border:none; outline:none;"/>
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button id="avia-font-apply" style="padding:6px 12px;">Apply</button>
<button id="avia-font-cancel" style="padding:6px 12px;">Cancel</button>
</div>
`;
document.body.appendChild(popup);
document.getElementById('avia-font-apply').onclick = () => {
const url = document.getElementById('avia-font-url').value;
if (!url) return;
localStorage.setItem('avia_custom_font_url', url);
applyFont(url);
alert("Font Applied.");
popup.remove();
};
document.getElementById('avia-font-cancel').onclick = () => popup.remove();
}
function showRemoveFontPopup() {
removeExistingPopup();
const popup = document.createElement('div');
popup.id = 'avia-remove-font-popup';
Object.assign(popup.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '16px',
background: '#1e1e1e',
color: '#fff',
borderRadius: '12px',
boxShadow: '0 8px 20px rgba(0,0,0,0.5)',
zIndex: 999999,
minWidth: '280px',
textAlign: 'center'
});
popup.innerHTML = `
<div style="margin-bottom:12px;">Are you sure you want to remove the custom font?</div>
<button id="avia-font-remove" style="padding:6px 12px;">Remove Font</button>
<button id="avia-font-cancel" style="padding:6px 12px; margin-left:6px;">Cancel</button>
`;
document.body.appendChild(popup);
document.getElementById('avia-font-remove').onclick = () => {
removeFont();
popup.remove();
};
document.getElementById('avia-font-cancel').onclick = () => popup.remove();
}
function removeExistingPopup() {
const existing = document.getElementById('avia-font-loader-popup') || document.getElementById('avia-remove-font-popup');
if (existing) existing.remove();
}
function applyFont(url) {
const fontName = "CustomFont" + Date.now();
let styleTag = document.getElementById('custom-font-style');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'custom-font-style';
document.head.appendChild(styleTag);
}
const ext = url.split('.').pop().toLowerCase();
const formatMap = {
ttf: 'truetype',
otf: 'opentype',
woff: 'woff',
woff2: 'woff2',
eot: 'embedded-opentype',
css: 'truetype'
};
const format = formatMap[ext] || '';
styleTag.textContent = `
@font-face {
font-family: '${fontName}';
src: url('${url}')${format ? " format('" + format + "')" : ""};
font-weight: normal;
font-style: normal;
}
body, body *:not(.material-symbols-outlined) {
font-family: '${fontName}', sans-serif !important;
}
`;
}
function removeFont() {
localStorage.removeItem('avia_custom_font_url');
const styleTag = document.getElementById('custom-font-style');
if (styleTag) styleTag.remove();
alert("Reverted Font To Original Settings.");
}
(function applySavedFont() {
const savedUrl = localStorage.getItem('avia_custom_font_url');
if (savedUrl) applyFont(savedUrl);
})();
function injectButtons() {
const appearanceBtn = Array.from(document.querySelectorAll('a')).find(a => a.textContent.trim() === 'Appearance');
if (!appearanceBtn) return;
const aviaHeader = [...document.querySelectorAll('span')]
.find(s => s.textContent.trim() === "AVIA CLIENT SETTINGS");
if (!aviaHeader) return;
const aviaContainer = aviaHeader.closest('.d_flex.flex-d_column');
if (!aviaContainer) return;
const targetParent = aviaContainer.querySelector('.d_flex.flex-d_column.gap_var\\(--gap-s\\)');
if (!targetParent) return;
if (!document.getElementById('stoat-fake-linktree')) {
const linktreeBtn = appearanceBtn.cloneNode(true);
linktreeBtn.id = 'stoat-fake-linktree';
const textNode = Array.from(linktreeBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
if (textNode) textNode.textContent = "(Sanctum) Ava's Linktree";
setIcon(linktreeBtn, "monitor");
linktreeBtn.addEventListener('click', () => window.open(LINKTREE_URL, "_blank"));
targetParent.appendChild(linktreeBtn);
const stoatBtn = appearanceBtn.cloneNode(true);
stoatBtn.id = 'stoat-fake-stoatserver';
const stoatTextNode = Array.from(stoatBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
if (stoatTextNode) stoatTextNode.textContent = "(Sanctum) Stoat Server";
setIcon(stoatBtn, "monitor");
stoatBtn.addEventListener('click', () => window.open(STOAT_SERVER_URL, "_blank"));
targetParent.appendChild(stoatBtn);
}
if (!document.getElementById('stoat-fake-loadfont')) {
const newBtn = appearanceBtn.cloneNode(true);
newBtn.id = 'stoat-fake-loadfont';
const textNode = Array.from(newBtn.querySelectorAll('div')).find(d => d.children.length === 0);
if (textNode) textNode.textContent = "(Sanctum) Font Loader";
setIcon(newBtn, "upload");
newBtn.addEventListener('click', showFontLoaderPopup);
targetParent.appendChild(newBtn);
if (!document.getElementById('stoat-fake-removefont')) {
const removeBtn = appearanceBtn.cloneNode(true);
removeBtn.id = 'stoat-fake-removefont';
const removeTextNode = Array.from(removeBtn.querySelectorAll('div')).find(d => d.children.length === 0);
if (removeTextNode) removeTextNode.textContent = "(Sanctum) Remove selected font";
setIcon(removeBtn, "refresh");
removeBtn.addEventListener('click', showRemoveFontPopup);
targetParent.appendChild(removeBtn);
}
}
if (!document.getElementById('stoat-fake-quickcss')) {
const quickCssBtn = appearanceBtn.cloneNode(true);
quickCssBtn.id = 'stoat-fake-quickcss';
const quickCssTextNode = Array.from(quickCssBtn.querySelectorAll('div')).find(d => d.children.length === 0);
if (quickCssTextNode) quickCssTextNode.textContent = "(Sanctum) QuickCSS";
setIcon(quickCssBtn, "code");
quickCssBtn.addEventListener('click', toggleQuickCSSPanel);
targetParent.appendChild(quickCssBtn);
}
}
function applyQuickCSS(css) {
let styleTag = document.getElementById('avia-quickcss-style');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'avia-quickcss-style';
document.head.appendChild(styleTag);
}
styleTag.textContent = css;
}
(function applySavedQuickCSS() {
const savedCSS = localStorage.getItem('avia_quickcss');
if (savedCSS) applyQuickCSS(savedCSS);
})();
function waitForBody(callback) {
if (document.body) callback();
else new MutationObserver((obs) => {
if (document.body) {
obs.disconnect();
callback();
}
}).observe(document.documentElement, { childList: true });
}
waitForBody(() => {
const observer = new MutationObserver(() => injectButtons());
observer.observe(document.body, { childList: true, subtree: true });
injectButtons();
});
preloadMonaco();
})();

575
avia_core/pluginsupport.js Normal file
View file

@ -0,0 +1,575 @@
(function () {
if (window.__AVIA_PLUGINS_LOADED__) return;
window.__AVIA_PLUGINS_LOADED__ = true;
const STORAGE_KEY = "avia_plugins";
const runningPlugins = {};
const pluginErrors = {};
const injectionQueue = [];
const getPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
const setPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
function normalizePluginUrl(url) {
try {
const u = new URL(url);
if (u.hostname === "github.com") {
const m = u.pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/);
if (m) {
return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}/${m[4]}`;
}
return url;
}
if (u.hostname === "raw.githubusercontent.com") return url;
if (u.hostname === "raw.codeberg.page") return url;
if (u.hostname === "codeberg.org") {
const parts = u.pathname.split("/").filter(Boolean);
if (parts.length >= 5 && (parts[2] === "raw" || parts[2] === "src")) {
const user = parts[0];
const repo = parts[1];
const branchName = parts[3] === "branch" || parts[3] === "commit" || parts[3] === "tag"
? parts[4]
: parts[3];
const fileStart = parts[3] === "branch" || parts[3] === "commit" || parts[3] === "tag"
? 5
: 4;
const filePath = parts.slice(fileStart).join("/");
return `https://raw.codeberg.page/${user}/${repo}/@${branchName}/${filePath}`;
}
if (parts.length >= 4 && parts[2] === "raw") {
const user = parts[0];
const repo = parts[1];
const branchName = parts[3];
const filePath = parts.slice(4).join("/");
return `https://raw.codeberg.page/${user}/${repo}/@${branchName}/${filePath}`;
}
}
} catch (_) {
}
return url;
}
async function processQueue() {
if (processQueue.running) return;
processQueue.running = true;
while (injectionQueue.length) {
const { plugin, force } = injectionQueue.shift();
await loadPluginInternal(plugin, force);
}
processQueue.running = false;
}
function queuePlugin(plugin, force = false) {
injectionQueue.push({ plugin, force });
processQueue();
}
async function loadPluginInternal(plugin, force = false) {
if (runningPlugins[plugin.url] && !force) return;
if (force) stopPlugin(plugin);
try {
const fetchUrl = normalizePluginUrl(plugin.url);
const res = await fetch(fetchUrl);
if (!res.ok) throw new Error("Fetch failed");
const code = await res.text();
delete pluginErrors[plugin.url];
const script = document.createElement("script");
script.textContent = code;
script.dataset.pluginUrl = plugin.url;
document.body.appendChild(script);
runningPlugins[plugin.url] = script;
} catch {
pluginErrors[plugin.url] = true;
}
renderPanel();
}
function stopPlugin(plugin) {
const script = runningPlugins[plugin.url];
if (!script) return;
script.remove();
delete runningPlugins[plugin.url];
delete pluginErrors[plugin.url];
renderPanel();
}
function preloadMonaco() {
return new Promise(resolve => {
if (window.monaco) return resolve();
const loader = document.createElement("script");
loader.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js";
loader.onload = function () {
require.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs" } });
require(["vs/editor/editor.main"], () => resolve());
};
document.head.appendChild(loader);
});
}
async function openViewerPanel(plugin) {
await preloadMonaco();
const existing = document.getElementById("avia-plugin-viewer-panel");
if (existing) existing.remove();
const panel = document.createElement("div");
panel.id = "avia-plugin-viewer-panel";
Object.assign(panel.style, {
position: "fixed",
bottom: "24px",
left: "24px",
width: "700px",
height: "480px",
background: "var(--md-sys-color-surface, #1e1e1e)",
borderRadius: "16px",
boxShadow: "0 8px 28px rgba(0,0,0,0.45)",
zIndex: "9999999",
display: "flex",
flexDirection: "column",
overflow: "hidden",
border: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(12px)",
color: "#fff"
});
const header = document.createElement("div");
Object.assign(header.style, {
padding: "14px 16px",
fontWeight: "600",
fontSize: "14px",
background: "var(--md-sys-color-surface-container, rgba(255,255,255,0.04))",
borderBottom: "1px solid rgba(255,255,255,0.08)",
cursor: "move",
display: "flex",
alignItems: "center",
gap: "10px",
flex: "0 0 auto"
});
const titleText = document.createElement("span");
titleText.textContent = `Viewing: ${plugin.name}`;
titleText.style.flex = "1";
const readOnlyBadge = document.createElement("span");
readOnlyBadge.textContent = "READ ONLY";
Object.assign(readOnlyBadge.style, {
fontSize: "10px",
fontWeight: "700",
letterSpacing: "0.08em",
padding: "2px 8px",
borderRadius: "20px",
background: "rgba(255,180,0,0.15)",
color: "#ffb400",
border: "1px solid rgba(255,180,0,0.3)"
});
const closeBtn = document.createElement("div");
closeBtn.textContent = "✕";
Object.assign(closeBtn.style, {
cursor: "pointer",
opacity: "0.6",
fontSize: "15px",
lineHeight: "1",
padding: "2px 4px"
});
closeBtn.onmouseenter = () => closeBtn.style.opacity = "1";
closeBtn.onmouseleave = () => closeBtn.style.opacity = "0.6";
closeBtn.onclick = () => panel.remove();
header.appendChild(titleText);
header.appendChild(readOnlyBadge);
header.appendChild(closeBtn);
const urlBar = document.createElement("div");
Object.assign(urlBar.style, {
padding: "8px 16px",
borderBottom: "1px solid rgba(255,255,255,0.06)",
fontSize: "11px",
color: "rgba(255,255,255,0.35)",
fontFamily: "monospace",
background: "rgba(0,0,0,0.15)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: "0 0 auto"
});
urlBar.textContent = plugin.url;
urlBar.title = plugin.url;
const editorContainer = document.createElement("div");
editorContainer.style.flex = "1";
editorContainer.style.overflow = "hidden";
const loadingMsg = document.createElement("div");
Object.assign(loadingMsg.style, {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
opacity: "0.4",
fontSize: "13px"
});
loadingMsg.textContent = "Fetching source…";
editorContainer.appendChild(loadingMsg);
panel.appendChild(header);
panel.appendChild(urlBar);
panel.appendChild(editorContainer);
document.body.appendChild(panel);
enableDragOn(panel, header);
let code;
try {
const fetchUrl = normalizePluginUrl(plugin.url);
const res = await fetch(fetchUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
code = await res.text();
} catch (err) {
loadingMsg.textContent = `Failed to fetch source: ${err.message}`;
loadingMsg.style.color = "#ff4d4d";
loadingMsg.style.opacity = "1";
return;
}
editorContainer.removeChild(loadingMsg);
monaco.editor.create(editorContainer, {
value: code,
language: "javascript",
theme: "vs-dark",
readOnly: true,
automaticLayout: true,
minimap: { enabled: true },
fontSize: 13,
scrollBeyondLastLine: false,
wordWrap: "off",
domReadOnly: true,
renderValidationDecorations: "off",
renderLineHighlight: "none",
cursorStyle: "block",
cursorBlinking: "solid"
});
}
function togglePluginsPanel() {
let panel = document.getElementById('avia-plugins-panel');
if (panel) {
panel.style.display = panel.style.display === 'none' ? 'flex' : 'none';
return;
}
panel = document.createElement('div');
panel.id = 'avia-plugins-panel';
Object.assign(panel.style, {
position: 'fixed',
bottom: '24px',
right: '24px',
width: '520px',
height: '460px',
background: 'var(--md-sys-color-surface, #1e1e1e)',
color: 'var(--md-sys-color-on-surface, #fff)',
borderRadius: '16px',
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
zIndex: '999999',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(12px)'
});
const header = document.createElement('div');
header.textContent = 'Plugins';
Object.assign(header.style, {
padding: '14px 16px',
fontWeight: '600',
fontSize: '14px',
background: 'var(--md-sys-color-surface-container, rgba(255,255,255,0.04))',
borderBottom: '1px solid rgba(255,255,255,0.08)',
cursor: 'move'
});
const closeBtn = document.createElement('div');
closeBtn.textContent = '✕';
Object.assign(closeBtn.style, {
position: 'absolute',
top: '12px',
right: '16px',
cursor: 'pointer',
opacity: '0.7'
});
closeBtn.onclick = () => panel.style.display = 'none';
const controlsBar = document.createElement('div');
Object.assign(controlsBar.style, {
padding: '12px 16px',
display: 'flex',
gap: '8px',
alignItems: 'center',
borderBottom: '1px solid rgba(255,255,255,0.08)',
flex: '0 0 auto'
});
const content = document.createElement('div');
content.id = 'avia-plugins-content';
Object.assign(content.style, {
flex: '1',
overflow: 'auto',
padding: '16px'
});
const nameInput = document.createElement('input');
nameInput.placeholder = 'Name';
styleInput(nameInput);
nameInput.style.width = '110px';
const urlInput = document.createElement('input');
urlInput.placeholder = 'Plugin URL';
styleInput(urlInput);
urlInput.style.flex = '1';
const addBtn = document.createElement('button');
addBtn.textContent = '+ Add';
styleBtn(addBtn);
addBtn.onclick = () => {
const name = nameInput.value.trim();
const url = urlInput.value.trim();
if (!name || !url) return;
const plugins = getPlugins();
plugins.push({ name, url, enabled: false });
setPlugins(plugins);
nameInput.value = '';
urlInput.value = '';
renderPanel();
};
const refreshAll = document.createElement('button');
refreshAll.textContent = 'Refresh';
styleBtn(refreshAll);
refreshAll.onclick = () => {
const plugins = getPlugins();
plugins.forEach(p => {
if (p.enabled) queuePlugin(p, true);
});
};
controlsBar.appendChild(nameInput);
controlsBar.appendChild(urlInput);
controlsBar.appendChild(addBtn);
controlsBar.appendChild(refreshAll);
panel.appendChild(header);
panel.appendChild(closeBtn);
panel.appendChild(controlsBar);
panel.appendChild(content);
document.body.appendChild(panel);
enableDragOn(panel, header);
renderPanel();
}
function renderPanel() {
const content = document.getElementById('avia-plugins-content');
if (!content) return;
content.innerHTML = '';
const plugins = getPlugins();
const runningSnapshot = { ...runningPlugins };
const errorSnapshot = { ...pluginErrors };
if (plugins.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'No plugins yet. Add one above.';
Object.assign(empty.style, { opacity: '0.4', fontSize: '13px' });
content.appendChild(empty);
return;
}
plugins.forEach((plugin, index) => {
const isRunning = !!runningSnapshot[plugin.url];
const hasError = !!errorSnapshot[plugin.url];
const row = document.createElement('div');
Object.assign(row.style, {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
padding: '10px 12px',
borderRadius: '10px',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)'
});
const left = document.createElement('div');
Object.assign(left.style, { display: 'flex', alignItems: 'center', gap: '10px' });
const statusDot = document.createElement('div');
Object.assign(statusDot.style, {
width: '10px',
height: '10px',
borderRadius: '50%',
flexShrink: '0'
});
if (hasError) {
statusDot.style.background = '#ff4d4d';
statusDot.style.boxShadow = '0 0 6px #ff4d4d';
} else if (isRunning) {
statusDot.style.background = '#4dff88';
statusDot.style.boxShadow = '0 0 6px #4dff88';
} else {
statusDot.style.background = '#777';
}
const name = document.createElement('div');
name.textContent = plugin.name;
name.style.fontSize = '13px';
left.appendChild(statusDot);
left.appendChild(name);
const controls = document.createElement('div');
Object.assign(controls.style, { display: 'flex', gap: '6px' });
const toggle = document.createElement('button');
toggle.textContent = plugin.enabled ? 'Disable' : 'Enable';
styleBtn(toggle);
toggle.onclick = () => {
plugin.enabled = !plugin.enabled;
setPlugins(plugins);
if (plugin.enabled) queuePlugin(plugin);
else stopPlugin(plugin);
renderPanel();
};
const viewBtn = document.createElement('button');
viewBtn.textContent = 'View';
styleBtn(viewBtn, 'rgba(100,160,255,0.15)');
viewBtn.onclick = () => openViewerPanel(plugin);
const remove = document.createElement('button');
remove.textContent = '✕';
styleBtn(remove, 'rgba(255,80,80,0.15)');
remove.onclick = () => {
stopPlugin(plugin);
plugins.splice(index, 1);
setPlugins(plugins);
renderPanel();
};
controls.appendChild(toggle);
controls.appendChild(viewBtn);
controls.appendChild(remove);
row.appendChild(left);
row.appendChild(controls);
content.appendChild(row);
});
}
function styleInput(input) {
Object.assign(input.style, {
padding: '6px 8px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '13px'
});
}
function styleBtn(btn, bg) {
Object.assign(btn.style, {
padding: '5px 12px',
borderRadius: '8px',
border: 'none',
background: bg || 'rgba(255,255,255,0.08)',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
whiteSpace: 'nowrap'
});
btn.onmouseenter = () => btn.style.opacity = '0.75';
btn.onmouseleave = () => btn.style.opacity = '1';
}
function enableDragOn(panel, header) {
let isDragging = false, offsetX, offsetY;
header.addEventListener('mousedown', e => {
isDragging = true;
offsetX = e.clientX - panel.offsetLeft;
offsetY = e.clientY - panel.offsetTop;
document.body.style.userSelect = 'none';
});
document.addEventListener('mouseup', () => {
isDragging = false;
document.body.style.userSelect = '';
});
document.addEventListener('mousemove', e => {
if (!isDragging) return;
panel.style.left = (e.clientX - offsetX) + 'px';
panel.style.top = (e.clientY - offsetY) + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
});
}
function injectButtons() {
if (document.getElementById('stoat-fake-plugins')) return;
const appearanceBtn = [...document.querySelectorAll('a')]
.find(a => a.textContent.trim() === 'Appearance');
if (!appearanceBtn) return;
const referenceNode = document.getElementById('stoat-fake-quickcss');
if (!referenceNode) return;
const pluginsBtn = appearanceBtn.cloneNode(true);
pluginsBtn.id = 'stoat-fake-plugins';
const textNode = [...pluginsBtn.querySelectorAll('div')]
.find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
if (textNode) textNode.textContent = "(Sanctum) Plugins";
const svgNS = "http://www.w3.org/2000/svg";
const oldSvg = pluginsBtn.querySelector('svg');
if (oldSvg) oldSvg.remove();
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", "20");
svg.setAttribute("height", "20");
svg.setAttribute("fill", "currentColor");
svg.style.marginRight = "8px";
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", "M20.5 11H19V7a2 2 0 00-2-2h-4V3.5a2.5 2.5 0 00-5 0V5H4a2 2 0 00-2 2v3.8h1.5c1.5 0 2.7 1.2 2.7 2.7S5 16.2 3.5 16.2H2V20a2 2 0 002 2h3.8v-1.5c0-1.5 1.2-2.7 2.7-2.7s2.7 1.2 2.7 2.7V22H17a2 2 0 002-2v-4h1.5a2.5 2.5 0 000-5z");
svg.appendChild(path);
pluginsBtn.insertBefore(svg, pluginsBtn.firstChild);
pluginsBtn.addEventListener('click', togglePluginsPanel);
referenceNode.parentElement.insertBefore(pluginsBtn, referenceNode.nextSibling);
}
function waitForBody(callback) {
if (document.body) callback();
else new MutationObserver((obs) => {
if (document.body) { obs.disconnect(); callback(); }
}).observe(document.documentElement, { childList: true });
}
waitForBody(() => {
const observer = new MutationObserver(() => injectButtons());
observer.observe(document.body, { childList: true, subtree: true });
injectButtons();
preloadMonaco();
});
getPlugins().forEach(plugin => {
if (plugin.enabled) queuePlugin(plugin);
});
})();

479
avia_core/repofrontend.js Normal file
View file

@ -0,0 +1,479 @@
(function () {
if (window.__AVIA_OFFICIAL_REPO_LOADED__) return;
window.__AVIA_OFFICIAL_REPO_LOADED__ = true;
const STORAGE_KEY = "avia_plugins";
const OFFICIAL_REPO_URL = "https://avalilac.github.io/PluginRepo/pluginrepobackend.js";
const THEMES_REGISTRY_URL = "https://avalilac.github.io/PluginRepo/themebackend/themerepobackend.js";
const getPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
const setPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
let repoContent;
let currentRepoData = [];
let currentThemeData = [];
let searchInput;
let activeTab = "plugins"; // "plugins" | "themes"
document.getElementById("avia-official-repo-btn")?.remove();
function triggerManagerRefresh() {
const panel = document.getElementById("avia-plugins-panel");
if (!panel) return;
const refreshBtn = Array.from(panel.querySelectorAll("button"))
.find(b => b.textContent.trim() === "Refresh");
if (refreshBtn) refreshBtn.click();
}
function updateInstallStates() {
if (!repoContent) return;
const installed = getPlugins().map(p => p.url);
repoContent.querySelectorAll("[data-link]").forEach(row => {
const link = row.getAttribute("data-link");
const btn = row.querySelector("button.install-btn");
if (!btn) return;
if (installed.includes(link)) {
btn.textContent = "Installed";
btn.disabled = true;
} else {
btn.textContent = "Install";
btn.disabled = false;
}
});
}
function renderRepo(data, filter = "") {
if (!repoContent) return;
currentRepoData = data.plugins;
repoContent.innerHTML = "";
const filtered = currentRepoData.filter(p =>
(p.name + " " + (p.author || "") + " " + (p.description || ""))
.toLowerCase()
.includes(filter.toLowerCase())
);
if (filtered.length === 0) {
repoContent.innerHTML = `<div style="opacity:0.5;text-align:center;margin-top:30px;">No plugins found.</div>`;
return;
}
filtered.forEach(repoPlugin => {
const row = document.createElement("div");
row.style.cssText = "display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;width:100%;min-width:0;";
row.setAttribute("data-link", repoPlugin.link);
const left = document.createElement("div");
left.style.cssText = "display:flex;flex-direction:column;flex:1;min-width:0;";
const title = document.createElement("div");
title.textContent = `${repoPlugin.name}${repoPlugin.author || "Unknown"}`;
title.style.cssText = "font-weight:500;word-break:break-word;";
const desc = document.createElement("div");
desc.textContent = repoPlugin.description || "";
desc.style.cssText = "font-size:12px;opacity:0.7;word-break:break-word;";
left.appendChild(title);
left.appendChild(desc);
const installBtn = document.createElement("button");
installBtn.className = "install-btn";
Object.assign(installBtn.style, {
padding: "6px 10px",
borderRadius: "8px",
border: "none",
cursor: "pointer",
background: "rgba(255,255,255,0.08)",
color: "#fff",
flexShrink: "0"
});
installBtn.onclick = () => {
const plugins = getPlugins();
if (!plugins.some(p => p.url === repoPlugin.link)) {
plugins.push({ name: repoPlugin.name, url: repoPlugin.link, enabled: false });
setPlugins(plugins);
window.dispatchEvent(new Event("avia-plugin-list-changed"));
triggerManagerRefresh();
renderRepo({ plugins: currentRepoData }, searchInput.value);
}
};
row.appendChild(left);
row.appendChild(installBtn);
repoContent.appendChild(row);
});
updateInstallStates();
}
function refetchPlugins() {
if (!repoContent) return;
repoContent.innerHTML = "Loading...";
function electronFetch() {
try {
const https = require("https");
https.get(OFFICIAL_REPO_URL, res => {
let data = "";
res.on("data", chunk => data += chunk);
res.on("end", () => renderRepo(JSON.parse(data)));
}).on("error", () => {
repoContent.innerHTML = "Failed to fetch repo.";
});
} catch {
repoContent.innerHTML = "Failed to fetch repo.";
}
}
try {
fetch(OFFICIAL_REPO_URL)
.then(res => res.json())
.then(data => renderRepo(data))
.catch(() => electronFetch());
} catch {
electronFetch();
}
}
const THEMES_STORAGE_KEY = "avia_themes";
const getStoredThemes = () => JSON.parse(localStorage.getItem(THEMES_STORAGE_KEY) || "[]");
const setStoredThemes = (data) => localStorage.setItem(THEMES_STORAGE_KEY, JSON.stringify(data));
function buildThemeCSS(theme, rawCSS) {
const header = `/* @name ${theme.name}\n @author ${theme.author || "Unknown"}\n @version 1.0\n @description Installed from Trusted Themes Repo\n*/\n`;
return header + rawCSS;
}
function installThemeCSS(theme, btn) {
btn.disabled = true;
btn.textContent = "Installing…";
fetch(theme.download)
.then(r => r.text())
.then(rawCSS => {
const css = buildThemeCSS(theme, rawCSS);
const themes = getStoredThemes();
const alreadyInstalled = themes.some(t => {
const match = t.css.match(/@name\s+(.+)/);
return match && match[1].trim() === theme.name;
});
if (alreadyInstalled) {
btn.textContent = "Installed";
return;
}
themes.push({ id: crypto.randomUUID(), css, enabled: true });
setStoredThemes(themes);
document.querySelectorAll(".avia-theme-style").forEach(e => e.remove());
getStoredThemes().forEach(t => {
if (!t.enabled) return;
const style = document.createElement("style");
style.className = "avia-theme-style";
style.textContent = t.css;
document.head.appendChild(style);
});
if (typeof window.__avia_refresh_themes_panel === "function") {
window.__avia_refresh_themes_panel();
}
btn.textContent = "Installed";
})
.catch(() => {
btn.textContent = "Install CSS";
btn.disabled = false;
alert("Failed to fetch theme CSS.");
});
}
function renderThemes(filter = "") {
if (!repoContent) return;
repoContent.innerHTML = "";
const filtered = currentThemeData.filter(t =>
(t.name + " " + (t.author || ""))
.toLowerCase()
.includes(filter.toLowerCase())
);
if (filtered.length === 0) {
repoContent.innerHTML = `<div style="opacity:0.5;text-align:center;margin-top:30px;">No themes found.</div>`;
return;
}
filtered.forEach(theme => {
const card = document.createElement("div");
card.style.cssText = "margin-bottom:14px;background:rgba(255,255,255,0.04);border-radius:12px;overflow:hidden;border:1px solid rgba(255,255,255,0.07);";
if (theme.preview) {
const img = document.createElement("img");
img.src = theme.preview;
img.alt = theme.name;
img.style.cssText = "width:100%;display:block;background:#111;object-fit:contain;";
img.onerror = () => img.style.display = "none";
card.appendChild(img);
}
const info = document.createElement("div");
info.style.cssText = "display:flex;justify-content:space-between;align-items:center;padding:10px 12px;gap:8px;";
const meta = document.createElement("div");
meta.style.cssText = "display:flex;flex-direction:column;min-width:0;flex:1;";
const name = document.createElement("div");
name.textContent = theme.name;
name.style.cssText = "font-weight:500;word-break:break-word;";
const author = document.createElement("div");
author.textContent = `by ${theme.author || "Unknown"}`;
author.style.cssText = "font-size:12px;opacity:0.6;";
meta.appendChild(name);
meta.appendChild(author);
const alreadyInstalled = getStoredThemes().some(t => {
const match = t.css.match(/@name\s+(.+)/);
return match && match[1].trim() === theme.name;
});
const dlBtn = document.createElement("button");
dlBtn.textContent = alreadyInstalled ? "Installed" : "Install CSS";
dlBtn.disabled = alreadyInstalled;
Object.assign(dlBtn.style, {
padding: "6px 10px",
borderRadius: "8px",
border: "none",
cursor: alreadyInstalled ? "default" : "pointer",
background: "rgba(255,255,255,0.08)",
color: "#fff",
flexShrink: "0",
fontSize: "12px",
whiteSpace: "nowrap"
});
dlBtn.onclick = () => installThemeCSS(theme, dlBtn);
info.appendChild(meta);
info.appendChild(dlBtn);
card.appendChild(info);
repoContent.appendChild(card);
});
}
function refetchThemes() {
if (!repoContent) return;
repoContent.innerHTML = "Loading themes...";
currentThemeData = [];
fetch(THEMES_REGISTRY_URL)
.then(r => r.json())
.then(async registry => {
const sources = registry.sources || [];
const results = await Promise.allSettled(
sources.map(s => fetch(s.url).then(r => r.json()))
);
results.forEach(r => {
if (r.status === "fulfilled") {
currentThemeData.push(...(r.value.themes || []));
}
});
renderThemes(searchInput.value);
})
.catch(() => {
if (repoContent) repoContent.innerHTML = "Failed to fetch themes.";
});
}
function switchTab(tab, tabPluginsBtn, tabThemesBtn) {
activeTab = tab;
const isPlugins = tab === "plugins";
tabPluginsBtn.style.background = isPlugins ? "rgba(255,255,255,0.12)" : "transparent";
tabPluginsBtn.style.color = isPlugins ? "#fff" : "rgba(255,255,255,0.45)";
tabThemesBtn.style.background = !isPlugins ? "rgba(255,255,255,0.12)" : "transparent";
tabThemesBtn.style.color = !isPlugins ? "#fff" : "rgba(255,255,255,0.45)";
searchInput.placeholder = isPlugins
? "Search plugins, authors, or descriptions"
: "Search themes or authors";
searchInput.value = "";
if (isPlugins) {
if (currentRepoData.length > 0) renderRepo({ plugins: currentRepoData });
else refetchPlugins();
} else {
if (currentThemeData.length > 0) renderThemes();
else refetchThemes();
}
}
function openWindow() {
let panel = document.getElementById("avia-official-repo-window");
if (panel) {
panel.style.display = panel.style.display === "none" ? "flex" : "none";
return;
}
panel = document.createElement("div");
panel.id = "avia-official-repo-window";
Object.assign(panel.style, {
position: "fixed",
bottom: "40px",
right: "40px",
width: "420px",
height: "520px",
background: "#1e1e1e",
color: "#fff",
borderRadius: "20px",
boxShadow: "0 12px 35px rgba(0,0,0,0.45)",
zIndex: 999999,
display: "flex",
flexDirection: "column",
overflow: "hidden",
border: "1px solid rgba(255,255,255,0.08)"
});
const header = document.createElement("div");
header.textContent = "Plugins & Themes Repo";
Object.assign(header.style, {
padding: "18px",
fontWeight: "600",
fontSize: "16px",
background: "rgba(255,255,255,0.04)",
borderBottom: "1px solid rgba(255,255,255,0.08)",
cursor: "move",
position: "relative",
textAlign: "center",
userSelect: "none"
});
let isDragging = false, offsetX = 0, offsetY = 0;
header.addEventListener("mousedown", (e) => {
isDragging = true;
const rect = panel.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
panel.style.bottom = "auto";
panel.style.right = "auto";
panel.style.left = rect.left + "px";
panel.style.top = rect.top + "px";
document.body.style.userSelect = "none";
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
panel.style.left = e.clientX - offsetX + "px";
panel.style.top = e.clientY - offsetY + "px";
});
document.addEventListener("mouseup", () => {
isDragging = false;
document.body.style.userSelect = "";
});
const close = document.createElement("div");
close.textContent = "✕";
Object.assign(close.style, { position: "absolute", right: "18px", top: "16px", cursor: "pointer" });
close.onclick = () => panel.style.display = "none";
header.appendChild(close);
const tabs = document.createElement("div");
tabs.style.cssText = "display:flex;gap:6px;padding:10px 12px 0;background:rgba(255,255,255,0.02);border-bottom:1px solid rgba(255,255,255,0.08);";
const tabStyle = "padding:6px 16px;border-radius:8px 8px 0 0;border:none;cursor:pointer;font-size:13px;font-weight:500;transition:background 0.15s,color 0.15s;font-family:inherit;";
const tabPluginsBtn = document.createElement("button");
tabPluginsBtn.textContent = "Plugins";
tabPluginsBtn.style.cssText = tabStyle;
const tabThemesBtn = document.createElement("button");
tabThemesBtn.textContent = "Themes";
tabThemesBtn.style.cssText = tabStyle;
tabPluginsBtn.onclick = () => switchTab("plugins", tabPluginsBtn, tabThemesBtn);
tabThemesBtn.onclick = () => switchTab("themes", tabPluginsBtn, tabThemesBtn);
tabs.appendChild(tabPluginsBtn);
tabs.appendChild(tabThemesBtn);
searchInput = document.createElement("input");
searchInput.placeholder = "Search plugins, authors, or descriptions";
Object.assign(searchInput.style, {
margin: "12px",
padding: "8px",
borderRadius: "8px",
border: "none",
outline: "none",
background: "rgba(255,255,255,0.06)",
color: "#fff"
});
searchInput.addEventListener("input", () => {
if (activeTab === "plugins") renderRepo({ plugins: currentRepoData }, searchInput.value);
else renderThemes(searchInput.value);
});
repoContent = document.createElement("div");
Object.assign(repoContent.style, {
flex: "1",
overflowY: "auto",
overflowX: "hidden",
padding: "0 12px 12px"
});
const container = document.createElement("div");
Object.assign(container.style, { flex: "1", display: "flex", flexDirection: "column", overflow: "hidden" });
container.appendChild(searchInput);
container.appendChild(repoContent);
panel.appendChild(header);
panel.appendChild(tabs);
panel.appendChild(container);
document.body.appendChild(panel);
switchTab("plugins", tabPluginsBtn, tabThemesBtn);
refetchPlugins();
}
function injectSettingsButton() {
if (document.getElementById("avia-official-repo-btn-settings")) return;
const appearanceBtn = [...document.querySelectorAll("a")]
.find(a => a.textContent.trim() === "Appearance");
const referenceNode = document.getElementById("stoat-fake-quickcss");
if (!appearanceBtn || !referenceNode) return;
const clone = appearanceBtn.cloneNode(true);
clone.id = "avia-official-repo-btn-settings";
const label = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
if (label) label.textContent = "(Sanctum) Plugins/Themes Repo";
const iconSpan = clone.querySelector("span.material-symbols-outlined");
if (iconSpan) {
iconSpan.textContent = "extension";
iconSpan.style.fontVariationSettings = "'FILL' 0,'wght' 400,'GRAD' 0";
}
clone.onclick = openWindow;
referenceNode.parentElement.insertBefore(clone, referenceNode.nextSibling);
}
window.addEventListener("avia-plugin-list-changed", () => {
if (document.getElementById("avia-official-repo-window")) {
updateInstallStates();
}
});
new MutationObserver(() => injectSettingsButton())
.observe(document.body, { childList: true, subtree: true });
injectSettingsButton();
})();

522
avia_core/themes.js Normal file
View file

@ -0,0 +1,522 @@
(function () {
if (window.__AVIA_THEMES_LOADED__) return;
window.__AVIA_THEMES_LOADED__ = true;
const STORAGE_KEY = "avia_themes";
let editingThemeId = null;
let monacoEditorInstance = null;
const TEMPLATE = `/*
@name Whatever name here
@author Whatever Author Here
@version 1.0
@description Whatever description here
*/
`;
const getThemes = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
const setThemes = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
function preloadMonaco() {
return new Promise(resolve => {
if (window.monaco) return resolve();
const loader = document.createElement("script");
loader.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js";
loader.onload = function () {
require.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs" } });
require(["vs/editor/editor.main"], () => resolve());
};
document.head.appendChild(loader);
});
}
function parseMeta(css) {
const name = css.match(/@name\s+(.+)/)?.[1] || "Unknown Theme";
const author = css.match(/@author\s+(.+)/)?.[1] || "Unknown";
const version = css.match(/@version\s+(.+)/)?.[1] || "1.0";
const rawDescription = css.match(/@description\s+(.+)/)?.[1] || "No Description Available";
const description = rawDescription.trim() === "*/" ? "No Description Available" : rawDescription;
return { name, author, version, description };
}
function sanitizeFilename(name) {
return name
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "")
.replace(/\s+/g, "_")
.replace(/\.+$/, "")
.trim() || "theme";
}
function downloadTheme(theme) {
const name = parseMeta(theme.css).name;
const filename = sanitizeFilename(name) + ".css";
const blob = new Blob([theme.css], { type: "text/css" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function applyThemes() {
document.querySelectorAll(".avia-theme-style").forEach(e => e.remove());
getThemes().forEach(theme => {
if (!theme.enabled) return;
const style = document.createElement("style");
style.className = "avia-theme-style";
style.textContent = theme.css;
document.head.appendChild(style);
});
}
function styleBtn(btn, bg) {
Object.assign(btn.style, {
padding: "5px 12px",
borderRadius: "8px",
border: "none",
background: bg || "rgba(255,255,255,0.08)",
color: "#fff",
cursor: "pointer",
fontSize: "12px",
whiteSpace: "nowrap",
fontWeight: "500"
});
btn.onmouseenter = () => btn.style.opacity = "0.75";
btn.onmouseleave = () => btn.style.opacity = "1";
}
function makeDraggable(panel, handle) {
let dragging = false, offsetX, offsetY;
handle.addEventListener("mousedown", e => {
dragging = true;
offsetX = e.clientX - panel.offsetLeft;
offsetY = e.clientY - panel.offsetTop;
document.body.style.userSelect = "none";
});
document.addEventListener("mouseup", () => { dragging = false; document.body.style.userSelect = ""; });
document.addEventListener("mousemove", e => {
if (!dragging) return;
panel.style.left = (e.clientX - offsetX) + "px";
panel.style.top = (e.clientY - offsetY) + "px";
panel.style.right = "auto";
panel.style.bottom = "auto";
});
}
async function openThemeEditor(themeId) {
await preloadMonaco();
editingThemeId = themeId;
const themes = getThemes();
const theme = themes.find(t => t.id === themeId);
if (!theme) return;
const meta = parseMeta(theme.css);
let panel = document.getElementById("avia-theme-editor");
if (panel) {
panel.style.display = "flex";
panel.querySelector("#avia-theme-editor-title").textContent = "Theme Editor — " + meta.name;
if (monacoEditorInstance) {
monacoEditorInstance._aviaThemeId = themeId;
const model = monacoEditorInstance.getModel();
if (model) model.setValue(theme.css || "");
}
return;
}
panel = document.createElement("div");
panel.id = "avia-theme-editor";
Object.assign(panel.style, {
position: "fixed",
bottom: "24px",
right: "24px",
width: "650px",
height: "420px",
background: "var(--md-sys-color-surface, #1e1e1e)",
color: "var(--md-sys-color-on-surface, #fff)",
borderRadius: "16px",
boxShadow: "0 8px 28px rgba(0,0,0,0.35)",
zIndex: "9999999",
display: "flex",
flexDirection: "column",
overflow: "hidden",
border: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(12px)"
});
const header = document.createElement("div");
header.id = "avia-theme-editor-title";
header.textContent = "Theme Editor — " + meta.name;
Object.assign(header.style, {
padding: "14px 16px",
fontWeight: "600",
fontSize: "14px",
background: "var(--md-sys-color-surface-container, rgba(255,255,255,0.04))",
borderBottom: "1px solid rgba(255,255,255,0.08)",
cursor: "move",
color: "#fff",
flex: "0 0 auto"
});
makeDraggable(panel, header);
const close = document.createElement("div");
close.textContent = "✕";
Object.assign(close.style, {
position: "absolute",
right: "16px",
top: "12px",
cursor: "pointer",
opacity: "0.6",
fontSize: "15px",
lineHeight: "1",
padding: "2px 4px",
color: "#fff"
});
close.onmouseenter = () => close.style.opacity = "1";
close.onmouseleave = () => close.style.opacity = "0.6";
close.onclick = () => panel.style.display = "none";
const editorContainer = document.createElement("div");
editorContainer.style.flex = "1";
panel.appendChild(header);
panel.appendChild(close);
panel.appendChild(editorContainer);
document.body.appendChild(panel);
monacoEditorInstance = monaco.editor.create(editorContainer, {
value: theme.css || "",
language: "css",
theme: "vs-dark",
automaticLayout: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
wordWrap: "on"
});
monacoEditorInstance._aviaThemeId = themeId;
monacoEditorInstance.onDidChangeModelContent(() => {
const id = monacoEditorInstance._aviaThemeId;
if (!id) return;
const value = monacoEditorInstance.getValue();
const all = getThemes();
const target = all.find(t => t.id === id);
if (!target) return;
target.css = value;
setThemes(all);
applyThemes();
header.textContent = "Theme Editor — " + parseMeta(value).name;
if (typeof window.__avia_refresh_themes_panel === "function") {
window.__avia_refresh_themes_panel();
}
});
}
function toggleThemesPanel() {
let panel = document.getElementById("avia-themes-panel");
if (panel) {
panel.style.display = panel.style.display === "none" ? "flex" : "none";
return;
}
panel = document.createElement("div");
panel.id = "avia-themes-panel";
Object.assign(panel.style, {
position: "fixed",
bottom: "40px",
right: "40px",
width: "500px",
height: "460px",
background: "var(--md-sys-color-surface, #1e1e1e)",
color: "var(--md-sys-color-on-surface, #fff)",
borderRadius: "16px",
boxShadow: "0 8px 28px rgba(0,0,0,0.35)",
zIndex: "999999",
display: "flex",
flexDirection: "column",
overflow: "hidden",
border: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(12px)"
});
const header = document.createElement("div");
header.textContent = "Themes";
Object.assign(header.style, {
padding: "14px 16px",
fontWeight: "600",
fontSize: "14px",
background: "var(--md-sys-color-surface-container, rgba(255,255,255,0.04))",
borderBottom: "1px solid rgba(255,255,255,0.08)",
cursor: "move"
});
makeDraggable(panel, header);
const close = document.createElement("div");
close.textContent = "✕";
Object.assign(close.style, {
position: "absolute",
right: "16px",
top: "12px",
cursor: "pointer",
opacity: "0.6",
fontSize: "15px",
lineHeight: "1",
padding: "2px 4px"
});
close.onmouseenter = () => close.style.opacity = "1";
close.onmouseleave = () => close.style.opacity = "0.6";
close.onclick = () => panel.style.display = "none";
const btnRow = document.createElement("div");
Object.assign(btnRow.style, {
display: "flex",
gap: "8px",
padding: "12px 16px",
borderBottom: "1px solid rgba(255,255,255,0.08)",
flex: "0 0 auto"
});
const importBtn = document.createElement("button");
importBtn.textContent = "Import Theme";
styleBtn(importBtn);
importBtn.style.flex = "1";
importBtn.style.padding = "8px 12px";
const newBtn = document.createElement("button");
newBtn.textContent = "+ New";
styleBtn(newBtn);
newBtn.style.flex = "1";
newBtn.style.padding = "8px 12px";
btnRow.appendChild(importBtn);
btnRow.appendChild(newBtn);
const list = document.createElement("div");
Object.assign(list.style, {
flex: "1",
overflowY: "auto",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "8px"
});
const dropOverlay = document.createElement("div");
dropOverlay.textContent = "Drop .css or .txt files here";
Object.assign(dropOverlay.style, {
position: "absolute",
inset: "0",
background: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "18px",
fontWeight: "600",
color: "#fff",
opacity: "0",
pointerEvents: "none",
transition: "opacity 0.15s ease",
borderRadius: "16px"
});
panel.appendChild(header);
panel.appendChild(close);
panel.appendChild(btnRow);
panel.appendChild(list);
panel.appendChild(dropOverlay);
document.body.appendChild(panel);
let dragDepth = 0;
panel.addEventListener("dragenter", e => {
e.preventDefault();
e.stopPropagation();
dragDepth++;
dropOverlay.style.opacity = "1";
panel.style.border = "1px dashed rgba(255,255,255,0.4)";
});
panel.addEventListener("dragover", e => {
e.preventDefault();
e.stopPropagation();
});
panel.addEventListener("dragleave", e => {
e.preventDefault();
e.stopPropagation();
dragDepth--;
if (dragDepth <= 0) {
dropOverlay.style.opacity = "0";
panel.style.border = "1px solid rgba(255,255,255,0.08)";
dragDepth = 0;
}
});
panel.addEventListener("drop", async e => {
e.preventDefault();
e.stopPropagation();
dropOverlay.style.opacity = "0";
panel.style.border = "1px solid rgba(255,255,255,0.08)";
dragDepth = 0;
const files = [...e.dataTransfer.files].filter(f => f.name.endsWith(".css") || f.name.endsWith(".txt"));
if (!files.length) return;
const themes = getThemes();
for (const file of files) {
const css = await file.text();
themes.push({ id: crypto.randomUUID(), css, enabled: true });
}
setThemes(themes);
applyThemes();
render();
});
function render() {
list.innerHTML = "";
const themes = getThemes();
if (themes.length === 0) {
const empty = document.createElement("div");
empty.textContent = "No themes yet. Import or create one above.";
Object.assign(empty.style, { opacity: "0.4", fontSize: "13px" });
list.appendChild(empty);
return;
}
themes.forEach(theme => {
const meta = parseMeta(theme.css);
const card = document.createElement("div");
Object.assign(card.style, {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 12px",
borderRadius: "10px",
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.06)"
});
const left = document.createElement("div");
Object.assign(left.style, { display: "flex", alignItems: "center", gap: "10px" });
const dot = document.createElement("div");
Object.assign(dot.style, {
width: "10px",
height: "10px",
borderRadius: "50%",
flexShrink: "0",
background: theme.enabled ? "#4dff88" : "#777",
boxShadow: theme.enabled ? "0 0 6px #4dff88" : "none"
});
const info = document.createElement("div");
info.innerHTML = `<div style="font-weight:600;font-size:13px">${meta.name}</div><div style="font-size:11px;opacity:.5">${meta.author} • v${meta.version}</div><div style="font-size:11px;opacity:.4">${meta.description}</div>`;
left.appendChild(dot);
left.appendChild(info);
const controls = document.createElement("div");
Object.assign(controls.style, { display: "flex", gap: "6px" });
const toggle = document.createElement("button");
toggle.textContent = theme.enabled ? "Disable" : "Enable";
styleBtn(toggle);
toggle.onclick = () => {
theme.enabled = !theme.enabled;
setThemes(themes);
applyThemes();
render();
};
const edit = document.createElement("button");
edit.textContent = "Edit";
styleBtn(edit, "rgba(100,160,255,0.15)");
edit.onclick = () => openThemeEditor(theme.id);
const dlBtn = document.createElement("button");
dlBtn.textContent = "Export";
styleBtn(dlBtn, "rgba(80,200,120,0.15)");
dlBtn.title = "Download theme as .css";
dlBtn.onclick = e => {
e.stopPropagation();
downloadTheme(theme);
};
const del = document.createElement("button");
del.textContent = "✕";
styleBtn(del, "rgba(255,80,80,0.15)");
del.onclick = () => {
const updated = themes.filter(t => t.id !== theme.id);
setThemes(updated);
applyThemes();
render();
};
controls.appendChild(toggle);
controls.appendChild(edit);
controls.appendChild(dlBtn);
controls.appendChild(del);
card.appendChild(left);
card.appendChild(controls);
list.appendChild(card);
});
}
window.__avia_refresh_themes_panel = render;
importBtn.onclick = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".css,.txt";
input.multiple = true;
input.onchange = async () => {
const files = [...input.files];
if (!files.length) return;
const themes = getThemes();
for (const file of files) {
const css = await file.text();
themes.push({ id: crypto.randomUUID(), css, enabled: true });
}
setThemes(themes);
applyThemes();
render();
};
input.click();
};
newBtn.onclick = () => {
const themes = getThemes();
themes.push({ id: crypto.randomUUID(), css: TEMPLATE, enabled: true });
setThemes(themes);
applyThemes();
render();
};
render();
}
function injectButton() {
if (document.getElementById("avia-themes-btn")) return;
const appearanceBtn = [...document.querySelectorAll("a")].find(a => a.textContent.trim() === "Appearance");
const quickCSS = document.getElementById("stoat-fake-quickcss");
if (!appearanceBtn || !quickCSS) return;
const clone = appearanceBtn.cloneNode(true);
clone.id = "avia-themes-btn";
const text = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
if (text) text.textContent = "(Sanctum) Themes";
clone.onclick = toggleThemesPanel;
quickCSS.parentElement.insertBefore(clone, quickCSS.nextSibling);
}
new MutationObserver(injectButton).observe(document.body, { childList: true, subtree: true });
injectButton();
applyThemes();
preloadMonaco();
})();

View file

@ -0,0 +1,10 @@
[Desktop Entry]
Name=Sanctum
Comment=Open source, user-first chat platform
Exec=sanctum
Terminal=false
Type=Application
Icon=cloud.mithraic.sanctum
Categories=Network;InstantMessaging
StartupWMClass=sanctum
X-Desktop-File-Install-Version=0.26

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<id>cloud.mithraic.sanctum</id>
<launchable type="desktop-id">cloud.mithraic.sanctum.desktop</launchable>
<name>Sanctum</name>
<developer id="cloud.mithraic">
<name>izzy</name>
</developer>
<summary>Open source, user-first chat platform</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>AGPL-3.0</project_license>
<description>
<p>Sanctum is a self-hosted Stoat client with Avia Client mod support.</p>
</description>
<content_rating type="oars-1.1">
<content_attribute id="social-chat">intense</content_attribute>
<content_attribute id="social-info">intense</content_attribute>
<content_attribute id="social-audio">intense</content_attribute>
<content_attribute id="social-contacts">intense</content_attribute>
</content_rating>
<requires>
<display_length compare="ge">940</display_length>
<internet>always</internet>
</requires>
<supports>
<control>keyboard</control>
<control>pointing</control>
</supports>
<releases>
<release date="2026-05-05" version="1.0.7">
<description>
<p>Fixed a main-process bootstrap race and improved VC sounds / game presence behavior.</p>
</description>
</release>
<release date="2026-04-22" version="1.0.0">
<description>
<p>Initial Sanctum release based on Avia Client with self-hosted instance support.</p>
</description>
</release>
</releases>
</component>

View file

@ -6,32 +6,27 @@ import { MakerSquirrel } from "@electron-forge/maker-squirrel";
import { MakerZIP } from "@electron-forge/maker-zip";
import { FusesPlugin } from "@electron-forge/plugin-fuses";
import { VitePlugin } from "@electron-forge/plugin-vite";
import { PublisherGithub } from "@electron-forge/publisher-github";
import { VitePluginBuildConfig } from "@electron-forge/plugin-vite/dist/Config";
import type { ForgeConfig } from "@electron-forge/shared-types";
import { FuseV1Options, FuseVersion } from "@electron/fuses";
// import { globSync } from "node:fs";
import * as fs from "fs";
const STRINGS = {
author: "Revolt Platforms LTD",
name: "Stoat",
execName: "stoat-desktop",
author: "izzy",
name: "Sanctum",
execName: "sanctum",
description: "Open source user-first chat platform.",
};
const ASSET_DIR = "assets/desktop";
const AVIA_ASSET_DIR = "avia_assets";
/**
* Build targets for the desktop app
*/
const makers: ForgeConfig["makers"] = [
new MakerSquirrel({
name: STRINGS.name,
authors: STRINGS.author,
// todo: hoist this
iconUrl: `https://stoat.chat/app/assets/icon-DUSNE-Pb.ico`,
// todo: loadingGif
setupIcon: `${ASSET_DIR}/icon.ico`,
setupIcon: `${AVIA_ASSET_DIR}/icon.ico`,
description: STRINGS.description,
exe: `${STRINGS.execName}.exe`,
setupExe: `${STRINGS.execName}-setup.exe`,
@ -40,21 +35,16 @@ const makers: ForgeConfig["makers"] = [
new MakerZIP({}),
];
// skip these makers in CI/CD
if (!process.env.PLATFORM) {
makers.push(
// must be manually built (freezes CI process)
// not much use in being published anyhow
new MakerAppX({
certPass: "",
packageExecutable: `app\\${STRINGS.execName}.exe`,
publisher: "CN=B040CC7E-0016-4AF5-957F-F8977A6CFA3B",
}),
// flatpak publishing should occur through flathub repos.
// this is just for testing purposes
new MakerFlatpak({
options: {
id: "chat.stoat.stoat-desktop",
id: "cloud.mithraic.sanctum",
description: STRINGS.description,
productName: STRINGS.name,
productDescription: STRINGS.description,
@ -69,7 +59,6 @@ if (!process.env.PLATFORM) {
} as unknown,
categories: ["Network"],
modules: [
// use the latest zypak -- Electron sandboxing for Flatpak
{
name: "zypak",
sources: [
@ -82,8 +71,6 @@ if (!process.env.PLATFORM) {
},
],
finishArgs: [
// default arguments found by running
// DEBUG=electron-installer-flatpak* pnpm make
"--socket=fallback-x11",
"--share=ipc",
"--device=dri",
@ -92,77 +79,73 @@ if (!process.env.PLATFORM) {
"--env=TMPDIR=/var/tmp",
"--share=network",
"--talk-name=org.freedesktop.Notifications",
// add Unity talk name for badges
"--talk-name=com.canonical.Unity",
],
// files: [
// // is this necessary?
// // https://stackoverflow.com/q/79745700
// ...[16, 32, 64, 128, 256, 512].map(
// (size) =>
// [
// `assets/desktop/hicolor/${size}x${size}.png`,
// `/app/share/icons/hicolor/${size}x${size}/apps/chat.stoat.stoat-desktop.png`,
// ] as [string, string],
// ),
// [
// `assets/desktop/icon.svg`,
// `/app/share/icons/hicolor/scalable/apps/chat.stoat.stoat-desktop.svg`,
// ] as [string, string],
// ],
files: [],
} as MakerFlatpakOptionsConfig,
/* as Omit<
MakerFlatpakOptionsConfig,
"files"
> */
}),
// testing purposes
new MakerDeb({
options: {
productName: STRINGS.name,
productDescription: STRINGS.description,
categories: ["Network"],
icon: `${ASSET_DIR}/icon.png`,
icon: `${AVIA_ASSET_DIR}/icon.png`,
},
}),
);
}
const customVitePluginBuild: VitePluginBuildConfig[] = [
{
entry: "avia_assets/icon.png",
config: "vite.main.config.ts",
target: "main",
},
{
entry: "about.html",
config: "vite.main.config.ts",
target: "main",
},
{
entry: "src/main.ts",
config: "vite.main.config.ts",
target: "main",
},
{
entry: "src/preload.ts",
config: "vite.preload.config.ts",
target: "preload",
},
];
fs.readdir("avia_core", (err: NodeJS.ErrnoException, files: string[]) => {
if (err) return;
for (const file of files) {
if (["js", "ts", "tsx"].includes(file.split(".").pop().toLowerCase())) {
customVitePluginBuild.push({
entry: `avia_core/${file}`,
config: "vite.main.config.ts",
target: "main",
});
}
}
});
const config: ForgeConfig = {
packagerConfig: {
asar: true,
name: STRINGS.name,
executableName: STRINGS.execName,
icon: `${ASSET_DIR}/icon`,
// extraResource: [
// // include all the asset files
// ...globSync(ASSET_DIR + "/**/*"),
// ],
icon: `${AVIA_ASSET_DIR}/icon`,
},
rebuildConfig: {},
makers,
plugins: [
new VitePlugin({
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
// If you are familiar with Vite configuration, it will look really familiar.
build: [
{
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
entry: "src/main.ts",
config: "vite.main.config.ts",
target: "main",
},
{
entry: "src/preload.ts",
config: "vite.preload.config.ts",
target: "preload",
},
],
build: customVitePluginBuild,
renderer: [],
}),
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
@ -173,14 +156,6 @@ const config: ForgeConfig = {
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
publishers: [
new PublisherGithub({
repository: {
owner: "stoatchat",
name: "for-desktop",
},
}),
],
};
export default config;

View file

@ -1,9 +1,10 @@
{
"name": "stoat-desktop",
"productName": "stoat-desktop",
"version": "1.3.0",
"name": "sanctum",
"productName": "Sanctum",
"version": "1.0.7",
"aviaVersion": "1.0.7",
"main": ".vite/build/main.js",
"repository": "stoatchat/desktop",
"repository": "https://git.mithraic.cloud/ad3laid3/sanctum",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
@ -37,6 +38,7 @@
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"electron": "38.1.2",
"electron-vite": "^5.0.0",
"eslint": "^8.57.1",
"eslint-plugin-import": "^2.32.0",
"json-schema-typed": "^8.0.1",
@ -52,8 +54,7 @@
"discord-rpc": "^4.0.1",
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^10.1.0",
"update-electron-app": "^3.1.1",
"utf-8-validate": "^6.0.5"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
}
"packageManager": "pnpm@10.33.0"
}

598
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 KiB

After

Width:  |  Height:  |  Size: 467 KiB

67
src/config.d.ts vendored
View file

@ -1,10 +1,15 @@
declare type DesktopConfig = {
firstLaunch: boolean;
customFrame: boolean;
customFrameNativeMenu: boolean;
minimiseToTray: boolean;
disableTrayClick: boolean;
spellchecker: boolean;
hardwareAcceleration: boolean;
discordRpc: boolean;
gamePresenceEnabled: boolean;
gamePresenceRestrictToAllowList: boolean;
gamePresenceAllowList: string;
windowState: {
x: number;
y: number;
@ -13,3 +18,65 @@ declare type DesktopConfig = {
isMaximised: boolean;
};
};
declare type VoiceOverlayMember = {
name: string;
speaking?: boolean;
muted?: boolean;
deafened?: boolean;
avatarUrl?: string;
};
declare type VoiceOverlayState = {
channelName?: string;
isInCall: boolean;
members: VoiceOverlayMember[];
selfMuted?: boolean;
selfDeafened?: boolean;
source?: string;
updatedAt?: number;
};
declare type SanctumGamePresence = {
title: string;
processName: string;
startedAt: number;
source: string;
};
declare type SanctumActivityState = {
game: SanctumGamePresence | null;
voice: VoiceOverlayState | null;
};
declare global {
interface Window {
native: {
versions: {
node: () => string;
chrome: () => string;
electron: () => string;
desktop: () => string;
aviaClient: () => string;
};
overlay: {
setVoiceState: (state: VoiceOverlayState | null) => void;
};
activity: {
getState: () => Promise<SanctumActivityState>;
onUpdate: (callback: (state: SanctumActivityState) => void) => () => void;
debugSetState: (state: SanctumActivityState) => Promise<SanctumActivityState>;
};
minimise: () => void;
maximise: () => void;
close: () => void;
setBadgeCount: (count: number) => void;
};
desktopConfig: {
get: () => DesktopConfig;
set: (config: DesktopConfig) => void;
getAutostart: () => Promise<boolean>;
setAutostart: (value: boolean) => Promise<boolean>;
};
}
}

4
src/hackfix.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*?asset" {
export const assetURL: string;
export default assetURL;
}

View file

@ -1,49 +1,119 @@
import { IUpdateInfo, updateElectronApp } from "update-electron-app";
import * as fs from "fs";
import * as path from "path";
import { BrowserWindow, Notification, app, shell } from "electron";
import { BrowserWindow, app, shell } from "electron";
import started from "electron-squirrel-startup";
import { aviaVersion } from "../package.json";
import { autoLaunch } from "./native/autoLaunch";
import { setBadgeCount } from "./native/badges";
import { config } from "./native/config";
import { initDiscordRpc } from "./native/discordRpc";
import { startGamePresenceMonitor } from "./native/gamePresence";
import { checkForUpdates } from "./native/updater";
import { initTray } from "./native/tray";
import { BUILD_URL, createMainWindow, mainWindow } from "./native/window";
// Squirrel-specific logic
// create/remove shortcuts on Windows when installing / uninstalling
// we just need to close out of the app immediately
if (process.platform === "linux") {
app.commandLine.appendSwitch("disable-gpu-sandbox");
app.commandLine.appendSwitch("no-zygote");
app.commandLine.appendSwitch("use-gl", "desktop");
}
const applyAppName = () => {
try {
app.setName("Sanctum");
app.name = "Sanctum";
if (process.platform === "win32") {
app.setAppUserModelId("cloud.mithraic.sanctum");
}
} catch {
/* empty */
}
};
if (started) {
app.quit();
}
// disable hw-accel if so requested
if (!config.hardwareAcceleration) {
app.disableHardwareAcceleration();
}
// ensure only one copy of the application can run
const acquiredLock = app.requestSingleInstanceLock();
const onNotifyUser = (_info: IUpdateInfo) => {
const notification = new Notification({
title: "Update Available",
body: "Restart the app to install the update.",
silent: true,
});
const loadInject = () => {
if (!mainWindow) return;
notification.show();
const wc = mainWindow.webContents;
wc.removeAllListeners("dom-ready");
wc.once("dom-ready", async () => {
try {
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
const builtInLocalPlugins = [
{
id: "sanctum-vcsounds",
name: "VCSounds",
code: fs.readFileSync(path.join(__dirname, "VCSounds.js"), "utf8"),
enabled: true,
locked: true,
},
];
await wc.executeJavaScript(
`window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__ = ${JSON.stringify(
builtInLocalPlugins,
)};`,
true,
);
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
const plugins: string[] = [
"inject.js",
"LocalPlugins.js",
"aviaclientcategory.js",
"themes.js",
"aviafavsystem.js",
"pluginsupport.js",
"aviaversion.js",
"repofrontend.js",
"ButtonFix.js",
"aviadesktopversion.js",
"customFrameNativeMenu.js",
"disableTrayIcon.js",
"gamePresenceSettings.js",
"clientBackup.js",
"LoginWithToken.js",
];
for (const plugin of plugins) {
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
const pluginPath: string = path.join(__dirname, plugin);
const pluginCode: string = fs.readFileSync(pluginPath, "utf8");
await wc.executeJavaScript(pluginCode, true);
}
} catch {
/* empty */
}
});
};
if (acquiredLock) {
// start auto update logic
updateElectronApp({ onNotifyUser });
// create and configure the app when electron is ready
app.on("ready", () => {
// create window and application contexts
app.whenReady().then(() => {
applyAppName();
createMainWindow();
if (mainWindow) {
mainWindow.setTitle("Sanctum");
mainWindow.on("page-title-updated", (e) => {
e.preventDefault();
mainWindow.setTitle("Sanctum");
});
}
loadInject();
// enable auto start on Windows and MacOS
if (config.firstLaunch) {
if (process.platform === "win32" || process.platform === "darwin") {
autoLaunch.enable();
@ -53,23 +123,27 @@ if (acquiredLock) {
initTray();
initDiscordRpc();
startGamePresenceMonitor();
checkForUpdates();
setBadgeCount(0);
// Windows specific fix for notifications
if (process.platform === "win32") {
app.setAppUserModelId("chat.stoat.notifications");
app.setAppUserModelId("cloud.mithraic.sanctum");
}
if (process.platform === "darwin") {
app.setAboutPanelOptions({
version: aviaVersion,
});
}
});
// focus the window if we try to launch again
app.on("second-instance", () => {
mainWindow.show();
mainWindow.restore();
mainWindow.focus();
});
// macOS specific behaviour to keep app active in dock:
// (irrespective of the minimise-to-tray option)
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
@ -79,22 +153,27 @@ if (acquiredLock) {
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
if (mainWindow) {
mainWindow.setTitle("Sanctum");
mainWindow.on("page-title-updated", (e) => {
e.preventDefault();
mainWindow.setTitle("Sanctum");
});
}
loadInject();
} else {
mainWindow.show();
mainWindow.focus();
}
});
// ensure URLs launch in external context
app.on("web-contents-created", (_, contents) => {
// prevent navigation out of build URL origin
contents.on("will-navigate", (event, navigationUrl) => {
if (new URL(navigationUrl).origin !== BUILD_URL.origin) {
event.preventDefault();
}
});
// handle links externally
contents.setWindowOpenHandler(({ url }) => {
if (
url.startsWith("http:") ||

61
src/native/about.ts Normal file
View file

@ -0,0 +1,61 @@
import { join } from "node:path";
import { BrowserWindow } from "electron";
import { mainWindow } from "./window";
// global reference to about window
export let aboutWindow: BrowserWindow;
// Create our about window
export function createAboutWindow() {
// If our about window already exists, show it
if (aboutWindow) {
aboutWindow.show();
return;
}
aboutWindow = new BrowserWindow({
minWidth: 300,
minHeight: 300,
width: 1024,
height: 720,
center: true,
backgroundColor: "#191919",
frame: true,
resizable: false,
minimizable: false,
parent: mainWindow,
paintWhenInitiallyHidden: true,
webPreferences: {
preload: join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
spellcheck: false,
devTools: false,
},
});
// Remove the default menu
aboutWindow.setMenu(null);
aboutWindow.loadFile(join(__dirname, "about.html"));
aboutWindow.on("ready-to-show", () => {
aboutWindow.show();
});
aboutWindow.on("closed", () => {
mainWindow.show();
mainWindow.focus();
aboutWindow = null;
});
// Close window on Escape
aboutWindow.webContents.on("before-input-event", (event, input) => {
if (input.key.toLowerCase() === "escape") {
event.preventDefault();
aboutWindow.close();
}
});
}

View file

@ -2,8 +2,6 @@ import AutoLaunch from "auto-launch";
import { ipcMain } from "electron";
import { mainWindow } from "./window";
export const autoLaunch = new AutoLaunch({
name: "Stoat",
});

View file

@ -13,9 +13,15 @@ const schema = {
customFrame: {
type: "boolean",
} as JSONSchema.Boolean,
customFrameNativeMenu: {
type: "boolean",
} as JSONSchema.Boolean,
minimiseToTray: {
type: "boolean",
} as JSONSchema.Boolean,
disableTrayClick: {
type: "boolean",
} as JSONSchema.Boolean,
startMinimisedToTray: {
type: "boolean",
} as JSONSchema.Boolean,
@ -28,6 +34,15 @@ const schema = {
discordRpc: {
type: "boolean",
} as JSONSchema.Boolean,
gamePresenceEnabled: {
type: "boolean",
} as JSONSchema.Boolean,
gamePresenceRestrictToAllowList: {
type: "boolean",
} as JSONSchema.Boolean,
gamePresenceAllowList: {
type: "string",
} as JSONSchema.String,
windowState: {
type: "object",
properties: {
@ -55,11 +70,16 @@ const store = new Store({
defaults: {
firstLaunch: true,
customFrame: true,
customFrameNativeMenu: false,
minimiseToTray: true,
disableTrayClick: false,
startMinimisedToTray: false,
spellchecker: true,
hardwareAcceleration: true,
discordRpc: true,
gamePresenceEnabled: true,
gamePresenceRestrictToAllowList: true,
gamePresenceAllowList: "",
windowState: {
x: 0,
y: 0,
@ -78,11 +98,16 @@ class Config {
mainWindow.webContents.send("config", {
firstLaunch: this.firstLaunch,
customFrame: this.customFrame,
customFrameNativeMenu: this.customFrameNativeMenu,
minimiseToTray: this.minimiseToTray,
disableTrayClick: this.disableTrayClick,
startMinimisedToTray: this.startMinimisedToTray,
spellchecker: this.spellchecker,
hardwareAcceleration: this.hardwareAcceleration,
discordRpc: this.discordRpc,
gamePresenceEnabled: this.gamePresenceEnabled,
gamePresenceRestrictToAllowList: this.gamePresenceRestrictToAllowList,
gamePresenceAllowList: this.gamePresenceAllowList,
windowState: this.windowState,
});
}
@ -113,6 +138,34 @@ class Config {
this.sync();
}
get customFrameNativeMenu() {
return (store as never as { get(k: string): boolean }).get("customFrameNativeMenu");
}
set customFrameNativeMenu(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"customFrameNativeMenu",
value,
);
this.sync();
}
get disableTrayClick() {
return (store as never as { get(k: string): boolean }).get(
"disableTrayClick",
);
}
set disableTrayClick(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"disableTrayClick",
value,
);
this.sync();
}
get minimiseToTray() {
return (store as never as { get(k: string): boolean }).get(
"minimiseToTray",
@ -192,6 +245,47 @@ class Config {
this.sync();
}
get gamePresenceEnabled() {
return (store as never as { get(k: string): boolean }).get("gamePresenceEnabled");
}
set gamePresenceEnabled(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"gamePresenceEnabled",
value,
);
this.sync();
}
get gamePresenceRestrictToAllowList() {
return (store as never as { get(k: string): boolean }).get(
"gamePresenceRestrictToAllowList",
);
}
set gamePresenceRestrictToAllowList(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"gamePresenceRestrictToAllowList",
value,
);
this.sync();
}
get gamePresenceAllowList() {
return (store as never as { get(k: string): string }).get("gamePresenceAllowList");
}
set gamePresenceAllowList(value: string) {
(store as never as { set(k: string, value: string): void }).set(
"gamePresenceAllowList",
value,
);
this.sync();
}
get windowState() {
return (
store as never as { get(k: string): DesktopConfig["windowState"] }

View file

@ -3,7 +3,32 @@ import { Client } from "discord-rpc";
import { config } from "./config";
// internal state
let rpc: Client;
let rpc: Client | undefined;
type RpcActivity = Parameters<Client["setActivity"]>[0];
const defaultActivity: RpcActivity = {
details: "Chatting with others on Sanctum",
state: "stoat.chat",
largeImageKey: "qr",
largeImageText: "Join Stoat!",
buttons: [
{
label: "Join Stoat",
url: "https://stoat.chat/",
},
],
};
let pendingActivity: RpcActivity = defaultActivity;
function applyActivity() {
if (!rpc) return;
try {
rpc.setActivity(pendingActivity);
} catch {
/* ignore transient RPC failures */
}
}
export async function initDiscordRpc() {
if (!config.discordRpc) return;
@ -14,31 +39,24 @@ export async function initDiscordRpc() {
try {
rpc = new Client({ transport: "ipc" });
rpc.on("ready", () =>
rpc.setActivity({
state: "stoat.chat",
details: "Chatting with others",
largeImageKey: "qr",
largeImageText: "Join Stoat!",
buttons: [
{
label: "Join Stoat",
url: "https://stoat.chat/",
},
],
}),
);
rpc.on("ready", applyActivity);
rpc.on("disconnected", reconnect);
rpc.login({ clientId: "872068124005007420" });
rpc.login({ clientId: "1490783938829090837" });
} catch (err) {
reconnect();
}
}
export function setDiscordActivity(activity: RpcActivity | null) {
pendingActivity = activity ?? defaultActivity;
applyActivity();
}
const reconnect = () => setTimeout(() => initDiscordRpc(), 1e4);
export async function destroyDiscordRpc() {
rpc?.destroy();
rpc = undefined;
}

173
src/native/gameCatalog.ts Normal file
View file

@ -0,0 +1,173 @@
type CandidateLike = {
processName: string;
title: string;
commandLine?: string;
};
type GameCatalogEntry = {
name: string;
aliases?: string[];
};
const GAME_CATALOG: GameCatalogEntry[] = [
{ name: "Apex Legends", aliases: ["apex", "r5apex"] },
{ name: "Among Us" },
{ name: "Assassin's Creed Mirage" },
{ name: "Assassin's Creed Valhalla" },
{ name: "Armored Core VI: Fires of Rubicon", aliases: ["armored core 6"] },
{ name: "Baldur's Gate 3", aliases: ["bg3", "baldurs gate 3", "baldursgate3"] },
{ name: "Black Myth: Wukong", aliases: ["blackmythwukong", "wukong"] },
{ name: "Brawlhalla" },
{ name: "Call of Duty: Black Ops 6", aliases: ["black ops 6", "codbo6"] },
{ name: "Call of Duty: Modern Warfare III", aliases: ["modern warfare 3", "mw3", "codmw3"] },
{ name: "Call of Duty: Warzone", aliases: ["warzone", "cod warzone"] },
{ name: "Celeste" },
{ name: "Cities: Skylines II", aliases: ["cities skylines 2", "skylines 2"] },
{ name: "Civilization VI", aliases: ["civ6", "civilization 6"] },
{ name: "Counter-Strike 2", aliases: ["cs2", "counter strike 2", "csgo", "counter strike global offensive"] },
{ name: "Cuphead" },
{ name: "Cyberpunk 2077", aliases: ["cyberpunk"] },
{ name: "Dark Souls III", aliases: ["dark souls 3"] },
{ name: "Dave the Diver" },
{ name: "Days Gone" },
{ name: "Dead by Daylight" },
{ name: "Dead Cells" },
{ name: "Deep Rock Galactic" },
{ name: "Destiny 2" },
{ name: "Diablo IV", aliases: ["diablo 4"] },
{ name: "Dota 2" },
{ name: "Dragon's Dogma 2", aliases: ["dragons dogma 2"] },
{ name: "Elden Ring" },
{ name: "Enshrouded" },
{ name: "Escape from Tarkov" },
{ name: "Euro Truck Simulator 2" },
{ name: "EVE Online" },
{ name: "Fall Guys" },
{ name: "Fallout 4" },
{ name: "Fallout 76" },
{ name: "Factorio" },
{ name: "F1 24" },
{ name: "Final Fantasy XIV", aliases: ["ffxiv"] },
{ name: "Forza Horizon 5" },
{ name: "Fortnite", aliases: ["fortniteclient", "fortniteclientwin64shipping"] },
{ name: "Genshin Impact", aliases: ["genshin", "genshinimpact", "yuanshen"] },
{ name: "Ghost of Tsushima" },
{ name: "God of War" },
{ name: "Grand Theft Auto V", aliases: ["gta5", "gta v"] },
{ name: "Grounded" },
{ name: "Guild Wars 2" },
{ name: "Hades" },
{ name: "Hades II" },
{ name: "Helldivers 2" },
{ name: "Hogwarts Legacy" },
{ name: "Hollow Knight" },
{ name: "Honkai: Star Rail", aliases: ["hkrpg", "hsr", "star rail"] },
{ name: "Honkai Impact 3rd" },
{ name: "Hunt: Showdown" },
{ name: "It Takes Two" },
{ name: "Kingdom Come: Deliverance" },
{ name: "League of Legends", aliases: ["leagueclient", "league of legends", "lolclient"] },
{ name: "Lethal Company" },
{ name: "Left 4 Dead 2" },
{ name: "Last Epoch" },
{ name: "Marvel Rivals" },
{ name: "Minecraft", aliases: ["minecraftlauncher", "minecraft java edition", "javaw"] },
{ name: "Monster Hunter: World", aliases: ["monster hunter world", "mhw"] },
{ name: "Monster Hunter Rise", aliases: ["monster hunter rise", "mhr"] },
{ name: "Mortal Kombat 1", aliases: ["mk1"] },
{ name: "Metaphor: ReFantazio" },
{ name: "No Man's Sky" },
{ name: "Once Human" },
{ name: "Overwatch 2", aliases: ["overwatch", "ow2"] },
{ name: "Palworld" },
{ name: "Path of Exile", aliases: ["poe", "pathofexile"] },
{ name: "Path of Exile 2", aliases: ["poe2", "pathofexile2"] },
{ name: "Persona 5 Royal" },
{ name: "Phasmophobia" },
{ name: "PUBG: Battlegrounds", aliases: ["pubg"] },
{ name: "Paladins" },
{ name: "Rainbow Six Siege", aliases: ["r6 siege", "r6siege", "siege"] },
{ name: "Red Dead Redemption 2", aliases: ["rdr2"] },
{ name: "Resident Evil 4", aliases: ["re4 remake", "resident evil 4 remake"] },
{ name: "Resident Evil Village", aliases: ["re8", "resident evil 8"] },
{ name: "Rocket League", aliases: ["rocketleague"] },
{ name: "Rust" },
{ name: "Satisfactory" },
{ name: "Sea of Thieves", aliases: ["seaofthieves"] },
{ name: "Skyrim Special Edition", aliases: ["skyrimse", "tesv special edition"] },
{ name: "Slay the Spire" },
{ name: "Sons of the Forest" },
{ name: "Spider-Man Remastered", aliases: ["spidermanremastered", "marvel spiderman remastered"] },
{ name: "Split Fiction" },
{ name: "Star Citizen" },
{ name: "Starfield" },
{ name: "Stardew Valley" },
{ name: "Street Fighter 6", aliases: ["sf6"] },
{ name: "Subnautica" },
{ name: "Team Fortress 2", aliases: ["tf2"] },
{ name: "Tekken 8", aliases: ["tekken8"] },
{ name: "Terraria" },
{ name: "The Elder Scrolls Online", aliases: ["eso"] },
{ name: "The Finals" },
{ name: "The Last of Us Part I", aliases: ["the last of us", "tlou"] },
{ name: "The Witcher 3", aliases: ["witcher 3", "witcher3"] },
{ name: "Titanfall 2" },
{ name: "VALORANT", aliases: ["valorant-win64-shipping", "valorant-win64", "valorant"] },
{ name: "V Rising" },
{ name: "Valheim" },
{ name: "Warframe" },
{ name: "War Thunder" },
{ name: "Wuthering Waves", aliases: ["wutheringwaves", "wuwa"] },
{ name: "World of Warcraft", aliases: ["wow", "wowclassic", "worldofwarcraft"] },
{ name: "World of Tanks", aliases: ["wot"] },
{ name: "World of Warships", aliases: ["wowships"] },
{ name: "Zenless Zone Zero", aliases: ["zzz"] },
];
const GAME_MATCHERS = GAME_CATALOG.flatMap((entry) =>
[entry.name, ...(entry.aliases || [])].flatMap((value) => buildNeedles(value)),
);
export function parseGameAllowList(raw: string) {
return String(raw || "")
.split(/[\n,]+/)
.map((value) => value.trim())
.filter(Boolean);
}
export function normalizeGameText(value: string) {
return String(value || "")
.toLowerCase()
.replace(/\.(exe|app|bat|sh)$/g, "")
.replace(/[^a-z0-9]+/g, "");
}
export function matchesKnownGame(candidate: CandidateLike, allowListRaw: string) {
const haystack = normalizeGameText(
`${candidate.processName} ${candidate.title} ${candidate.commandLine || ""}`,
);
if (!haystack) return false;
if (GAME_MATCHERS.some((matcher) => haystack.includes(matcher))) return true;
return parseGameAllowList(allowListRaw).some((item) =>
haystack.includes(normalizeGameText(item)),
);
}
function buildNeedles(value: string) {
const raw = String(value || "").trim();
if (!raw) return [];
const collapsed = raw.replace(/[']/g, "");
const variants = [
raw,
raw.toLowerCase(),
collapsed,
collapsed.toLowerCase(),
normalizeGameText(raw),
normalizeGameText(collapsed),
];
return Array.from(new Set(variants.map((item) => item.trim()).filter(Boolean)));
}

373
src/native/gameOverlay.ts Normal file
View file

@ -0,0 +1,373 @@
import { BrowserWindow, ipcMain, screen } from "electron";
import { config } from "./config";
import { mainWindow } from "./window";
type GamePresence = {
title: string;
processName: string;
startedAt: number;
source: string;
};
type OverlayState = {
game: GamePresence | null;
voice: VoiceOverlayState | null;
};
let overlayWindow: BrowserWindow | null = null;
let currentState: OverlayState = {
game: null,
voice: null,
};
function publishActivityState() {
const state = currentState;
mainWindow?.webContents.send("sanctum-activity:update", state);
overlayWindow?.webContents.send("sanctum-activity:update", state);
}
const HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
:root {
color-scheme: dark;
--bg: rgba(18, 20, 28, 0.88);
--border: rgba(255, 255, 255, 0.08);
--text: rgba(255, 255, 255, 0.96);
--muted: rgba(255, 255, 255, 0.58);
--accent: #8fb2ff;
--speaking: #59f2a3;
}
* { box-sizing: border-box; }
body {
margin: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: transparent;
color: var(--text);
user-select: none;
}
.shell {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 0;
border-radius: 0;
background: transparent;
border: none;
backdrop-filter: none;
box-shadow: none;
opacity: 1;
transition: opacity 160ms ease, transform 160ms ease;
}
.shell.is-flashing {
opacity: 1;
}
.voice {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.members {
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
align-items: center;
}
.member {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 999px;
border: none;
background: transparent;
color: rgba(255,255,255,0.94);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
position: relative;
text-transform: uppercase;
overflow: hidden;
background: transparent;
outline: none;
opacity: 0.22;
transition: opacity 90ms linear;
}
.avatar {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 999px;
object-fit: cover;
clip-path: circle(50% at 50% 50%);
background: transparent;
pointer-events: none;
}
.member.speaking {
opacity: 1;
}
.member.self.speaking {
opacity: 1;
}
.member .initials {
position: relative;
z-index: 1;
text-shadow: 0 1px 1px rgba(0,0,0,0.35);
}
.member.has-avatar {
color: transparent;
text-shadow: none;
}
.member.has-avatar .initials {
opacity: 0;
}
</style>
</head>
<body>
<div class="shell">
<div class="members" id="members"></div>
</div>
<script>
const { ipcRenderer } = require("electron");
const state = { game: null, voice: null };
const membersEl = document.getElementById("members");
const shellEl = document.querySelector(".shell");
let previousVoiceSignature = "";
let flashTimeout = null;
function getInitials(name) {
const value = String(name || "").trim();
if (!value) return "?";
const parts = value.split(/\s+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
function isSpeakingMember(member) {
return Boolean(member?.speaking);
}
function voiceSignature(voice) {
if (!voice || !voice.members) return "";
return voice.members
.map((member) => [
String(member?.name || "").trim().toLowerCase(),
String(member?.avatarUrl || "").trim(),
].join(":"))
.join("|");
}
function flashShell() {
if (!shellEl) return;
shellEl.classList.add("is-flashing");
clearTimeout(flashTimeout);
flashTimeout = setTimeout(() => {
shellEl.classList.remove("is-flashing");
}, 1400);
}
function render() {
const voice = state.voice;
const hasSpeaking = Boolean(voice?.members?.some((member) => member?.speaking));
membersEl.innerHTML = "";
if (!voice || !voice.members || !voice.members.length) {
return;
}
for (const member of voice.members.slice(0, 5)) {
const row = document.createElement("div");
row.className = "member" + (isSpeakingMember(member) ? " speaking" : "") + (member.name === "You" ? " self" : "");
row.title = member.name || "Unknown";
const initials = document.createElement("span");
initials.className = "initials";
initials.textContent = getInitials(member.name);
if (member.avatarUrl) {
row.classList.add("has-avatar");
const img = document.createElement("img");
img.className = "avatar";
img.alt = member.name || "Avatar";
img.draggable = false;
img.src = String(member.avatarUrl);
row.appendChild(img);
} else {
row.classList.remove("has-avatar");
}
row.appendChild(initials);
membersEl.appendChild(row);
}
}
ipcRenderer.on("overlay-state", (_, next) => {
state.game = next?.game || null;
state.voice = next?.voice || null;
const nextSignature = voiceSignature(state.voice);
if (nextSignature && nextSignature !== previousVoiceSignature) {
flashShell();
}
previousVoiceSignature = nextSignature;
if (!state.voice || !state.voice.members || !state.voice.members.length) {
flashShell();
}
render();
});
render();
</script>
</body>
</html>`;
function getOverlayBounds() {
const display = screen.getPrimaryDisplay();
return { ...display.workArea };
}
function ensureOverlayWindow() {
if (overlayWindow) return overlayWindow;
const bounds = getOverlayBounds();
overlayWindow = new BrowserWindow({
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
frame: false,
transparent: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
skipTaskbar: true,
focusable: false,
show: false,
alwaysOnTop: true,
hasShadow: false,
backgroundColor: "#00000000",
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
overlayWindow.setMenu(null);
overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
overlayWindow.setIgnoreMouseEvents(true, { forward: true });
overlayWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(HTML));
overlayWindow.webContents.on("did-finish-load", () => {
syncOverlayWindow();
});
overlayWindow.on("closed", () => {
overlayWindow = null;
});
return overlayWindow;
}
function shouldShowOverlay() {
if (!config.gamePresenceEnabled) return false;
return Boolean(
currentState.game &&
currentState.voice &&
currentState.voice.isInCall,
);
}
function syncOverlayWindow() {
if (!overlayWindow) return;
if (!shouldShowOverlay()) {
if (overlayWindow.isVisible()) overlayWindow.hide();
return;
}
overlayWindow.showInactive();
overlayWindow.webContents.send("overlay-state", currentState);
}
export function setGamePresence(game: GamePresence | null) {
if (!config.gamePresenceEnabled) {
currentState = {
...currentState,
game: null,
};
syncOverlayWindow();
publishActivityState();
return;
}
if (game && /^(sanctum|stoat|electron)$/i.test(game.processName)) {
game = null;
}
currentState = {
...currentState,
game,
};
ensureOverlayWindow();
syncOverlayWindow();
publishActivityState();
}
export function setVoiceOverlayState(voice: VoiceOverlayState | null) {
if (!config.gamePresenceEnabled) {
currentState = {
...currentState,
voice,
};
publishActivityState();
return;
}
currentState = {
...currentState,
voice,
};
ensureOverlayWindow();
syncOverlayWindow();
publishActivityState();
}
export function debugSetActivityState(state: OverlayState) {
currentState = {
game: state.game || null,
voice: state.voice || null,
};
ensureOverlayWindow();
syncOverlayWindow();
publishActivityState();
}
ipcMain.on("overlay:set-voice-state", (_event, state: VoiceOverlayState | null) => {
setVoiceOverlayState(state);
});
ipcMain.handle("sanctum-activity:get-state", () => currentState);
ipcMain.handle("sanctum-activity:debug-set-state", (_event, state: OverlayState) => {
debugSetActivityState(state);
return currentState;
});
export function getCurrentGamePresence() {
return currentState.game;
}

335
src/native/gamePresence.ts Normal file
View file

@ -0,0 +1,335 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { config } from "./config";
import { matchesKnownGame } from "./gameCatalog";
import { getCurrentGamePresence, setGamePresence } from "./gameOverlay";
type Candidate = {
title: string;
processName: string;
source: string;
commandLine?: string;
};
const execFileAsync = promisify(execFile);
let monitorTimer: NodeJS.Timeout | null = null;
const IGNORE_PATTERNS = [
/^sanctum$/i,
/^cloud\.mithraic\.sanctum$/i,
/^mithral$/i,
/^stoat$/i,
/^electron$/i,
/^chrome$/i,
/^google chrome$/i,
/^msedge$/i,
/^microsoft edge$/i,
/^firefox$/i,
/^brave$/i,
/^brave browser$/i,
/^vivaldi$/i,
/^opera$/i,
/^opera gx$/i,
/^arc$/i,
/^safari$/i,
/^finder$/i,
/^launchpad$/i,
/^terminal$/i,
/^iterm2$/i,
/^steam$/i,
/^steamwebhelper$/i,
/^discord$/i,
/^slack$/i,
/^teams$/i,
/^zoom$/i,
/^notion$/i,
/^obsidian$/i,
/^spotify$/i,
/^telegram$/i,
/^whatsapp$/i,
/^code$/i,
/^visual studio code$/i,
/^node$/i,
/^explorer$/i,
/^file explorer$/i,
/^system$/i,
/^systemsettings$/i,
/^settings$/i,
/^textedit$/i,
/^notes$/i,
/^preview$/i,
/^activity monitor$/i,
/^app store$/i,
/^messages$/i,
/^mail$/i,
/^outlook$/i,
/^word$/i,
/^excel$/i,
/^powerpoint$/i,
/^python$/i,
/^bash$/i,
/^zsh$/i,
/^sh$/i,
/^ps$/i,
/^tasklist$/i,
/^powershell$/i,
/^pwsh$/i,
/^xprop$/i,
/^xdotool$/i,
/^osascript$/i,
];
const SELF_PATTERNS = [
/sanctum/i,
/stoat/i,
/cloud\.mithraic\.sanctum/i,
/mithraic\.space/i,
/stoat\.chat/i,
/electron-forge/i,
/\/home\/[^/]+\/sanctum/i,
/[A-Z]:\\.*\\sanctum/i,
];
export function startGamePresenceMonitor() {
if (monitorTimer) return;
void refreshGamePresence();
monitorTimer = setInterval(() => {
void refreshGamePresence();
}, 2500);
}
async function refreshGamePresence() {
if (!config.gamePresenceEnabled) {
if (getCurrentGamePresence()) {
setGamePresence(null);
}
return;
}
const next = await detectGameCandidate();
const current = getCurrentGamePresence();
const knownMatch = next ? matchesKnownGame(next, config.gamePresenceAllowList) : false;
const accepted = next && !isSelfAppCandidate(next) && knownMatch ? next : null;
const same =
(!!current &&
!!accepted &&
current.processName === accepted.processName &&
current.title === accepted.title) ||
(!current && !accepted);
if (same) return;
if (accepted) {
console.info("[gamePresence] detected", accepted.processName, accepted.title, accepted.source);
} else if (current) {
console.info("[gamePresence] cleared");
}
setGamePresence(accepted);
}
async function detectGameCandidate(): Promise<Candidate | null> {
try {
if (process.platform === "win32") {
return await detectWindowsGame();
}
if (process.platform === "darwin") {
return await detectMacGame();
}
return await detectUnixGame();
} catch {
return null;
}
}
async function detectWindowsGame(): Promise<Candidate | null> {
const script = [
"$sig=@'",
"using System;",
"using System.Text;",
"using System.Runtime.InteropServices;",
"public static class Win32 {",
" [DllImport(\"user32.dll\")] public static extern IntPtr GetForegroundWindow();",
" [DllImport(\"user32.dll\", SetLastError=true)] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);",
" [DllImport(\"user32.dll\", CharSet=CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);",
"}",
"'@;",
"Add-Type $sig | Out-Null;",
"$h=[Win32]::GetForegroundWindow();",
"$pid=0;",
"[void][Win32]::GetWindowThreadProcessId($h,[ref]$pid);",
"$p=Get-Process -Id $pid -ErrorAction SilentlyContinue;",
"$sb=New-Object System.Text.StringBuilder 512;",
"[void][Win32]::GetWindowText($h,$sb,$sb.Capacity);",
"if ($p) { Write-Output ($p.ProcessName + '|' + $sb.ToString()) }",
].join(" ");
const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
]);
const parsed = parseCandidateLine(stdout.trim(), "foreground-window");
return parsed && !isIgnoredCandidate(parsed.processName, parsed.title, parsed.commandLine || "")
? parsed
: null;
}
async function detectMacGame(): Promise<Candidate | null> {
const { stdout } = await execFileAsync("osascript", [
"-e",
'tell application "System Events" to get name of first process whose frontmost is true',
]);
const processName = stdout.trim();
if (!processName || isIgnoredCandidate(processName, processName, "")) return null;
return {
processName,
title: formatGameTitle(processName),
source: "macOS frontmost app",
};
}
async function detectUnixGame(): Promise<Candidate | null> {
const xpropCandidate = await detectLinuxX11Game();
if (xpropCandidate) return xpropCandidate;
const { stdout } = await execFileAsync("sh", [
"-lc",
[
"if command -v xdotool >/dev/null 2>&1; then",
" title=$(xdotool getactivewindow getwindowname 2>/dev/null || true)",
" pid=$(xdotool getactivewindow getwindowpid 2>/dev/null || true)",
" if [ -n \"$pid\" ]; then",
" name=$(ps -p \"$pid\" -o comm= 2>/dev/null | head -n 1 | tr -d '\\n')",
" args=$(ps -p \"$pid\" -o args= 2>/dev/null | head -n 1 | tr -d '\\n')",
" printf '%s|%s|%s\\n' \"$name\" \"$title\" \"$args\"",
" exit 0",
" fi",
"fi",
"exit 0",
].join("\n"),
]);
const trimmed = stdout.trim();
if (!trimmed) return null;
const parsed = parseCandidateLine(trimmed, "foreground-window");
if (parsed && !isIgnoredCandidate(parsed.processName, parsed.title, parsed.commandLine || "")) return parsed;
return null;
}
async function detectLinuxX11Game(): Promise<Candidate | null> {
try {
const { stdout: activeWindow } = await execFileAsync("xprop", [
"-root",
"_NET_ACTIVE_WINDOW",
]);
const match = activeWindow.match(/0x[0-9a-fA-F]+/);
if (!match) return null;
const windowId = match[0];
const { stdout } = await execFileAsync("xprop", [
"-id",
windowId,
"WM_CLASS",
"WM_NAME",
"_NET_WM_NAME",
]);
const parts = stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const className = extractQuotedValue(parts.find((line) => line.startsWith("WM_CLASS")) || "");
const name =
extractQuotedValue(parts.find((line) => line.startsWith("_NET_WM_NAME")) || "") ||
extractQuotedValue(parts.find((line) => line.startsWith("WM_NAME")) || "");
const processName = (className || name || "unknown").trim();
const title = (name || className || "").trim();
if (!processName) return null;
if (isIgnoredCandidate(processName, title || processName, "")) return null;
return {
processName,
title: title || formatGameTitle(processName),
source: "xprop foreground window",
};
} catch {
return null;
}
}
function extractQuotedValue(line: string) {
const quoted = line.match(/"([^"]+)"/g);
if (!quoted || !quoted.length) return "";
return quoted.map((value) => value.replace(/^"|"$/g, "")).join(" ");
}
function parseCandidateLine(line: string, source: string): Candidate | null {
if (!line) return null;
const [processNameRaw, titleRaw = "", commandLineRaw = ""] = line.split("|");
const processName = (processNameRaw || "").trim();
const title =
source === "process scan"
? formatGameTitle(processName)
: (titleRaw || "").trim() || formatGameTitle(processName);
const commandLine = (commandLineRaw || "").trim();
if (!processName) return null;
return {
title,
processName,
source,
commandLine: commandLine || undefined,
};
}
function isIgnoredCandidate(processName: string, title: string, commandLine = "") {
if (isSelfString(processName) || isSelfString(title) || isSelfString(commandLine)) return true;
if (IGNORE_PATTERNS.some((pattern) => pattern.test(processName))) return true;
if (IGNORE_PATTERNS.some((pattern) => pattern.test(title))) return true;
if (commandLine && IGNORE_PATTERNS.some((pattern) => pattern.test(commandLine))) return true;
return false;
}
function isSelfAppCandidate(candidate: Candidate) {
return isSelfString(candidate.processName) || isSelfString(candidate.title) || isSelfString(candidate.commandLine || "");
}
function isSelfString(value: string) {
const normalized = String(value || "");
return SELF_PATTERNS.some((pattern) => pattern.test(normalized));
}
function formatGameTitle(raw: string) {
const cleaned = raw
.replace(/\.(exe|app|bat|sh)$/i, "")
.replace(/[_.-]+/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.trim();
if (!cleaned) return raw || "Unknown Game";
return cleaned
.split(/\s+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}

View file

@ -1,9 +1,11 @@
import { Menu, Tray, nativeImage } from "electron";
import { Menu, Tray, app, nativeImage } from "electron";
import trayIconAsset from "../../assets/desktop/icon.png?asset";
import macOsTrayIconAsset from "../../assets/desktop/iconTemplate.png?asset";
import { version } from "../../package.json";
import trayIconAsset from "../../avia_assets/icon.png?asset";
import macOsTrayIconAsset from "../../avia_assets/iconTemplate.png?asset";
import { aviaVersion, version } from "../../package.json";
import { createAboutWindow } from "./about";
import { config } from "./config";
import { mainWindow, quitApp } from "./window";
// internal tray state
@ -25,14 +27,18 @@ export function initTray() {
const trayIcon = createTrayIcon();
tray = new Tray(trayIcon);
updateTrayMenu();
tray.setToolTip("Stoat for Desktop");
tray.setToolTip("Sanctum for Desktop");
tray.setImage(trayIcon);
tray.on("click", () => {
config.sync();
if (config.disableTrayClick) {
return;
}
if (mainWindow.isVisible()) {
mainWindow.hide();
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
mainWindow.show();
mainWindow.focus();
}
});
}
@ -40,18 +46,30 @@ export function initTray() {
export function updateTrayMenu() {
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: "Stoat for Desktop", type: "normal", enabled: false },
{ label: "Sanctum for Desktop", type: "normal", enabled: false },
{
label: "Version",
label: "Versions",
type: "submenu",
submenu: Menu.buildFromTemplate([
{
label: version,
label: `Stoat Desktop: ${version}`,
type: "normal",
enabled: false,
},
{
label: `Sanctum: ${aviaVersion}`,
type: "normal",
enabled: false,
},
]),
},
{
label: "About",
type: "normal",
click() {
createAboutWindow();
},
},
{ type: "separator" },
{
label: mainWindow.isVisible() ? "Hide App" : "Show App",
@ -64,6 +82,15 @@ export function updateTrayMenu() {
}
},
},
{ type: "separator" },
{
label: "Restart App",
type: "normal",
click() {
app.relaunch();
app.quit();
},
},
{
label: "Quit App",
type: "normal",

View file

@ -0,0 +1,83 @@
import { BrowserWindow, app, ipcMain } from "electron";
let win: BrowserWindow | null = null;
const HTML = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,sans-serif;background:#1e1e2e;color:#cdd6f4;
display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100vh;padding:28px 32px;gap:14px;user-select:none;-webkit-app-region:drag}
h2{font-size:15px;font-weight:600;letter-spacing:.3px}
#status{font-size:13px;color:#a6adc8}
.track{width:100%;height:6px;background:#313244;border-radius:3px}
#bar{height:6px;background:#89b4fa;border-radius:3px;width:0%;transition:width .4s ease}
#btn{display:none;margin-top:6px;padding:9px 24px;background:#89b4fa;color:#1e1e2e;
border:none;border-radius:6px;font-size:13px;font-weight:700;cursor:pointer;
-webkit-app-region:no-drag}
#btn:hover{background:#b4befe}
</style></head><body>
<h2 id="title">Updating Sanctum</h2>
<div id="status">Downloading</div>
<div class="track"><div id="bar"></div></div>
<button id="btn">Restart Now</button>
<script>
const {ipcRenderer}=require('electron');
ipcRenderer.on('upd-progress',(_,p)=>{
document.getElementById('bar').style.width=p+'%';
document.getElementById('status').textContent=p<100?'Downloading… '+p+'%':'Installing…';
});
ipcRenderer.on('upd-ready',(_,v)=>{
document.getElementById('title').textContent='Sanctum '+v+' installed';
document.getElementById('bar').style.width='100%';
var s=document.getElementById('status');
var n=2;
s.textContent='Restarting in '+n+'…';
var t=setInterval(function(){
n--;
if(n<=0){clearInterval(t);s.textContent='Restarting…';}
else s.textContent='Restarting in '+n+'…';
},1000);
});
ipcRenderer.on('upd-error',(_,msg)=>{
document.getElementById('title').textContent='Update Failed';
document.getElementById('status').textContent=msg;
document.getElementById('bar').style.background='#f38ba8';
});
</script></body></html>`;
export function showUpdateWindow() {
if (win) { win.focus(); return; }
win = new BrowserWindow({
width: 400,
height: 180,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
title: "Sanctum Update",
frame: false,
alwaysOnTop: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(HTML));
win.on("closed", () => { win = null; });
}
export function setUpdateProgress(percent: number) {
win?.webContents.send("upd-progress", Math.round(percent));
}
export function setUpdateReady(version: string, relaunch: boolean) {
win?.webContents.send("upd-ready", version);
setTimeout(() => {
if (relaunch) app.relaunch();
app.exit(0);
}, 3000);
}
export function setUpdateError(msg: string) {
win?.webContents.send("upd-error", msg);
}

154
src/native/updater.ts Normal file
View file

@ -0,0 +1,154 @@
import { Notification, app, ipcMain } from "electron";
import { exec, spawn } from "child_process";
import { createWriteStream, mkdirSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { tmpdir } from "os";
import { showUpdateWindow, setUpdateProgress, setUpdateReady, setUpdateError } from "./update-window";
ipcMain.handle("checkForUpdates", () => checkForUpdates());
const RELEASES_URL =
"https://git.mithraic.cloud/api/v1/repos/ad3laid3/sanctum/releases/latest";
interface Asset {
name: string;
browser_download_url: string;
}
interface Release {
tag_name: string;
html_url: string;
assets: Asset[];
}
export async function checkForUpdates() {
try {
console.log("[updater] checking:", RELEASES_URL);
const res = await fetch(RELEASES_URL);
if (!res.ok) {
console.error("[updater] releases API returned", res.status, res.statusText);
notify("Update Check Failed", `API returned ${res.status}`);
return;
}
const release = (await res.json()) as Release;
const latest = release.tag_name.replace(/^v/, "");
const current = app.getVersion();
console.log(`[updater] current=${current} latest=${latest}`);
if (!isNewer(latest, current)) {
notify("Already Up to Date", `You're on Sanctum ${current}.`);
return;
}
const asset = findAsset(release.assets);
if (!asset) {
const names = release.assets.map(a => a.name).join(", ");
console.error("[updater] no matching asset for platform:", process.platform, names);
notify("Update Failed", `No ${process.platform} asset found.`);
return;
}
console.log(`[updater] update available: ${current}${latest}, downloading ${asset.name}`);
showUpdateWindow();
await downloadAndInstall(asset.browser_download_url, latest);
} catch (err) {
console.error("[updater] update check failed:", err);
setUpdateError(String(err));
}
}
function findAsset(assets: Asset[]): Asset | undefined {
if (process.platform === "linux")
return assets.find((a) => a.name.includes("linux") && a.name.endsWith(".zip"));
if (process.platform === "win32")
return assets.find((a) => a.name.includes("win32") && a.name.endsWith(".zip"));
}
async function downloadAndInstall(url: string, version: string) {
const tmpDir = join(tmpdir(), `sanctum-update-${version}`);
mkdirSync(tmpDir, { recursive: true });
const zipPath = join(tmpDir, "update.zip");
const extractDir = join(tmpDir, "extracted");
const installDir = dirname(process.execPath);
console.log(`[updater] downloading from ${url}`);
const res = await fetch(url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const total = Number(res.headers.get("content-length")) || 0;
let downloaded = 0;
const writer = createWriteStream(zipPath);
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
writer.write(value);
downloaded += value.length;
if (total > 0) setUpdateProgress(Math.round((downloaded / total) * 90));
}
await new Promise<void>(resolve => writer.end(resolve));
setUpdateProgress(95);
console.log(`[updater] download complete, extracting to ${installDir}`);
if (process.platform === "win32") {
// Extract zip while the app is still running (no locked files yet)
await new Promise<void>((resolve, reject) => {
const cmd = `powershell -Command "Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${extractDir}'"`;
exec(cmd, (err, _stdout, stderr) => {
if (err) { console.error("[updater] extract failed:", stderr); reject(err); }
else resolve();
});
});
// Write a batch script that runs after we exit: waits, copies, relaunches
const batchPath = join(tmpDir, "apply-update.bat");
const bat = [
"@echo off",
"timeout /t 3 /nobreak >nul",
`for /d %%D in ("${extractDir}\\*") do set SUB=%%D`,
`xcopy /E /Y /I "%SUB%\\*" "${installDir}\\"`,
`start "" "${join(installDir, "sanctum.exe")}"`,
`del "%~f0"`,
].join("\r\n");
writeFileSync(batchPath, bat);
// Spawn batch truly detached so it outlives this process
const child = spawn("cmd.exe", ["/C", batchPath], {
detached: true,
stdio: "ignore",
windowsHide: false,
});
child.unref();
setUpdateReady(version, false); // batch script handles relaunch
} else {
await new Promise<void>((resolve, reject) => {
const cmd = `unzip -o "${zipPath}" -d "${extractDir}" && SUBDIR=$(ls "${extractDir}" | head -1) && rm -f "${installDir}/sanctum" && cp -rT "${extractDir}/$SUBDIR" "${installDir}"`;
exec(cmd, { shell: "/bin/bash" }, (err, _stdout, stderr) => {
if (err) { console.error("[updater] extract failed:", stderr); reject(err); }
else resolve();
});
});
setUpdateReady(version, true);
}
}
function notify(title: string, body: string) {
const n = new Notification({ title, body, silent: true });
n.show();
return n;
}
function isNewer(latest: string, current: string): boolean {
const parse = (v: string) => v.split(".").map(p => Number(p) || 0);
const [lA, lB, lC] = parse(latest);
const [cA, cB, cC] = parse(current);
if (lA !== cA) return lA > cA;
if (lB !== cB) return lB > cB;
return lC > cC;
}

View file

@ -9,8 +9,9 @@ import {
nativeImage,
} from "electron";
import windowIconAsset from "../../assets/desktop/icon.png?asset";
import windowIconAsset from "../../avia_assets/icon.png?asset";
import { aboutWindow, createAboutWindow } from "./about";
import { config } from "./config";
import { updateTrayMenu } from "./tray";
@ -21,7 +22,7 @@ export let mainWindow: BrowserWindow;
export const BUILD_URL = new URL(
app.commandLine.hasSwitch("force-server")
? app.commandLine.getSwitchValue("force-server")
: /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://stoat.chat/app",
: /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://mithraic.space/app",
);
// internal window state
@ -48,6 +49,18 @@ export function createMainWindow() {
height: 720,
backgroundColor: "#191919",
frame: !config.customFrame,
...(config.customFrame && config.customFrameNativeMenu
? {
// remove the default titlebar
titleBarStyle: "hidden",
// expose window controls in Windows/Linux
...(process.platform !== "darwin"
? {
titleBarOverlay: true,
}
: {}),
}
: {}),
icon: windowIcon,
show: !startHidden,
webPreferences: {
@ -56,6 +69,7 @@ export function createMainWindow() {
contextIsolation: true,
nodeIntegration: false,
spellcheck: true,
devTools: true,
},
});
@ -132,17 +146,60 @@ export function createMainWindow() {
// reset zoom to default.
event.preventDefault();
mainWindow.webContents.setZoomLevel(0);
} else if (input.key === "F1") {
event.preventDefault();
createAboutWindow();
} else if (
input.key === "F5" ||
((input.control || input.meta) && input.key.toLowerCase() === "r")
) {
event.preventDefault();
mainWindow.webContents.reload();
} else if (input.key === "F12") {
event.preventDefault();
if (mainWindow.webContents.isDevToolsOpened()) {
mainWindow.webContents.closeDevTools();
} else {
mainWindow.webContents.openDevTools({ mode: "detach" });
}
} else if (
input.meta &&
input.key === "," &&
process.platform === "darwin"
) {
event.preventDefault();
mainWindow.webContents.executeJavaScript(`(() => {
var escButton = document.querySelector("#floating .top_0 > button");
var settingsPanel = document.querySelector("#root div[aria-label='Settings'] > a");
if (escButton) escButton.click();
if (!escButton && settingsPanel) settingsPanel.click();
})();`);
}
});
// send the config
mainWindow.webContents.on("did-finish-load", () => config.sync());
const initialCustomFrame: boolean = config.customFrame;
const initialCFNM: boolean = config.customFrameNativeMenu;
mainWindow.webContents.on("did-finish-load", () => {
// send the config
config.sync();
// on macOS add margin to the title, and hide custom controls
// We only use initial values other the menu can disappear
if (process.platform === "darwin" && initialCustomFrame && initialCFNM) {
mainWindow.webContents.insertCSS(`
#root > div[style="display: flex; flex-direction: column; height: 100%;"] > div > div.h_29px {
&> div.d_flex:first-child {
margin-left: 75px;
}
&> a.place-items_center {
display: none;
}
}
`);
}
});
// configure spellchecker context menu
mainWindow.webContents.on("context-menu", (_, params) => {

View file

@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer } from "electron";
import { version } from "../../package.json";
import { aviaVersion, version } from "../../package.json";
contextBridge.exposeInMainWorld("native", {
versions: {
@ -8,6 +8,23 @@ contextBridge.exposeInMainWorld("native", {
chrome: () => process.versions.chrome,
electron: () => process.versions.electron,
desktop: () => version,
aviaClient: () => aviaVersion,
},
overlay: {
setVoiceState: (state: VoiceOverlayState | null) =>
ipcRenderer.send("overlay:set-voice-state", state),
},
activity: {
getState: () => ipcRenderer.invoke("sanctum-activity:get-state"),
onUpdate: (callback: (state: SanctumActivityState) => void) => {
const listener = (_event: unknown, state: SanctumActivityState) => callback(state);
ipcRenderer.on("sanctum-activity:update", listener);
return () => ipcRenderer.removeListener("sanctum-activity:update", listener);
},
debugSetState: (state: SanctumActivityState) =>
ipcRenderer.invoke("sanctum-activity:debug-set-state", state) as Promise<SanctumActivityState>,
},
minimise: () => ipcRenderer.send("minimise"),