Compare commits
29 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 | ||
|
|
74c941e5b8 | ||
|
|
00c7572cd0 |
35 changed files with 2694 additions and 205 deletions
88
.github/workflows/build.yml
vendored
88
.github/workflows/build.yml
vendored
|
|
@ -1,13 +1,20 @@
|
||||||
|
name: Build and Release Sanctum
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build App
|
name: Build App
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
@ -16,13 +23,80 @@ jobs:
|
||||||
- name: Checkout assets
|
- name: Checkout assets
|
||||||
run: git -c submodule."assets".update=checkout submodule update --init assets
|
run: git -c submodule."assets".update=checkout submodule update --init assets
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Node
|
||||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
uses: actions/setup-node@v4
|
||||||
with:
|
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
|
run: pnpm install
|
||||||
|
|
||||||
- name: Build
|
- name: Build Linux & Windows
|
||||||
run: pnpm run package
|
# 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
|
||||||
2
.github/workflows/release-webhook.yml
vendored
2
.github/workflows/release-webhook.yml
vendored
|
|
@ -11,7 +11,7 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
release-webhook:
|
release-webhook:
|
||||||
name: Send Release Webhook
|
name: Send Release Webhook
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Send release notification webhook
|
- name: Send release notification webhook
|
||||||
|
|
|
||||||
2
.github/workflows/validate-pr-title.yml
vendored
2
.github/workflows/validate-pr-title.yml
vendored
|
|
@ -14,7 +14,7 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
main:
|
main:
|
||||||
name: Validate PR title
|
name: Validate PR title
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
steps:
|
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.
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
Avia Client for Desktop
|
Avia Client for Desktop
|
||||||
"stoat desktop"
|
"stoat desktop"
|
||||||
</h1>
|
</h1>
|
||||||
|
<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
|
Application for Windows, macOS, and Linux. now with avia client injected
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
<br/>
|
||||||
|
|
@ -70,3 +71,5 @@ pnpm run:nix --force-server=http://localhost:5173
|
||||||
# a better solution would be telling
|
# a better solution would be telling
|
||||||
# Electron Forge where system Electron is
|
# Electron Forge where system Electron is
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 47 KiB |
|
|
@ -3,15 +3,63 @@
|
||||||
if (window.__AVIA_LOCAL_PLUGINS_LOADED__) return;
|
if (window.__AVIA_LOCAL_PLUGINS_LOADED__) return;
|
||||||
window.__AVIA_LOCAL_PLUGINS_LOADED__ = true;
|
window.__AVIA_LOCAL_PLUGINS_LOADED__ = true;
|
||||||
|
|
||||||
const STORAGE_KEY = "avia_local_plugins";
|
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 runningLocalPlugins = {};
|
const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||||
const localPluginErrors = {};
|
const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
|
|
||||||
const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
function upsertBuiltinLocalPlugins() {
|
||||||
const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
if (!BUILTIN_SEED.length) return;
|
||||||
|
|
||||||
function preloadMonaco() {
|
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 => {
|
return new Promise(resolve => {
|
||||||
if (window.monaco) return resolve();
|
if (window.monaco) return resolve();
|
||||||
const loader = document.createElement("script");
|
const loader = document.createElement("script");
|
||||||
|
|
@ -421,11 +469,11 @@
|
||||||
renderLocalPanel();
|
renderLocalPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLocalPanel() {
|
function renderLocalPanel() {
|
||||||
const content = document.getElementById("avia-local-plugins-content");
|
const content = document.getElementById("avia-local-plugins-content");
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
content.innerHTML = "";
|
content.innerHTML = "";
|
||||||
const plugins = getLocalPlugins();
|
const plugins = getLocalPlugins();
|
||||||
|
|
||||||
if (plugins.length === 0) {
|
if (plugins.length === 0) {
|
||||||
const empty = document.createElement("div");
|
const empty = document.createElement("div");
|
||||||
|
|
@ -436,9 +484,10 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins.forEach((plugin, index) => {
|
plugins.forEach((plugin, index) => {
|
||||||
const isRunning = !!runningLocalPlugins[plugin.id];
|
const isRunning = !!runningLocalPlugins[plugin.id];
|
||||||
const hasError = !!localPluginErrors[plugin.id];
|
const hasError = !!localPluginErrors[plugin.id];
|
||||||
|
const isBuiltin = !!plugin.locked || !!plugin.builtin;
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
Object.assign(row.style, {
|
Object.assign(row.style, {
|
||||||
|
|
@ -467,74 +516,93 @@
|
||||||
statusDot.style.background = "#777";
|
statusDot.style.background = "#777";
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = document.createElement("div");
|
const name = document.createElement("div");
|
||||||
name.textContent = plugin.name;
|
name.textContent = plugin.name;
|
||||||
name.style.fontSize = "13px";
|
name.style.fontSize = "13px";
|
||||||
|
|
||||||
left.appendChild(statusDot);
|
left.appendChild(statusDot);
|
||||||
left.appendChild(name);
|
left.appendChild(name);
|
||||||
|
|
||||||
const controls = document.createElement("div");
|
if (isBuiltin) {
|
||||||
Object.assign(controls.style, { display: "flex", gap: "6px" });
|
const badge = document.createElement("div");
|
||||||
|
badge.textContent = "Built-in";
|
||||||
const editBtn = document.createElement("button");
|
Object.assign(badge.style, {
|
||||||
editBtn.textContent = "✏ Edit";
|
fontSize: "10px",
|
||||||
styleLocalBtn(editBtn, "rgba(100,140,255,0.2)");
|
padding: "2px 7px",
|
||||||
editBtn.onclick = () => {
|
borderRadius: "999px",
|
||||||
openEditorPanel(plugin, (newCode, andRun) => {
|
background: "rgba(120,170,255,0.16)",
|
||||||
const all = getLocalPlugins();
|
color: "#a9c4ff",
|
||||||
const target = all.find(p => p.id === plugin.id);
|
border: "1px solid rgba(120,170,255,0.28)",
|
||||||
if (target) {
|
marginLeft: "2px",
|
||||||
target.code = newCode;
|
textTransform: "uppercase",
|
||||||
plugin.code = newCode;
|
letterSpacing: "0.06em",
|
||||||
setLocalPlugins(all);
|
});
|
||||||
}
|
left.appendChild(badge);
|
||||||
if (andRun) {
|
}
|
||||||
plugin.enabled = true;
|
|
||||||
if (target) target.enabled = true;
|
const controls = document.createElement("div");
|
||||||
setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p));
|
Object.assign(controls.style, { display: "flex", gap: "6px" });
|
||||||
runLocalPlugin(plugin);
|
|
||||||
}
|
if (!isBuiltin) {
|
||||||
renderLocalPanel();
|
const editBtn = document.createElement("button");
|
||||||
});
|
editBtn.textContent = "✏ Edit";
|
||||||
};
|
styleLocalBtn(editBtn, "rgba(100,140,255,0.2)");
|
||||||
|
editBtn.onclick = () => {
|
||||||
const toggleBtn = document.createElement("button");
|
openEditorPanel(plugin, (newCode, andRun) => {
|
||||||
toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable";
|
const all = getLocalPlugins();
|
||||||
styleLocalBtn(toggleBtn);
|
const target = all.find(p => p.id === plugin.id);
|
||||||
toggleBtn.onclick = () => {
|
if (target) {
|
||||||
const all = getLocalPlugins();
|
target.code = newCode;
|
||||||
const target = all.find(p => p.id === plugin.id);
|
plugin.code = newCode;
|
||||||
if (!target) return;
|
setLocalPlugins(all);
|
||||||
target.enabled = !target.enabled;
|
}
|
||||||
plugin.enabled = target.enabled;
|
if (andRun) {
|
||||||
setLocalPlugins(all);
|
plugin.enabled = true;
|
||||||
if (target.enabled) runLocalPlugin(plugin);
|
if (target) target.enabled = true;
|
||||||
else stopLocalPlugin(plugin);
|
setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p));
|
||||||
renderLocalPanel();
|
runLocalPlugin(plugin);
|
||||||
};
|
}
|
||||||
|
renderLocalPanel();
|
||||||
const removeBtn = document.createElement("button");
|
});
|
||||||
removeBtn.textContent = "✕";
|
};
|
||||||
styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)");
|
|
||||||
removeBtn.onclick = () => {
|
const toggleBtn = document.createElement("button");
|
||||||
stopLocalPlugin(plugin);
|
toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable";
|
||||||
const editorPanel = document.getElementById("avia-local-editor-panel");
|
styleLocalBtn(toggleBtn);
|
||||||
if (editorPanel) editorPanel.remove();
|
toggleBtn.onclick = () => {
|
||||||
const all = getLocalPlugins();
|
const all = getLocalPlugins();
|
||||||
all.splice(all.findIndex(p => p.id === plugin.id), 1);
|
const target = all.find(p => p.id === plugin.id);
|
||||||
setLocalPlugins(all);
|
if (!target) return;
|
||||||
renderLocalPanel();
|
target.enabled = !target.enabled;
|
||||||
};
|
plugin.enabled = target.enabled;
|
||||||
|
setLocalPlugins(all);
|
||||||
controls.appendChild(editBtn);
|
if (target.enabled) runLocalPlugin(plugin);
|
||||||
controls.appendChild(toggleBtn);
|
else stopLocalPlugin(plugin);
|
||||||
controls.appendChild(removeBtn);
|
renderLocalPanel();
|
||||||
row.appendChild(left);
|
};
|
||||||
row.appendChild(controls);
|
|
||||||
content.appendChild(row);
|
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) {
|
function styleLocalInput(input) {
|
||||||
Object.assign(input.style, {
|
Object.assign(input.style, {
|
||||||
|
|
@ -576,7 +644,7 @@
|
||||||
|
|
||||||
const textNode = [...localBtn.querySelectorAll("div")]
|
const textNode = [...localBtn.querySelectorAll("div")]
|
||||||
.find(d => d.children.length === 0 && d.textContent.trim() === "Appearance");
|
.find(d => d.children.length === 0 && d.textContent.trim() === "Appearance");
|
||||||
if (textNode) textNode.textContent = "(Avia) Local Plugins";
|
if (textNode) textNode.textContent = "(Sanctum) Local Plugins";
|
||||||
|
|
||||||
const oldSvg = localBtn.querySelector("svg");
|
const oldSvg = localBtn.querySelector("svg");
|
||||||
if (oldSvg) oldSvg.remove();
|
if (oldSvg) oldSvg.remove();
|
||||||
|
|
@ -604,15 +672,16 @@
|
||||||
}).observe(document.documentElement, { childList: true });
|
}).observe(document.documentElement, { childList: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForBody(() => {
|
waitForBody(() => {
|
||||||
const observer = new MutationObserver(() => injectLocalButton());
|
const observer = new MutationObserver(() => injectLocalButton());
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
injectLocalButton();
|
injectLocalButton();
|
||||||
});
|
});
|
||||||
|
|
||||||
getLocalPlugins().forEach(plugin => {
|
upsertBuiltinLocalPlugins();
|
||||||
if (plugin.enabled) runLocalPlugin(plugin);
|
getLocalPlugins().forEach(plugin => {
|
||||||
});
|
if (plugin.enabled) runLocalPlugin(plugin);
|
||||||
|
});
|
||||||
|
|
||||||
preloadMonaco();
|
preloadMonaco();
|
||||||
|
|
||||||
|
|
|
||||||
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();
|
||||||
|
})();
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
el.dataset.aviaPatched = "true";
|
el.dataset.aviaPatched = "true";
|
||||||
|
|
||||||
nameDiv.textContent = "Avia Client Desktop";
|
nameDiv.textContent = "Sanctum Desktop";
|
||||||
versionSpan.textContent = `Version ${aviaVersion} (Based on Stoat ${stoatVersion})`;
|
versionSpan.textContent = `Version ${aviaVersion} (Based on Stoat ${stoatVersion})`;
|
||||||
|
|
||||||
textContainer.style.whiteSpace = "normal";
|
textContainer.style.whiteSpace = "normal";
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
el.dataset.aviaPatched = "true";
|
el.dataset.aviaPatched = "true";
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
Avia Client Desktop<br>
|
Sanctum Desktop<br>
|
||||||
<span style="font-size:10px;opacity:0.7;">
|
<span style="font-size:10px;opacity:0.7;">
|
||||||
Based on Stoat ${stoatVersion}
|
Based on Stoat ${stoatVersion}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@
|
||||||
clone.setAttribute("data-lsbackup-entry", "true");
|
clone.setAttribute("data-lsbackup-entry", "true");
|
||||||
|
|
||||||
const title = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > div");
|
const title = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > div");
|
||||||
if (title) title.textContent = "AviaClient Backup";
|
if (title) title.textContent = "Sanctum Backup";
|
||||||
|
|
||||||
const desc = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > span");
|
const desc = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > span");
|
||||||
if (desc) desc.textContent = "Backup or Restore all client data";
|
if (desc) desc.textContent = "Backup or Restore all client data";
|
||||||
|
|
|
||||||
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 });
|
||||||
|
})();
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
const STYLE_ID = "headliner-style";
|
const STYLE_ID = "headliner-style";
|
||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
content: "Stoat V 1.6.0 - Avia Client",
|
content: "Sanctum",
|
||||||
left: "32",
|
left: "32",
|
||||||
top: "56",
|
top: "56",
|
||||||
fontSize: "15",
|
fontSize: "15",
|
||||||
|
|
@ -18,7 +18,12 @@
|
||||||
|
|
||||||
function loadSettings() {
|
function loadSettings() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem("headlinerSettings")) || { ...defaults };
|
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 {
|
} catch {
|
||||||
return { ...defaults };
|
return { ...defaults };
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +41,7 @@
|
||||||
}
|
}
|
||||||
.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\\) {
|
.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;
|
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 {
|
.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}";
|
content: "${s.content}";
|
||||||
|
|
@ -45,7 +51,7 @@
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: ${s.fontSize}px;
|
font-size: ${s.fontSize}px;
|
||||||
font-weight: ${s.fontWeight};
|
font-weight: ${s.fontWeight};
|
||||||
color: var(--md-sys-color-on-surface);
|
color: var(--md-sys-color-on-surface) !important;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
@ -80,7 +86,7 @@
|
||||||
applyCSS();
|
applyCSS();
|
||||||
} else {
|
} else {
|
||||||
clone.setAttribute("data-active", "false");
|
clone.setAttribute("data-active", "false");
|
||||||
if (desc) desc.textContent = "Modify the Stoat name in the titlebar to say anything you want";
|
if (desc) desc.textContent = "Modify the Sanctum name in the titlebar to say anything you want";
|
||||||
if (checkbox) checkbox.removeAttribute("checked");
|
if (checkbox) checkbox.removeAttribute("checked");
|
||||||
removeCSS();
|
removeCSS();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,7 @@
|
||||||
const linktreeBtn = appearanceBtn.cloneNode(true);
|
const linktreeBtn = appearanceBtn.cloneNode(true);
|
||||||
linktreeBtn.id = 'stoat-fake-linktree';
|
linktreeBtn.id = 'stoat-fake-linktree';
|
||||||
const textNode = Array.from(linktreeBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
|
const textNode = Array.from(linktreeBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
|
||||||
if (textNode) textNode.textContent = "(Avia) Ava's Linktree";
|
if (textNode) textNode.textContent = "(Sanctum) Ava's Linktree";
|
||||||
setIcon(linktreeBtn, "monitor");
|
setIcon(linktreeBtn, "monitor");
|
||||||
linktreeBtn.addEventListener('click', () => window.open(LINKTREE_URL, "_blank"));
|
linktreeBtn.addEventListener('click', () => window.open(LINKTREE_URL, "_blank"));
|
||||||
targetParent.appendChild(linktreeBtn);
|
targetParent.appendChild(linktreeBtn);
|
||||||
|
|
@ -288,7 +288,7 @@
|
||||||
const stoatBtn = appearanceBtn.cloneNode(true);
|
const stoatBtn = appearanceBtn.cloneNode(true);
|
||||||
stoatBtn.id = 'stoat-fake-stoatserver';
|
stoatBtn.id = 'stoat-fake-stoatserver';
|
||||||
const stoatTextNode = Array.from(stoatBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
|
const stoatTextNode = Array.from(stoatBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
|
||||||
if (stoatTextNode) stoatTextNode.textContent = "(Avia) Stoat Server";
|
if (stoatTextNode) stoatTextNode.textContent = "(Sanctum) Stoat Server";
|
||||||
setIcon(stoatBtn, "monitor");
|
setIcon(stoatBtn, "monitor");
|
||||||
stoatBtn.addEventListener('click', () => window.open(STOAT_SERVER_URL, "_blank"));
|
stoatBtn.addEventListener('click', () => window.open(STOAT_SERVER_URL, "_blank"));
|
||||||
targetParent.appendChild(stoatBtn);
|
targetParent.appendChild(stoatBtn);
|
||||||
|
|
@ -298,7 +298,7 @@
|
||||||
const newBtn = appearanceBtn.cloneNode(true);
|
const newBtn = appearanceBtn.cloneNode(true);
|
||||||
newBtn.id = 'stoat-fake-loadfont';
|
newBtn.id = 'stoat-fake-loadfont';
|
||||||
const textNode = Array.from(newBtn.querySelectorAll('div')).find(d => d.children.length === 0);
|
const textNode = Array.from(newBtn.querySelectorAll('div')).find(d => d.children.length === 0);
|
||||||
if (textNode) textNode.textContent = "(Avia) Font Loader";
|
if (textNode) textNode.textContent = "(Sanctum) Font Loader";
|
||||||
setIcon(newBtn, "upload");
|
setIcon(newBtn, "upload");
|
||||||
newBtn.addEventListener('click', showFontLoaderPopup);
|
newBtn.addEventListener('click', showFontLoaderPopup);
|
||||||
targetParent.appendChild(newBtn);
|
targetParent.appendChild(newBtn);
|
||||||
|
|
@ -307,7 +307,7 @@
|
||||||
const removeBtn = appearanceBtn.cloneNode(true);
|
const removeBtn = appearanceBtn.cloneNode(true);
|
||||||
removeBtn.id = 'stoat-fake-removefont';
|
removeBtn.id = 'stoat-fake-removefont';
|
||||||
const removeTextNode = Array.from(removeBtn.querySelectorAll('div')).find(d => d.children.length === 0);
|
const removeTextNode = Array.from(removeBtn.querySelectorAll('div')).find(d => d.children.length === 0);
|
||||||
if (removeTextNode) removeTextNode.textContent = "(Avia) Remove selected font";
|
if (removeTextNode) removeTextNode.textContent = "(Sanctum) Remove selected font";
|
||||||
setIcon(removeBtn, "refresh");
|
setIcon(removeBtn, "refresh");
|
||||||
removeBtn.addEventListener('click', showRemoveFontPopup);
|
removeBtn.addEventListener('click', showRemoveFontPopup);
|
||||||
targetParent.appendChild(removeBtn);
|
targetParent.appendChild(removeBtn);
|
||||||
|
|
@ -318,7 +318,7 @@
|
||||||
const quickCssBtn = appearanceBtn.cloneNode(true);
|
const quickCssBtn = appearanceBtn.cloneNode(true);
|
||||||
quickCssBtn.id = 'stoat-fake-quickcss';
|
quickCssBtn.id = 'stoat-fake-quickcss';
|
||||||
const quickCssTextNode = Array.from(quickCssBtn.querySelectorAll('div')).find(d => d.children.length === 0);
|
const quickCssTextNode = Array.from(quickCssBtn.querySelectorAll('div')).find(d => d.children.length === 0);
|
||||||
if (quickCssTextNode) quickCssTextNode.textContent = "(Avia) QuickCSS";
|
if (quickCssTextNode) quickCssTextNode.textContent = "(Sanctum) QuickCSS";
|
||||||
setIcon(quickCssBtn, "code");
|
setIcon(quickCssBtn, "code");
|
||||||
quickCssBtn.addEventListener('click', toggleQuickCSSPanel);
|
quickCssBtn.addEventListener('click', toggleQuickCSSPanel);
|
||||||
targetParent.appendChild(quickCssBtn);
|
targetParent.appendChild(quickCssBtn);
|
||||||
|
|
|
||||||
|
|
@ -536,7 +536,7 @@
|
||||||
pluginsBtn.id = 'stoat-fake-plugins';
|
pluginsBtn.id = 'stoat-fake-plugins';
|
||||||
const textNode = [...pluginsBtn.querySelectorAll('div')]
|
const textNode = [...pluginsBtn.querySelectorAll('div')]
|
||||||
.find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
|
.find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
|
||||||
if (textNode) textNode.textContent = "(Avia) Plugins";
|
if (textNode) textNode.textContent = "(Sanctum) Plugins";
|
||||||
const svgNS = "http://www.w3.org/2000/svg";
|
const svgNS = "http://www.w3.org/2000/svg";
|
||||||
const oldSvg = pluginsBtn.querySelector('svg');
|
const oldSvg = pluginsBtn.querySelector('svg');
|
||||||
if (oldSvg) oldSvg.remove();
|
if (oldSvg) oldSvg.remove();
|
||||||
|
|
|
||||||
|
|
@ -453,7 +453,7 @@
|
||||||
clone.id = "avia-official-repo-btn-settings";
|
clone.id = "avia-official-repo-btn-settings";
|
||||||
|
|
||||||
const label = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
|
const label = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
|
||||||
if (label) label.textContent = "(Avia) Plugins/Themes Repo";
|
if (label) label.textContent = "(Sanctum) Plugins/Themes Repo";
|
||||||
|
|
||||||
const iconSpan = clone.querySelector("span.material-symbols-outlined");
|
const iconSpan = clone.querySelector("span.material-symbols-outlined");
|
||||||
if (iconSpan) {
|
if (iconSpan) {
|
||||||
|
|
|
||||||
|
|
@ -509,7 +509,7 @@
|
||||||
const clone = appearanceBtn.cloneNode(true);
|
const clone = appearanceBtn.cloneNode(true);
|
||||||
clone.id = "avia-themes-btn";
|
clone.id = "avia-themes-btn";
|
||||||
const text = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
|
const text = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
|
||||||
if (text) text.textContent = "(Avia) Themes";
|
if (text) text.textContent = "(Sanctum) Themes";
|
||||||
clone.onclick = toggleThemesPanel;
|
clone.onclick = toggleThemesPanel;
|
||||||
quickCSS.parentElement.insertBefore(clone, quickCSS.nextSibling);
|
quickCSS.parentElement.insertBefore(clone, quickCSS.nextSibling);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<caption>Main window</caption>
|
<caption>Main window</caption>
|
||||||
<image>screenshot.png</image>
|
<image>https://raw.githubusercontent.com/stoatchat/for-desktop/b57faa2c59865fea15a879c9a9304271067d0020/screenshot.png</image>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
<releases>
|
<releases>
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
@ -7,15 +7,14 @@ import { MakerZIP } from "@electron-forge/maker-zip";
|
||||||
import { FusesPlugin } from "@electron-forge/plugin-fuses";
|
import { FusesPlugin } from "@electron-forge/plugin-fuses";
|
||||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||||
import { VitePluginBuildConfig } from "@electron-forge/plugin-vite/dist/Config";
|
import { VitePluginBuildConfig } from "@electron-forge/plugin-vite/dist/Config";
|
||||||
import { PublisherGithub } from "@electron-forge/publisher-github";
|
|
||||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||||
import { FuseV1Options, FuseVersion } from "@electron/fuses";
|
import { FuseV1Options, FuseVersion } from "@electron/fuses";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
|
|
||||||
const STRINGS = {
|
const STRINGS = {
|
||||||
author: "Revolt Platforms LTD",
|
author: "izzy",
|
||||||
name: "AviaClient",
|
name: "Sanctum",
|
||||||
execName: "aviaclient-desktop",
|
execName: "sanctum",
|
||||||
description: "Open source user-first chat platform.",
|
description: "Open source user-first chat platform.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -45,7 +44,7 @@ if (!process.env.PLATFORM) {
|
||||||
}),
|
}),
|
||||||
new MakerFlatpak({
|
new MakerFlatpak({
|
||||||
options: {
|
options: {
|
||||||
id: "chat.stoat.stoat-desktop",
|
id: "cloud.mithraic.sanctum",
|
||||||
description: STRINGS.description,
|
description: STRINGS.description,
|
||||||
productName: STRINGS.name,
|
productName: STRINGS.name,
|
||||||
productDescription: STRINGS.description,
|
productDescription: STRINGS.description,
|
||||||
|
|
@ -157,14 +156,6 @@ const config: ForgeConfig = {
|
||||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
publishers: [
|
|
||||||
new PublisherGithub({
|
|
||||||
repository: {
|
|
||||||
owner: "AvaLilac",
|
|
||||||
name: "for-desktop",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "stoat-desktop",
|
"name": "sanctum",
|
||||||
"productName": "stoat-desktop",
|
"productName": "Sanctum",
|
||||||
"version": "1.3.0",
|
"version": "1.0.7",
|
||||||
"aviaVersion": "1.6.0",
|
"aviaVersion": "1.0.7",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"repository": "stoatchat/desktop",
|
"repository": "https://git.mithraic.cloud/ad3laid3/sanctum",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-forge start",
|
"start": "electron-forge start",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
|
|
@ -54,7 +54,6 @@
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
"update-electron-app": "^3.1.2",
|
|
||||||
"utf-8-validate": "^6.0.5"
|
"utf-8-validate": "^6.0.5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.0"
|
"packageManager": "pnpm@10.33.0"
|
||||||
|
|
|
||||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
|
|
@ -29,9 +29,6 @@ importers:
|
||||||
electron-store:
|
electron-store:
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
update-electron-app:
|
|
||||||
specifier: ^3.1.2
|
|
||||||
version: 3.1.2
|
|
||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
specifier: ^6.0.5
|
specifier: ^6.0.5
|
||||||
version: 6.0.5
|
version: 6.0.5
|
||||||
|
|
@ -910,56 +907,67 @@ packages:
|
||||||
resolution: {integrity: sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==}
|
resolution: {integrity: sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.52.2':
|
'@rollup/rollup-linux-arm-musleabihf@4.52.2':
|
||||||
resolution: {integrity: sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==}
|
resolution: {integrity: sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.52.2':
|
'@rollup/rollup-linux-arm64-gnu@4.52.2':
|
||||||
resolution: {integrity: sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==}
|
resolution: {integrity: sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.52.2':
|
'@rollup/rollup-linux-arm64-musl@4.52.2':
|
||||||
resolution: {integrity: sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==}
|
resolution: {integrity: sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.52.2':
|
'@rollup/rollup-linux-loong64-gnu@4.52.2':
|
||||||
resolution: {integrity: sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==}
|
resolution: {integrity: sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.52.2':
|
'@rollup/rollup-linux-ppc64-gnu@4.52.2':
|
||||||
resolution: {integrity: sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==}
|
resolution: {integrity: sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.52.2':
|
'@rollup/rollup-linux-riscv64-gnu@4.52.2':
|
||||||
resolution: {integrity: sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==}
|
resolution: {integrity: sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.52.2':
|
'@rollup/rollup-linux-riscv64-musl@4.52.2':
|
||||||
resolution: {integrity: sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==}
|
resolution: {integrity: sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.52.2':
|
'@rollup/rollup-linux-s390x-gnu@4.52.2':
|
||||||
resolution: {integrity: sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==}
|
resolution: {integrity: sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.52.2':
|
'@rollup/rollup-linux-x64-gnu@4.52.2':
|
||||||
resolution: {integrity: sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==}
|
resolution: {integrity: sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.52.2':
|
'@rollup/rollup-linux-x64-musl@4.52.2':
|
||||||
resolution: {integrity: sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==}
|
resolution: {integrity: sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openharmony-arm64@4.52.2':
|
'@rollup/rollup-openharmony-arm64@4.52.2':
|
||||||
resolution: {integrity: sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==}
|
resolution: {integrity: sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==}
|
||||||
|
|
@ -1977,9 +1985,6 @@ packages:
|
||||||
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
github-url-to-object@4.0.6:
|
|
||||||
resolution: {integrity: sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ==}
|
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
@ -2267,9 +2272,6 @@ packages:
|
||||||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
is-url@1.2.4:
|
|
||||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
|
||||||
|
|
||||||
is-weakmap@2.0.2:
|
is-weakmap@2.0.2:
|
||||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -3302,9 +3304,6 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
update-electron-app@3.1.2:
|
|
||||||
resolution: {integrity: sha512-htLyPJv7mEoCpaSzCg0W3Hxz7ID0GC7BIhhpK32/ITG7McrWak4aOkLEOjJheKAI94AxtBVTjCk4EFIvyttw2w==}
|
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
|
@ -5835,10 +5834,6 @@ snapshots:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
|
|
||||||
github-url-to-object@4.0.6:
|
|
||||||
dependencies:
|
|
||||||
is-url: 1.2.4
|
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
|
|
@ -6146,8 +6141,6 @@ snapshots:
|
||||||
|
|
||||||
is-unicode-supported@0.1.0: {}
|
is-unicode-supported@0.1.0: {}
|
||||||
|
|
||||||
is-url@1.2.4: {}
|
|
||||||
|
|
||||||
is-weakmap@2.0.2: {}
|
is-weakmap@2.0.2: {}
|
||||||
|
|
||||||
is-weakref@1.1.1:
|
is-weakref@1.1.1:
|
||||||
|
|
@ -7218,11 +7211,6 @@ snapshots:
|
||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
update-electron-app@3.1.2:
|
|
||||||
dependencies:
|
|
||||||
github-url-to-object: 4.0.6
|
|
||||||
ms: 2.1.3
|
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
|
||||||
65
src/config.d.ts
vendored
65
src/config.d.ts
vendored
|
|
@ -7,6 +7,9 @@ declare type DesktopConfig = {
|
||||||
spellchecker: boolean;
|
spellchecker: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
discordRpc: boolean;
|
discordRpc: boolean;
|
||||||
|
gamePresenceEnabled: boolean;
|
||||||
|
gamePresenceRestrictToAllowList: boolean;
|
||||||
|
gamePresenceAllowList: string;
|
||||||
windowState: {
|
windowState: {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|
@ -15,3 +18,65 @@ declare type DesktopConfig = {
|
||||||
isMaximised: boolean;
|
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>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
71
src/main.ts
71
src/main.ts
|
|
@ -1,8 +1,7 @@
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { updateElectronApp } from "update-electron-app";
|
|
||||||
|
|
||||||
import { BrowserWindow, Notification, app, shell } from "electron";
|
import { BrowserWindow, app, shell } from "electron";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
|
|
||||||
import { aviaVersion } from "../package.json";
|
import { aviaVersion } from "../package.json";
|
||||||
|
|
@ -11,15 +10,23 @@ import { autoLaunch } from "./native/autoLaunch";
|
||||||
import { setBadgeCount } from "./native/badges";
|
import { setBadgeCount } from "./native/badges";
|
||||||
import { config } from "./native/config";
|
import { config } from "./native/config";
|
||||||
import { initDiscordRpc } from "./native/discordRpc";
|
import { initDiscordRpc } from "./native/discordRpc";
|
||||||
|
import { startGamePresenceMonitor } from "./native/gamePresence";
|
||||||
|
import { checkForUpdates } from "./native/updater";
|
||||||
import { initTray } from "./native/tray";
|
import { initTray } from "./native/tray";
|
||||||
import { BUILD_URL, createMainWindow, mainWindow } from "./native/window";
|
import { BUILD_URL, createMainWindow, mainWindow } from "./native/window";
|
||||||
|
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
app.commandLine.appendSwitch("disable-gpu-sandbox");
|
||||||
|
app.commandLine.appendSwitch("no-zygote");
|
||||||
|
app.commandLine.appendSwitch("use-gl", "desktop");
|
||||||
|
}
|
||||||
|
|
||||||
const applyAppName = () => {
|
const applyAppName = () => {
|
||||||
try {
|
try {
|
||||||
app.setName("AviaClient");
|
app.setName("Sanctum");
|
||||||
app.name = "AviaClient";
|
app.name = "Sanctum";
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
app.setAppUserModelId("AviaClient");
|
app.setAppUserModelId("cloud.mithraic.sanctum");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* empty */
|
/* empty */
|
||||||
|
|
@ -36,21 +43,34 @@ if (!config.hardwareAcceleration) {
|
||||||
|
|
||||||
const acquiredLock = app.requestSingleInstanceLock();
|
const acquiredLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
const onNotifyUser = () => {
|
|
||||||
const notification = new Notification({
|
|
||||||
title: "Update Available",
|
|
||||||
body: "Restart the app to install the update.",
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
notification.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadInject = () => {
|
const loadInject = () => {
|
||||||
if (!mainWindow) return;
|
if (!mainWindow) return;
|
||||||
|
|
||||||
mainWindow.webContents.on("dom-ready", async () => {
|
const wc = mainWindow.webContents;
|
||||||
|
wc.removeAllListeners("dom-ready");
|
||||||
|
wc.once("dom-ready", async () => {
|
||||||
try {
|
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[] = [
|
const plugins: string[] = [
|
||||||
"inject.js",
|
"inject.js",
|
||||||
"LocalPlugins.js",
|
"LocalPlugins.js",
|
||||||
|
|
@ -61,18 +81,19 @@ const loadInject = () => {
|
||||||
"aviaversion.js",
|
"aviaversion.js",
|
||||||
"repofrontend.js",
|
"repofrontend.js",
|
||||||
"ButtonFix.js",
|
"ButtonFix.js",
|
||||||
"headliner.js",
|
|
||||||
"aviadesktopversion.js",
|
"aviadesktopversion.js",
|
||||||
"customFrameNativeMenu.js",
|
"customFrameNativeMenu.js",
|
||||||
"disableTrayIcon.js",
|
"disableTrayIcon.js",
|
||||||
|
"gamePresenceSettings.js",
|
||||||
"clientBackup.js",
|
"clientBackup.js",
|
||||||
"LoginWithToken.js",
|
"LoginWithToken.js",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
|
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
|
||||||
const pluginPath: string = path.join(__dirname, plugin);
|
const pluginPath: string = path.join(__dirname, plugin);
|
||||||
const pluginCode: string = fs.readFileSync(pluginPath, "utf8");
|
const pluginCode: string = fs.readFileSync(pluginPath, "utf8");
|
||||||
await mainWindow.webContents.executeJavaScript(pluginCode, true);
|
await wc.executeJavaScript(pluginCode, true);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* empty */
|
/* empty */
|
||||||
|
|
@ -81,16 +102,14 @@ const loadInject = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (acquiredLock) {
|
if (acquiredLock) {
|
||||||
updateElectronApp({ onNotifyUser });
|
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
applyAppName();
|
applyAppName();
|
||||||
createMainWindow();
|
createMainWindow();
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
mainWindow.setTitle("AviaClient");
|
mainWindow.setTitle("Sanctum");
|
||||||
mainWindow.on("page-title-updated", (e) => {
|
mainWindow.on("page-title-updated", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
mainWindow.setTitle("AviaClient");
|
mainWindow.setTitle("Sanctum");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
loadInject();
|
loadInject();
|
||||||
|
|
@ -104,10 +123,12 @@ if (acquiredLock) {
|
||||||
|
|
||||||
initTray();
|
initTray();
|
||||||
initDiscordRpc();
|
initDiscordRpc();
|
||||||
|
startGamePresenceMonitor();
|
||||||
|
checkForUpdates();
|
||||||
setBadgeCount(0);
|
setBadgeCount(0);
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
app.setAppUserModelId("AviaClient");
|
app.setAppUserModelId("cloud.mithraic.sanctum");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
|
|
@ -133,10 +154,10 @@ if (acquiredLock) {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createMainWindow();
|
createMainWindow();
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
mainWindow.setTitle("AviaClient");
|
mainWindow.setTitle("Sanctum");
|
||||||
mainWindow.on("page-title-updated", (e) => {
|
mainWindow.on("page-title-updated", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
mainWindow.setTitle("AviaClient");
|
mainWindow.setTitle("Sanctum");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
loadInject();
|
loadInject();
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,15 @@ const schema = {
|
||||||
discordRpc: {
|
discordRpc: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
} as JSONSchema.Boolean,
|
} as JSONSchema.Boolean,
|
||||||
|
gamePresenceEnabled: {
|
||||||
|
type: "boolean",
|
||||||
|
} as JSONSchema.Boolean,
|
||||||
|
gamePresenceRestrictToAllowList: {
|
||||||
|
type: "boolean",
|
||||||
|
} as JSONSchema.Boolean,
|
||||||
|
gamePresenceAllowList: {
|
||||||
|
type: "string",
|
||||||
|
} as JSONSchema.String,
|
||||||
windowState: {
|
windowState: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
|
@ -68,6 +77,9 @@ const store = new Store({
|
||||||
spellchecker: true,
|
spellchecker: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
discordRpc: true,
|
discordRpc: true,
|
||||||
|
gamePresenceEnabled: true,
|
||||||
|
gamePresenceRestrictToAllowList: true,
|
||||||
|
gamePresenceAllowList: "",
|
||||||
windowState: {
|
windowState: {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
|
@ -93,6 +105,9 @@ class Config {
|
||||||
spellchecker: this.spellchecker,
|
spellchecker: this.spellchecker,
|
||||||
hardwareAcceleration: this.hardwareAcceleration,
|
hardwareAcceleration: this.hardwareAcceleration,
|
||||||
discordRpc: this.discordRpc,
|
discordRpc: this.discordRpc,
|
||||||
|
gamePresenceEnabled: this.gamePresenceEnabled,
|
||||||
|
gamePresenceRestrictToAllowList: this.gamePresenceRestrictToAllowList,
|
||||||
|
gamePresenceAllowList: this.gamePresenceAllowList,
|
||||||
windowState: this.windowState,
|
windowState: this.windowState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +245,47 @@ class Config {
|
||||||
this.sync();
|
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() {
|
get windowState() {
|
||||||
return (
|
return (
|
||||||
store as never as { get(k: string): DesktopConfig["windowState"] }
|
store as never as { get(k: string): DesktopConfig["windowState"] }
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,32 @@ import { Client } from "discord-rpc";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
|
|
||||||
// internal state
|
// 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() {
|
export async function initDiscordRpc() {
|
||||||
if (!config.discordRpc) return;
|
if (!config.discordRpc) return;
|
||||||
|
|
@ -14,20 +39,7 @@ export async function initDiscordRpc() {
|
||||||
try {
|
try {
|
||||||
rpc = new Client({ transport: "ipc" });
|
rpc = new Client({ transport: "ipc" });
|
||||||
|
|
||||||
rpc.on("ready", () =>
|
rpc.on("ready", applyActivity);
|
||||||
rpc.setActivity({
|
|
||||||
details: "Chatting with others on AviaClient",
|
|
||||||
state: "stoat.chat",
|
|
||||||
largeImageKey: "qr",
|
|
||||||
largeImageText: "Join Stoat!",
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
label: "Join Stoat",
|
|
||||||
url: "https://stoat.chat/",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
rpc.on("disconnected", reconnect);
|
rpc.on("disconnected", reconnect);
|
||||||
|
|
||||||
|
|
@ -37,8 +49,14 @@ export async function initDiscordRpc() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setDiscordActivity(activity: RpcActivity | null) {
|
||||||
|
pendingActivity = activity ?? defaultActivity;
|
||||||
|
applyActivity();
|
||||||
|
}
|
||||||
|
|
||||||
const reconnect = () => setTimeout(() => initDiscordRpc(), 1e4);
|
const reconnect = () => setTimeout(() => initDiscordRpc(), 1e4);
|
||||||
|
|
||||||
export async function destroyDiscordRpc() {
|
export async function destroyDiscordRpc() {
|
||||||
rpc?.destroy();
|
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(" ");
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ export function initTray() {
|
||||||
const trayIcon = createTrayIcon();
|
const trayIcon = createTrayIcon();
|
||||||
tray = new Tray(trayIcon);
|
tray = new Tray(trayIcon);
|
||||||
updateTrayMenu();
|
updateTrayMenu();
|
||||||
tray.setToolTip("AviaClient for Desktop");
|
tray.setToolTip("Sanctum for Desktop");
|
||||||
tray.setImage(trayIcon);
|
tray.setImage(trayIcon);
|
||||||
tray.on("click", () => {
|
tray.on("click", () => {
|
||||||
config.sync();
|
config.sync();
|
||||||
|
|
@ -46,7 +46,7 @@ export function initTray() {
|
||||||
export function updateTrayMenu() {
|
export function updateTrayMenu() {
|
||||||
tray.setContextMenu(
|
tray.setContextMenu(
|
||||||
Menu.buildFromTemplate([
|
Menu.buildFromTemplate([
|
||||||
{ label: "AviaClient for Desktop", type: "normal", enabled: false },
|
{ label: "Sanctum for Desktop", type: "normal", enabled: false },
|
||||||
{
|
{
|
||||||
label: "Versions",
|
label: "Versions",
|
||||||
type: "submenu",
|
type: "submenu",
|
||||||
|
|
@ -57,7 +57,7 @@ export function updateTrayMenu() {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `AviaClient: ${aviaVersion}`,
|
label: `Sanctum: ${aviaVersion}`,
|
||||||
type: "normal",
|
type: "normal",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ export let mainWindow: BrowserWindow;
|
||||||
export const BUILD_URL = new URL(
|
export const BUILD_URL = new URL(
|
||||||
app.commandLine.hasSwitch("force-server")
|
app.commandLine.hasSwitch("force-server")
|
||||||
? app.commandLine.getSwitchValue("force-server")
|
? app.commandLine.getSwitchValue("force-server")
|
||||||
: /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://beta.revolt.chat",
|
: /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://mithraic.space/app",
|
||||||
);
|
);
|
||||||
|
|
||||||
// internal window state
|
// internal window state
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,22 @@ contextBridge.exposeInMainWorld("native", {
|
||||||
aviaClient: () => aviaVersion,
|
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"),
|
minimise: () => ipcRenderer.send("minimise"),
|
||||||
maximise: () => ipcRenderer.send("maximise"),
|
maximise: () => ipcRenderer.send("maximise"),
|
||||||
close: () => ipcRenderer.send("close"),
|
close: () => ipcRenderer.send("close"),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue