Compare commits
No commits in common. "main" and "v1.0.1" have entirely different histories.
16 changed files with 117 additions and 2275 deletions
12
GEMINI.md
12
GEMINI.md
|
|
@ -1,12 +0,0 @@
|
|||
# Agent Mandates
|
||||
|
||||
## Versioning and Release Workflow
|
||||
Before every `git push` that includes code changes, you MUST perform the following steps:
|
||||
|
||||
1. **Bump Version:** Increment the version in `package.json` (both `version` and `aviaVersion`).
|
||||
2. **Update Branding:** If a version string is hardcoded in UI plugins, update it to match the new version.
|
||||
3. **Migration Logic:** Update any migration logic in plugins to ensure users on the previous version are automatically updated to the new default.
|
||||
4. **Tagging:** Create the git tag corresponding to the new version with a 'v' prefix (e.g., `git tag v1.0.x`).
|
||||
5. **Push:** Push both the branch and the tags to the remote repository (`git push origin main --tags`).
|
||||
|
||||
This ensures the internal app state matches the release tag and prevents auto-updater loops.
|
||||
|
|
@ -71,5 +71,3 @@ pnpm run:nix --force-server=http://localhost:5173
|
|||
# a better solution would be telling
|
||||
# Electron Forge where system Electron is
|
||||
```
|
||||
|
||||
`VCSounds.js` ships as a built-in local plugin now, so it is seeded automatically on launch and cannot be accidentally removed from the release install.
|
||||
|
|
|
|||
|
|
@ -3,63 +3,15 @@
|
|||
if (window.__AVIA_LOCAL_PLUGINS_LOADED__) return;
|
||||
window.__AVIA_LOCAL_PLUGINS_LOADED__ = true;
|
||||
|
||||
const STORAGE_KEY = "avia_local_plugins";
|
||||
const BUILTIN_SEED = Array.isArray(window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__)
|
||||
? window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__
|
||||
: [];
|
||||
|
||||
const runningLocalPlugins = {};
|
||||
const localPluginErrors = {};
|
||||
const STORAGE_KEY = "avia_local_plugins";
|
||||
|
||||
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() {
|
||||
const runningLocalPlugins = {};
|
||||
const localPluginErrors = {};
|
||||
|
||||
const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
|
||||
function preloadMonaco() {
|
||||
return new Promise(resolve => {
|
||||
if (window.monaco) return resolve();
|
||||
const loader = document.createElement("script");
|
||||
|
|
@ -469,11 +421,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");
|
||||
|
|
@ -484,10 +436,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
plugins.forEach((plugin, index) => {
|
||||
const isRunning = !!runningLocalPlugins[plugin.id];
|
||||
const hasError = !!localPluginErrors[plugin.id];
|
||||
const isBuiltin = !!plugin.locked || !!plugin.builtin;
|
||||
plugins.forEach((plugin, index) => {
|
||||
const isRunning = !!runningLocalPlugins[plugin.id];
|
||||
const hasError = !!localPluginErrors[plugin.id];
|
||||
|
||||
const row = document.createElement("div");
|
||||
Object.assign(row.style, {
|
||||
|
|
@ -516,93 +467,74 @@
|
|||
statusDot.style.background = "#777";
|
||||
}
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.textContent = plugin.name;
|
||||
name.style.fontSize = "13px";
|
||||
|
||||
left.appendChild(statusDot);
|
||||
left.appendChild(name);
|
||||
|
||||
if (isBuiltin) {
|
||||
const badge = document.createElement("div");
|
||||
badge.textContent = "Built-in";
|
||||
Object.assign(badge.style, {
|
||||
fontSize: "10px",
|
||||
padding: "2px 7px",
|
||||
borderRadius: "999px",
|
||||
background: "rgba(120,170,255,0.16)",
|
||||
color: "#a9c4ff",
|
||||
border: "1px solid rgba(120,170,255,0.28)",
|
||||
marginLeft: "2px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
});
|
||||
left.appendChild(badge);
|
||||
}
|
||||
|
||||
const controls = document.createElement("div");
|
||||
Object.assign(controls.style, { display: "flex", gap: "6px" });
|
||||
|
||||
if (!isBuiltin) {
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.textContent = "✏ Edit";
|
||||
styleLocalBtn(editBtn, "rgba(100,140,255,0.2)");
|
||||
editBtn.onclick = () => {
|
||||
openEditorPanel(plugin, (newCode, andRun) => {
|
||||
const all = getLocalPlugins();
|
||||
const target = all.find(p => p.id === plugin.id);
|
||||
if (target) {
|
||||
target.code = newCode;
|
||||
plugin.code = newCode;
|
||||
setLocalPlugins(all);
|
||||
}
|
||||
if (andRun) {
|
||||
plugin.enabled = true;
|
||||
if (target) target.enabled = true;
|
||||
setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p));
|
||||
runLocalPlugin(plugin);
|
||||
}
|
||||
renderLocalPanel();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable";
|
||||
styleLocalBtn(toggleBtn);
|
||||
toggleBtn.onclick = () => {
|
||||
const all = getLocalPlugins();
|
||||
const target = all.find(p => p.id === plugin.id);
|
||||
if (!target) return;
|
||||
target.enabled = !target.enabled;
|
||||
plugin.enabled = target.enabled;
|
||||
setLocalPlugins(all);
|
||||
if (target.enabled) runLocalPlugin(plugin);
|
||||
else stopLocalPlugin(plugin);
|
||||
renderLocalPanel();
|
||||
};
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.textContent = "✕";
|
||||
styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)");
|
||||
removeBtn.onclick = () => {
|
||||
stopLocalPlugin(plugin);
|
||||
const editorPanel = document.getElementById("avia-local-editor-panel");
|
||||
if (editorPanel) editorPanel.remove();
|
||||
const all = getLocalPlugins();
|
||||
all.splice(all.findIndex(p => p.id === plugin.id), 1);
|
||||
setLocalPlugins(all);
|
||||
renderLocalPanel();
|
||||
};
|
||||
|
||||
controls.appendChild(editBtn);
|
||||
controls.appendChild(toggleBtn);
|
||||
controls.appendChild(removeBtn);
|
||||
}
|
||||
row.appendChild(left);
|
||||
row.appendChild(controls);
|
||||
content.appendChild(row);
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function styleLocalInput(input) {
|
||||
Object.assign(input.style, {
|
||||
|
|
@ -672,16 +604,15 @@
|
|||
}).observe(document.documentElement, { childList: true });
|
||||
}
|
||||
|
||||
waitForBody(() => {
|
||||
const observer = new MutationObserver(() => injectLocalButton());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
injectLocalButton();
|
||||
});
|
||||
|
||||
upsertBuiltinLocalPlugins();
|
||||
getLocalPlugins().forEach(plugin => {
|
||||
if (plugin.enabled) runLocalPlugin(plugin);
|
||||
});
|
||||
waitForBody(() => {
|
||||
const observer = new MutationObserver(() => injectLocalButton());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
injectLocalButton();
|
||||
});
|
||||
|
||||
getLocalPlugins().forEach(plugin => {
|
||||
if (plugin.enabled) runLocalPlugin(plugin);
|
||||
});
|
||||
|
||||
preloadMonaco();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,605 +0,0 @@
|
|||
(function () {
|
||||
if (window.__VC_SOUNDS__) return;
|
||||
window.__VC_SOUNDS__ = true;
|
||||
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
document.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
if (ctx.state === "suspended") ctx.resume();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
function playNote(freq, startTime, duration, volume) {
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.type = "sine";
|
||||
osc1.frequency.value = freq;
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(ctx.destination);
|
||||
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.type = "triangle";
|
||||
osc2.frequency.value = freq / 2;
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(ctx.destination);
|
||||
|
||||
gain1.gain.setValueAtTime(0, startTime);
|
||||
gain1.gain.linearRampToValueAtTime(volume, startTime + 0.03);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||
|
||||
gain2.gain.setValueAtTime(0, startTime);
|
||||
gain2.gain.linearRampToValueAtTime(volume * 0.4, startTime + 0.03);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + duration + 0.05);
|
||||
osc2.start(startTime);
|
||||
osc2.stop(startTime + duration + 0.05);
|
||||
}
|
||||
|
||||
function playJoin() {
|
||||
if (ctx.state === "suspended") ctx.resume();
|
||||
const t = ctx.currentTime + 0.01;
|
||||
playNote(294, t, 0.35, 0.14);
|
||||
playNote(370, t + 0.28, 0.45, 0.11);
|
||||
}
|
||||
|
||||
function playLeave() {
|
||||
if (ctx.state === "suspended") ctx.resume();
|
||||
const t = ctx.currentTime + 0.01;
|
||||
playNote(370, t, 0.35, 0.14);
|
||||
playNote(294, t + 0.28, 0.45, 0.11);
|
||||
}
|
||||
|
||||
let inVoice = false;
|
||||
let initialising = false;
|
||||
let initTimer = null;
|
||||
let globalObserver = null;
|
||||
let refreshTimer = null;
|
||||
let lastVoiceState = null;
|
||||
let lastMemberIdentityKey = "";
|
||||
let leaveWatchdog = null;
|
||||
const recentRowActivity = new Map();
|
||||
|
||||
function onSelfJoined() {
|
||||
if (inVoice) return;
|
||||
inVoice = true;
|
||||
initialising = true;
|
||||
playJoin();
|
||||
console.debug("[VCSounds] self joined");
|
||||
clearTimeout(initTimer);
|
||||
initTimer = setTimeout(() => {
|
||||
initialising = false;
|
||||
}, 1500);
|
||||
clearTimeout(leaveWatchdog);
|
||||
leaveWatchdog = null;
|
||||
publishVoiceState();
|
||||
}
|
||||
|
||||
function onSelfLeft() {
|
||||
if (!inVoice) return;
|
||||
inVoice = false;
|
||||
initialising = false;
|
||||
clearTimeout(initTimer);
|
||||
clearTimeout(leaveWatchdog);
|
||||
leaveWatchdog = null;
|
||||
recentRowActivity.clear();
|
||||
lastVoiceState = null;
|
||||
lastMemberIdentityKey = "";
|
||||
playLeave();
|
||||
console.debug("[VCSounds] self left");
|
||||
const overlayApi = window.native?.overlay;
|
||||
if (overlayApi && typeof overlayApi.setVoiceState === "function") {
|
||||
overlayApi.setVoiceState(null);
|
||||
}
|
||||
}
|
||||
|
||||
function isParticipantEntry(el) {
|
||||
if (el.nodeType !== 1) return false;
|
||||
const c = String(el.className || "");
|
||||
if (!el.isConnected) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 24 || rect.height < 24) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity || "1") <= 0.05) return false;
|
||||
return (
|
||||
c.includes("p_var(--gap-sm)") &&
|
||||
c.includes("pos_relative") &&
|
||||
c.includes("d_flex") &&
|
||||
c.includes("ai_center")
|
||||
);
|
||||
}
|
||||
|
||||
function findParticipantEntry(node) {
|
||||
let current = node && node.nodeType === 1 ? node : node?.parentElement || null;
|
||||
while (current) {
|
||||
if (isParticipantEntry(current)) return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function markRowActivity(node) {
|
||||
const entry = findParticipantEntry(node);
|
||||
if (!entry) return;
|
||||
recentRowActivity.set(entry, Date.now());
|
||||
}
|
||||
|
||||
function pruneRowActivity() {
|
||||
const cutoff = Date.now() - 15000;
|
||||
for (const [entry, at] of recentRowActivity.entries()) {
|
||||
if (typeof at !== "number" || at < cutoff || !entry.isConnected) {
|
||||
recentRowActivity.delete(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pseudoStyleIsVisible(style) {
|
||||
if (!style) return false;
|
||||
const content = String(style.content || "").toLowerCase();
|
||||
if (content === "none") return false;
|
||||
return (
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
parseFloat(style.opacity || "1") > 0.05
|
||||
);
|
||||
}
|
||||
|
||||
function pseudoLooksActive(style) {
|
||||
if (!pseudoStyleIsVisible(style)) return false;
|
||||
|
||||
const boxShadow = String(style.boxShadow || "").toLowerCase();
|
||||
const outlineWidth = parseFloat(style.outlineWidth || "0");
|
||||
const borderWidth = parseFloat(style.borderWidth || "0");
|
||||
const filter = String(style.filter || "").toLowerCase();
|
||||
const transform = String(style.transform || "").toLowerCase();
|
||||
const background = String(style.backgroundColor || "").toLowerCase();
|
||||
const borderColor = String(style.borderColor || "").toLowerCase();
|
||||
const borderRadius = String(style.borderRadius || "").toLowerCase();
|
||||
const size = Math.max(parseFloat(style.width || "0"), parseFloat(style.height || "0"));
|
||||
|
||||
const ringish =
|
||||
boxShadow !== "none" ||
|
||||
outlineWidth > 0 ||
|
||||
borderWidth > 0 ||
|
||||
filter !== "none" ||
|
||||
transform !== "none";
|
||||
const accentish =
|
||||
background.includes("rgba") ||
|
||||
background.includes("rgb(") ||
|
||||
borderColor.includes("rgba") ||
|
||||
borderColor.includes("rgb(");
|
||||
const circular = borderRadius.includes("50%") || borderRadius.includes("999");
|
||||
|
||||
return Boolean(size <= 80 && circular && (ringish || accentish));
|
||||
}
|
||||
|
||||
function looksLikeVoiceActivityIndicator(node) {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.width < 8 || rect.height < 8) return false;
|
||||
|
||||
const style = window.getComputedStyle(node);
|
||||
if (!style || style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity || "1") <= 0.05) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const size = Math.min(rect.width, rect.height);
|
||||
const borderRadius = String(style.borderRadius || "").toLowerCase();
|
||||
const boxShadow = String(style.boxShadow || "").toLowerCase();
|
||||
const outlineWidth = parseFloat(style.outlineWidth || "0");
|
||||
const borderWidth = parseFloat(style.borderWidth || "0");
|
||||
const filter = String(style.filter || "").toLowerCase();
|
||||
const transform = String(style.transform || "").toLowerCase();
|
||||
const animation = String(style.animationName || "").toLowerCase();
|
||||
const transition = String(style.transitionProperty || "").toLowerCase();
|
||||
const background = String(style.backgroundColor || "").toLowerCase();
|
||||
const borderColor = String(style.borderColor || "").toLowerCase();
|
||||
|
||||
const circular = borderRadius.includes("50%") || borderRadius.includes("999");
|
||||
const ringish =
|
||||
boxShadow !== "none" ||
|
||||
outlineWidth > 0 ||
|
||||
borderWidth > 0 ||
|
||||
filter !== "none" ||
|
||||
transform !== "none" ||
|
||||
animation !== "none" ||
|
||||
transition !== "none";
|
||||
const accentish =
|
||||
background.includes("rgba") ||
|
||||
background.includes("rgb(") ||
|
||||
borderColor.includes("rgba") ||
|
||||
borderColor.includes("rgb(");
|
||||
|
||||
if (size <= 80 && circular && (ringish || accentish)) return true;
|
||||
|
||||
const before = window.getComputedStyle(node, "::before");
|
||||
const after = window.getComputedStyle(node, "::after");
|
||||
if (pseudoLooksActive(before) || pseudoLooksActive(after)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function startObserver() {
|
||||
if (globalObserver) return;
|
||||
globalObserver = new MutationObserver((mutations) => {
|
||||
if (!inVoice || initialising) return;
|
||||
for (const m of mutations) {
|
||||
markRowActivity(m.target);
|
||||
for (const node of m.addedNodes) {
|
||||
markRowActivity(node);
|
||||
if (isParticipantEntry(node)) {
|
||||
console.debug("[VCSounds] participant joined");
|
||||
playJoin();
|
||||
}
|
||||
}
|
||||
for (const node of m.removedNodes) {
|
||||
markRowActivity(node);
|
||||
if (isParticipantEntry(node)) {
|
||||
console.debug("[VCSounds] participant left");
|
||||
playLeave();
|
||||
}
|
||||
}
|
||||
}
|
||||
publishVoiceState();
|
||||
});
|
||||
globalObserver.observe(document.body, { childList: true, subtree: true });
|
||||
globalObserver.observe(document.body, { attributes: true, subtree: true, attributeFilter: ["class", "aria-label", "style", "data-speaking", "data-active", "data-state", "title"] });
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = setInterval(() => {
|
||||
if (inVoice && !initialising) publishVoiceState();
|
||||
}, 700);
|
||||
}
|
||||
}
|
||||
|
||||
function getParticipantName(entry) {
|
||||
const text = (entry.textContent || "").replace(/\s+/g, " ").trim();
|
||||
if (!text) return "Unknown";
|
||||
return text.length > 40 ? text.slice(0, 40) : text;
|
||||
}
|
||||
|
||||
function isSpeakingEntry(entry) {
|
||||
const recentActivityAt = recentRowActivity.get(entry);
|
||||
const recentActivity = typeof recentActivityAt === "number" && Date.now() - recentActivityAt < 1200;
|
||||
const nodes = [entry, ...Array.from(entry.querySelectorAll("*"))];
|
||||
for (const node of nodes) {
|
||||
const className = String(node.className || "").toLowerCase();
|
||||
const label = String(node.getAttribute?.("aria-label") || "").toLowerCase();
|
||||
const title = String(node.getAttribute?.("title") || "").toLowerCase();
|
||||
const text = String(node.textContent || "").toLowerCase();
|
||||
const state = String(node.getAttribute?.("data-state") || "").toLowerCase();
|
||||
const active = String(node.getAttribute?.("data-active") || "").toLowerCase();
|
||||
const speaking = String(node.getAttribute?.("data-speaking") || "").toLowerCase();
|
||||
const style = String(node.getAttribute?.("style") || "").toLowerCase();
|
||||
const attrs = typeof node.getAttributeNames === "function" ? node.getAttributeNames().map((name) => name.toLowerCase()) : [];
|
||||
|
||||
if (
|
||||
className.includes("speaking") ||
|
||||
className.includes("voice-activity") ||
|
||||
className.includes("active-speaker") ||
|
||||
className.includes("active") ||
|
||||
label.includes("speaking") ||
|
||||
label.includes("active") ||
|
||||
label.includes("voice activity") ||
|
||||
title.includes("speaking") ||
|
||||
title.includes("active") ||
|
||||
text.includes("speaking") ||
|
||||
text.includes("voice activity") ||
|
||||
state === "speaking" ||
|
||||
active === "true" ||
|
||||
speaking === "true" ||
|
||||
style.includes("speaking") ||
|
||||
attrs.includes("data-speaking") ||
|
||||
attrs.includes("data-active") ||
|
||||
attrs.includes("data-state")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (recentActivity && nodes.some(looksLikeVoiceActivityIndicator)) return true;
|
||||
if (nodes.some(looksLikeVoiceActivityIndicator)) return true;
|
||||
|
||||
return !!entry.querySelector(
|
||||
"[data-speaking='true'], [data-active='true'], [data-state='speaking'], [aria-label*='speaking'], [aria-label*='voice activity'], [title*='speaking'], [class*='speaking'], [class*='active-speaker'], [class*='voice-activity'], [class*='active']",
|
||||
);
|
||||
}
|
||||
|
||||
function extractAvatarUrl(entry) {
|
||||
const candidates = Array.from(entry.querySelectorAll("img, source, [style*='background-image'], [style*='background']"));
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.tagName === "IMG") {
|
||||
const src = candidate.currentSrc || candidate.src || candidate.getAttribute("src") || "";
|
||||
if (src) return src;
|
||||
}
|
||||
|
||||
const style = String(candidate.getAttribute("style") || "");
|
||||
const match = style.match(/url\(["']?([^"')]+)["']?\)/i);
|
||||
if (match && match[1]) return match[1];
|
||||
}
|
||||
|
||||
const img = entry.querySelector("img");
|
||||
if (img) {
|
||||
const src = img.currentSrc || img.src || img.getAttribute("src") || "";
|
||||
if (src) return src;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeAvatarUrl(url) {
|
||||
return String(url || "")
|
||||
.replace(/\/original(?=$|[?#])/i, "")
|
||||
.replace(/[?#].*$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isAvatarLikeSrc(src) {
|
||||
const value = String(src || "").toLowerCase();
|
||||
return (
|
||||
value.includes("/avatars/") ||
|
||||
value.includes("/default_avatar") ||
|
||||
value.includes("/avatar") ||
|
||||
value.includes("/icons/") ||
|
||||
value.includes("avatar")
|
||||
);
|
||||
}
|
||||
|
||||
function isVisibleElement(el) {
|
||||
if (!el || el.nodeType !== 1) return false;
|
||||
if (!el.isConnected) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 8 || rect.height < 8) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (!style) return false;
|
||||
return style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity || "1") > 0.05;
|
||||
}
|
||||
|
||||
function findAvatarRoot(img) {
|
||||
let current = img && img.parentElement;
|
||||
let depth = 0;
|
||||
while (current && depth < 6) {
|
||||
if (!isVisibleElement(current)) {
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = current.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(current);
|
||||
const radius = String(style.borderRadius || "").toLowerCase();
|
||||
const clip = String(style.clipPath || "").toLowerCase();
|
||||
const circular = radius.includes("50%") || radius.includes("999") || clip.includes("circle");
|
||||
if (circular || (rect.width >= 24 && rect.height >= 24 && rect.width <= 240 && rect.height <= 240)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
return img?.parentElement || null;
|
||||
}
|
||||
|
||||
function collectSpeakingAvatarUrls() {
|
||||
const speaking = new Set();
|
||||
const ringTiles = Array.from(document.querySelectorAll("*")).filter((el) => {
|
||||
const cls = String(el.className || "");
|
||||
return (
|
||||
el.isConnected &&
|
||||
cls.includes("vc_tile") &&
|
||||
cls.includes("ring-c_var(--md-sys-color-primary)")
|
||||
);
|
||||
});
|
||||
|
||||
for (const tile of ringTiles) {
|
||||
const imageNodes = Array.from(tile.querySelectorAll("img")).filter((img) => isVisibleElement(img));
|
||||
for (const img of imageNodes) {
|
||||
const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || "");
|
||||
if (src && isAvatarLikeSrc(src)) {
|
||||
speaking.add(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const images = Array.from(document.querySelectorAll("img")).filter((img) => {
|
||||
if (!isVisibleElement(img)) return false;
|
||||
const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || "");
|
||||
return isAvatarLikeSrc(src);
|
||||
});
|
||||
|
||||
for (const img of images) {
|
||||
const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || "");
|
||||
const root = findAvatarRoot(img);
|
||||
if (!root) continue;
|
||||
|
||||
const nodes = [root, ...Array.from(root.querySelectorAll("*"))].slice(0, 40);
|
||||
const speaks = nodes.some(looksLikeVoiceActivityIndicator) || nodes.some(isSpeakingMarkerNode);
|
||||
if (speaks) speaking.add(src);
|
||||
}
|
||||
|
||||
return speaking;
|
||||
}
|
||||
|
||||
function isSpeakingMarkerNode(node) {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
const className = String(node.className || "").toLowerCase();
|
||||
const label = String(node.getAttribute?.("aria-label") || "").toLowerCase();
|
||||
const title = String(node.getAttribute?.("title") || "").toLowerCase();
|
||||
const text = String(node.textContent || "").toLowerCase();
|
||||
const state = String(node.getAttribute?.("data-state") || "").toLowerCase();
|
||||
const active = String(node.getAttribute?.("data-active") || "").toLowerCase();
|
||||
const speaking = String(node.getAttribute?.("data-speaking") || "").toLowerCase();
|
||||
|
||||
return Boolean(
|
||||
className.includes("speaking") ||
|
||||
className.includes("active-speaker") ||
|
||||
className.includes("voice-activity") ||
|
||||
label.includes("speaking") ||
|
||||
label.includes("voice activity") ||
|
||||
title.includes("speaking") ||
|
||||
title.includes("voice activity") ||
|
||||
text.includes("speaking") ||
|
||||
text.includes("voice activity") ||
|
||||
state === "speaking" ||
|
||||
active === "true" ||
|
||||
speaking === "true"
|
||||
);
|
||||
}
|
||||
|
||||
function detectSelfFlags() {
|
||||
const buttons = Array.from(document.querySelectorAll("button"));
|
||||
const muteButton = buttons.find((button) =>
|
||||
/unmute|mute/i.test(
|
||||
button.getAttribute("aria-label") || button.title || button.textContent || "",
|
||||
),
|
||||
);
|
||||
const deafenButton = buttons.find((button) =>
|
||||
/undeafen|deafen/i.test(
|
||||
button.getAttribute("aria-label") || button.title || button.textContent || "",
|
||||
),
|
||||
);
|
||||
return {
|
||||
selfMuted: muteButton
|
||||
? /unmute/i.test(
|
||||
muteButton.getAttribute("aria-label") ||
|
||||
muteButton.title ||
|
||||
muteButton.textContent ||
|
||||
"",
|
||||
)
|
||||
: undefined,
|
||||
selfDeafened: deafenButton
|
||||
? /undeafen/i.test(
|
||||
deafenButton.getAttribute("aria-label") ||
|
||||
deafenButton.title ||
|
||||
deafenButton.textContent ||
|
||||
"",
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function collectVoiceState() {
|
||||
const speakingAvatars = collectSpeakingAvatarUrls();
|
||||
const members = Array.from(document.querySelectorAll("*"))
|
||||
.filter(isParticipantEntry)
|
||||
.slice(0, 10)
|
||||
.map((entry) => ({
|
||||
name: getParticipantName(entry),
|
||||
speaking: isSpeakingEntry(entry),
|
||||
avatarUrl: normalizeAvatarUrl(extractAvatarUrl(entry)) || undefined,
|
||||
}))
|
||||
.map((member) => ({
|
||||
...member,
|
||||
speaking:
|
||||
member.speaking ||
|
||||
(member.avatarUrl ? speakingAvatars.has(normalizeAvatarUrl(member.avatarUrl)) : false),
|
||||
}));
|
||||
const selfFlags = detectSelfFlags();
|
||||
return {
|
||||
channelName: "Voice call",
|
||||
// The join/leave hook is authoritative for whether the overlay should show.
|
||||
// The DOM scan is only used to enrich the overlay with members while in call.
|
||||
isInCall: inVoice,
|
||||
members,
|
||||
selfMuted: selfFlags.selfMuted,
|
||||
selfDeafened: selfFlags.selfDeafened,
|
||||
source: "voice DOM",
|
||||
};
|
||||
}
|
||||
|
||||
function memberIdentityKey(members) {
|
||||
return members
|
||||
.map((member) =>
|
||||
[
|
||||
String(member?.name || "").trim().toLowerCase(),
|
||||
String(member?.avatarUrl || "").trim().toLowerCase(),
|
||||
].join(":"),
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function publishVoiceState() {
|
||||
const overlayApi = window.native?.overlay;
|
||||
if (!overlayApi || typeof overlayApi.setVoiceState !== "function") return;
|
||||
|
||||
pruneRowActivity();
|
||||
const next = collectVoiceState();
|
||||
const voiceState = inVoice ? next : null;
|
||||
const nextMemberKey = memberIdentityKey(next.members);
|
||||
const nextKey = JSON.stringify(voiceState);
|
||||
const memberChanged = nextMemberKey !== lastMemberIdentityKey;
|
||||
if (memberChanged && inVoice && !initialising && lastMemberIdentityKey) {
|
||||
const previousMembers = new Set(
|
||||
lastMemberIdentityKey
|
||||
.split("|")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const currentMembers = new Set(
|
||||
nextMemberKey
|
||||
.split("|")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const added = [...currentMembers].some((entry) => !previousMembers.has(entry));
|
||||
const removed = [...previousMembers].some((entry) => !currentMembers.has(entry));
|
||||
if (added) playJoin();
|
||||
if (removed) playLeave();
|
||||
}
|
||||
lastMemberIdentityKey = nextMemberKey;
|
||||
if (nextKey === lastVoiceState) return;
|
||||
lastVoiceState = nextKey;
|
||||
|
||||
if (inVoice && next.members.length === 0) {
|
||||
if (!leaveWatchdog) {
|
||||
leaveWatchdog = setTimeout(() => {
|
||||
leaveWatchdog = null;
|
||||
if (!inVoice) return;
|
||||
const stillEmpty = collectVoiceState().members.length === 0;
|
||||
if (stillEmpty) {
|
||||
overlayApi.setVoiceState(null);
|
||||
onSelfLeft();
|
||||
}
|
||||
}, 2200);
|
||||
}
|
||||
} else {
|
||||
clearTimeout(leaveWatchdog);
|
||||
leaveWatchdog = null;
|
||||
}
|
||||
|
||||
overlayApi.setVoiceState(voiceState);
|
||||
}
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async function (...args) {
|
||||
const url = typeof args[0] === "string" ? args[0] : args[0]?.url ?? "";
|
||||
const response = await originalFetch.apply(this, args);
|
||||
if (url.includes("/join_call") && response.ok) {
|
||||
setTimeout(onSelfJoined, 300);
|
||||
}
|
||||
if (/(leave_call|leave-?call|disconnect|close_call)/i.test(url) && response.ok) {
|
||||
setTimeout(onSelfLeft, 150);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.WebSocket = function (url, protocols) {
|
||||
const ws = protocols
|
||||
? new OriginalWebSocket(url, protocols)
|
||||
: new OriginalWebSocket(url);
|
||||
if (typeof url === "string" && url.includes("/livekit/rtc")) {
|
||||
ws.addEventListener("close", () => onSelfLeft());
|
||||
}
|
||||
return ws;
|
||||
};
|
||||
Object.assign(window.WebSocket, OriginalWebSocket);
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
|
||||
startObserver();
|
||||
publishVoiceState();
|
||||
})();
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
(function () {
|
||||
if (window.__sanctumGamePresenceSettings) return;
|
||||
window.__sanctumGamePresenceSettings = true;
|
||||
|
||||
const CLONE_ATTR = "data-sanctum-game-presence";
|
||||
const PANEL_ATTR = "data-sanctum-game-presence-panel";
|
||||
const POPULAR_GAMES = [
|
||||
"Apex Legends",
|
||||
"Among Us",
|
||||
"Assassin's Creed Mirage",
|
||||
"Assassin's Creed Valhalla",
|
||||
"Armored Core VI: Fires of Rubicon",
|
||||
"Baldur's Gate 3",
|
||||
"Black Myth: Wukong",
|
||||
"Brawlhalla",
|
||||
"Call of Duty: Black Ops 6",
|
||||
"Call of Duty: Modern Warfare III",
|
||||
"Call of Duty: Warzone",
|
||||
"Celeste",
|
||||
"Cities: Skylines II",
|
||||
"Civilization VI",
|
||||
"Counter-Strike 2",
|
||||
"Cuphead",
|
||||
"Cyberpunk 2077",
|
||||
"Dark Souls III",
|
||||
"Dave the Diver",
|
||||
"Days Gone",
|
||||
"Dead by Daylight",
|
||||
"Dead Cells",
|
||||
"Deep Rock Galactic",
|
||||
"Destiny 2",
|
||||
"Diablo IV",
|
||||
"Dota 2",
|
||||
"Dragon's Dogma 2",
|
||||
"Elden Ring",
|
||||
"Enshrouded",
|
||||
"Escape from Tarkov",
|
||||
"Euro Truck Simulator 2",
|
||||
"EVE Online",
|
||||
"Fall Guys",
|
||||
"Fallout 4",
|
||||
"Fallout 76",
|
||||
"Factorio",
|
||||
"F1 24",
|
||||
"Final Fantasy XIV",
|
||||
"Forza Horizon 5",
|
||||
"Fortnite",
|
||||
"Genshin Impact",
|
||||
"Ghost of Tsushima",
|
||||
"God of War",
|
||||
"Grand Theft Auto V",
|
||||
"Grounded",
|
||||
"Guild Wars 2",
|
||||
"Hades",
|
||||
"Hades II",
|
||||
"Helldivers 2",
|
||||
"Hogwarts Legacy",
|
||||
"Hollow Knight",
|
||||
"Honkai: Star Rail",
|
||||
"Honkai Impact 3rd",
|
||||
"Hunt: Showdown",
|
||||
"It Takes Two",
|
||||
"Kingdom Come: Deliverance",
|
||||
"League of Legends",
|
||||
"Lethal Company",
|
||||
"Left 4 Dead 2",
|
||||
"Last Epoch",
|
||||
"Marvel Rivals",
|
||||
"Minecraft",
|
||||
"Monster Hunter: World",
|
||||
"Monster Hunter Rise",
|
||||
"Mortal Kombat 1",
|
||||
"Metaphor: ReFantazio",
|
||||
"No Man's Sky",
|
||||
"Once Human",
|
||||
"Overwatch 2",
|
||||
"Palworld",
|
||||
"Path of Exile",
|
||||
"Path of Exile 2",
|
||||
"Persona 5 Royal",
|
||||
"Phasmophobia",
|
||||
"PUBG: Battlegrounds",
|
||||
"Paladins",
|
||||
"Rainbow Six Siege",
|
||||
"Red Dead Redemption 2",
|
||||
"Resident Evil 4",
|
||||
"Resident Evil Village",
|
||||
"Rocket League",
|
||||
"Rust",
|
||||
"Satisfactory",
|
||||
"Sea of Thieves",
|
||||
"Skyrim Special Edition",
|
||||
"Slay the Spire",
|
||||
"Sons of the Forest",
|
||||
"Spider-Man Remastered",
|
||||
"Split Fiction",
|
||||
"Star Citizen",
|
||||
"Starfield",
|
||||
"Stardew Valley",
|
||||
"Street Fighter 6",
|
||||
"Subnautica",
|
||||
"Team Fortress 2",
|
||||
"Tekken 8",
|
||||
"Terraria",
|
||||
"The Elder Scrolls Online",
|
||||
"The Finals",
|
||||
"The Last of Us Part I",
|
||||
"The Witcher 3",
|
||||
"Titanfall 2",
|
||||
"VALORANT",
|
||||
"V Rising",
|
||||
"Valheim",
|
||||
"Warframe",
|
||||
"War Thunder",
|
||||
"Wuthering Waves",
|
||||
"World of Warcraft",
|
||||
"World of Tanks",
|
||||
"World of Warships",
|
||||
"Zenless Zone Zero",
|
||||
];
|
||||
|
||||
function toggleCheckbox(elem, value) {
|
||||
const checkbox = elem.querySelector("mdui-checkbox");
|
||||
if (!checkbox) return;
|
||||
|
||||
if (value) {
|
||||
checkbox.setAttribute("checked", "");
|
||||
checkbox.setAttribute("value", "on");
|
||||
} else {
|
||||
checkbox.removeAttribute("checked");
|
||||
checkbox.setAttribute("value", "off");
|
||||
}
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
return window.desktopConfig.get();
|
||||
}
|
||||
|
||||
function setConfig(next) {
|
||||
window.desktopConfig.set(next);
|
||||
}
|
||||
|
||||
function buildPanel() {
|
||||
const panel = document.createElement("div");
|
||||
panel.setAttribute(PANEL_ATTR, "true");
|
||||
panel.style.cssText = `
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
const note = document.createElement("div");
|
||||
note.style.cssText = "font-size:12px; opacity:0.75; line-height:1.35;";
|
||||
note.textContent =
|
||||
"Sanctum only lights up for games in its built-in catalog or names you add below.";
|
||||
panel.appendChild(note);
|
||||
|
||||
const allowLabel = document.createElement("div");
|
||||
allowLabel.textContent = "Allowed games / windows";
|
||||
allowLabel.style.cssText = "font-size:12px; font-weight:600;";
|
||||
panel.appendChild(allowLabel);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = getConfig().gamePresenceAllowList || "";
|
||||
textarea.rows = 4;
|
||||
textarea.placeholder = "Examples: Fortnite, Valorant, Counter-Strike 2, Baldur's Gate 3";
|
||||
textarea.style.cssText = `
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 88px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
textarea.addEventListener("input", () => {
|
||||
const config = getConfig();
|
||||
config.gamePresenceAllowList = textarea.value;
|
||||
setConfig(config);
|
||||
});
|
||||
panel.appendChild(textarea);
|
||||
|
||||
const pickerLabel = document.createElement("div");
|
||||
pickerLabel.textContent = "Popular games";
|
||||
pickerLabel.style.cssText = "font-size:12px; font-weight:600; margin-top:2px;";
|
||||
panel.appendChild(pickerLabel);
|
||||
|
||||
const pickerHint = document.createElement("div");
|
||||
pickerHint.textContent = "Search and add games from the built-in catalog.";
|
||||
pickerHint.style.cssText = "font-size:11px; opacity:0.7; line-height:1.35;";
|
||||
panel.appendChild(pickerHint);
|
||||
|
||||
const pickerRow = document.createElement("div");
|
||||
pickerRow.style.cssText = "display:flex; gap:8px; align-items:center;";
|
||||
|
||||
const pickerSearch = document.createElement("input");
|
||||
pickerSearch.type = "search";
|
||||
pickerSearch.placeholder = "Search popular games";
|
||||
pickerSearch.style.cssText = `
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
`;
|
||||
|
||||
const pickerAdd = document.createElement("button");
|
||||
pickerAdd.type = "button";
|
||||
pickerAdd.textContent = "Add";
|
||||
pickerAdd.style.cssText = `
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
pickerRow.appendChild(pickerSearch);
|
||||
pickerRow.appendChild(pickerAdd);
|
||||
panel.appendChild(pickerRow);
|
||||
|
||||
const pickerList = document.createElement("div");
|
||||
pickerList.style.cssText = `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 6px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
`;
|
||||
panel.appendChild(pickerList);
|
||||
|
||||
function existingEntries() {
|
||||
return textarea.value
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function addGameToAllowList(name) {
|
||||
const current = new Set(existingEntries().map((item) => item.toLowerCase()));
|
||||
if (current.has(name.toLowerCase())) return;
|
||||
const next = existingEntries();
|
||||
next.push(name);
|
||||
textarea.value = next.join("\n");
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function renderPicker() {
|
||||
const query = pickerSearch.value.trim().toLowerCase();
|
||||
const selected = new Set(existingEntries().map((item) => item.toLowerCase()));
|
||||
pickerList.innerHTML = "";
|
||||
|
||||
const matches = POPULAR_GAMES.filter((game) => !query || game.toLowerCase().includes(query)).slice(0, 40);
|
||||
for (const game of matches) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = selected.has(game.toLowerCase()) ? `✓ ${game}` : game;
|
||||
button.title = selected.has(game.toLowerCase()) ? "Already added" : `Add ${game}`;
|
||||
button.style.cssText = `
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid ${selected.has(game.toLowerCase()) ? "rgba(104, 126, 255, 0.55)" : "rgba(255,255,255,0.12)"};
|
||||
background: ${selected.has(game.toLowerCase()) ? "rgba(104, 126, 255, 0.16)" : "rgba(255,255,255,0.05)"};
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
button.addEventListener("click", () => addGameToAllowList(game));
|
||||
pickerList.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
pickerSearch.addEventListener("input", renderPicker);
|
||||
pickerAdd.addEventListener("click", () => {
|
||||
const query = pickerSearch.value.trim();
|
||||
if (!query) return;
|
||||
const exact = POPULAR_GAMES.find((game) => game.toLowerCase() === query.toLowerCase());
|
||||
if (exact) {
|
||||
addGameToAllowList(exact);
|
||||
return;
|
||||
}
|
||||
addGameToAllowList(query);
|
||||
});
|
||||
textarea.addEventListener("input", renderPicker);
|
||||
renderPicker();
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
function createButton(baseElem) {
|
||||
const row = baseElem.cloneNode(true);
|
||||
row.setAttribute(CLONE_ATTR, "true");
|
||||
|
||||
const title = row.querySelector("div.d_flex.flex-g_1 > div");
|
||||
const desc = row.querySelector("div.d_flex.flex-g_1 > span");
|
||||
const icon = row.querySelector("div.w_36px span.material-symbols-outlined");
|
||||
const existingIcon = row.querySelector("div.w_36px");
|
||||
|
||||
if (title) title.textContent = "Gameplay overlay";
|
||||
if (desc) desc.textContent = "Shows the mini voice overlay while you are in a game.";
|
||||
if (icon) icon.textContent = "sports_esports";
|
||||
|
||||
if (existingIcon) {
|
||||
existingIcon.title = "Toggle gameplay sharing settings";
|
||||
existingIcon.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
const settingsBtn = document.createElement("div");
|
||||
settingsBtn.title = "Edit gameplay sharing";
|
||||
settingsBtn.style.cssText = "cursor: pointer; z-index: 10; flex-shrink: 0; margin-left: 6px;";
|
||||
settingsBtn.innerHTML = `
|
||||
<div class="fill_var(--md-sys-color-on-surface) bg_var(--md-sys-color-surface-dim) w_36px h_36px d_flex flex-sh_0 ai_center jc_center bdr_var(--borderRadius-full)">
|
||||
<span aria-hidden="true" class="material-symbols-outlined fs_inherit fw_undefined!" style="display:block;font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0;">settings</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const iconSlot = row.querySelector(".d_flex.ai_center.jc_center, .w_36px");
|
||||
if (iconSlot && iconSlot.parentNode) {
|
||||
iconSlot.parentNode.appendChild(settingsBtn);
|
||||
} else {
|
||||
row.appendChild(settingsBtn);
|
||||
}
|
||||
|
||||
const panel = buildPanel();
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.cssText = "display:flex; flex-direction:column;";
|
||||
|
||||
const applyState = () => {
|
||||
const config = getConfig();
|
||||
toggleCheckbox(row, config.gamePresenceEnabled);
|
||||
if (config.gamePresenceEnabled) {
|
||||
row.setAttribute("data-active", "true");
|
||||
} else {
|
||||
row.setAttribute("data-active", "false");
|
||||
}
|
||||
};
|
||||
|
||||
row.addEventListener("click", (e) => {
|
||||
if (settingsBtn.contains(e.target)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const config = getConfig();
|
||||
config.gamePresenceEnabled = !config.gamePresenceEnabled;
|
||||
setConfig(config);
|
||||
applyState();
|
||||
});
|
||||
|
||||
settingsBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
panel.style.display = panel.style.display === "flex" ? "none" : "flex";
|
||||
});
|
||||
|
||||
applyState();
|
||||
wrapper.appendChild(row);
|
||||
wrapper.appendChild(panel);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function injectButton() {
|
||||
const base = Array.from(document.querySelectorAll("a")).find((e) => {
|
||||
const t = e.querySelector("div.d_flex.flex-g_1 > div");
|
||||
return t && t.textContent.trim() === "Discord RPC";
|
||||
});
|
||||
|
||||
if (!base) return;
|
||||
if (document.querySelector(`[${CLONE_ATTR}]`)) return;
|
||||
|
||||
const newButton = createButton(base);
|
||||
base.parentNode.appendChild(newButton);
|
||||
}
|
||||
|
||||
injectButton();
|
||||
|
||||
const observer = new MutationObserver(() => injectButton());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
const STYLE_ID = "headliner-style";
|
||||
|
||||
const defaults = {
|
||||
content: "Sanctum",
|
||||
content: "Stoat V 1.0.0 - Sanctum",
|
||||
left: "32",
|
||||
top: "56",
|
||||
fontSize: "15",
|
||||
|
|
@ -18,12 +18,7 @@
|
|||
|
||||
function loadSettings() {
|
||||
try {
|
||||
let s = JSON.parse(localStorage.getItem("headlinerSettings"));
|
||||
if (s && /^Sanctum V 1\.0\.[0-9]+$/.test(s.content)) {
|
||||
s.content = defaults.content;
|
||||
saveSettings(s);
|
||||
}
|
||||
return s || { ...defaults };
|
||||
return JSON.parse(localStorage.getItem("headlinerSettings")) || { ...defaults };
|
||||
} catch {
|
||||
return { ...defaults };
|
||||
}
|
||||
|
|
@ -41,7 +36,6 @@
|
|||
}
|
||||
.flex-sh_0.h_29px.us_none.d_flex.ai_center.fill_var\\(--md-sys-color-on-surface\\).c_var\\(--md-sys-color-outline\\).bg_var\\(--md-sys-color-surface-container-high\\) {
|
||||
position: relative !important;
|
||||
color: transparent !important;
|
||||
}
|
||||
.flex-sh_0.h_29px.us_none.d_flex.ai_center.fill_var\\(--md-sys-color-on-surface\\).c_var\\(--md-sys-color-outline\\).bg_var\\(--md-sys-color-surface-container-high\\)::before {
|
||||
content: "${s.content}";
|
||||
|
|
@ -51,7 +45,7 @@
|
|||
transform: translateY(-50%);
|
||||
font-size: ${s.fontSize}px;
|
||||
font-weight: ${s.fontWeight};
|
||||
color: var(--md-sys-color-on-surface) !important;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
|
@ -86,7 +80,7 @@
|
|||
applyCSS();
|
||||
} else {
|
||||
clone.setAttribute("data-active", "false");
|
||||
if (desc) desc.textContent = "Modify the Sanctum name in the titlebar to say anything you want";
|
||||
if (desc) desc.textContent = "Modify the Stoat name in the titlebar to say anything you want";
|
||||
if (checkbox) checkbox.removeAttribute("checked");
|
||||
removeCSS();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,6 @@
|
|||
<control>pointing</control>
|
||||
</supports>
|
||||
<releases>
|
||||
<release date="2026-05-05" version="1.0.7">
|
||||
<description>
|
||||
<p>Fixed a main-process bootstrap race and improved VC sounds / game presence behavior.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release date="2026-04-22" version="1.0.0">
|
||||
<description>
|
||||
<p>Initial Sanctum release based on Avia Client with self-hosted instance support.</p>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"name": "sanctum",
|
||||
"productName": "Sanctum",
|
||||
"version": "1.0.7",
|
||||
"aviaVersion": "1.0.7",
|
||||
"version": "1.0.0",
|
||||
"aviaVersion": "1.0.0",
|
||||
"main": ".vite/build/main.js",
|
||||
"repository": "https://git.mithraic.cloud/ad3laid3/sanctum",
|
||||
"scripts": {
|
||||
|
|
|
|||
65
src/config.d.ts
vendored
65
src/config.d.ts
vendored
|
|
@ -7,9 +7,6 @@ declare type DesktopConfig = {
|
|||
spellchecker: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
discordRpc: boolean;
|
||||
gamePresenceEnabled: boolean;
|
||||
gamePresenceRestrictToAllowList: boolean;
|
||||
gamePresenceAllowList: string;
|
||||
windowState: {
|
||||
x: number;
|
||||
y: number;
|
||||
|
|
@ -18,65 +15,3 @@ declare type DesktopConfig = {
|
|||
isMaximised: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
declare type VoiceOverlayMember = {
|
||||
name: string;
|
||||
speaking?: boolean;
|
||||
muted?: boolean;
|
||||
deafened?: boolean;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
declare type VoiceOverlayState = {
|
||||
channelName?: string;
|
||||
isInCall: boolean;
|
||||
members: VoiceOverlayMember[];
|
||||
selfMuted?: boolean;
|
||||
selfDeafened?: boolean;
|
||||
source?: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
declare type SanctumGamePresence = {
|
||||
title: string;
|
||||
processName: string;
|
||||
startedAt: number;
|
||||
source: string;
|
||||
};
|
||||
|
||||
declare type SanctumActivityState = {
|
||||
game: SanctumGamePresence | null;
|
||||
voice: VoiceOverlayState | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
native: {
|
||||
versions: {
|
||||
node: () => string;
|
||||
chrome: () => string;
|
||||
electron: () => string;
|
||||
desktop: () => string;
|
||||
aviaClient: () => string;
|
||||
};
|
||||
overlay: {
|
||||
setVoiceState: (state: VoiceOverlayState | null) => void;
|
||||
};
|
||||
activity: {
|
||||
getState: () => Promise<SanctumActivityState>;
|
||||
onUpdate: (callback: (state: SanctumActivityState) => void) => () => void;
|
||||
debugSetState: (state: SanctumActivityState) => Promise<SanctumActivityState>;
|
||||
};
|
||||
minimise: () => void;
|
||||
maximise: () => void;
|
||||
close: () => void;
|
||||
setBadgeCount: (count: number) => void;
|
||||
};
|
||||
desktopConfig: {
|
||||
get: () => DesktopConfig;
|
||||
set: (config: DesktopConfig) => void;
|
||||
getAutostart: () => Promise<boolean>;
|
||||
setAutostart: (value: boolean) => Promise<boolean>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
src/main.ts
32
src/main.ts
|
|
@ -10,7 +10,6 @@ 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";
|
||||
|
|
@ -46,31 +45,8 @@ const acquiredLock = app.requestSingleInstanceLock();
|
|||
const loadInject = () => {
|
||||
if (!mainWindow) return;
|
||||
|
||||
const wc = mainWindow.webContents;
|
||||
wc.removeAllListeners("dom-ready");
|
||||
wc.once("dom-ready", async () => {
|
||||
mainWindow.webContents.on("dom-ready", async () => {
|
||||
try {
|
||||
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
|
||||
|
||||
const builtInLocalPlugins = [
|
||||
{
|
||||
id: "sanctum-vcsounds",
|
||||
name: "VCSounds",
|
||||
code: fs.readFileSync(path.join(__dirname, "VCSounds.js"), "utf8"),
|
||||
enabled: true,
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
|
||||
await wc.executeJavaScript(
|
||||
`window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__ = ${JSON.stringify(
|
||||
builtInLocalPlugins,
|
||||
)};`,
|
||||
true,
|
||||
);
|
||||
|
||||
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
|
||||
|
||||
const plugins: string[] = [
|
||||
"inject.js",
|
||||
"LocalPlugins.js",
|
||||
|
|
@ -81,19 +57,18 @@ const loadInject = () => {
|
|||
"aviaversion.js",
|
||||
"repofrontend.js",
|
||||
"ButtonFix.js",
|
||||
"headliner.js",
|
||||
"aviadesktopversion.js",
|
||||
"customFrameNativeMenu.js",
|
||||
"disableTrayIcon.js",
|
||||
"gamePresenceSettings.js",
|
||||
"clientBackup.js",
|
||||
"LoginWithToken.js",
|
||||
];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
|
||||
const pluginPath: string = path.join(__dirname, plugin);
|
||||
const pluginCode: string = fs.readFileSync(pluginPath, "utf8");
|
||||
await wc.executeJavaScript(pluginCode, true);
|
||||
await mainWindow.webContents.executeJavaScript(pluginCode, true);
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
|
|
@ -123,7 +98,6 @@ if (acquiredLock) {
|
|||
|
||||
initTray();
|
||||
initDiscordRpc();
|
||||
startGamePresenceMonitor();
|
||||
checkForUpdates();
|
||||
setBadgeCount(0);
|
||||
|
||||
|
|
|
|||
|
|
@ -34,15 +34,6 @@ const schema = {
|
|||
discordRpc: {
|
||||
type: "boolean",
|
||||
} as JSONSchema.Boolean,
|
||||
gamePresenceEnabled: {
|
||||
type: "boolean",
|
||||
} as JSONSchema.Boolean,
|
||||
gamePresenceRestrictToAllowList: {
|
||||
type: "boolean",
|
||||
} as JSONSchema.Boolean,
|
||||
gamePresenceAllowList: {
|
||||
type: "string",
|
||||
} as JSONSchema.String,
|
||||
windowState: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -77,9 +68,6 @@ const store = new Store({
|
|||
spellchecker: true,
|
||||
hardwareAcceleration: true,
|
||||
discordRpc: true,
|
||||
gamePresenceEnabled: true,
|
||||
gamePresenceRestrictToAllowList: true,
|
||||
gamePresenceAllowList: "",
|
||||
windowState: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
|
@ -105,9 +93,6 @@ class Config {
|
|||
spellchecker: this.spellchecker,
|
||||
hardwareAcceleration: this.hardwareAcceleration,
|
||||
discordRpc: this.discordRpc,
|
||||
gamePresenceEnabled: this.gamePresenceEnabled,
|
||||
gamePresenceRestrictToAllowList: this.gamePresenceRestrictToAllowList,
|
||||
gamePresenceAllowList: this.gamePresenceAllowList,
|
||||
windowState: this.windowState,
|
||||
});
|
||||
}
|
||||
|
|
@ -245,47 +230,6 @@ class Config {
|
|||
this.sync();
|
||||
}
|
||||
|
||||
get gamePresenceEnabled() {
|
||||
return (store as never as { get(k: string): boolean }).get("gamePresenceEnabled");
|
||||
}
|
||||
|
||||
set gamePresenceEnabled(value: boolean) {
|
||||
(store as never as { set(k: string, value: boolean): void }).set(
|
||||
"gamePresenceEnabled",
|
||||
value,
|
||||
);
|
||||
|
||||
this.sync();
|
||||
}
|
||||
|
||||
get gamePresenceRestrictToAllowList() {
|
||||
return (store as never as { get(k: string): boolean }).get(
|
||||
"gamePresenceRestrictToAllowList",
|
||||
);
|
||||
}
|
||||
|
||||
set gamePresenceRestrictToAllowList(value: boolean) {
|
||||
(store as never as { set(k: string, value: boolean): void }).set(
|
||||
"gamePresenceRestrictToAllowList",
|
||||
value,
|
||||
);
|
||||
|
||||
this.sync();
|
||||
}
|
||||
|
||||
get gamePresenceAllowList() {
|
||||
return (store as never as { get(k: string): string }).get("gamePresenceAllowList");
|
||||
}
|
||||
|
||||
set gamePresenceAllowList(value: string) {
|
||||
(store as never as { set(k: string, value: string): void }).set(
|
||||
"gamePresenceAllowList",
|
||||
value,
|
||||
);
|
||||
|
||||
this.sync();
|
||||
}
|
||||
|
||||
get windowState() {
|
||||
return (
|
||||
store as never as { get(k: string): DesktopConfig["windowState"] }
|
||||
|
|
|
|||
|
|
@ -3,32 +3,7 @@ import { Client } from "discord-rpc";
|
|||
import { config } from "./config";
|
||||
|
||||
// internal state
|
||||
let rpc: Client | undefined;
|
||||
type RpcActivity = Parameters<Client["setActivity"]>[0];
|
||||
|
||||
const defaultActivity: RpcActivity = {
|
||||
details: "Chatting with others on Sanctum",
|
||||
state: "stoat.chat",
|
||||
largeImageKey: "qr",
|
||||
largeImageText: "Join Stoat!",
|
||||
buttons: [
|
||||
{
|
||||
label: "Join Stoat",
|
||||
url: "https://stoat.chat/",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let pendingActivity: RpcActivity = defaultActivity;
|
||||
|
||||
function applyActivity() {
|
||||
if (!rpc) return;
|
||||
try {
|
||||
rpc.setActivity(pendingActivity);
|
||||
} catch {
|
||||
/* ignore transient RPC failures */
|
||||
}
|
||||
}
|
||||
let rpc: Client;
|
||||
|
||||
export async function initDiscordRpc() {
|
||||
if (!config.discordRpc) return;
|
||||
|
|
@ -39,7 +14,20 @@ export async function initDiscordRpc() {
|
|||
try {
|
||||
rpc = new Client({ transport: "ipc" });
|
||||
|
||||
rpc.on("ready", applyActivity);
|
||||
rpc.on("ready", () =>
|
||||
rpc.setActivity({
|
||||
details: "Chatting with others on Sanctum",
|
||||
state: "stoat.chat",
|
||||
largeImageKey: "qr",
|
||||
largeImageText: "Join Stoat!",
|
||||
buttons: [
|
||||
{
|
||||
label: "Join Stoat",
|
||||
url: "https://stoat.chat/",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
rpc.on("disconnected", reconnect);
|
||||
|
||||
|
|
@ -49,14 +37,8 @@ export async function initDiscordRpc() {
|
|||
}
|
||||
}
|
||||
|
||||
export function setDiscordActivity(activity: RpcActivity | null) {
|
||||
pendingActivity = activity ?? defaultActivity;
|
||||
applyActivity();
|
||||
}
|
||||
|
||||
const reconnect = () => setTimeout(() => initDiscordRpc(), 1e4);
|
||||
|
||||
export async function destroyDiscordRpc() {
|
||||
rpc?.destroy();
|
||||
rpc = undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
type CandidateLike = {
|
||||
processName: string;
|
||||
title: string;
|
||||
commandLine?: string;
|
||||
};
|
||||
|
||||
type GameCatalogEntry = {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
};
|
||||
|
||||
const GAME_CATALOG: GameCatalogEntry[] = [
|
||||
{ name: "Apex Legends", aliases: ["apex", "r5apex"] },
|
||||
{ name: "Among Us" },
|
||||
{ name: "Assassin's Creed Mirage" },
|
||||
{ name: "Assassin's Creed Valhalla" },
|
||||
{ name: "Armored Core VI: Fires of Rubicon", aliases: ["armored core 6"] },
|
||||
{ name: "Baldur's Gate 3", aliases: ["bg3", "baldurs gate 3", "baldursgate3"] },
|
||||
{ name: "Black Myth: Wukong", aliases: ["blackmythwukong", "wukong"] },
|
||||
{ name: "Brawlhalla" },
|
||||
{ name: "Call of Duty: Black Ops 6", aliases: ["black ops 6", "codbo6"] },
|
||||
{ name: "Call of Duty: Modern Warfare III", aliases: ["modern warfare 3", "mw3", "codmw3"] },
|
||||
{ name: "Call of Duty: Warzone", aliases: ["warzone", "cod warzone"] },
|
||||
{ name: "Celeste" },
|
||||
{ name: "Cities: Skylines II", aliases: ["cities skylines 2", "skylines 2"] },
|
||||
{ name: "Civilization VI", aliases: ["civ6", "civilization 6"] },
|
||||
{ name: "Counter-Strike 2", aliases: ["cs2", "counter strike 2", "csgo", "counter strike global offensive"] },
|
||||
{ name: "Cuphead" },
|
||||
{ name: "Cyberpunk 2077", aliases: ["cyberpunk"] },
|
||||
{ name: "Dark Souls III", aliases: ["dark souls 3"] },
|
||||
{ name: "Dave the Diver" },
|
||||
{ name: "Days Gone" },
|
||||
{ name: "Dead by Daylight" },
|
||||
{ name: "Dead Cells" },
|
||||
{ name: "Deep Rock Galactic" },
|
||||
{ name: "Destiny 2" },
|
||||
{ name: "Diablo IV", aliases: ["diablo 4"] },
|
||||
{ name: "Dota 2" },
|
||||
{ name: "Dragon's Dogma 2", aliases: ["dragons dogma 2"] },
|
||||
{ name: "Elden Ring" },
|
||||
{ name: "Enshrouded" },
|
||||
{ name: "Escape from Tarkov" },
|
||||
{ name: "Euro Truck Simulator 2" },
|
||||
{ name: "EVE Online" },
|
||||
{ name: "Fall Guys" },
|
||||
{ name: "Fallout 4" },
|
||||
{ name: "Fallout 76" },
|
||||
{ name: "Factorio" },
|
||||
{ name: "F1 24" },
|
||||
{ name: "Final Fantasy XIV", aliases: ["ffxiv"] },
|
||||
{ name: "Forza Horizon 5" },
|
||||
{ name: "Fortnite", aliases: ["fortniteclient", "fortniteclientwin64shipping"] },
|
||||
{ name: "Genshin Impact", aliases: ["genshin", "genshinimpact", "yuanshen"] },
|
||||
{ name: "Ghost of Tsushima" },
|
||||
{ name: "God of War" },
|
||||
{ name: "Grand Theft Auto V", aliases: ["gta5", "gta v"] },
|
||||
{ name: "Grounded" },
|
||||
{ name: "Guild Wars 2" },
|
||||
{ name: "Hades" },
|
||||
{ name: "Hades II" },
|
||||
{ name: "Helldivers 2" },
|
||||
{ name: "Hogwarts Legacy" },
|
||||
{ name: "Hollow Knight" },
|
||||
{ name: "Honkai: Star Rail", aliases: ["hkrpg", "hsr", "star rail"] },
|
||||
{ name: "Honkai Impact 3rd" },
|
||||
{ name: "Hunt: Showdown" },
|
||||
{ name: "It Takes Two" },
|
||||
{ name: "Kingdom Come: Deliverance" },
|
||||
{ name: "League of Legends", aliases: ["leagueclient", "league of legends", "lolclient"] },
|
||||
{ name: "Lethal Company" },
|
||||
{ name: "Left 4 Dead 2" },
|
||||
{ name: "Last Epoch" },
|
||||
{ name: "Marvel Rivals" },
|
||||
{ name: "Minecraft", aliases: ["minecraftlauncher", "minecraft java edition", "javaw"] },
|
||||
{ name: "Monster Hunter: World", aliases: ["monster hunter world", "mhw"] },
|
||||
{ name: "Monster Hunter Rise", aliases: ["monster hunter rise", "mhr"] },
|
||||
{ name: "Mortal Kombat 1", aliases: ["mk1"] },
|
||||
{ name: "Metaphor: ReFantazio" },
|
||||
{ name: "No Man's Sky" },
|
||||
{ name: "Once Human" },
|
||||
{ name: "Overwatch 2", aliases: ["overwatch", "ow2"] },
|
||||
{ name: "Palworld" },
|
||||
{ name: "Path of Exile", aliases: ["poe", "pathofexile"] },
|
||||
{ name: "Path of Exile 2", aliases: ["poe2", "pathofexile2"] },
|
||||
{ name: "Persona 5 Royal" },
|
||||
{ name: "Phasmophobia" },
|
||||
{ name: "PUBG: Battlegrounds", aliases: ["pubg"] },
|
||||
{ name: "Paladins" },
|
||||
{ name: "Rainbow Six Siege", aliases: ["r6 siege", "r6siege", "siege"] },
|
||||
{ name: "Red Dead Redemption 2", aliases: ["rdr2"] },
|
||||
{ name: "Resident Evil 4", aliases: ["re4 remake", "resident evil 4 remake"] },
|
||||
{ name: "Resident Evil Village", aliases: ["re8", "resident evil 8"] },
|
||||
{ name: "Rocket League", aliases: ["rocketleague"] },
|
||||
{ name: "Rust" },
|
||||
{ name: "Satisfactory" },
|
||||
{ name: "Sea of Thieves", aliases: ["seaofthieves"] },
|
||||
{ name: "Skyrim Special Edition", aliases: ["skyrimse", "tesv special edition"] },
|
||||
{ name: "Slay the Spire" },
|
||||
{ name: "Sons of the Forest" },
|
||||
{ name: "Spider-Man Remastered", aliases: ["spidermanremastered", "marvel spiderman remastered"] },
|
||||
{ name: "Split Fiction" },
|
||||
{ name: "Star Citizen" },
|
||||
{ name: "Starfield" },
|
||||
{ name: "Stardew Valley" },
|
||||
{ name: "Street Fighter 6", aliases: ["sf6"] },
|
||||
{ name: "Subnautica" },
|
||||
{ name: "Team Fortress 2", aliases: ["tf2"] },
|
||||
{ name: "Tekken 8", aliases: ["tekken8"] },
|
||||
{ name: "Terraria" },
|
||||
{ name: "The Elder Scrolls Online", aliases: ["eso"] },
|
||||
{ name: "The Finals" },
|
||||
{ name: "The Last of Us Part I", aliases: ["the last of us", "tlou"] },
|
||||
{ name: "The Witcher 3", aliases: ["witcher 3", "witcher3"] },
|
||||
{ name: "Titanfall 2" },
|
||||
{ name: "VALORANT", aliases: ["valorant-win64-shipping", "valorant-win64", "valorant"] },
|
||||
{ name: "V Rising" },
|
||||
{ name: "Valheim" },
|
||||
{ name: "Warframe" },
|
||||
{ name: "War Thunder" },
|
||||
{ name: "Wuthering Waves", aliases: ["wutheringwaves", "wuwa"] },
|
||||
{ name: "World of Warcraft", aliases: ["wow", "wowclassic", "worldofwarcraft"] },
|
||||
{ name: "World of Tanks", aliases: ["wot"] },
|
||||
{ name: "World of Warships", aliases: ["wowships"] },
|
||||
{ name: "Zenless Zone Zero", aliases: ["zzz"] },
|
||||
];
|
||||
|
||||
const GAME_MATCHERS = GAME_CATALOG.flatMap((entry) =>
|
||||
[entry.name, ...(entry.aliases || [])].flatMap((value) => buildNeedles(value)),
|
||||
);
|
||||
|
||||
export function parseGameAllowList(raw: string) {
|
||||
return String(raw || "")
|
||||
.split(/[\n,]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeGameText(value: string) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
.replace(/\.(exe|app|bat|sh)$/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
export function matchesKnownGame(candidate: CandidateLike, allowListRaw: string) {
|
||||
const haystack = normalizeGameText(
|
||||
`${candidate.processName} ${candidate.title} ${candidate.commandLine || ""}`,
|
||||
);
|
||||
|
||||
if (!haystack) return false;
|
||||
if (GAME_MATCHERS.some((matcher) => haystack.includes(matcher))) return true;
|
||||
|
||||
return parseGameAllowList(allowListRaw).some((item) =>
|
||||
haystack.includes(normalizeGameText(item)),
|
||||
);
|
||||
}
|
||||
|
||||
function buildNeedles(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return [];
|
||||
|
||||
const collapsed = raw.replace(/[’']/g, "");
|
||||
const variants = [
|
||||
raw,
|
||||
raw.toLowerCase(),
|
||||
collapsed,
|
||||
collapsed.toLowerCase(),
|
||||
normalizeGameText(raw),
|
||||
normalizeGameText(collapsed),
|
||||
];
|
||||
|
||||
return Array.from(new Set(variants.map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
|
||||
import { config } from "./config";
|
||||
import { mainWindow } from "./window";
|
||||
|
||||
type GamePresence = {
|
||||
title: string;
|
||||
processName: string;
|
||||
startedAt: number;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type OverlayState = {
|
||||
game: GamePresence | null;
|
||||
voice: VoiceOverlayState | null;
|
||||
};
|
||||
|
||||
let overlayWindow: BrowserWindow | null = null;
|
||||
let currentState: OverlayState = {
|
||||
game: null,
|
||||
voice: null,
|
||||
};
|
||||
|
||||
function publishActivityState() {
|
||||
const state = currentState;
|
||||
mainWindow?.webContents.send("sanctum-activity:update", state);
|
||||
overlayWindow?.webContents.send("sanctum-activity:update", state);
|
||||
}
|
||||
|
||||
const HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: rgba(18, 20, 28, 0.88);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: rgba(255, 255, 255, 0.96);
|
||||
--muted: rgba(255, 255, 255, 0.58);
|
||||
--accent: #8fb2ff;
|
||||
--speaking: #59f2a3;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
.shell {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.shell.is-flashing {
|
||||
opacity: 1;
|
||||
}
|
||||
.voice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
}
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.94);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
position: relative;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
opacity: 0.22;
|
||||
transition: opacity 90ms linear;
|
||||
}
|
||||
.avatar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
clip-path: circle(50% at 50% 50%);
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.member.speaking {
|
||||
opacity: 1;
|
||||
}
|
||||
.member.self.speaking {
|
||||
opacity: 1;
|
||||
}
|
||||
.member .initials {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.35);
|
||||
}
|
||||
.member.has-avatar {
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
}
|
||||
.member.has-avatar .initials {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="members" id="members"></div>
|
||||
</div>
|
||||
<script>
|
||||
const { ipcRenderer } = require("electron");
|
||||
|
||||
const state = { game: null, voice: null };
|
||||
const membersEl = document.getElementById("members");
|
||||
const shellEl = document.querySelector(".shell");
|
||||
let previousVoiceSignature = "";
|
||||
let flashTimeout = null;
|
||||
|
||||
function getInitials(name) {
|
||||
const value = String(name || "").trim();
|
||||
if (!value) return "?";
|
||||
const parts = value.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
function isSpeakingMember(member) {
|
||||
return Boolean(member?.speaking);
|
||||
}
|
||||
|
||||
function voiceSignature(voice) {
|
||||
if (!voice || !voice.members) return "";
|
||||
return voice.members
|
||||
.map((member) => [
|
||||
String(member?.name || "").trim().toLowerCase(),
|
||||
String(member?.avatarUrl || "").trim(),
|
||||
].join(":"))
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function flashShell() {
|
||||
if (!shellEl) return;
|
||||
shellEl.classList.add("is-flashing");
|
||||
clearTimeout(flashTimeout);
|
||||
flashTimeout = setTimeout(() => {
|
||||
shellEl.classList.remove("is-flashing");
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const voice = state.voice;
|
||||
const hasSpeaking = Boolean(voice?.members?.some((member) => member?.speaking));
|
||||
membersEl.innerHTML = "";
|
||||
|
||||
if (!voice || !voice.members || !voice.members.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const member of voice.members.slice(0, 5)) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "member" + (isSpeakingMember(member) ? " speaking" : "") + (member.name === "You" ? " self" : "");
|
||||
row.title = member.name || "Unknown";
|
||||
const initials = document.createElement("span");
|
||||
initials.className = "initials";
|
||||
initials.textContent = getInitials(member.name);
|
||||
if (member.avatarUrl) {
|
||||
row.classList.add("has-avatar");
|
||||
const img = document.createElement("img");
|
||||
img.className = "avatar";
|
||||
img.alt = member.name || "Avatar";
|
||||
img.draggable = false;
|
||||
img.src = String(member.avatarUrl);
|
||||
row.appendChild(img);
|
||||
} else {
|
||||
row.classList.remove("has-avatar");
|
||||
}
|
||||
row.appendChild(initials);
|
||||
membersEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on("overlay-state", (_, next) => {
|
||||
state.game = next?.game || null;
|
||||
state.voice = next?.voice || null;
|
||||
const nextSignature = voiceSignature(state.voice);
|
||||
if (nextSignature && nextSignature !== previousVoiceSignature) {
|
||||
flashShell();
|
||||
}
|
||||
previousVoiceSignature = nextSignature;
|
||||
if (!state.voice || !state.voice.members || !state.voice.members.length) {
|
||||
flashShell();
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function getOverlayBounds() {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
return { ...display.workArea };
|
||||
}
|
||||
|
||||
function ensureOverlayWindow() {
|
||||
if (overlayWindow) return overlayWindow;
|
||||
|
||||
const bounds = getOverlayBounds();
|
||||
|
||||
overlayWindow = new BrowserWindow({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
skipTaskbar: true,
|
||||
focusable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
hasShadow: false,
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
|
||||
overlayWindow.setMenu(null);
|
||||
overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
overlayWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
overlayWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(HTML));
|
||||
overlayWindow.webContents.on("did-finish-load", () => {
|
||||
syncOverlayWindow();
|
||||
});
|
||||
|
||||
overlayWindow.on("closed", () => {
|
||||
overlayWindow = null;
|
||||
});
|
||||
|
||||
return overlayWindow;
|
||||
}
|
||||
|
||||
function shouldShowOverlay() {
|
||||
if (!config.gamePresenceEnabled) return false;
|
||||
return Boolean(
|
||||
currentState.game &&
|
||||
currentState.voice &&
|
||||
currentState.voice.isInCall,
|
||||
);
|
||||
}
|
||||
|
||||
function syncOverlayWindow() {
|
||||
if (!overlayWindow) return;
|
||||
|
||||
if (!shouldShowOverlay()) {
|
||||
if (overlayWindow.isVisible()) overlayWindow.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
overlayWindow.showInactive();
|
||||
overlayWindow.webContents.send("overlay-state", currentState);
|
||||
}
|
||||
|
||||
export function setGamePresence(game: GamePresence | null) {
|
||||
if (!config.gamePresenceEnabled) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
game: null,
|
||||
};
|
||||
syncOverlayWindow();
|
||||
publishActivityState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (game && /^(sanctum|stoat|electron)$/i.test(game.processName)) {
|
||||
game = null;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
game,
|
||||
};
|
||||
|
||||
ensureOverlayWindow();
|
||||
syncOverlayWindow();
|
||||
publishActivityState();
|
||||
}
|
||||
|
||||
export function setVoiceOverlayState(voice: VoiceOverlayState | null) {
|
||||
if (!config.gamePresenceEnabled) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
voice,
|
||||
};
|
||||
publishActivityState();
|
||||
return;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
voice,
|
||||
};
|
||||
|
||||
ensureOverlayWindow();
|
||||
syncOverlayWindow();
|
||||
publishActivityState();
|
||||
}
|
||||
|
||||
export function debugSetActivityState(state: OverlayState) {
|
||||
currentState = {
|
||||
game: state.game || null,
|
||||
voice: state.voice || null,
|
||||
};
|
||||
|
||||
ensureOverlayWindow();
|
||||
syncOverlayWindow();
|
||||
publishActivityState();
|
||||
}
|
||||
|
||||
ipcMain.on("overlay:set-voice-state", (_event, state: VoiceOverlayState | null) => {
|
||||
setVoiceOverlayState(state);
|
||||
});
|
||||
|
||||
ipcMain.handle("sanctum-activity:get-state", () => currentState);
|
||||
ipcMain.handle("sanctum-activity:debug-set-state", (_event, state: OverlayState) => {
|
||||
debugSetActivityState(state);
|
||||
return currentState;
|
||||
});
|
||||
|
||||
export function getCurrentGamePresence() {
|
||||
return currentState.game;
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { config } from "./config";
|
||||
import { matchesKnownGame } from "./gameCatalog";
|
||||
import { getCurrentGamePresence, setGamePresence } from "./gameOverlay";
|
||||
|
||||
type Candidate = {
|
||||
title: string;
|
||||
processName: string;
|
||||
source: string;
|
||||
commandLine?: string;
|
||||
};
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let monitorTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
/^sanctum$/i,
|
||||
/^cloud\.mithraic\.sanctum$/i,
|
||||
/^mithral$/i,
|
||||
/^stoat$/i,
|
||||
/^electron$/i,
|
||||
/^chrome$/i,
|
||||
/^google chrome$/i,
|
||||
/^msedge$/i,
|
||||
/^microsoft edge$/i,
|
||||
/^firefox$/i,
|
||||
/^brave$/i,
|
||||
/^brave browser$/i,
|
||||
/^vivaldi$/i,
|
||||
/^opera$/i,
|
||||
/^opera gx$/i,
|
||||
/^arc$/i,
|
||||
/^safari$/i,
|
||||
/^finder$/i,
|
||||
/^launchpad$/i,
|
||||
/^terminal$/i,
|
||||
/^iterm2$/i,
|
||||
/^steam$/i,
|
||||
/^steamwebhelper$/i,
|
||||
/^discord$/i,
|
||||
/^slack$/i,
|
||||
/^teams$/i,
|
||||
/^zoom$/i,
|
||||
/^notion$/i,
|
||||
/^obsidian$/i,
|
||||
/^spotify$/i,
|
||||
/^telegram$/i,
|
||||
/^whatsapp$/i,
|
||||
/^code$/i,
|
||||
/^visual studio code$/i,
|
||||
/^node$/i,
|
||||
/^explorer$/i,
|
||||
/^file explorer$/i,
|
||||
/^system$/i,
|
||||
/^systemsettings$/i,
|
||||
/^settings$/i,
|
||||
/^textedit$/i,
|
||||
/^notes$/i,
|
||||
/^preview$/i,
|
||||
/^activity monitor$/i,
|
||||
/^app store$/i,
|
||||
/^messages$/i,
|
||||
/^mail$/i,
|
||||
/^outlook$/i,
|
||||
/^word$/i,
|
||||
/^excel$/i,
|
||||
/^powerpoint$/i,
|
||||
/^python$/i,
|
||||
/^bash$/i,
|
||||
/^zsh$/i,
|
||||
/^sh$/i,
|
||||
/^ps$/i,
|
||||
/^tasklist$/i,
|
||||
/^powershell$/i,
|
||||
/^pwsh$/i,
|
||||
/^xprop$/i,
|
||||
/^xdotool$/i,
|
||||
/^osascript$/i,
|
||||
];
|
||||
|
||||
const SELF_PATTERNS = [
|
||||
/sanctum/i,
|
||||
/stoat/i,
|
||||
/cloud\.mithraic\.sanctum/i,
|
||||
/mithraic\.space/i,
|
||||
/stoat\.chat/i,
|
||||
/electron-forge/i,
|
||||
/\/home\/[^/]+\/sanctum/i,
|
||||
/[A-Z]:\\.*\\sanctum/i,
|
||||
];
|
||||
|
||||
export function startGamePresenceMonitor() {
|
||||
if (monitorTimer) return;
|
||||
void refreshGamePresence();
|
||||
monitorTimer = setInterval(() => {
|
||||
void refreshGamePresence();
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function refreshGamePresence() {
|
||||
if (!config.gamePresenceEnabled) {
|
||||
if (getCurrentGamePresence()) {
|
||||
setGamePresence(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = await detectGameCandidate();
|
||||
const current = getCurrentGamePresence();
|
||||
const knownMatch = next ? matchesKnownGame(next, config.gamePresenceAllowList) : false;
|
||||
const accepted = next && !isSelfAppCandidate(next) && knownMatch ? next : null;
|
||||
|
||||
const same =
|
||||
(!!current &&
|
||||
!!accepted &&
|
||||
current.processName === accepted.processName &&
|
||||
current.title === accepted.title) ||
|
||||
(!current && !accepted);
|
||||
|
||||
if (same) return;
|
||||
|
||||
if (accepted) {
|
||||
console.info("[gamePresence] detected", accepted.processName, accepted.title, accepted.source);
|
||||
} else if (current) {
|
||||
console.info("[gamePresence] cleared");
|
||||
}
|
||||
|
||||
setGamePresence(accepted);
|
||||
}
|
||||
|
||||
async function detectGameCandidate(): Promise<Candidate | null> {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
return await detectWindowsGame();
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return await detectMacGame();
|
||||
}
|
||||
|
||||
return await detectUnixGame();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectWindowsGame(): Promise<Candidate | null> {
|
||||
const script = [
|
||||
"$sig=@'",
|
||||
"using System;",
|
||||
"using System.Text;",
|
||||
"using System.Runtime.InteropServices;",
|
||||
"public static class Win32 {",
|
||||
" [DllImport(\"user32.dll\")] public static extern IntPtr GetForegroundWindow();",
|
||||
" [DllImport(\"user32.dll\", SetLastError=true)] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);",
|
||||
" [DllImport(\"user32.dll\", CharSet=CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);",
|
||||
"}",
|
||||
"'@;",
|
||||
"Add-Type $sig | Out-Null;",
|
||||
"$h=[Win32]::GetForegroundWindow();",
|
||||
"$pid=0;",
|
||||
"[void][Win32]::GetWindowThreadProcessId($h,[ref]$pid);",
|
||||
"$p=Get-Process -Id $pid -ErrorAction SilentlyContinue;",
|
||||
"$sb=New-Object System.Text.StringBuilder 512;",
|
||||
"[void][Win32]::GetWindowText($h,$sb,$sb.Capacity);",
|
||||
"if ($p) { Write-Output ($p.ProcessName + '|' + $sb.ToString()) }",
|
||||
].join(" ");
|
||||
|
||||
const { stdout } = await execFileAsync("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
]);
|
||||
|
||||
const parsed = parseCandidateLine(stdout.trim(), "foreground-window");
|
||||
return parsed && !isIgnoredCandidate(parsed.processName, parsed.title, parsed.commandLine || "")
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
async function detectMacGame(): Promise<Candidate | null> {
|
||||
const { stdout } = await execFileAsync("osascript", [
|
||||
"-e",
|
||||
'tell application "System Events" to get name of first process whose frontmost is true',
|
||||
]);
|
||||
|
||||
const processName = stdout.trim();
|
||||
if (!processName || isIgnoredCandidate(processName, processName, "")) return null;
|
||||
|
||||
return {
|
||||
processName,
|
||||
title: formatGameTitle(processName),
|
||||
source: "macOS frontmost app",
|
||||
};
|
||||
}
|
||||
|
||||
async function detectUnixGame(): Promise<Candidate | null> {
|
||||
const xpropCandidate = await detectLinuxX11Game();
|
||||
if (xpropCandidate) return xpropCandidate;
|
||||
|
||||
const { stdout } = await execFileAsync("sh", [
|
||||
"-lc",
|
||||
[
|
||||
"if command -v xdotool >/dev/null 2>&1; then",
|
||||
" title=$(xdotool getactivewindow getwindowname 2>/dev/null || true)",
|
||||
" pid=$(xdotool getactivewindow getwindowpid 2>/dev/null || true)",
|
||||
" if [ -n \"$pid\" ]; then",
|
||||
" name=$(ps -p \"$pid\" -o comm= 2>/dev/null | head -n 1 | tr -d '\\n')",
|
||||
" args=$(ps -p \"$pid\" -o args= 2>/dev/null | head -n 1 | tr -d '\\n')",
|
||||
" printf '%s|%s|%s\\n' \"$name\" \"$title\" \"$args\"",
|
||||
" exit 0",
|
||||
" fi",
|
||||
"fi",
|
||||
"exit 0",
|
||||
].join("\n"),
|
||||
]);
|
||||
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parsed = parseCandidateLine(trimmed, "foreground-window");
|
||||
if (parsed && !isIgnoredCandidate(parsed.processName, parsed.title, parsed.commandLine || "")) return parsed;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function detectLinuxX11Game(): Promise<Candidate | null> {
|
||||
try {
|
||||
const { stdout: activeWindow } = await execFileAsync("xprop", [
|
||||
"-root",
|
||||
"_NET_ACTIVE_WINDOW",
|
||||
]);
|
||||
|
||||
const match = activeWindow.match(/0x[0-9a-fA-F]+/);
|
||||
if (!match) return null;
|
||||
|
||||
const windowId = match[0];
|
||||
const { stdout } = await execFileAsync("xprop", [
|
||||
"-id",
|
||||
windowId,
|
||||
"WM_CLASS",
|
||||
"WM_NAME",
|
||||
"_NET_WM_NAME",
|
||||
]);
|
||||
|
||||
const parts = stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const className = extractQuotedValue(parts.find((line) => line.startsWith("WM_CLASS")) || "");
|
||||
const name =
|
||||
extractQuotedValue(parts.find((line) => line.startsWith("_NET_WM_NAME")) || "") ||
|
||||
extractQuotedValue(parts.find((line) => line.startsWith("WM_NAME")) || "");
|
||||
|
||||
const processName = (className || name || "unknown").trim();
|
||||
const title = (name || className || "").trim();
|
||||
|
||||
if (!processName) return null;
|
||||
if (isIgnoredCandidate(processName, title || processName, "")) return null;
|
||||
|
||||
return {
|
||||
processName,
|
||||
title: title || formatGameTitle(processName),
|
||||
source: "xprop foreground window",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractQuotedValue(line: string) {
|
||||
const quoted = line.match(/"([^"]+)"/g);
|
||||
if (!quoted || !quoted.length) return "";
|
||||
return quoted.map((value) => value.replace(/^"|"$/g, "")).join(" ");
|
||||
}
|
||||
|
||||
function parseCandidateLine(line: string, source: string): Candidate | null {
|
||||
if (!line) return null;
|
||||
|
||||
const [processNameRaw, titleRaw = "", commandLineRaw = ""] = line.split("|");
|
||||
const processName = (processNameRaw || "").trim();
|
||||
const title =
|
||||
source === "process scan"
|
||||
? formatGameTitle(processName)
|
||||
: (titleRaw || "").trim() || formatGameTitle(processName);
|
||||
const commandLine = (commandLineRaw || "").trim();
|
||||
|
||||
if (!processName) return null;
|
||||
|
||||
return {
|
||||
title,
|
||||
processName,
|
||||
source,
|
||||
commandLine: commandLine || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isIgnoredCandidate(processName: string, title: string, commandLine = "") {
|
||||
if (isSelfString(processName) || isSelfString(title) || isSelfString(commandLine)) return true;
|
||||
if (IGNORE_PATTERNS.some((pattern) => pattern.test(processName))) return true;
|
||||
if (IGNORE_PATTERNS.some((pattern) => pattern.test(title))) return true;
|
||||
if (commandLine && IGNORE_PATTERNS.some((pattern) => pattern.test(commandLine))) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSelfAppCandidate(candidate: Candidate) {
|
||||
return isSelfString(candidate.processName) || isSelfString(candidate.title) || isSelfString(candidate.commandLine || "");
|
||||
}
|
||||
|
||||
function isSelfString(value: string) {
|
||||
const normalized = String(value || "");
|
||||
return SELF_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function formatGameTitle(raw: string) {
|
||||
const cleaned = raw
|
||||
.replace(/\.(exe|app|bat|sh)$/i, "")
|
||||
.replace(/[_.-]+/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.trim();
|
||||
|
||||
if (!cleaned) return raw || "Unknown Game";
|
||||
|
||||
return cleaned
|
||||
.split(/\s+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
|
@ -11,22 +11,6 @@ contextBridge.exposeInMainWorld("native", {
|
|||
aviaClient: () => aviaVersion,
|
||||
},
|
||||
|
||||
overlay: {
|
||||
setVoiceState: (state: VoiceOverlayState | null) =>
|
||||
ipcRenderer.send("overlay:set-voice-state", state),
|
||||
},
|
||||
|
||||
activity: {
|
||||
getState: () => ipcRenderer.invoke("sanctum-activity:get-state"),
|
||||
onUpdate: (callback: (state: SanctumActivityState) => void) => {
|
||||
const listener = (_event: unknown, state: SanctumActivityState) => callback(state);
|
||||
ipcRenderer.on("sanctum-activity:update", listener);
|
||||
return () => ipcRenderer.removeListener("sanctum-activity:update", listener);
|
||||
},
|
||||
debugSetState: (state: SanctumActivityState) =>
|
||||
ipcRenderer.invoke("sanctum-activity:debug-set-state", state) as Promise<SanctumActivityState>,
|
||||
},
|
||||
|
||||
minimise: () => ipcRenderer.send("minimise"),
|
||||
maximise: () => ipcRenderer.send("maximise"),
|
||||
close: () => ipcRenderer.send("close"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue