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