Compare commits
191 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e3f165857 | ||
|
|
8a9d621456 | ||
|
|
3055e283a1 | ||
|
|
44ee4970f9 | ||
|
|
90797d6dd9 | ||
|
|
194199daed | ||
|
|
19a1b41e6d | ||
|
|
c5e8c49bd9 | ||
|
|
398451d7c7 | ||
|
|
2ca7e2e0d2 | ||
|
|
95bb71b60f | ||
|
|
692c4834cd | ||
|
|
3ca0b7b395 | ||
|
|
a47440a3ef | ||
|
|
c8f8212d7a | ||
|
|
657bf6d0d0 | ||
|
|
9d361c35cc | ||
|
|
93d2558324 | ||
|
|
4f5cbbb3c2 | ||
|
|
cc8ba75694 | ||
|
|
225c623ecb | ||
|
|
1eb09a589a | ||
|
|
821ff30d40 | ||
|
|
d98d6d5441 | ||
|
|
ac7f85f679 | ||
|
|
222d796843 | ||
|
|
0abe72a3c6 | ||
|
|
4b05dd4fbe | ||
|
|
0e40c6c6ec | ||
|
|
4f13d893cd | ||
|
|
593271c2b9 | ||
|
|
dec110d795 | ||
|
|
8b0e6588fd | ||
|
|
0c2c081ab0 | ||
|
|
35ee57483a | ||
|
|
a632ecde33 | ||
|
|
4f845f58a9 | ||
|
|
d165a77875 | ||
|
|
7ca75e859e | ||
|
|
c330333ee6 | ||
|
|
c15d64164b | ||
|
|
4208ee0ec2 | ||
|
|
50b05be8fc | ||
|
|
a81c7b092b | ||
|
|
5d1a5e3ecb | ||
|
|
b3c4959c7c | ||
|
|
841a9be2cf | ||
|
|
60fb61a1db | ||
|
|
24c9cc51f4 | ||
|
|
da383611c0 | ||
|
|
741102d6eb | ||
|
|
22785a9860 | ||
|
|
e56455fbaf | ||
|
|
0c2d23d029 | ||
|
|
363f9f675b | ||
|
|
c386cb6cba | ||
|
|
b5f8edeb4d | ||
|
|
b9a3d37c9a | ||
|
|
962e4b188b | ||
|
|
6fbd8a18f6 | ||
|
|
b22130645a | ||
|
|
7d07e3fcc8 | ||
|
|
b59ab85e7a | ||
|
|
5a3c1458ac | ||
|
|
262c74fefe | ||
|
|
da075829dc | ||
|
|
60dbbde2af | ||
|
|
c0eb4129ac | ||
|
|
2c7a864815 | ||
|
|
590729ccff | ||
|
|
c407a89142 | ||
|
|
fa6cfb86f3 | ||
|
|
b292308cd5 | ||
|
|
a60a60ab4b | ||
|
|
2f2b4474b4 | ||
|
|
0e1c696a68 | ||
|
|
f31c2ca067 | ||
|
|
fbf53123fa | ||
|
|
85aaf5946d | ||
|
|
cec88cfe3f | ||
|
|
0e903e71df | ||
|
|
f95cc126ce | ||
|
|
09662fc37e | ||
|
|
efbba2a65f | ||
|
|
149930460b | ||
|
|
05decfbae4 | ||
|
|
727ba0f1df | ||
|
|
a7115dc1d4 | ||
|
|
5e0b70056e | ||
|
|
a63ce3c41f | ||
|
|
0dd42efcaa | ||
|
|
ac1b93677c | ||
|
|
73af6e3062 | ||
|
|
47e524f0d2 | ||
|
|
191e23b61f | ||
|
|
94610845be | ||
|
|
34fd294be9 | ||
|
|
7110c2202f | ||
|
|
e5731054a7 | ||
|
|
1e8310874c | ||
|
|
95715a4fcd | ||
|
|
a61fbb695a | ||
|
|
b2bb58b9f0 | ||
|
|
97662d2e15 | ||
|
|
a9f67aa099 | ||
|
|
0b849eb62b | ||
|
|
ff169347df | ||
|
|
b6cd8524dc | ||
|
|
a8af809134 | ||
|
|
ef57607bc6 | ||
|
|
63d735f265 | ||
|
|
5a0959d721 | ||
|
|
82c81a6424 | ||
|
|
4bcbd24d99 | ||
|
|
671e67cde4 | ||
|
|
0e8441e709 | ||
|
|
8d32b73d98 | ||
|
|
02003514df | ||
|
|
a1463ea803 | ||
|
|
457f2e5b21 | ||
|
|
23241d8777 | ||
|
|
fe3a7c479b | ||
|
|
02185d005a | ||
|
|
542023c6a5 | ||
|
|
cbec0b1804 | ||
|
|
982088c96d | ||
|
|
77c2337b84 | ||
|
|
901e416b89 | ||
|
|
9db9cd9373 | ||
|
|
02f1eb08ff | ||
|
|
910b356071 | ||
|
|
63d5e94e6c | ||
|
|
a5327b96cc | ||
|
|
bba0bb2c0e | ||
|
|
a926345013 | ||
|
|
5ac04dff7e | ||
|
|
dad8a675e2 | ||
|
|
db0e1b9647 | ||
|
|
581cf4fce5 | ||
|
|
3d738bfe0e | ||
|
|
dd90d70560 | ||
|
|
8b81078429 | ||
|
|
76bbebec1b | ||
|
|
d0196cd675 | ||
|
|
5ad6819747 | ||
|
|
449c819214 | ||
|
|
e9b04f7dc5 | ||
|
|
92ec5e1a9f | ||
|
|
c9165c682e | ||
|
|
f9c3108590 | ||
|
|
f9a01a3a1a | ||
|
|
356964d1ee | ||
|
|
bb93c62e92 | ||
|
|
994381411d | ||
|
|
638df6aafd | ||
|
|
eff6fe57f9 | ||
|
|
45b96855eb | ||
|
|
b2afc5d363 | ||
|
|
84ecbc5233 | ||
|
|
133576411f | ||
|
|
d6932130c4 | ||
|
|
a848275ec8 | ||
|
|
175fb81bcc | ||
|
|
e20818fb5b | ||
|
|
d94e02e42c | ||
|
|
61d3ca0fbe | ||
|
|
8e6ed4bbb2 | ||
|
|
cbd5f599d9 | ||
|
|
5696875641 | ||
|
|
a6cd720bc1 | ||
|
|
9712f52e63 | ||
|
|
5c3b0073e2 | ||
|
|
58088aa094 | ||
|
|
b18aacba42 | ||
|
|
f535e1249c | ||
|
|
cfeb0637e6 | ||
|
|
3256318e1b | ||
|
|
fb6a811b63 | ||
|
|
db426339ea | ||
|
|
4f4f475ee7 | ||
|
|
c4d24aa88b | ||
|
|
d57e155e63 | ||
|
|
ccf04bfce9 | ||
|
|
21833d3acf | ||
|
|
4e00fe9399 | ||
|
|
c973707d99 | ||
|
|
9a6fbbf8f0 | ||
|
|
1ab4b95570 | ||
|
|
8dfb49c8fe | ||
|
|
d32fd3b7df | ||
|
|
fa3bdc7019 |
50 changed files with 7881 additions and 291 deletions
88
.github/workflows/build.yml
vendored
88
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
88
.github/workflows/release-please.yml
vendored
88
.github/workflows/release-please.yml
vendored
|
|
@ -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 }}
|
||||
4
.github/workflows/release-webhook.yml
vendored
4
.github/workflows/release-webhook.yml
vendored
|
|
@ -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\"}"
|
||||
|
|
|
|||
2
.github/workflows/validate-pr-title.yml
vendored
2
.github/workflows/validate-pr-title.yml
vendored
|
|
@ -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
12
GEMINI.md
Normal 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.
|
||||
45
README.md
45
README.md
|
|
@ -1,15 +1,10 @@
|
|||
<div align="center">
|
||||
<h1>
|
||||
Stoat for Desktop
|
||||
|
||||
[](https://github.com/stoatchat/for-desktop/stargazers)
|
||||
[](https://github.com/stoatchat/for-desktop/network/members)
|
||||
[](https://github.com/stoatchat/for-desktop/pulls)
|
||||
[](https://github.com/stoatchat/for-desktop/issues)
|
||||
[](https://github.com/stoatchat/for-desktop/graphs/contributors)
|
||||
[](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
611
about.html
Normal 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
BIN
avia_assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 325 KiB |
BIN
avia_assets/icon.icon/Assets/IMG_0248.PNG
Normal file
BIN
avia_assets/icon.icon/Assets/IMG_0248.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
75
avia_assets/icon.icon/icon.json
Normal file
75
avia_assets/icon.icon/icon.json
Normal 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
BIN
avia_assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
BIN
avia_assets/iconTemplate.png
Normal file
BIN
avia_assets/iconTemplate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
29
avia_core/ButtonFix.js
Normal file
29
avia_core/ButtonFix.js
Normal 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
688
avia_core/LocalPlugins.js
Normal 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
139
avia_core/LoginWithToken.js
Normal 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
605
avia_core/VCSounds.js
Normal 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();
|
||||
})();
|
||||
34
avia_core/aviaclientcategory.js
Normal file
34
avia_core/aviaclientcategory.js
Normal 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();
|
||||
|
||||
})();
|
||||
40
avia_core/aviadesktopversion.js
Normal file
40
avia_core/aviadesktopversion.js
Normal 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
349
avia_core/aviafavsystem.js
Normal 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
34
avia_core/aviaversion.js
Normal 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
175
avia_core/clientBackup.js
Normal 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 });
|
||||
})();
|
||||
57
avia_core/customFrameNativeMenu.js
Normal file
57
avia_core/customFrameNativeMenu.js
Normal 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 });
|
||||
})();
|
||||
66
avia_core/disableTrayIcon.js
Normal file
66
avia_core/disableTrayIcon.js
Normal 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 });
|
||||
})();
|
||||
397
avia_core/gamePresenceSettings.js
Normal file
397
avia_core/gamePresenceSettings.js
Normal 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
244
avia_core/headliner.js
Normal 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: "FILL" 0, "wght" 400, "GRAD" 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
361
avia_core/inject.js
Normal 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
575
avia_core/pluginsupport.js
Normal 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
479
avia_core/repofrontend.js
Normal 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
522
avia_core/themes.js
Normal 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();
|
||||
|
||||
})();
|
||||
10
cloud.mithraic.sanctum.desktop
Normal file
10
cloud.mithraic.sanctum.desktop
Normal 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
|
||||
41
cloud.mithraic.sanctum.metainfo.xml
Normal file
41
cloud.mithraic.sanctum.metainfo.xml
Normal 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>
|
||||
121
forge.config.ts
121
forge.config.ts
|
|
@ -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;
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -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
598
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 702 KiB After Width: | Height: | Size: 467 KiB |
67
src/config.d.ts
vendored
67
src/config.d.ts
vendored
|
|
@ -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
4
src/hackfix.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module "*?asset" {
|
||||
export const assetURL: string;
|
||||
export default assetURL;
|
||||
}
|
||||
139
src/main.ts
139
src/main.ts
|
|
@ -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
61
src/native/about.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2,8 +2,6 @@ import AutoLaunch from "auto-launch";
|
|||
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { mainWindow } from "./window";
|
||||
|
||||
export const autoLaunch = new AutoLaunch({
|
||||
name: "Stoat",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
173
src/native/gameCatalog.ts
Normal 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
373
src/native/gameOverlay.ts
Normal 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
335
src/native/gamePresence.ts
Normal 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(" ");
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
83
src/native/update-window.ts
Normal file
83
src/native/update-window.ts
Normal 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
154
src/native/updater.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue