Compare commits

...

6 commits
1.0.1 ... main

Author SHA1 Message Date
MiTHRAL
1e3f165857 v1.0.7
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m55s
2026-05-05 20:50:53 -04:00
MiTHRAL
8a9d621456 v1.0.6
Some checks failed
Build and Release Sanctum / Build App (push) Has been cancelled
2026-05-05 20:48:42 -04:00
MiTHRAL
3055e283a1 v1.0.5
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m55s
2026-05-05 20:40:57 -04:00
MiTHRAL
44ee4970f9 fix: remove custom headliner to resolve title overlap and bump to 1.0.4
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m48s
2026-04-24 16:11:13 -04:00
MiTHRAL
90797d6dd9 chore: bump version to 1.0.3 and clarify tagging mandate
All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m50s
2026-04-24 15:36:44 -04:00
MiTHRAL
194199daed chore: bump version to 1.0.2 and add agent mandates
Some checks failed
Build and Release Sanctum / Build App (push) Has been cancelled
2026-04-24 15:35:21 -04:00
16 changed files with 2267 additions and 115 deletions

12
GEMINI.md Normal file
View file

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

View file

@ -71,3 +71,5 @@ pnpm run:nix --force-server=http://localhost:5173
# a better solution would be telling # a better solution would be telling
# Electron Forge where system Electron is # Electron Forge where system Electron is
``` ```
`VCSounds.js` ships as a built-in local plugin now, so it is seeded automatically on launch and cannot be accidentally removed from the release install.

View file

@ -4,6 +4,9 @@
window.__AVIA_LOCAL_PLUGINS_LOADED__ = true; window.__AVIA_LOCAL_PLUGINS_LOADED__ = true;
const STORAGE_KEY = "avia_local_plugins"; const STORAGE_KEY = "avia_local_plugins";
const BUILTIN_SEED = Array.isArray(window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__)
? window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__
: [];
const runningLocalPlugins = {}; const runningLocalPlugins = {};
const localPluginErrors = {}; const localPluginErrors = {};
@ -11,6 +14,51 @@
const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); 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() { function preloadMonaco() {
return new Promise(resolve => { return new Promise(resolve => {
if (window.monaco) return resolve(); if (window.monaco) return resolve();
@ -439,6 +487,7 @@
plugins.forEach((plugin, index) => { plugins.forEach((plugin, index) => {
const isRunning = !!runningLocalPlugins[plugin.id]; const isRunning = !!runningLocalPlugins[plugin.id];
const hasError = !!localPluginErrors[plugin.id]; const hasError = !!localPluginErrors[plugin.id];
const isBuiltin = !!plugin.locked || !!plugin.builtin;
const row = document.createElement("div"); const row = document.createElement("div");
Object.assign(row.style, { Object.assign(row.style, {
@ -474,62 +523,81 @@
left.appendChild(statusDot); left.appendChild(statusDot);
left.appendChild(name); 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"); const controls = document.createElement("div");
Object.assign(controls.style, { display: "flex", gap: "6px" }); Object.assign(controls.style, { display: "flex", gap: "6px" });
const editBtn = document.createElement("button"); if (!isBuiltin) {
editBtn.textContent = "✏ Edit"; const editBtn = document.createElement("button");
styleLocalBtn(editBtn, "rgba(100,140,255,0.2)"); editBtn.textContent = "✏ Edit";
editBtn.onclick = () => { styleLocalBtn(editBtn, "rgba(100,140,255,0.2)");
openEditorPanel(plugin, (newCode, andRun) => { editBtn.onclick = () => {
openEditorPanel(plugin, (newCode, andRun) => {
const all = getLocalPlugins();
const target = all.find(p => p.id === plugin.id);
if (target) {
target.code = newCode;
plugin.code = newCode;
setLocalPlugins(all);
}
if (andRun) {
plugin.enabled = true;
if (target) target.enabled = true;
setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p));
runLocalPlugin(plugin);
}
renderLocalPanel();
});
};
const toggleBtn = document.createElement("button");
toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable";
styleLocalBtn(toggleBtn);
toggleBtn.onclick = () => {
const all = getLocalPlugins(); const all = getLocalPlugins();
const target = all.find(p => p.id === plugin.id); const target = all.find(p => p.id === plugin.id);
if (target) { if (!target) return;
target.code = newCode; target.enabled = !target.enabled;
plugin.code = newCode; plugin.enabled = target.enabled;
setLocalPlugins(all); setLocalPlugins(all);
} if (target.enabled) runLocalPlugin(plugin);
if (andRun) { else stopLocalPlugin(plugin);
plugin.enabled = true;
if (target) target.enabled = true;
setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p));
runLocalPlugin(plugin);
}
renderLocalPanel(); renderLocalPanel();
}); };
};
const toggleBtn = document.createElement("button"); const removeBtn = document.createElement("button");
toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable"; removeBtn.textContent = "✕";
styleLocalBtn(toggleBtn); styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)");
toggleBtn.onclick = () => { removeBtn.onclick = () => {
const all = getLocalPlugins(); stopLocalPlugin(plugin);
const target = all.find(p => p.id === plugin.id); const editorPanel = document.getElementById("avia-local-editor-panel");
if (!target) return; if (editorPanel) editorPanel.remove();
target.enabled = !target.enabled; const all = getLocalPlugins();
plugin.enabled = target.enabled; all.splice(all.findIndex(p => p.id === plugin.id), 1);
setLocalPlugins(all); setLocalPlugins(all);
if (target.enabled) runLocalPlugin(plugin); renderLocalPanel();
else stopLocalPlugin(plugin); };
renderLocalPanel();
};
const removeBtn = document.createElement("button"); controls.appendChild(editBtn);
removeBtn.textContent = "✕"; controls.appendChild(toggleBtn);
styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)"); controls.appendChild(removeBtn);
removeBtn.onclick = () => { }
stopLocalPlugin(plugin);
const editorPanel = document.getElementById("avia-local-editor-panel");
if (editorPanel) editorPanel.remove();
const all = getLocalPlugins();
all.splice(all.findIndex(p => p.id === plugin.id), 1);
setLocalPlugins(all);
renderLocalPanel();
};
controls.appendChild(editBtn);
controls.appendChild(toggleBtn);
controls.appendChild(removeBtn);
row.appendChild(left); row.appendChild(left);
row.appendChild(controls); row.appendChild(controls);
content.appendChild(row); content.appendChild(row);
@ -610,6 +678,7 @@
injectLocalButton(); injectLocalButton();
}); });
upsertBuiltinLocalPlugins();
getLocalPlugins().forEach(plugin => { getLocalPlugins().forEach(plugin => {
if (plugin.enabled) runLocalPlugin(plugin); if (plugin.enabled) runLocalPlugin(plugin);
}); });

605
avia_core/VCSounds.js Normal file
View file

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

View file

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

View file

@ -9,7 +9,7 @@
const STYLE_ID = "headliner-style"; const STYLE_ID = "headliner-style";
const defaults = { const defaults = {
content: "Sanctum V 1.0.1", content: "Sanctum",
left: "32", left: "32",
top: "56", top: "56",
fontSize: "15", fontSize: "15",
@ -19,7 +19,7 @@
function loadSettings() { function loadSettings() {
try { try {
let s = JSON.parse(localStorage.getItem("headlinerSettings")); let s = JSON.parse(localStorage.getItem("headlinerSettings"));
if (s && (s.content === "Stoat V 1.0.0 - Sanctum" || s.content === "Sanctum V 1.0.0")) { if (s && /^Sanctum V 1\.0\.[0-9]+$/.test(s.content)) {
s.content = defaults.content; s.content = defaults.content;
saveSettings(s); saveSettings(s);
} }

View file

@ -27,6 +27,11 @@
<control>pointing</control> <control>pointing</control>
</supports> </supports>
<releases> <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"> <release date="2026-04-22" version="1.0.0">
<description> <description>
<p>Initial Sanctum release based on Avia Client with self-hosted instance support.</p> <p>Initial Sanctum release based on Avia Client with self-hosted instance support.</p>

View file

@ -1,8 +1,8 @@
{ {
"name": "sanctum", "name": "sanctum",
"productName": "Sanctum", "productName": "Sanctum",
"version": "1.0.1", "version": "1.0.7",
"aviaVersion": "1.0.1", "aviaVersion": "1.0.7",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"repository": "https://git.mithraic.cloud/ad3laid3/sanctum", "repository": "https://git.mithraic.cloud/ad3laid3/sanctum",
"scripts": { "scripts": {

65
src/config.d.ts vendored
View file

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

View file

@ -10,6 +10,7 @@ import { autoLaunch } from "./native/autoLaunch";
import { setBadgeCount } from "./native/badges"; import { setBadgeCount } from "./native/badges";
import { config } from "./native/config"; import { config } from "./native/config";
import { initDiscordRpc } from "./native/discordRpc"; import { initDiscordRpc } from "./native/discordRpc";
import { startGamePresenceMonitor } from "./native/gamePresence";
import { checkForUpdates } from "./native/updater"; import { checkForUpdates } from "./native/updater";
import { initTray } from "./native/tray"; import { initTray } from "./native/tray";
import { BUILD_URL, createMainWindow, mainWindow } from "./native/window"; import { BUILD_URL, createMainWindow, mainWindow } from "./native/window";
@ -45,8 +46,31 @@ const acquiredLock = app.requestSingleInstanceLock();
const loadInject = () => { const loadInject = () => {
if (!mainWindow) return; if (!mainWindow) return;
mainWindow.webContents.on("dom-ready", async () => { const wc = mainWindow.webContents;
wc.removeAllListeners("dom-ready");
wc.once("dom-ready", async () => {
try { try {
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
const builtInLocalPlugins = [
{
id: "sanctum-vcsounds",
name: "VCSounds",
code: fs.readFileSync(path.join(__dirname, "VCSounds.js"), "utf8"),
enabled: true,
locked: true,
},
];
await wc.executeJavaScript(
`window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__ = ${JSON.stringify(
builtInLocalPlugins,
)};`,
true,
);
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
const plugins: string[] = [ const plugins: string[] = [
"inject.js", "inject.js",
"LocalPlugins.js", "LocalPlugins.js",
@ -57,18 +81,19 @@ const loadInject = () => {
"aviaversion.js", "aviaversion.js",
"repofrontend.js", "repofrontend.js",
"ButtonFix.js", "ButtonFix.js",
"headliner.js",
"aviadesktopversion.js", "aviadesktopversion.js",
"customFrameNativeMenu.js", "customFrameNativeMenu.js",
"disableTrayIcon.js", "disableTrayIcon.js",
"gamePresenceSettings.js",
"clientBackup.js", "clientBackup.js",
"LoginWithToken.js", "LoginWithToken.js",
]; ];
for (const plugin of plugins) { for (const plugin of plugins) {
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
const pluginPath: string = path.join(__dirname, plugin); const pluginPath: string = path.join(__dirname, plugin);
const pluginCode: string = fs.readFileSync(pluginPath, "utf8"); const pluginCode: string = fs.readFileSync(pluginPath, "utf8");
await mainWindow.webContents.executeJavaScript(pluginCode, true); await wc.executeJavaScript(pluginCode, true);
} }
} catch { } catch {
/* empty */ /* empty */
@ -98,6 +123,7 @@ if (acquiredLock) {
initTray(); initTray();
initDiscordRpc(); initDiscordRpc();
startGamePresenceMonitor();
checkForUpdates(); checkForUpdates();
setBadgeCount(0); setBadgeCount(0);

View file

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

View file

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

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

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

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

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

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

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

View file

@ -11,6 +11,22 @@ contextBridge.exposeInMainWorld("native", {
aviaClient: () => aviaVersion, aviaClient: () => aviaVersion,
}, },
overlay: {
setVoiceState: (state: VoiceOverlayState | null) =>
ipcRenderer.send("overlay:set-voice-state", state),
},
activity: {
getState: () => ipcRenderer.invoke("sanctum-activity:get-state"),
onUpdate: (callback: (state: SanctumActivityState) => void) => {
const listener = (_event: unknown, state: SanctumActivityState) => callback(state);
ipcRenderer.on("sanctum-activity:update", listener);
return () => ipcRenderer.removeListener("sanctum-activity:update", listener);
},
debugSetState: (state: SanctumActivityState) =>
ipcRenderer.invoke("sanctum-activity:debug-set-state", state) as Promise<SanctumActivityState>,
},
minimise: () => ipcRenderer.send("minimise"), minimise: () => ipcRenderer.send("minimise"),
maximise: () => ipcRenderer.send("maximise"), maximise: () => ipcRenderer.send("maximise"),
close: () => ipcRenderer.send("close"), close: () => ipcRenderer.send("close"),