From 3055e283a19005c208ba0c9d0e30472aa8fe15b4 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Tue, 5 May 2026 20:40:57 -0400 Subject: [PATCH] v1.0.5 --- README.md | 2 + avia_core/LocalPlugins.js | 255 ++++++++----- avia_core/VCSounds.js | 570 ++++++++++++++++++++++++++++ avia_core/gamePresenceSettings.js | 397 +++++++++++++++++++ avia_core/headliner.js | 4 +- cloud.mithraic.sanctum.metainfo.xml | 5 + package.json | 4 +- src/config.d.ts | 65 ++++ src/main.ts | 20 + src/native/config.ts | 56 +++ src/native/discordRpc.ts | 48 ++- src/native/gameCatalog.ts | 173 +++++++++ src/native/gameOverlay.ts | 373 ++++++++++++++++++ src/native/gamePresence.ts | 335 ++++++++++++++++ src/world/window.ts | 16 + 15 files changed, 2211 insertions(+), 112 deletions(-) create mode 100644 avia_core/VCSounds.js create mode 100644 avia_core/gamePresenceSettings.js create mode 100644 src/native/gameCatalog.ts create mode 100644 src/native/gameOverlay.ts create mode 100644 src/native/gamePresence.ts diff --git a/README.md b/README.md index 2779e54..d7b3350 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,5 @@ 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. diff --git a/avia_core/LocalPlugins.js b/avia_core/LocalPlugins.js index 40a88c5..a298c2f 100644 --- a/avia_core/LocalPlugins.js +++ b/avia_core/LocalPlugins.js @@ -3,15 +3,63 @@ if (window.__AVIA_LOCAL_PLUGINS_LOADED__) return; window.__AVIA_LOCAL_PLUGINS_LOADED__ = true; - const STORAGE_KEY = "avia_local_plugins"; + const STORAGE_KEY = "avia_local_plugins"; + const BUILTIN_SEED = Array.isArray(window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__) + ? window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__ + : []; + + const runningLocalPlugins = {}; + const localPluginErrors = {}; - const runningLocalPlugins = {}; - const localPluginErrors = {}; - - const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); - const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); - - function preloadMonaco() { + const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); + const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + + function upsertBuiltinLocalPlugins() { + if (!BUILTIN_SEED.length) return; + + const plugins = getLocalPlugins(); + let dirty = false; + + for (const builtin of BUILTIN_SEED) { + const next = { + id: builtin.id, + name: builtin.name, + code: builtin.code || "", + enabled: true, + locked: true, + builtin: true, + }; + + const existingIndex = plugins.findIndex((plugin) => + plugin.id === next.id || plugin.name === next.name + ); + + if (existingIndex >= 0) { + const current = plugins[existingIndex]; + const merged = { + ...current, + ...next, + enabled: true, + locked: true, + builtin: true, + }; + + if ( + JSON.stringify(current) !== JSON.stringify(merged) + ) { + plugins[existingIndex] = merged; + dirty = true; + } + } else { + plugins.push(next); + dirty = true; + } + } + + if (dirty) setLocalPlugins(plugins); + } + + function preloadMonaco() { return new Promise(resolve => { if (window.monaco) return resolve(); const loader = document.createElement("script"); @@ -421,11 +469,11 @@ renderLocalPanel(); } - function renderLocalPanel() { - const content = document.getElementById("avia-local-plugins-content"); - if (!content) return; - content.innerHTML = ""; - const plugins = getLocalPlugins(); + function renderLocalPanel() { + const content = document.getElementById("avia-local-plugins-content"); + if (!content) return; + content.innerHTML = ""; + const plugins = getLocalPlugins(); if (plugins.length === 0) { const empty = document.createElement("div"); @@ -436,9 +484,10 @@ return; } - plugins.forEach((plugin, index) => { - const isRunning = !!runningLocalPlugins[plugin.id]; - const hasError = !!localPluginErrors[plugin.id]; + 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, { @@ -467,74 +516,93 @@ statusDot.style.background = "#777"; } - const name = document.createElement("div"); - name.textContent = plugin.name; - name.style.fontSize = "13px"; - - left.appendChild(statusDot); - left.appendChild(name); - - const controls = document.createElement("div"); - Object.assign(controls.style, { display: "flex", gap: "6px" }); - - const editBtn = document.createElement("button"); - editBtn.textContent = "✏ Edit"; - styleLocalBtn(editBtn, "rgba(100,140,255,0.2)"); - editBtn.onclick = () => { - openEditorPanel(plugin, (newCode, andRun) => { - const all = getLocalPlugins(); - const target = all.find(p => p.id === plugin.id); - if (target) { - target.code = newCode; - plugin.code = newCode; - setLocalPlugins(all); - } - if (andRun) { - plugin.enabled = true; - if (target) target.enabled = true; - setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p)); - runLocalPlugin(plugin); - } - renderLocalPanel(); - }); - }; - - const toggleBtn = document.createElement("button"); - toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable"; - styleLocalBtn(toggleBtn); - toggleBtn.onclick = () => { - const all = getLocalPlugins(); - const target = all.find(p => p.id === plugin.id); - if (!target) return; - target.enabled = !target.enabled; - plugin.enabled = target.enabled; - setLocalPlugins(all); - if (target.enabled) runLocalPlugin(plugin); - else stopLocalPlugin(plugin); - renderLocalPanel(); - }; - - const removeBtn = document.createElement("button"); - removeBtn.textContent = "✕"; - styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)"); - removeBtn.onclick = () => { - stopLocalPlugin(plugin); - const editorPanel = document.getElementById("avia-local-editor-panel"); - if (editorPanel) editorPanel.remove(); - const all = getLocalPlugins(); - all.splice(all.findIndex(p => p.id === plugin.id), 1); - setLocalPlugins(all); - renderLocalPanel(); - }; - - controls.appendChild(editBtn); - controls.appendChild(toggleBtn); - controls.appendChild(removeBtn); - row.appendChild(left); - row.appendChild(controls); - content.appendChild(row); - }); - } + const name = document.createElement("div"); + name.textContent = plugin.name; + name.style.fontSize = "13px"; + + left.appendChild(statusDot); + left.appendChild(name); + + if (isBuiltin) { + const badge = document.createElement("div"); + badge.textContent = "Built-in"; + Object.assign(badge.style, { + fontSize: "10px", + padding: "2px 7px", + borderRadius: "999px", + background: "rgba(120,170,255,0.16)", + color: "#a9c4ff", + border: "1px solid rgba(120,170,255,0.28)", + marginLeft: "2px", + textTransform: "uppercase", + letterSpacing: "0.06em", + }); + left.appendChild(badge); + } + + const controls = document.createElement("div"); + Object.assign(controls.style, { display: "flex", gap: "6px" }); + + if (!isBuiltin) { + const editBtn = document.createElement("button"); + editBtn.textContent = "✏ Edit"; + styleLocalBtn(editBtn, "rgba(100,140,255,0.2)"); + editBtn.onclick = () => { + openEditorPanel(plugin, (newCode, andRun) => { + const all = getLocalPlugins(); + const target = all.find(p => p.id === plugin.id); + if (target) { + target.code = newCode; + plugin.code = newCode; + setLocalPlugins(all); + } + if (andRun) { + plugin.enabled = true; + if (target) target.enabled = true; + setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p)); + runLocalPlugin(plugin); + } + renderLocalPanel(); + }); + }; + + const toggleBtn = document.createElement("button"); + toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable"; + styleLocalBtn(toggleBtn); + toggleBtn.onclick = () => { + const all = getLocalPlugins(); + const target = all.find(p => p.id === plugin.id); + if (!target) return; + target.enabled = !target.enabled; + plugin.enabled = target.enabled; + setLocalPlugins(all); + if (target.enabled) runLocalPlugin(plugin); + else stopLocalPlugin(plugin); + renderLocalPanel(); + }; + + const removeBtn = document.createElement("button"); + removeBtn.textContent = "✕"; + styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)"); + removeBtn.onclick = () => { + stopLocalPlugin(plugin); + const editorPanel = document.getElementById("avia-local-editor-panel"); + if (editorPanel) editorPanel.remove(); + const all = getLocalPlugins(); + all.splice(all.findIndex(p => p.id === plugin.id), 1); + setLocalPlugins(all); + renderLocalPanel(); + }; + + controls.appendChild(editBtn); + controls.appendChild(toggleBtn); + controls.appendChild(removeBtn); + } + row.appendChild(left); + row.appendChild(controls); + content.appendChild(row); + }); + } function styleLocalInput(input) { Object.assign(input.style, { @@ -604,15 +672,16 @@ }).observe(document.documentElement, { childList: true }); } - waitForBody(() => { - const observer = new MutationObserver(() => injectLocalButton()); - observer.observe(document.body, { childList: true, subtree: true }); - injectLocalButton(); - }); - - getLocalPlugins().forEach(plugin => { - if (plugin.enabled) runLocalPlugin(plugin); - }); + waitForBody(() => { + const observer = new MutationObserver(() => injectLocalButton()); + observer.observe(document.body, { childList: true, subtree: true }); + injectLocalButton(); + }); + + upsertBuiltinLocalPlugins(); + getLocalPlugins().forEach(plugin => { + if (plugin.enabled) runLocalPlugin(plugin); + }); preloadMonaco(); diff --git a/avia_core/VCSounds.js b/avia_core/VCSounds.js new file mode 100644 index 0000000..32623e4 --- /dev/null +++ b/avia_core/VCSounds.js @@ -0,0 +1,570 @@ +(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 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; + 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 publishVoiceState() { + const overlayApi = window.native?.overlay; + if (!overlayApi || typeof overlayApi.setVoiceState !== "function") return; + + pruneRowActivity(); + const next = collectVoiceState(); + const voiceState = inVoice ? next : null; + const nextKey = JSON.stringify(voiceState); + 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(); +})(); diff --git a/avia_core/gamePresenceSettings.js b/avia_core/gamePresenceSettings.js new file mode 100644 index 0000000..0c19d68 --- /dev/null +++ b/avia_core/gamePresenceSettings.js @@ -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 = ` +
+ +
+ `; + + 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 }); +})(); diff --git a/avia_core/headliner.js b/avia_core/headliner.js index 421551f..23ca193 100644 --- a/avia_core/headliner.js +++ b/avia_core/headliner.js @@ -9,7 +9,7 @@ const STYLE_ID = "headliner-style"; const defaults = { - content: "Sanctum V 1.0.3", + content: "Sanctum", left: "32", top: "56", fontSize: "15", @@ -19,7 +19,7 @@ function loadSettings() { try { let s = JSON.parse(localStorage.getItem("headlinerSettings")); - if (s && (s.content === "Stoat V 1.0.0 - Sanctum" || s.content === "Sanctum V 1.0.0" || s.content === "Sanctum V 1.0.1" || s.content === "Sanctum V 1.0.2")) { + if (s && /^Sanctum V 1\.0\.[0-9]+$/.test(s.content)) { s.content = defaults.content; saveSettings(s); } diff --git a/cloud.mithraic.sanctum.metainfo.xml b/cloud.mithraic.sanctum.metainfo.xml index c47252c..6b86a82 100644 --- a/cloud.mithraic.sanctum.metainfo.xml +++ b/cloud.mithraic.sanctum.metainfo.xml @@ -27,6 +27,11 @@ pointing + + +

Gameplay overlay and improved game presence support.

+
+

Initial Sanctum release based on Avia Client with self-hosted instance support.

diff --git a/package.json b/package.json index 27400b2..93854a8 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "sanctum", "productName": "Sanctum", - "version": "1.0.4", - "aviaVersion": "1.0.4", + "version": "1.0.5", + "aviaVersion": "1.0.5", "main": ".vite/build/main.js", "repository": "https://git.mithraic.cloud/ad3laid3/sanctum", "scripts": { diff --git a/src/config.d.ts b/src/config.d.ts index 8a20869..c821860 100644 --- a/src/config.d.ts +++ b/src/config.d.ts @@ -7,6 +7,9 @@ declare type DesktopConfig = { spellchecker: boolean; hardwareAcceleration: boolean; discordRpc: boolean; + gamePresenceEnabled: boolean; + gamePresenceRestrictToAllowList: boolean; + gamePresenceAllowList: string; windowState: { x: number; y: number; @@ -15,3 +18,65 @@ declare type DesktopConfig = { isMaximised: boolean; }; }; + +declare type VoiceOverlayMember = { + name: string; + speaking?: boolean; + muted?: boolean; + deafened?: boolean; + avatarUrl?: string; +}; + +declare type VoiceOverlayState = { + channelName?: string; + isInCall: boolean; + members: VoiceOverlayMember[]; + selfMuted?: boolean; + selfDeafened?: boolean; + source?: string; + updatedAt?: number; +}; + +declare type SanctumGamePresence = { + title: string; + processName: string; + startedAt: number; + source: string; +}; + +declare type SanctumActivityState = { + game: SanctumGamePresence | null; + voice: VoiceOverlayState | null; +}; + +declare global { + interface Window { + native: { + versions: { + node: () => string; + chrome: () => string; + electron: () => string; + desktop: () => string; + aviaClient: () => string; + }; + overlay: { + setVoiceState: (state: VoiceOverlayState | null) => void; + }; + activity: { + getState: () => Promise; + onUpdate: (callback: (state: SanctumActivityState) => void) => () => void; + debugSetState: (state: SanctumActivityState) => Promise; + }; + minimise: () => void; + maximise: () => void; + close: () => void; + setBadgeCount: (count: number) => void; + }; + desktopConfig: { + get: () => DesktopConfig; + set: (config: DesktopConfig) => void; + getAutostart: () => Promise; + setAutostart: (value: boolean) => Promise; + }; + } +} diff --git a/src/main.ts b/src/main.ts index 68afbf3..2af85e9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ 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"; @@ -47,6 +48,23 @@ const loadInject = () => { mainWindow.webContents.on("dom-ready", async () => { try { + const builtInLocalPlugins = [ + { + id: "sanctum-vcsounds", + name: "VCSounds", + code: fs.readFileSync(path.join(__dirname, "VCSounds.js"), "utf8"), + enabled: true, + locked: true, + }, + ]; + + await mainWindow.webContents.executeJavaScript( + `window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__ = ${JSON.stringify( + builtInLocalPlugins, + )};`, + true, + ); + const plugins: string[] = [ "inject.js", "LocalPlugins.js", @@ -60,6 +78,7 @@ const loadInject = () => { "aviadesktopversion.js", "customFrameNativeMenu.js", "disableTrayIcon.js", + "gamePresenceSettings.js", "clientBackup.js", "LoginWithToken.js", ]; @@ -97,6 +116,7 @@ if (acquiredLock) { initTray(); initDiscordRpc(); + startGamePresenceMonitor(); checkForUpdates(); setBadgeCount(0); diff --git a/src/native/config.ts b/src/native/config.ts index eb844c8..637d7d7 100644 --- a/src/native/config.ts +++ b/src/native/config.ts @@ -34,6 +34,15 @@ const schema = { discordRpc: { type: "boolean", } as JSONSchema.Boolean, + gamePresenceEnabled: { + type: "boolean", + } as JSONSchema.Boolean, + gamePresenceRestrictToAllowList: { + type: "boolean", + } as JSONSchema.Boolean, + gamePresenceAllowList: { + type: "string", + } as JSONSchema.String, windowState: { type: "object", properties: { @@ -68,6 +77,9 @@ const store = new Store({ spellchecker: true, hardwareAcceleration: true, discordRpc: true, + gamePresenceEnabled: true, + gamePresenceRestrictToAllowList: true, + gamePresenceAllowList: "", windowState: { x: 0, y: 0, @@ -93,6 +105,9 @@ class Config { spellchecker: this.spellchecker, hardwareAcceleration: this.hardwareAcceleration, discordRpc: this.discordRpc, + gamePresenceEnabled: this.gamePresenceEnabled, + gamePresenceRestrictToAllowList: this.gamePresenceRestrictToAllowList, + gamePresenceAllowList: this.gamePresenceAllowList, windowState: this.windowState, }); } @@ -230,6 +245,47 @@ class Config { this.sync(); } + get gamePresenceEnabled() { + return (store as never as { get(k: string): boolean }).get("gamePresenceEnabled"); + } + + set gamePresenceEnabled(value: boolean) { + (store as never as { set(k: string, value: boolean): void }).set( + "gamePresenceEnabled", + value, + ); + + this.sync(); + } + + get gamePresenceRestrictToAllowList() { + return (store as never as { get(k: string): boolean }).get( + "gamePresenceRestrictToAllowList", + ); + } + + set gamePresenceRestrictToAllowList(value: boolean) { + (store as never as { set(k: string, value: boolean): void }).set( + "gamePresenceRestrictToAllowList", + value, + ); + + this.sync(); + } + + get gamePresenceAllowList() { + return (store as never as { get(k: string): string }).get("gamePresenceAllowList"); + } + + set gamePresenceAllowList(value: string) { + (store as never as { set(k: string, value: string): void }).set( + "gamePresenceAllowList", + value, + ); + + this.sync(); + } + get windowState() { return ( store as never as { get(k: string): DesktopConfig["windowState"] } diff --git a/src/native/discordRpc.ts b/src/native/discordRpc.ts index 9a00663..375c884 100644 --- a/src/native/discordRpc.ts +++ b/src/native/discordRpc.ts @@ -3,7 +3,32 @@ import { Client } from "discord-rpc"; import { config } from "./config"; // internal state -let rpc: Client; +let rpc: Client | undefined; +type RpcActivity = Parameters[0]; + +const defaultActivity: RpcActivity = { + details: "Chatting with others on Sanctum", + state: "stoat.chat", + largeImageKey: "qr", + largeImageText: "Join Stoat!", + buttons: [ + { + label: "Join Stoat", + url: "https://stoat.chat/", + }, + ], +}; + +let pendingActivity: RpcActivity = defaultActivity; + +function applyActivity() { + if (!rpc) return; + try { + rpc.setActivity(pendingActivity); + } catch { + /* ignore transient RPC failures */ + } +} export async function initDiscordRpc() { if (!config.discordRpc) return; @@ -14,20 +39,7 @@ export async function initDiscordRpc() { try { rpc = new Client({ transport: "ipc" }); - rpc.on("ready", () => - 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("ready", applyActivity); 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); export async function destroyDiscordRpc() { rpc?.destroy(); + rpc = undefined; } diff --git a/src/native/gameCatalog.ts b/src/native/gameCatalog.ts new file mode 100644 index 0000000..5eaab7a --- /dev/null +++ b/src/native/gameCatalog.ts @@ -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))); +} diff --git a/src/native/gameOverlay.ts b/src/native/gameOverlay.ts new file mode 100644 index 0000000..6ba2e34 --- /dev/null +++ b/src/native/gameOverlay.ts @@ -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 = ` + + + + + + +
+
+
+ + +`; + +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; +} diff --git a/src/native/gamePresence.ts b/src/native/gamePresence.ts new file mode 100644 index 0000000..1c368e8 --- /dev/null +++ b/src/native/gamePresence.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(" "); +} diff --git a/src/world/window.ts b/src/world/window.ts index e5bf9c8..84946dc 100644 --- a/src/world/window.ts +++ b/src/world/window.ts @@ -11,6 +11,22 @@ 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, + }, + minimise: () => ipcRenderer.send("minimise"), maximise: () => ipcRenderer.send("maximise"), close: () => ipcRenderer.send("close"),