Compare commits

..

No commits in common. "main" and "1.6.0" have entirely different histories.
main ... 1.6.0

35 changed files with 205 additions and 2694 deletions

View file

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

View file

@ -11,7 +11,7 @@ on:
jobs:
release-webhook:
name: Send Release Webhook
runs-on: docker
runs-on: ubuntu-latest
steps:
- name: Send release notification webhook

View file

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

View file

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

View file

@ -3,7 +3,6 @@
Avia Client for Desktop
"stoat desktop"
</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
</div>
<br/>
@ -71,5 +70,3 @@ pnpm run:nix --force-server=http://localhost:5173
# a better solution would be telling
# 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: 47 KiB

After

Width:  |  Height:  |  Size: 4.6 MiB

View file

@ -4,9 +4,6 @@
window.__AVIA_LOCAL_PLUGINS_LOADED__ = true;
const STORAGE_KEY = "avia_local_plugins";
const BUILTIN_SEED = Array.isArray(window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__)
? window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__
: [];
const runningLocalPlugins = {};
const localPluginErrors = {};
@ -14,51 +11,6 @@
const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
function upsertBuiltinLocalPlugins() {
if (!BUILTIN_SEED.length) return;
const plugins = getLocalPlugins();
let dirty = false;
for (const builtin of BUILTIN_SEED) {
const next = {
id: builtin.id,
name: builtin.name,
code: builtin.code || "",
enabled: true,
locked: true,
builtin: true,
};
const existingIndex = plugins.findIndex((plugin) =>
plugin.id === next.id || plugin.name === next.name
);
if (existingIndex >= 0) {
const current = plugins[existingIndex];
const merged = {
...current,
...next,
enabled: true,
locked: true,
builtin: true,
};
if (
JSON.stringify(current) !== JSON.stringify(merged)
) {
plugins[existingIndex] = merged;
dirty = true;
}
} else {
plugins.push(next);
dirty = true;
}
}
if (dirty) setLocalPlugins(plugins);
}
function preloadMonaco() {
return new Promise(resolve => {
if (window.monaco) return resolve();
@ -487,7 +439,6 @@
plugins.forEach((plugin, index) => {
const isRunning = !!runningLocalPlugins[plugin.id];
const hasError = !!localPluginErrors[plugin.id];
const isBuiltin = !!plugin.locked || !!plugin.builtin;
const row = document.createElement("div");
Object.assign(row.style, {
@ -523,27 +474,9 @@
left.appendChild(statusDot);
left.appendChild(name);
if (isBuiltin) {
const badge = document.createElement("div");
badge.textContent = "Built-in";
Object.assign(badge.style, {
fontSize: "10px",
padding: "2px 7px",
borderRadius: "999px",
background: "rgba(120,170,255,0.16)",
color: "#a9c4ff",
border: "1px solid rgba(120,170,255,0.28)",
marginLeft: "2px",
textTransform: "uppercase",
letterSpacing: "0.06em",
});
left.appendChild(badge);
}
const controls = document.createElement("div");
Object.assign(controls.style, { display: "flex", gap: "6px" });
if (!isBuiltin) {
const editBtn = document.createElement("button");
editBtn.textContent = "✏ Edit";
styleLocalBtn(editBtn, "rgba(100,140,255,0.2)");
@ -597,7 +530,6 @@
controls.appendChild(editBtn);
controls.appendChild(toggleBtn);
controls.appendChild(removeBtn);
}
row.appendChild(left);
row.appendChild(controls);
content.appendChild(row);
@ -644,7 +576,7 @@
const textNode = [...localBtn.querySelectorAll("div")]
.find(d => d.children.length === 0 && d.textContent.trim() === "Appearance");
if (textNode) textNode.textContent = "(Sanctum) Local Plugins";
if (textNode) textNode.textContent = "(Avia) Local Plugins";
const oldSvg = localBtn.querySelector("svg");
if (oldSvg) oldSvg.remove();
@ -678,7 +610,6 @@
injectLocalButton();
});
upsertBuiltinLocalPlugins();
getLocalPlugins().forEach(plugin => {
if (plugin.enabled) runLocalPlugin(plugin);
});

View file

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

View file

@ -26,7 +26,7 @@
el.dataset.aviaPatched = "true";
nameDiv.textContent = "Sanctum Desktop";
nameDiv.textContent = "Avia Client Desktop";
versionSpan.textContent = `Version ${aviaVersion} (Based on Stoat ${stoatVersion})`;
textContainer.style.whiteSpace = "normal";

View file

@ -15,7 +15,7 @@
el.dataset.aviaPatched = "true";
el.innerHTML = `
Sanctum Desktop<br>
Avia Client Desktop<br>
<span style="font-size:10px;opacity:0.7;">
Based on Stoat ${stoatVersion}
</span>

View file

@ -129,7 +129,7 @@
clone.setAttribute("data-lsbackup-entry", "true");
const title = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > div");
if (title) title.textContent = "Sanctum Backup";
if (title) title.textContent = "AviaClient Backup";
const desc = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > span");
if (desc) desc.textContent = "Backup or Restore all client data";

View file

@ -1,397 +0,0 @@
(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 });
})();

View file

@ -9,7 +9,7 @@
const STYLE_ID = "headliner-style";
const defaults = {
content: "Sanctum",
content: "Stoat V 1.6.0 - Avia Client",
left: "32",
top: "56",
fontSize: "15",
@ -18,12 +18,7 @@
function loadSettings() {
try {
let s = JSON.parse(localStorage.getItem("headlinerSettings"));
if (s && /^Sanctum V 1\.0\.[0-9]+$/.test(s.content)) {
s.content = defaults.content;
saveSettings(s);
}
return s || { ...defaults };
return JSON.parse(localStorage.getItem("headlinerSettings")) || { ...defaults };
} catch {
return { ...defaults };
}
@ -41,7 +36,6 @@
}
.flex-sh_0.h_29px.us_none.d_flex.ai_center.fill_var\\(--md-sys-color-on-surface\\).c_var\\(--md-sys-color-outline\\).bg_var\\(--md-sys-color-surface-container-high\\) {
position: relative !important;
color: transparent !important;
}
.flex-sh_0.h_29px.us_none.d_flex.ai_center.fill_var\\(--md-sys-color-on-surface\\).c_var\\(--md-sys-color-outline\\).bg_var\\(--md-sys-color-surface-container-high\\)::before {
content: "${s.content}";
@ -51,7 +45,7 @@
transform: translateY(-50%);
font-size: ${s.fontSize}px;
font-weight: ${s.fontWeight};
color: var(--md-sys-color-on-surface) !important;
color: var(--md-sys-color-on-surface);
pointer-events: none;
}
`;
@ -86,7 +80,7 @@
applyCSS();
} else {
clone.setAttribute("data-active", "false");
if (desc) desc.textContent = "Modify the Sanctum name in the titlebar to say anything you want";
if (desc) desc.textContent = "Modify the Stoat name in the titlebar to say anything you want";
if (checkbox) checkbox.removeAttribute("checked");
removeCSS();
}

View file

@ -280,7 +280,7 @@
const linktreeBtn = appearanceBtn.cloneNode(true);
linktreeBtn.id = 'stoat-fake-linktree';
const textNode = Array.from(linktreeBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
if (textNode) textNode.textContent = "(Sanctum) Ava's Linktree";
if (textNode) textNode.textContent = "(Avia) Ava's Linktree";
setIcon(linktreeBtn, "monitor");
linktreeBtn.addEventListener('click', () => window.open(LINKTREE_URL, "_blank"));
targetParent.appendChild(linktreeBtn);
@ -288,7 +288,7 @@
const stoatBtn = appearanceBtn.cloneNode(true);
stoatBtn.id = 'stoat-fake-stoatserver';
const stoatTextNode = Array.from(stoatBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
if (stoatTextNode) stoatTextNode.textContent = "(Sanctum) Stoat Server";
if (stoatTextNode) stoatTextNode.textContent = "(Avia) Stoat Server";
setIcon(stoatBtn, "monitor");
stoatBtn.addEventListener('click', () => window.open(STOAT_SERVER_URL, "_blank"));
targetParent.appendChild(stoatBtn);
@ -298,7 +298,7 @@
const newBtn = appearanceBtn.cloneNode(true);
newBtn.id = 'stoat-fake-loadfont';
const textNode = Array.from(newBtn.querySelectorAll('div')).find(d => d.children.length === 0);
if (textNode) textNode.textContent = "(Sanctum) Font Loader";
if (textNode) textNode.textContent = "(Avia) Font Loader";
setIcon(newBtn, "upload");
newBtn.addEventListener('click', showFontLoaderPopup);
targetParent.appendChild(newBtn);
@ -307,7 +307,7 @@
const removeBtn = appearanceBtn.cloneNode(true);
removeBtn.id = 'stoat-fake-removefont';
const removeTextNode = Array.from(removeBtn.querySelectorAll('div')).find(d => d.children.length === 0);
if (removeTextNode) removeTextNode.textContent = "(Sanctum) Remove selected font";
if (removeTextNode) removeTextNode.textContent = "(Avia) Remove selected font";
setIcon(removeBtn, "refresh");
removeBtn.addEventListener('click', showRemoveFontPopup);
targetParent.appendChild(removeBtn);
@ -318,7 +318,7 @@
const quickCssBtn = appearanceBtn.cloneNode(true);
quickCssBtn.id = 'stoat-fake-quickcss';
const quickCssTextNode = Array.from(quickCssBtn.querySelectorAll('div')).find(d => d.children.length === 0);
if (quickCssTextNode) quickCssTextNode.textContent = "(Sanctum) QuickCSS";
if (quickCssTextNode) quickCssTextNode.textContent = "(Avia) QuickCSS";
setIcon(quickCssBtn, "code");
quickCssBtn.addEventListener('click', toggleQuickCSSPanel);
targetParent.appendChild(quickCssBtn);

View file

@ -536,7 +536,7 @@
pluginsBtn.id = 'stoat-fake-plugins';
const textNode = [...pluginsBtn.querySelectorAll('div')]
.find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
if (textNode) textNode.textContent = "(Sanctum) Plugins";
if (textNode) textNode.textContent = "(Avia) Plugins";
const svgNS = "http://www.w3.org/2000/svg";
const oldSvg = pluginsBtn.querySelector('svg');
if (oldSvg) oldSvg.remove();

View file

@ -453,7 +453,7 @@
clone.id = "avia-official-repo-btn-settings";
const label = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
if (label) label.textContent = "(Sanctum) Plugins/Themes Repo";
if (label) label.textContent = "(Avia) Plugins/Themes Repo";
const iconSpan = clone.querySelector("span.material-symbols-outlined");
if (iconSpan) {

View file

@ -509,7 +509,7 @@
const clone = appearanceBtn.cloneNode(true);
clone.id = "avia-themes-btn";
const text = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
if (text) text.textContent = "(Sanctum) Themes";
if (text) text.textContent = "(Avia) Themes";
clone.onclick = toggleThemesPanel;
quickCSS.parentElement.insertBefore(clone, quickCSS.nextSibling);
}

View file

@ -19,7 +19,7 @@
<screenshots>
<screenshot type="default">
<caption>Main window</caption>
<image>https://raw.githubusercontent.com/stoatchat/for-desktop/b57faa2c59865fea15a879c9a9304271067d0020/screenshot.png</image>
<image>screenshot.png</image>
</screenshot>
</screenshots>
<releases>

View file

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

View file

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

View file

@ -7,14 +7,15 @@ import { MakerZIP } from "@electron-forge/maker-zip";
import { FusesPlugin } from "@electron-forge/plugin-fuses";
import { VitePlugin } from "@electron-forge/plugin-vite";
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 { FuseV1Options, FuseVersion } from "@electron/fuses";
import * as fs from "fs";
const STRINGS = {
author: "izzy",
name: "Sanctum",
execName: "sanctum",
author: "Revolt Platforms LTD",
name: "AviaClient",
execName: "aviaclient-desktop",
description: "Open source user-first chat platform.",
};
@ -44,7 +45,7 @@ if (!process.env.PLATFORM) {
}),
new MakerFlatpak({
options: {
id: "cloud.mithraic.sanctum",
id: "chat.stoat.stoat-desktop",
description: STRINGS.description,
productName: STRINGS.name,
productDescription: STRINGS.description,
@ -156,6 +157,14 @@ const config: ForgeConfig = {
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
publishers: [
new PublisherGithub({
repository: {
owner: "AvaLilac",
name: "for-desktop",
},
}),
],
};
export default config;

View file

@ -1,10 +1,10 @@
{
"name": "sanctum",
"productName": "Sanctum",
"version": "1.0.7",
"aviaVersion": "1.0.7",
"name": "stoat-desktop",
"productName": "stoat-desktop",
"version": "1.3.0",
"aviaVersion": "1.6.0",
"main": ".vite/build/main.js",
"repository": "https://git.mithraic.cloud/ad3laid3/sanctum",
"repository": "stoatchat/desktop",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
@ -54,6 +54,7 @@
"discord-rpc": "^4.0.1",
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^10.1.0",
"update-electron-app": "^3.1.2",
"utf-8-validate": "^6.0.5"
},
"packageManager": "pnpm@10.33.0"

34
pnpm-lock.yaml generated
View file

@ -29,6 +29,9 @@ importers:
electron-store:
specifier: ^10.1.0
version: 10.1.0
update-electron-app:
specifier: ^3.1.2
version: 3.1.2
utf-8-validate:
specifier: ^6.0.5
version: 6.0.5
@ -907,67 +910,56 @@ packages:
resolution: {integrity: sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.2':
resolution: {integrity: sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.2':
resolution: {integrity: sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.2':
resolution: {integrity: sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.2':
resolution: {integrity: sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.2':
resolution: {integrity: sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.2':
resolution: {integrity: sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.2':
resolution: {integrity: sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.2':
resolution: {integrity: sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.2':
resolution: {integrity: sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.2':
resolution: {integrity: sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.2':
resolution: {integrity: sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==}
@ -1985,6 +1977,9 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
github-url-to-object@4.0.6:
resolution: {integrity: sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -2272,6 +2267,9 @@ packages:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@ -3304,6 +3302,9 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
update-electron-app@3.1.2:
resolution: {integrity: sha512-htLyPJv7mEoCpaSzCg0W3Hxz7ID0GC7BIhhpK32/ITG7McrWak4aOkLEOjJheKAI94AxtBVTjCk4EFIvyttw2w==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -5834,6 +5835,10 @@ snapshots:
es-errors: 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:
dependencies:
is-glob: 4.0.3
@ -6141,6 +6146,8 @@ snapshots:
is-unicode-supported@0.1.0: {}
is-url@1.2.4: {}
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@ -7211,6 +7218,11 @@ snapshots:
escalade: 3.2.0
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:
dependencies:
punycode: 2.3.1

65
src/config.d.ts vendored
View file

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

View file

@ -1,7 +1,8 @@
import * as fs from "fs";
import * as path from "path";
import { updateElectronApp } from "update-electron-app";
import { BrowserWindow, app, shell } from "electron";
import { BrowserWindow, Notification, app, shell } from "electron";
import started from "electron-squirrel-startup";
import { aviaVersion } from "../package.json";
@ -10,23 +11,15 @@ import { autoLaunch } from "./native/autoLaunch";
import { setBadgeCount } from "./native/badges";
import { config } from "./native/config";
import { initDiscordRpc } from "./native/discordRpc";
import { startGamePresenceMonitor } from "./native/gamePresence";
import { checkForUpdates } from "./native/updater";
import { initTray } from "./native/tray";
import { BUILD_URL, createMainWindow, mainWindow } from "./native/window";
if (process.platform === "linux") {
app.commandLine.appendSwitch("disable-gpu-sandbox");
app.commandLine.appendSwitch("no-zygote");
app.commandLine.appendSwitch("use-gl", "desktop");
}
const applyAppName = () => {
try {
app.setName("Sanctum");
app.name = "Sanctum";
app.setName("AviaClient");
app.name = "AviaClient";
if (process.platform === "win32") {
app.setAppUserModelId("cloud.mithraic.sanctum");
app.setAppUserModelId("AviaClient");
}
} catch {
/* empty */
@ -43,34 +36,21 @@ if (!config.hardwareAcceleration) {
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 = () => {
if (!mainWindow) return;
const wc = mainWindow.webContents;
wc.removeAllListeners("dom-ready");
wc.once("dom-ready", async () => {
mainWindow.webContents.on("dom-ready", async () => {
try {
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
const builtInLocalPlugins = [
{
id: "sanctum-vcsounds",
name: "VCSounds",
code: fs.readFileSync(path.join(__dirname, "VCSounds.js"), "utf8"),
enabled: true,
locked: true,
},
];
await wc.executeJavaScript(
`window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__ = ${JSON.stringify(
builtInLocalPlugins,
)};`,
true,
);
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
const plugins: string[] = [
"inject.js",
"LocalPlugins.js",
@ -81,19 +61,18 @@ const loadInject = () => {
"aviaversion.js",
"repofrontend.js",
"ButtonFix.js",
"headliner.js",
"aviadesktopversion.js",
"customFrameNativeMenu.js",
"disableTrayIcon.js",
"gamePresenceSettings.js",
"clientBackup.js",
"LoginWithToken.js",
];
for (const plugin of plugins) {
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
const pluginPath: string = path.join(__dirname, plugin);
const pluginCode: string = fs.readFileSync(pluginPath, "utf8");
await wc.executeJavaScript(pluginCode, true);
await mainWindow.webContents.executeJavaScript(pluginCode, true);
}
} catch {
/* empty */
@ -102,14 +81,16 @@ const loadInject = () => {
};
if (acquiredLock) {
updateElectronApp({ onNotifyUser });
app.whenReady().then(() => {
applyAppName();
createMainWindow();
if (mainWindow) {
mainWindow.setTitle("Sanctum");
mainWindow.setTitle("AviaClient");
mainWindow.on("page-title-updated", (e) => {
e.preventDefault();
mainWindow.setTitle("Sanctum");
mainWindow.setTitle("AviaClient");
});
}
loadInject();
@ -123,12 +104,10 @@ if (acquiredLock) {
initTray();
initDiscordRpc();
startGamePresenceMonitor();
checkForUpdates();
setBadgeCount(0);
if (process.platform === "win32") {
app.setAppUserModelId("cloud.mithraic.sanctum");
app.setAppUserModelId("AviaClient");
}
if (process.platform === "darwin") {
@ -154,10 +133,10 @@ if (acquiredLock) {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
if (mainWindow) {
mainWindow.setTitle("Sanctum");
mainWindow.setTitle("AviaClient");
mainWindow.on("page-title-updated", (e) => {
e.preventDefault();
mainWindow.setTitle("Sanctum");
mainWindow.setTitle("AviaClient");
});
}
loadInject();

View file

@ -34,15 +34,6 @@ const schema = {
discordRpc: {
type: "boolean",
} as JSONSchema.Boolean,
gamePresenceEnabled: {
type: "boolean",
} as JSONSchema.Boolean,
gamePresenceRestrictToAllowList: {
type: "boolean",
} as JSONSchema.Boolean,
gamePresenceAllowList: {
type: "string",
} as JSONSchema.String,
windowState: {
type: "object",
properties: {
@ -77,9 +68,6 @@ const store = new Store({
spellchecker: true,
hardwareAcceleration: true,
discordRpc: true,
gamePresenceEnabled: true,
gamePresenceRestrictToAllowList: true,
gamePresenceAllowList: "",
windowState: {
x: 0,
y: 0,
@ -105,9 +93,6 @@ class Config {
spellchecker: this.spellchecker,
hardwareAcceleration: this.hardwareAcceleration,
discordRpc: this.discordRpc,
gamePresenceEnabled: this.gamePresenceEnabled,
gamePresenceRestrictToAllowList: this.gamePresenceRestrictToAllowList,
gamePresenceAllowList: this.gamePresenceAllowList,
windowState: this.windowState,
});
}
@ -245,47 +230,6 @@ class Config {
this.sync();
}
get gamePresenceEnabled() {
return (store as never as { get(k: string): boolean }).get("gamePresenceEnabled");
}
set gamePresenceEnabled(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"gamePresenceEnabled",
value,
);
this.sync();
}
get gamePresenceRestrictToAllowList() {
return (store as never as { get(k: string): boolean }).get(
"gamePresenceRestrictToAllowList",
);
}
set gamePresenceRestrictToAllowList(value: boolean) {
(store as never as { set(k: string, value: boolean): void }).set(
"gamePresenceRestrictToAllowList",
value,
);
this.sync();
}
get gamePresenceAllowList() {
return (store as never as { get(k: string): string }).get("gamePresenceAllowList");
}
set gamePresenceAllowList(value: string) {
(store as never as { set(k: string, value: string): void }).set(
"gamePresenceAllowList",
value,
);
this.sync();
}
get windowState() {
return (
store as never as { get(k: string): DesktopConfig["windowState"] }

View file

@ -3,32 +3,7 @@ import { Client } from "discord-rpc";
import { config } from "./config";
// internal state
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 */
}
}
let rpc: Client;
export async function initDiscordRpc() {
if (!config.discordRpc) return;
@ -39,7 +14,20 @@ export async function initDiscordRpc() {
try {
rpc = new Client({ transport: "ipc" });
rpc.on("ready", applyActivity);
rpc.on("ready", () =>
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);
@ -49,14 +37,8 @@ export async function initDiscordRpc() {
}
}
export function setDiscordActivity(activity: RpcActivity | null) {
pendingActivity = activity ?? defaultActivity;
applyActivity();
}
const reconnect = () => setTimeout(() => initDiscordRpc(), 1e4);
export async function destroyDiscordRpc() {
rpc?.destroy();
rpc = undefined;
}

View file

@ -1,173 +0,0 @@
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)));
}

View file

@ -1,373 +0,0 @@
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;
}

View file

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

View file

@ -27,7 +27,7 @@ export function initTray() {
const trayIcon = createTrayIcon();
tray = new Tray(trayIcon);
updateTrayMenu();
tray.setToolTip("Sanctum for Desktop");
tray.setToolTip("AviaClient for Desktop");
tray.setImage(trayIcon);
tray.on("click", () => {
config.sync();
@ -46,7 +46,7 @@ export function initTray() {
export function updateTrayMenu() {
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: "Sanctum for Desktop", type: "normal", enabled: false },
{ label: "AviaClient for Desktop", type: "normal", enabled: false },
{
label: "Versions",
type: "submenu",
@ -57,7 +57,7 @@ export function updateTrayMenu() {
enabled: false,
},
{
label: `Sanctum: ${aviaVersion}`,
label: `AviaClient: ${aviaVersion}`,
type: "normal",
enabled: false,
},

View file

@ -1,83 +0,0 @@
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);
}

View file

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

View file

@ -22,7 +22,7 @@ export let mainWindow: BrowserWindow;
export const BUILD_URL = new URL(
app.commandLine.hasSwitch("force-server")
? app.commandLine.getSwitchValue("force-server")
: /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://mithraic.space/app",
: /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://beta.revolt.chat",
);
// internal window state

View file

@ -11,22 +11,6 @@ contextBridge.exposeInMainWorld("native", {
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"),
maximise: () => ipcRenderer.send("maximise"),
close: () => ipcRenderer.send("close"),