Compare commits
No commits in common. "main" and "1.6.0" have entirely different histories.
35 changed files with 205 additions and 2694 deletions
88
.github/workflows/build.yml
vendored
88
.github/workflows/build.yml
vendored
|
|
@ -1,20 +1,13 @@
|
|||
name: Build and Release Sanctum
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build App
|
||||
runs-on: docker
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -23,80 +16,13 @@ jobs:
|
|||
- name: Checkout assets
|
||||
run: git -c submodule."assets".update=checkout submodule update --init assets
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||
with:
|
||||
node-version: 20
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm@10.18.1
|
||||
|
||||
- name: Install System Dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y zip jq curl
|
||||
|
||||
- name: Install Project Dependencies
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build Linux & Windows
|
||||
# We run both; if one fails, the whole job stops before reaching release logic
|
||||
run: |
|
||||
pnpm exec electron-forge make --platform linux --arch x64 --targets @electron-forge/maker-zip
|
||||
pnpm exec electron-forge make --platform win32 --arch x64 --targets @electron-forge/maker-zip
|
||||
|
||||
- name: Create or Update Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
BASE_URL: "https://git.mithraic.cloud/api/v1/repos/${{ github.repository }}"
|
||||
run: |
|
||||
echo "Processing release for $TAG..."
|
||||
|
||||
# 1. Try to get existing release ID
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$BASE_URL/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
|
||||
# 2. Create release if it doesn't exist
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Creating new release..."
|
||||
RELEASE_ID=$(curl -s -X POST "$BASE_URL/releases" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"Sanctum $TAG\",\"draft\":false,\"prerelease\":false}" | jq -r '.id')
|
||||
fi
|
||||
|
||||
if [ "$RELEASE_ID" == "null" ] || [ -z "$RELEASE_ID" ]; then
|
||||
echo "::error::Could not determine Release ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release ID: $RELEASE_ID"
|
||||
|
||||
# 3. Upload loop with conflict handling
|
||||
find out/make -type f -name "*.zip" | while read FILE; do
|
||||
NAME=$(basename "$FILE")
|
||||
echo "Targeting asset: $NAME"
|
||||
|
||||
# Check for existing asset with same name
|
||||
EXISTING_ASSET_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$BASE_URL/releases/$RELEASE_ID/assets" | \
|
||||
jq -r ".[] | select(.name==\"$NAME\") | .id // empty")
|
||||
|
||||
if [ ! -z "$EXISTING_ASSET_ID" ]; then
|
||||
echo "Asset already exists (ID: $EXISTING_ASSET_ID). Deleting to avoid conflict..."
|
||||
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID"
|
||||
fi
|
||||
|
||||
echo "Uploading $NAME..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-F "attachment=@$FILE")
|
||||
|
||||
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
|
||||
echo "✅ Successfully uploaded $NAME"
|
||||
else
|
||||
echo "❌ Failed to upload $NAME (HTTP $HTTP_CODE)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Build
|
||||
run: pnpm run package
|
||||
|
|
|
|||
2
.github/workflows/release-webhook.yml
vendored
2
.github/workflows/release-webhook.yml
vendored
|
|
@ -11,7 +11,7 @@ on:
|
|||
jobs:
|
||||
release-webhook:
|
||||
name: Send Release Webhook
|
||||
runs-on: docker
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Send release notification webhook
|
||||
|
|
|
|||
2
.github/workflows/validate-pr-title.yml
vendored
2
.github/workflows/validate-pr-title.yml
vendored
|
|
@ -14,7 +14,7 @@ on:
|
|||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: docker
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
|
|
|
|||
12
GEMINI.md
12
GEMINI.md
|
|
@ -1,12 +0,0 @@
|
|||
# Agent Mandates
|
||||
|
||||
## Versioning and Release Workflow
|
||||
Before every `git push` that includes code changes, you MUST perform the following steps:
|
||||
|
||||
1. **Bump Version:** Increment the version in `package.json` (both `version` and `aviaVersion`).
|
||||
2. **Update Branding:** If a version string is hardcoded in UI plugins, update it to match the new version.
|
||||
3. **Migration Logic:** Update any migration logic in plugins to ensure users on the previous version are automatically updated to the new default.
|
||||
4. **Tagging:** Create the git tag corresponding to the new version with a 'v' prefix (e.g., `git tag v1.0.x`).
|
||||
5. **Push:** Push both the branch and the tags to the remote repository (`git push origin main --tags`).
|
||||
|
||||
This ensures the internal app state matches the release tag and prevents auto-updater loops.
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
Avia Client for Desktop
|
||||
"stoat desktop"
|
||||
</h1>
|
||||
<img width="256" height="256" alt="aurora" src="https://github.com/user-attachments/assets/dc3adfa3-ce3b-41ef-bdfd-9ca66d333e24" /><br />
|
||||
Application for Windows, macOS, and Linux. now with avia client injected
|
||||
</div>
|
||||
<br/>
|
||||
|
|
@ -71,5 +70,3 @@ pnpm run:nix --force-server=http://localhost:5173
|
|||
# a better solution would be telling
|
||||
# Electron Forge where system Electron is
|
||||
```
|
||||
|
||||
`VCSounds.js` ships as a built-in local plugin now, so it is seeded automatically on launch and cannot be accidentally removed from the release install.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 4.6 MiB |
|
|
@ -3,63 +3,15 @@
|
|||
if (window.__AVIA_LOCAL_PLUGINS_LOADED__) return;
|
||||
window.__AVIA_LOCAL_PLUGINS_LOADED__ = true;
|
||||
|
||||
const STORAGE_KEY = "avia_local_plugins";
|
||||
const BUILTIN_SEED = Array.isArray(window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__)
|
||||
? window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__
|
||||
: [];
|
||||
|
||||
const runningLocalPlugins = {};
|
||||
const localPluginErrors = {};
|
||||
const STORAGE_KEY = "avia_local_plugins";
|
||||
|
||||
const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
|
||||
function upsertBuiltinLocalPlugins() {
|
||||
if (!BUILTIN_SEED.length) return;
|
||||
|
||||
const plugins = getLocalPlugins();
|
||||
let dirty = false;
|
||||
|
||||
for (const builtin of BUILTIN_SEED) {
|
||||
const next = {
|
||||
id: builtin.id,
|
||||
name: builtin.name,
|
||||
code: builtin.code || "",
|
||||
enabled: true,
|
||||
locked: true,
|
||||
builtin: true,
|
||||
};
|
||||
|
||||
const existingIndex = plugins.findIndex((plugin) =>
|
||||
plugin.id === next.id || plugin.name === next.name
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const current = plugins[existingIndex];
|
||||
const merged = {
|
||||
...current,
|
||||
...next,
|
||||
enabled: true,
|
||||
locked: true,
|
||||
builtin: true,
|
||||
};
|
||||
|
||||
if (
|
||||
JSON.stringify(current) !== JSON.stringify(merged)
|
||||
) {
|
||||
plugins[existingIndex] = merged;
|
||||
dirty = true;
|
||||
}
|
||||
} else {
|
||||
plugins.push(next);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) setLocalPlugins(plugins);
|
||||
}
|
||||
|
||||
function preloadMonaco() {
|
||||
const runningLocalPlugins = {};
|
||||
const localPluginErrors = {};
|
||||
|
||||
const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
|
||||
function preloadMonaco() {
|
||||
return new Promise(resolve => {
|
||||
if (window.monaco) return resolve();
|
||||
const loader = document.createElement("script");
|
||||
|
|
@ -469,11 +421,11 @@
|
|||
renderLocalPanel();
|
||||
}
|
||||
|
||||
function renderLocalPanel() {
|
||||
const content = document.getElementById("avia-local-plugins-content");
|
||||
if (!content) return;
|
||||
content.innerHTML = "";
|
||||
const plugins = getLocalPlugins();
|
||||
function renderLocalPanel() {
|
||||
const content = document.getElementById("avia-local-plugins-content");
|
||||
if (!content) return;
|
||||
content.innerHTML = "";
|
||||
const plugins = getLocalPlugins();
|
||||
|
||||
if (plugins.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
|
|
@ -484,10 +436,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
plugins.forEach((plugin, index) => {
|
||||
const isRunning = !!runningLocalPlugins[plugin.id];
|
||||
const hasError = !!localPluginErrors[plugin.id];
|
||||
const isBuiltin = !!plugin.locked || !!plugin.builtin;
|
||||
plugins.forEach((plugin, index) => {
|
||||
const isRunning = !!runningLocalPlugins[plugin.id];
|
||||
const hasError = !!localPluginErrors[plugin.id];
|
||||
|
||||
const row = document.createElement("div");
|
||||
Object.assign(row.style, {
|
||||
|
|
@ -516,93 +467,74 @@
|
|||
statusDot.style.background = "#777";
|
||||
}
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.textContent = plugin.name;
|
||||
name.style.fontSize = "13px";
|
||||
|
||||
left.appendChild(statusDot);
|
||||
left.appendChild(name);
|
||||
|
||||
if (isBuiltin) {
|
||||
const badge = document.createElement("div");
|
||||
badge.textContent = "Built-in";
|
||||
Object.assign(badge.style, {
|
||||
fontSize: "10px",
|
||||
padding: "2px 7px",
|
||||
borderRadius: "999px",
|
||||
background: "rgba(120,170,255,0.16)",
|
||||
color: "#a9c4ff",
|
||||
border: "1px solid rgba(120,170,255,0.28)",
|
||||
marginLeft: "2px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
});
|
||||
left.appendChild(badge);
|
||||
}
|
||||
|
||||
const controls = document.createElement("div");
|
||||
Object.assign(controls.style, { display: "flex", gap: "6px" });
|
||||
|
||||
if (!isBuiltin) {
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.textContent = "✏ Edit";
|
||||
styleLocalBtn(editBtn, "rgba(100,140,255,0.2)");
|
||||
editBtn.onclick = () => {
|
||||
openEditorPanel(plugin, (newCode, andRun) => {
|
||||
const all = getLocalPlugins();
|
||||
const target = all.find(p => p.id === plugin.id);
|
||||
if (target) {
|
||||
target.code = newCode;
|
||||
plugin.code = newCode;
|
||||
setLocalPlugins(all);
|
||||
}
|
||||
if (andRun) {
|
||||
plugin.enabled = true;
|
||||
if (target) target.enabled = true;
|
||||
setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p));
|
||||
runLocalPlugin(plugin);
|
||||
}
|
||||
renderLocalPanel();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable";
|
||||
styleLocalBtn(toggleBtn);
|
||||
toggleBtn.onclick = () => {
|
||||
const all = getLocalPlugins();
|
||||
const target = all.find(p => p.id === plugin.id);
|
||||
if (!target) return;
|
||||
target.enabled = !target.enabled;
|
||||
plugin.enabled = target.enabled;
|
||||
setLocalPlugins(all);
|
||||
if (target.enabled) runLocalPlugin(plugin);
|
||||
else stopLocalPlugin(plugin);
|
||||
renderLocalPanel();
|
||||
};
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.textContent = "✕";
|
||||
styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)");
|
||||
removeBtn.onclick = () => {
|
||||
stopLocalPlugin(plugin);
|
||||
const editorPanel = document.getElementById("avia-local-editor-panel");
|
||||
if (editorPanel) editorPanel.remove();
|
||||
const all = getLocalPlugins();
|
||||
all.splice(all.findIndex(p => p.id === plugin.id), 1);
|
||||
setLocalPlugins(all);
|
||||
renderLocalPanel();
|
||||
};
|
||||
|
||||
controls.appendChild(editBtn);
|
||||
controls.appendChild(toggleBtn);
|
||||
controls.appendChild(removeBtn);
|
||||
}
|
||||
row.appendChild(left);
|
||||
row.appendChild(controls);
|
||||
content.appendChild(row);
|
||||
});
|
||||
}
|
||||
const name = document.createElement("div");
|
||||
name.textContent = plugin.name;
|
||||
name.style.fontSize = "13px";
|
||||
|
||||
left.appendChild(statusDot);
|
||||
left.appendChild(name);
|
||||
|
||||
const controls = document.createElement("div");
|
||||
Object.assign(controls.style, { display: "flex", gap: "6px" });
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.textContent = "✏ Edit";
|
||||
styleLocalBtn(editBtn, "rgba(100,140,255,0.2)");
|
||||
editBtn.onclick = () => {
|
||||
openEditorPanel(plugin, (newCode, andRun) => {
|
||||
const all = getLocalPlugins();
|
||||
const target = all.find(p => p.id === plugin.id);
|
||||
if (target) {
|
||||
target.code = newCode;
|
||||
plugin.code = newCode;
|
||||
setLocalPlugins(all);
|
||||
}
|
||||
if (andRun) {
|
||||
plugin.enabled = true;
|
||||
if (target) target.enabled = true;
|
||||
setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p));
|
||||
runLocalPlugin(plugin);
|
||||
}
|
||||
renderLocalPanel();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable";
|
||||
styleLocalBtn(toggleBtn);
|
||||
toggleBtn.onclick = () => {
|
||||
const all = getLocalPlugins();
|
||||
const target = all.find(p => p.id === plugin.id);
|
||||
if (!target) return;
|
||||
target.enabled = !target.enabled;
|
||||
plugin.enabled = target.enabled;
|
||||
setLocalPlugins(all);
|
||||
if (target.enabled) runLocalPlugin(plugin);
|
||||
else stopLocalPlugin(plugin);
|
||||
renderLocalPanel();
|
||||
};
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.textContent = "✕";
|
||||
styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)");
|
||||
removeBtn.onclick = () => {
|
||||
stopLocalPlugin(plugin);
|
||||
const editorPanel = document.getElementById("avia-local-editor-panel");
|
||||
if (editorPanel) editorPanel.remove();
|
||||
const all = getLocalPlugins();
|
||||
all.splice(all.findIndex(p => p.id === plugin.id), 1);
|
||||
setLocalPlugins(all);
|
||||
renderLocalPanel();
|
||||
};
|
||||
|
||||
controls.appendChild(editBtn);
|
||||
controls.appendChild(toggleBtn);
|
||||
controls.appendChild(removeBtn);
|
||||
row.appendChild(left);
|
||||
row.appendChild(controls);
|
||||
content.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function styleLocalInput(input) {
|
||||
Object.assign(input.style, {
|
||||
|
|
@ -644,7 +576,7 @@
|
|||
|
||||
const textNode = [...localBtn.querySelectorAll("div")]
|
||||
.find(d => d.children.length === 0 && d.textContent.trim() === "Appearance");
|
||||
if (textNode) textNode.textContent = "(Sanctum) Local Plugins";
|
||||
if (textNode) textNode.textContent = "(Avia) Local Plugins";
|
||||
|
||||
const oldSvg = localBtn.querySelector("svg");
|
||||
if (oldSvg) oldSvg.remove();
|
||||
|
|
@ -672,16 +604,15 @@
|
|||
}).observe(document.documentElement, { childList: true });
|
||||
}
|
||||
|
||||
waitForBody(() => {
|
||||
const observer = new MutationObserver(() => injectLocalButton());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
injectLocalButton();
|
||||
});
|
||||
|
||||
upsertBuiltinLocalPlugins();
|
||||
getLocalPlugins().forEach(plugin => {
|
||||
if (plugin.enabled) runLocalPlugin(plugin);
|
||||
});
|
||||
waitForBody(() => {
|
||||
const observer = new MutationObserver(() => injectLocalButton());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
injectLocalButton();
|
||||
});
|
||||
|
||||
getLocalPlugins().forEach(plugin => {
|
||||
if (plugin.enabled) runLocalPlugin(plugin);
|
||||
});
|
||||
|
||||
preloadMonaco();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,605 +0,0 @@
|
|||
(function () {
|
||||
if (window.__VC_SOUNDS__) return;
|
||||
window.__VC_SOUNDS__ = true;
|
||||
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
document.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
if (ctx.state === "suspended") ctx.resume();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
function playNote(freq, startTime, duration, volume) {
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.type = "sine";
|
||||
osc1.frequency.value = freq;
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(ctx.destination);
|
||||
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.type = "triangle";
|
||||
osc2.frequency.value = freq / 2;
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(ctx.destination);
|
||||
|
||||
gain1.gain.setValueAtTime(0, startTime);
|
||||
gain1.gain.linearRampToValueAtTime(volume, startTime + 0.03);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||
|
||||
gain2.gain.setValueAtTime(0, startTime);
|
||||
gain2.gain.linearRampToValueAtTime(volume * 0.4, startTime + 0.03);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + duration + 0.05);
|
||||
osc2.start(startTime);
|
||||
osc2.stop(startTime + duration + 0.05);
|
||||
}
|
||||
|
||||
function playJoin() {
|
||||
if (ctx.state === "suspended") ctx.resume();
|
||||
const t = ctx.currentTime + 0.01;
|
||||
playNote(294, t, 0.35, 0.14);
|
||||
playNote(370, t + 0.28, 0.45, 0.11);
|
||||
}
|
||||
|
||||
function playLeave() {
|
||||
if (ctx.state === "suspended") ctx.resume();
|
||||
const t = ctx.currentTime + 0.01;
|
||||
playNote(370, t, 0.35, 0.14);
|
||||
playNote(294, t + 0.28, 0.45, 0.11);
|
||||
}
|
||||
|
||||
let inVoice = false;
|
||||
let initialising = false;
|
||||
let initTimer = null;
|
||||
let globalObserver = null;
|
||||
let refreshTimer = null;
|
||||
let lastVoiceState = null;
|
||||
let lastMemberIdentityKey = "";
|
||||
let leaveWatchdog = null;
|
||||
const recentRowActivity = new Map();
|
||||
|
||||
function onSelfJoined() {
|
||||
if (inVoice) return;
|
||||
inVoice = true;
|
||||
initialising = true;
|
||||
playJoin();
|
||||
console.debug("[VCSounds] self joined");
|
||||
clearTimeout(initTimer);
|
||||
initTimer = setTimeout(() => {
|
||||
initialising = false;
|
||||
}, 1500);
|
||||
clearTimeout(leaveWatchdog);
|
||||
leaveWatchdog = null;
|
||||
publishVoiceState();
|
||||
}
|
||||
|
||||
function onSelfLeft() {
|
||||
if (!inVoice) return;
|
||||
inVoice = false;
|
||||
initialising = false;
|
||||
clearTimeout(initTimer);
|
||||
clearTimeout(leaveWatchdog);
|
||||
leaveWatchdog = null;
|
||||
recentRowActivity.clear();
|
||||
lastVoiceState = null;
|
||||
lastMemberIdentityKey = "";
|
||||
playLeave();
|
||||
console.debug("[VCSounds] self left");
|
||||
const overlayApi = window.native?.overlay;
|
||||
if (overlayApi && typeof overlayApi.setVoiceState === "function") {
|
||||
overlayApi.setVoiceState(null);
|
||||
}
|
||||
}
|
||||
|
||||
function isParticipantEntry(el) {
|
||||
if (el.nodeType !== 1) return false;
|
||||
const c = String(el.className || "");
|
||||
if (!el.isConnected) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 24 || rect.height < 24) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity || "1") <= 0.05) return false;
|
||||
return (
|
||||
c.includes("p_var(--gap-sm)") &&
|
||||
c.includes("pos_relative") &&
|
||||
c.includes("d_flex") &&
|
||||
c.includes("ai_center")
|
||||
);
|
||||
}
|
||||
|
||||
function findParticipantEntry(node) {
|
||||
let current = node && node.nodeType === 1 ? node : node?.parentElement || null;
|
||||
while (current) {
|
||||
if (isParticipantEntry(current)) return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function markRowActivity(node) {
|
||||
const entry = findParticipantEntry(node);
|
||||
if (!entry) return;
|
||||
recentRowActivity.set(entry, Date.now());
|
||||
}
|
||||
|
||||
function pruneRowActivity() {
|
||||
const cutoff = Date.now() - 15000;
|
||||
for (const [entry, at] of recentRowActivity.entries()) {
|
||||
if (typeof at !== "number" || at < cutoff || !entry.isConnected) {
|
||||
recentRowActivity.delete(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pseudoStyleIsVisible(style) {
|
||||
if (!style) return false;
|
||||
const content = String(style.content || "").toLowerCase();
|
||||
if (content === "none") return false;
|
||||
return (
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
parseFloat(style.opacity || "1") > 0.05
|
||||
);
|
||||
}
|
||||
|
||||
function pseudoLooksActive(style) {
|
||||
if (!pseudoStyleIsVisible(style)) return false;
|
||||
|
||||
const boxShadow = String(style.boxShadow || "").toLowerCase();
|
||||
const outlineWidth = parseFloat(style.outlineWidth || "0");
|
||||
const borderWidth = parseFloat(style.borderWidth || "0");
|
||||
const filter = String(style.filter || "").toLowerCase();
|
||||
const transform = String(style.transform || "").toLowerCase();
|
||||
const background = String(style.backgroundColor || "").toLowerCase();
|
||||
const borderColor = String(style.borderColor || "").toLowerCase();
|
||||
const borderRadius = String(style.borderRadius || "").toLowerCase();
|
||||
const size = Math.max(parseFloat(style.width || "0"), parseFloat(style.height || "0"));
|
||||
|
||||
const ringish =
|
||||
boxShadow !== "none" ||
|
||||
outlineWidth > 0 ||
|
||||
borderWidth > 0 ||
|
||||
filter !== "none" ||
|
||||
transform !== "none";
|
||||
const accentish =
|
||||
background.includes("rgba") ||
|
||||
background.includes("rgb(") ||
|
||||
borderColor.includes("rgba") ||
|
||||
borderColor.includes("rgb(");
|
||||
const circular = borderRadius.includes("50%") || borderRadius.includes("999");
|
||||
|
||||
return Boolean(size <= 80 && circular && (ringish || accentish));
|
||||
}
|
||||
|
||||
function looksLikeVoiceActivityIndicator(node) {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.width < 8 || rect.height < 8) return false;
|
||||
|
||||
const style = window.getComputedStyle(node);
|
||||
if (!style || style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity || "1") <= 0.05) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const size = Math.min(rect.width, rect.height);
|
||||
const borderRadius = String(style.borderRadius || "").toLowerCase();
|
||||
const boxShadow = String(style.boxShadow || "").toLowerCase();
|
||||
const outlineWidth = parseFloat(style.outlineWidth || "0");
|
||||
const borderWidth = parseFloat(style.borderWidth || "0");
|
||||
const filter = String(style.filter || "").toLowerCase();
|
||||
const transform = String(style.transform || "").toLowerCase();
|
||||
const animation = String(style.animationName || "").toLowerCase();
|
||||
const transition = String(style.transitionProperty || "").toLowerCase();
|
||||
const background = String(style.backgroundColor || "").toLowerCase();
|
||||
const borderColor = String(style.borderColor || "").toLowerCase();
|
||||
|
||||
const circular = borderRadius.includes("50%") || borderRadius.includes("999");
|
||||
const ringish =
|
||||
boxShadow !== "none" ||
|
||||
outlineWidth > 0 ||
|
||||
borderWidth > 0 ||
|
||||
filter !== "none" ||
|
||||
transform !== "none" ||
|
||||
animation !== "none" ||
|
||||
transition !== "none";
|
||||
const accentish =
|
||||
background.includes("rgba") ||
|
||||
background.includes("rgb(") ||
|
||||
borderColor.includes("rgba") ||
|
||||
borderColor.includes("rgb(");
|
||||
|
||||
if (size <= 80 && circular && (ringish || accentish)) return true;
|
||||
|
||||
const before = window.getComputedStyle(node, "::before");
|
||||
const after = window.getComputedStyle(node, "::after");
|
||||
if (pseudoLooksActive(before) || pseudoLooksActive(after)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function startObserver() {
|
||||
if (globalObserver) return;
|
||||
globalObserver = new MutationObserver((mutations) => {
|
||||
if (!inVoice || initialising) return;
|
||||
for (const m of mutations) {
|
||||
markRowActivity(m.target);
|
||||
for (const node of m.addedNodes) {
|
||||
markRowActivity(node);
|
||||
if (isParticipantEntry(node)) {
|
||||
console.debug("[VCSounds] participant joined");
|
||||
playJoin();
|
||||
}
|
||||
}
|
||||
for (const node of m.removedNodes) {
|
||||
markRowActivity(node);
|
||||
if (isParticipantEntry(node)) {
|
||||
console.debug("[VCSounds] participant left");
|
||||
playLeave();
|
||||
}
|
||||
}
|
||||
}
|
||||
publishVoiceState();
|
||||
});
|
||||
globalObserver.observe(document.body, { childList: true, subtree: true });
|
||||
globalObserver.observe(document.body, { attributes: true, subtree: true, attributeFilter: ["class", "aria-label", "style", "data-speaking", "data-active", "data-state", "title"] });
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = setInterval(() => {
|
||||
if (inVoice && !initialising) publishVoiceState();
|
||||
}, 700);
|
||||
}
|
||||
}
|
||||
|
||||
function getParticipantName(entry) {
|
||||
const text = (entry.textContent || "").replace(/\s+/g, " ").trim();
|
||||
if (!text) return "Unknown";
|
||||
return text.length > 40 ? text.slice(0, 40) : text;
|
||||
}
|
||||
|
||||
function isSpeakingEntry(entry) {
|
||||
const recentActivityAt = recentRowActivity.get(entry);
|
||||
const recentActivity = typeof recentActivityAt === "number" && Date.now() - recentActivityAt < 1200;
|
||||
const nodes = [entry, ...Array.from(entry.querySelectorAll("*"))];
|
||||
for (const node of nodes) {
|
||||
const className = String(node.className || "").toLowerCase();
|
||||
const label = String(node.getAttribute?.("aria-label") || "").toLowerCase();
|
||||
const title = String(node.getAttribute?.("title") || "").toLowerCase();
|
||||
const text = String(node.textContent || "").toLowerCase();
|
||||
const state = String(node.getAttribute?.("data-state") || "").toLowerCase();
|
||||
const active = String(node.getAttribute?.("data-active") || "").toLowerCase();
|
||||
const speaking = String(node.getAttribute?.("data-speaking") || "").toLowerCase();
|
||||
const style = String(node.getAttribute?.("style") || "").toLowerCase();
|
||||
const attrs = typeof node.getAttributeNames === "function" ? node.getAttributeNames().map((name) => name.toLowerCase()) : [];
|
||||
|
||||
if (
|
||||
className.includes("speaking") ||
|
||||
className.includes("voice-activity") ||
|
||||
className.includes("active-speaker") ||
|
||||
className.includes("active") ||
|
||||
label.includes("speaking") ||
|
||||
label.includes("active") ||
|
||||
label.includes("voice activity") ||
|
||||
title.includes("speaking") ||
|
||||
title.includes("active") ||
|
||||
text.includes("speaking") ||
|
||||
text.includes("voice activity") ||
|
||||
state === "speaking" ||
|
||||
active === "true" ||
|
||||
speaking === "true" ||
|
||||
style.includes("speaking") ||
|
||||
attrs.includes("data-speaking") ||
|
||||
attrs.includes("data-active") ||
|
||||
attrs.includes("data-state")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (recentActivity && nodes.some(looksLikeVoiceActivityIndicator)) return true;
|
||||
if (nodes.some(looksLikeVoiceActivityIndicator)) return true;
|
||||
|
||||
return !!entry.querySelector(
|
||||
"[data-speaking='true'], [data-active='true'], [data-state='speaking'], [aria-label*='speaking'], [aria-label*='voice activity'], [title*='speaking'], [class*='speaking'], [class*='active-speaker'], [class*='voice-activity'], [class*='active']",
|
||||
);
|
||||
}
|
||||
|
||||
function extractAvatarUrl(entry) {
|
||||
const candidates = Array.from(entry.querySelectorAll("img, source, [style*='background-image'], [style*='background']"));
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.tagName === "IMG") {
|
||||
const src = candidate.currentSrc || candidate.src || candidate.getAttribute("src") || "";
|
||||
if (src) return src;
|
||||
}
|
||||
|
||||
const style = String(candidate.getAttribute("style") || "");
|
||||
const match = style.match(/url\(["']?([^"')]+)["']?\)/i);
|
||||
if (match && match[1]) return match[1];
|
||||
}
|
||||
|
||||
const img = entry.querySelector("img");
|
||||
if (img) {
|
||||
const src = img.currentSrc || img.src || img.getAttribute("src") || "";
|
||||
if (src) return src;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeAvatarUrl(url) {
|
||||
return String(url || "")
|
||||
.replace(/\/original(?=$|[?#])/i, "")
|
||||
.replace(/[?#].*$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isAvatarLikeSrc(src) {
|
||||
const value = String(src || "").toLowerCase();
|
||||
return (
|
||||
value.includes("/avatars/") ||
|
||||
value.includes("/default_avatar") ||
|
||||
value.includes("/avatar") ||
|
||||
value.includes("/icons/") ||
|
||||
value.includes("avatar")
|
||||
);
|
||||
}
|
||||
|
||||
function isVisibleElement(el) {
|
||||
if (!el || el.nodeType !== 1) return false;
|
||||
if (!el.isConnected) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 8 || rect.height < 8) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (!style) return false;
|
||||
return style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity || "1") > 0.05;
|
||||
}
|
||||
|
||||
function findAvatarRoot(img) {
|
||||
let current = img && img.parentElement;
|
||||
let depth = 0;
|
||||
while (current && depth < 6) {
|
||||
if (!isVisibleElement(current)) {
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = current.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(current);
|
||||
const radius = String(style.borderRadius || "").toLowerCase();
|
||||
const clip = String(style.clipPath || "").toLowerCase();
|
||||
const circular = radius.includes("50%") || radius.includes("999") || clip.includes("circle");
|
||||
if (circular || (rect.width >= 24 && rect.height >= 24 && rect.width <= 240 && rect.height <= 240)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
return img?.parentElement || null;
|
||||
}
|
||||
|
||||
function collectSpeakingAvatarUrls() {
|
||||
const speaking = new Set();
|
||||
const ringTiles = Array.from(document.querySelectorAll("*")).filter((el) => {
|
||||
const cls = String(el.className || "");
|
||||
return (
|
||||
el.isConnected &&
|
||||
cls.includes("vc_tile") &&
|
||||
cls.includes("ring-c_var(--md-sys-color-primary)")
|
||||
);
|
||||
});
|
||||
|
||||
for (const tile of ringTiles) {
|
||||
const imageNodes = Array.from(tile.querySelectorAll("img")).filter((img) => isVisibleElement(img));
|
||||
for (const img of imageNodes) {
|
||||
const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || "");
|
||||
if (src && isAvatarLikeSrc(src)) {
|
||||
speaking.add(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const images = Array.from(document.querySelectorAll("img")).filter((img) => {
|
||||
if (!isVisibleElement(img)) return false;
|
||||
const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || "");
|
||||
return isAvatarLikeSrc(src);
|
||||
});
|
||||
|
||||
for (const img of images) {
|
||||
const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || "");
|
||||
const root = findAvatarRoot(img);
|
||||
if (!root) continue;
|
||||
|
||||
const nodes = [root, ...Array.from(root.querySelectorAll("*"))].slice(0, 40);
|
||||
const speaks = nodes.some(looksLikeVoiceActivityIndicator) || nodes.some(isSpeakingMarkerNode);
|
||||
if (speaks) speaking.add(src);
|
||||
}
|
||||
|
||||
return speaking;
|
||||
}
|
||||
|
||||
function isSpeakingMarkerNode(node) {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
const className = String(node.className || "").toLowerCase();
|
||||
const label = String(node.getAttribute?.("aria-label") || "").toLowerCase();
|
||||
const title = String(node.getAttribute?.("title") || "").toLowerCase();
|
||||
const text = String(node.textContent || "").toLowerCase();
|
||||
const state = String(node.getAttribute?.("data-state") || "").toLowerCase();
|
||||
const active = String(node.getAttribute?.("data-active") || "").toLowerCase();
|
||||
const speaking = String(node.getAttribute?.("data-speaking") || "").toLowerCase();
|
||||
|
||||
return Boolean(
|
||||
className.includes("speaking") ||
|
||||
className.includes("active-speaker") ||
|
||||
className.includes("voice-activity") ||
|
||||
label.includes("speaking") ||
|
||||
label.includes("voice activity") ||
|
||||
title.includes("speaking") ||
|
||||
title.includes("voice activity") ||
|
||||
text.includes("speaking") ||
|
||||
text.includes("voice activity") ||
|
||||
state === "speaking" ||
|
||||
active === "true" ||
|
||||
speaking === "true"
|
||||
);
|
||||
}
|
||||
|
||||
function detectSelfFlags() {
|
||||
const buttons = Array.from(document.querySelectorAll("button"));
|
||||
const muteButton = buttons.find((button) =>
|
||||
/unmute|mute/i.test(
|
||||
button.getAttribute("aria-label") || button.title || button.textContent || "",
|
||||
),
|
||||
);
|
||||
const deafenButton = buttons.find((button) =>
|
||||
/undeafen|deafen/i.test(
|
||||
button.getAttribute("aria-label") || button.title || button.textContent || "",
|
||||
),
|
||||
);
|
||||
return {
|
||||
selfMuted: muteButton
|
||||
? /unmute/i.test(
|
||||
muteButton.getAttribute("aria-label") ||
|
||||
muteButton.title ||
|
||||
muteButton.textContent ||
|
||||
"",
|
||||
)
|
||||
: undefined,
|
||||
selfDeafened: deafenButton
|
||||
? /undeafen/i.test(
|
||||
deafenButton.getAttribute("aria-label") ||
|
||||
deafenButton.title ||
|
||||
deafenButton.textContent ||
|
||||
"",
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function collectVoiceState() {
|
||||
const speakingAvatars = collectSpeakingAvatarUrls();
|
||||
const members = Array.from(document.querySelectorAll("*"))
|
||||
.filter(isParticipantEntry)
|
||||
.slice(0, 10)
|
||||
.map((entry) => ({
|
||||
name: getParticipantName(entry),
|
||||
speaking: isSpeakingEntry(entry),
|
||||
avatarUrl: normalizeAvatarUrl(extractAvatarUrl(entry)) || undefined,
|
||||
}))
|
||||
.map((member) => ({
|
||||
...member,
|
||||
speaking:
|
||||
member.speaking ||
|
||||
(member.avatarUrl ? speakingAvatars.has(normalizeAvatarUrl(member.avatarUrl)) : false),
|
||||
}));
|
||||
const selfFlags = detectSelfFlags();
|
||||
return {
|
||||
channelName: "Voice call",
|
||||
// The join/leave hook is authoritative for whether the overlay should show.
|
||||
// The DOM scan is only used to enrich the overlay with members while in call.
|
||||
isInCall: inVoice,
|
||||
members,
|
||||
selfMuted: selfFlags.selfMuted,
|
||||
selfDeafened: selfFlags.selfDeafened,
|
||||
source: "voice DOM",
|
||||
};
|
||||
}
|
||||
|
||||
function memberIdentityKey(members) {
|
||||
return members
|
||||
.map((member) =>
|
||||
[
|
||||
String(member?.name || "").trim().toLowerCase(),
|
||||
String(member?.avatarUrl || "").trim().toLowerCase(),
|
||||
].join(":"),
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function publishVoiceState() {
|
||||
const overlayApi = window.native?.overlay;
|
||||
if (!overlayApi || typeof overlayApi.setVoiceState !== "function") return;
|
||||
|
||||
pruneRowActivity();
|
||||
const next = collectVoiceState();
|
||||
const voiceState = inVoice ? next : null;
|
||||
const nextMemberKey = memberIdentityKey(next.members);
|
||||
const nextKey = JSON.stringify(voiceState);
|
||||
const memberChanged = nextMemberKey !== lastMemberIdentityKey;
|
||||
if (memberChanged && inVoice && !initialising && lastMemberIdentityKey) {
|
||||
const previousMembers = new Set(
|
||||
lastMemberIdentityKey
|
||||
.split("|")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const currentMembers = new Set(
|
||||
nextMemberKey
|
||||
.split("|")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const added = [...currentMembers].some((entry) => !previousMembers.has(entry));
|
||||
const removed = [...previousMembers].some((entry) => !currentMembers.has(entry));
|
||||
if (added) playJoin();
|
||||
if (removed) playLeave();
|
||||
}
|
||||
lastMemberIdentityKey = nextMemberKey;
|
||||
if (nextKey === lastVoiceState) return;
|
||||
lastVoiceState = nextKey;
|
||||
|
||||
if (inVoice && next.members.length === 0) {
|
||||
if (!leaveWatchdog) {
|
||||
leaveWatchdog = setTimeout(() => {
|
||||
leaveWatchdog = null;
|
||||
if (!inVoice) return;
|
||||
const stillEmpty = collectVoiceState().members.length === 0;
|
||||
if (stillEmpty) {
|
||||
overlayApi.setVoiceState(null);
|
||||
onSelfLeft();
|
||||
}
|
||||
}, 2200);
|
||||
}
|
||||
} else {
|
||||
clearTimeout(leaveWatchdog);
|
||||
leaveWatchdog = null;
|
||||
}
|
||||
|
||||
overlayApi.setVoiceState(voiceState);
|
||||
}
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async function (...args) {
|
||||
const url = typeof args[0] === "string" ? args[0] : args[0]?.url ?? "";
|
||||
const response = await originalFetch.apply(this, args);
|
||||
if (url.includes("/join_call") && response.ok) {
|
||||
setTimeout(onSelfJoined, 300);
|
||||
}
|
||||
if (/(leave_call|leave-?call|disconnect|close_call)/i.test(url) && response.ok) {
|
||||
setTimeout(onSelfLeft, 150);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.WebSocket = function (url, protocols) {
|
||||
const ws = protocols
|
||||
? new OriginalWebSocket(url, protocols)
|
||||
: new OriginalWebSocket(url);
|
||||
if (typeof url === "string" && url.includes("/livekit/rtc")) {
|
||||
ws.addEventListener("close", () => onSelfLeft());
|
||||
}
|
||||
return ws;
|
||||
};
|
||||
Object.assign(window.WebSocket, OriginalWebSocket);
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
|
||||
startObserver();
|
||||
publishVoiceState();
|
||||
})();
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
el.dataset.aviaPatched = "true";
|
||||
|
||||
nameDiv.textContent = "Sanctum Desktop";
|
||||
nameDiv.textContent = "Avia Client Desktop";
|
||||
versionSpan.textContent = `Version ${aviaVersion} (Based on Stoat ${stoatVersion})`;
|
||||
|
||||
textContainer.style.whiteSpace = "normal";
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
el.dataset.aviaPatched = "true";
|
||||
|
||||
el.innerHTML = `
|
||||
Sanctum Desktop<br>
|
||||
Avia Client Desktop<br>
|
||||
<span style="font-size:10px;opacity:0.7;">
|
||||
Based on Stoat ${stoatVersion}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@
|
|||
clone.setAttribute("data-lsbackup-entry", "true");
|
||||
|
||||
const title = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > div");
|
||||
if (title) title.textContent = "Sanctum Backup";
|
||||
if (title) title.textContent = "AviaClient Backup";
|
||||
|
||||
const desc = clone.querySelector("div.d_flex.flex-g_1.flex-d_column > span");
|
||||
if (desc) desc.textContent = "Backup or Restore all client data";
|
||||
|
|
|
|||
|
|
@ -1,397 +0,0 @@
|
|||
(function () {
|
||||
if (window.__sanctumGamePresenceSettings) return;
|
||||
window.__sanctumGamePresenceSettings = true;
|
||||
|
||||
const CLONE_ATTR = "data-sanctum-game-presence";
|
||||
const PANEL_ATTR = "data-sanctum-game-presence-panel";
|
||||
const POPULAR_GAMES = [
|
||||
"Apex Legends",
|
||||
"Among Us",
|
||||
"Assassin's Creed Mirage",
|
||||
"Assassin's Creed Valhalla",
|
||||
"Armored Core VI: Fires of Rubicon",
|
||||
"Baldur's Gate 3",
|
||||
"Black Myth: Wukong",
|
||||
"Brawlhalla",
|
||||
"Call of Duty: Black Ops 6",
|
||||
"Call of Duty: Modern Warfare III",
|
||||
"Call of Duty: Warzone",
|
||||
"Celeste",
|
||||
"Cities: Skylines II",
|
||||
"Civilization VI",
|
||||
"Counter-Strike 2",
|
||||
"Cuphead",
|
||||
"Cyberpunk 2077",
|
||||
"Dark Souls III",
|
||||
"Dave the Diver",
|
||||
"Days Gone",
|
||||
"Dead by Daylight",
|
||||
"Dead Cells",
|
||||
"Deep Rock Galactic",
|
||||
"Destiny 2",
|
||||
"Diablo IV",
|
||||
"Dota 2",
|
||||
"Dragon's Dogma 2",
|
||||
"Elden Ring",
|
||||
"Enshrouded",
|
||||
"Escape from Tarkov",
|
||||
"Euro Truck Simulator 2",
|
||||
"EVE Online",
|
||||
"Fall Guys",
|
||||
"Fallout 4",
|
||||
"Fallout 76",
|
||||
"Factorio",
|
||||
"F1 24",
|
||||
"Final Fantasy XIV",
|
||||
"Forza Horizon 5",
|
||||
"Fortnite",
|
||||
"Genshin Impact",
|
||||
"Ghost of Tsushima",
|
||||
"God of War",
|
||||
"Grand Theft Auto V",
|
||||
"Grounded",
|
||||
"Guild Wars 2",
|
||||
"Hades",
|
||||
"Hades II",
|
||||
"Helldivers 2",
|
||||
"Hogwarts Legacy",
|
||||
"Hollow Knight",
|
||||
"Honkai: Star Rail",
|
||||
"Honkai Impact 3rd",
|
||||
"Hunt: Showdown",
|
||||
"It Takes Two",
|
||||
"Kingdom Come: Deliverance",
|
||||
"League of Legends",
|
||||
"Lethal Company",
|
||||
"Left 4 Dead 2",
|
||||
"Last Epoch",
|
||||
"Marvel Rivals",
|
||||
"Minecraft",
|
||||
"Monster Hunter: World",
|
||||
"Monster Hunter Rise",
|
||||
"Mortal Kombat 1",
|
||||
"Metaphor: ReFantazio",
|
||||
"No Man's Sky",
|
||||
"Once Human",
|
||||
"Overwatch 2",
|
||||
"Palworld",
|
||||
"Path of Exile",
|
||||
"Path of Exile 2",
|
||||
"Persona 5 Royal",
|
||||
"Phasmophobia",
|
||||
"PUBG: Battlegrounds",
|
||||
"Paladins",
|
||||
"Rainbow Six Siege",
|
||||
"Red Dead Redemption 2",
|
||||
"Resident Evil 4",
|
||||
"Resident Evil Village",
|
||||
"Rocket League",
|
||||
"Rust",
|
||||
"Satisfactory",
|
||||
"Sea of Thieves",
|
||||
"Skyrim Special Edition",
|
||||
"Slay the Spire",
|
||||
"Sons of the Forest",
|
||||
"Spider-Man Remastered",
|
||||
"Split Fiction",
|
||||
"Star Citizen",
|
||||
"Starfield",
|
||||
"Stardew Valley",
|
||||
"Street Fighter 6",
|
||||
"Subnautica",
|
||||
"Team Fortress 2",
|
||||
"Tekken 8",
|
||||
"Terraria",
|
||||
"The Elder Scrolls Online",
|
||||
"The Finals",
|
||||
"The Last of Us Part I",
|
||||
"The Witcher 3",
|
||||
"Titanfall 2",
|
||||
"VALORANT",
|
||||
"V Rising",
|
||||
"Valheim",
|
||||
"Warframe",
|
||||
"War Thunder",
|
||||
"Wuthering Waves",
|
||||
"World of Warcraft",
|
||||
"World of Tanks",
|
||||
"World of Warships",
|
||||
"Zenless Zone Zero",
|
||||
];
|
||||
|
||||
function toggleCheckbox(elem, value) {
|
||||
const checkbox = elem.querySelector("mdui-checkbox");
|
||||
if (!checkbox) return;
|
||||
|
||||
if (value) {
|
||||
checkbox.setAttribute("checked", "");
|
||||
checkbox.setAttribute("value", "on");
|
||||
} else {
|
||||
checkbox.removeAttribute("checked");
|
||||
checkbox.setAttribute("value", "off");
|
||||
}
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
return window.desktopConfig.get();
|
||||
}
|
||||
|
||||
function setConfig(next) {
|
||||
window.desktopConfig.set(next);
|
||||
}
|
||||
|
||||
function buildPanel() {
|
||||
const panel = document.createElement("div");
|
||||
panel.setAttribute(PANEL_ATTR, "true");
|
||||
panel.style.cssText = `
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
const note = document.createElement("div");
|
||||
note.style.cssText = "font-size:12px; opacity:0.75; line-height:1.35;";
|
||||
note.textContent =
|
||||
"Sanctum only lights up for games in its built-in catalog or names you add below.";
|
||||
panel.appendChild(note);
|
||||
|
||||
const allowLabel = document.createElement("div");
|
||||
allowLabel.textContent = "Allowed games / windows";
|
||||
allowLabel.style.cssText = "font-size:12px; font-weight:600;";
|
||||
panel.appendChild(allowLabel);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = getConfig().gamePresenceAllowList || "";
|
||||
textarea.rows = 4;
|
||||
textarea.placeholder = "Examples: Fortnite, Valorant, Counter-Strike 2, Baldur's Gate 3";
|
||||
textarea.style.cssText = `
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 88px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
textarea.addEventListener("input", () => {
|
||||
const config = getConfig();
|
||||
config.gamePresenceAllowList = textarea.value;
|
||||
setConfig(config);
|
||||
});
|
||||
panel.appendChild(textarea);
|
||||
|
||||
const pickerLabel = document.createElement("div");
|
||||
pickerLabel.textContent = "Popular games";
|
||||
pickerLabel.style.cssText = "font-size:12px; font-weight:600; margin-top:2px;";
|
||||
panel.appendChild(pickerLabel);
|
||||
|
||||
const pickerHint = document.createElement("div");
|
||||
pickerHint.textContent = "Search and add games from the built-in catalog.";
|
||||
pickerHint.style.cssText = "font-size:11px; opacity:0.7; line-height:1.35;";
|
||||
panel.appendChild(pickerHint);
|
||||
|
||||
const pickerRow = document.createElement("div");
|
||||
pickerRow.style.cssText = "display:flex; gap:8px; align-items:center;";
|
||||
|
||||
const pickerSearch = document.createElement("input");
|
||||
pickerSearch.type = "search";
|
||||
pickerSearch.placeholder = "Search popular games";
|
||||
pickerSearch.style.cssText = `
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
`;
|
||||
|
||||
const pickerAdd = document.createElement("button");
|
||||
pickerAdd.type = "button";
|
||||
pickerAdd.textContent = "Add";
|
||||
pickerAdd.style.cssText = `
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
pickerRow.appendChild(pickerSearch);
|
||||
pickerRow.appendChild(pickerAdd);
|
||||
panel.appendChild(pickerRow);
|
||||
|
||||
const pickerList = document.createElement("div");
|
||||
pickerList.style.cssText = `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 6px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
`;
|
||||
panel.appendChild(pickerList);
|
||||
|
||||
function existingEntries() {
|
||||
return textarea.value
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function addGameToAllowList(name) {
|
||||
const current = new Set(existingEntries().map((item) => item.toLowerCase()));
|
||||
if (current.has(name.toLowerCase())) return;
|
||||
const next = existingEntries();
|
||||
next.push(name);
|
||||
textarea.value = next.join("\n");
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function renderPicker() {
|
||||
const query = pickerSearch.value.trim().toLowerCase();
|
||||
const selected = new Set(existingEntries().map((item) => item.toLowerCase()));
|
||||
pickerList.innerHTML = "";
|
||||
|
||||
const matches = POPULAR_GAMES.filter((game) => !query || game.toLowerCase().includes(query)).slice(0, 40);
|
||||
for (const game of matches) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = selected.has(game.toLowerCase()) ? `✓ ${game}` : game;
|
||||
button.title = selected.has(game.toLowerCase()) ? "Already added" : `Add ${game}`;
|
||||
button.style.cssText = `
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid ${selected.has(game.toLowerCase()) ? "rgba(104, 126, 255, 0.55)" : "rgba(255,255,255,0.12)"};
|
||||
background: ${selected.has(game.toLowerCase()) ? "rgba(104, 126, 255, 0.16)" : "rgba(255,255,255,0.05)"};
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
button.addEventListener("click", () => addGameToAllowList(game));
|
||||
pickerList.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
pickerSearch.addEventListener("input", renderPicker);
|
||||
pickerAdd.addEventListener("click", () => {
|
||||
const query = pickerSearch.value.trim();
|
||||
if (!query) return;
|
||||
const exact = POPULAR_GAMES.find((game) => game.toLowerCase() === query.toLowerCase());
|
||||
if (exact) {
|
||||
addGameToAllowList(exact);
|
||||
return;
|
||||
}
|
||||
addGameToAllowList(query);
|
||||
});
|
||||
textarea.addEventListener("input", renderPicker);
|
||||
renderPicker();
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
function createButton(baseElem) {
|
||||
const row = baseElem.cloneNode(true);
|
||||
row.setAttribute(CLONE_ATTR, "true");
|
||||
|
||||
const title = row.querySelector("div.d_flex.flex-g_1 > div");
|
||||
const desc = row.querySelector("div.d_flex.flex-g_1 > span");
|
||||
const icon = row.querySelector("div.w_36px span.material-symbols-outlined");
|
||||
const existingIcon = row.querySelector("div.w_36px");
|
||||
|
||||
if (title) title.textContent = "Gameplay overlay";
|
||||
if (desc) desc.textContent = "Shows the mini voice overlay while you are in a game.";
|
||||
if (icon) icon.textContent = "sports_esports";
|
||||
|
||||
if (existingIcon) {
|
||||
existingIcon.title = "Toggle gameplay sharing settings";
|
||||
existingIcon.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
const settingsBtn = document.createElement("div");
|
||||
settingsBtn.title = "Edit gameplay sharing";
|
||||
settingsBtn.style.cssText = "cursor: pointer; z-index: 10; flex-shrink: 0; margin-left: 6px;";
|
||||
settingsBtn.innerHTML = `
|
||||
<div class="fill_var(--md-sys-color-on-surface) bg_var(--md-sys-color-surface-dim) w_36px h_36px d_flex flex-sh_0 ai_center jc_center bdr_var(--borderRadius-full)">
|
||||
<span aria-hidden="true" class="material-symbols-outlined fs_inherit fw_undefined!" style="display:block;font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0;">settings</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const iconSlot = row.querySelector(".d_flex.ai_center.jc_center, .w_36px");
|
||||
if (iconSlot && iconSlot.parentNode) {
|
||||
iconSlot.parentNode.appendChild(settingsBtn);
|
||||
} else {
|
||||
row.appendChild(settingsBtn);
|
||||
}
|
||||
|
||||
const panel = buildPanel();
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.cssText = "display:flex; flex-direction:column;";
|
||||
|
||||
const applyState = () => {
|
||||
const config = getConfig();
|
||||
toggleCheckbox(row, config.gamePresenceEnabled);
|
||||
if (config.gamePresenceEnabled) {
|
||||
row.setAttribute("data-active", "true");
|
||||
} else {
|
||||
row.setAttribute("data-active", "false");
|
||||
}
|
||||
};
|
||||
|
||||
row.addEventListener("click", (e) => {
|
||||
if (settingsBtn.contains(e.target)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const config = getConfig();
|
||||
config.gamePresenceEnabled = !config.gamePresenceEnabled;
|
||||
setConfig(config);
|
||||
applyState();
|
||||
});
|
||||
|
||||
settingsBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
panel.style.display = panel.style.display === "flex" ? "none" : "flex";
|
||||
});
|
||||
|
||||
applyState();
|
||||
wrapper.appendChild(row);
|
||||
wrapper.appendChild(panel);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function injectButton() {
|
||||
const base = Array.from(document.querySelectorAll("a")).find((e) => {
|
||||
const t = e.querySelector("div.d_flex.flex-g_1 > div");
|
||||
return t && t.textContent.trim() === "Discord RPC";
|
||||
});
|
||||
|
||||
if (!base) return;
|
||||
if (document.querySelector(`[${CLONE_ATTR}]`)) return;
|
||||
|
||||
const newButton = createButton(base);
|
||||
base.parentNode.appendChild(newButton);
|
||||
}
|
||||
|
||||
injectButton();
|
||||
|
||||
const observer = new MutationObserver(() => injectButton());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
const STYLE_ID = "headliner-style";
|
||||
|
||||
const defaults = {
|
||||
content: "Sanctum",
|
||||
content: "Stoat V 1.6.0 - Avia Client",
|
||||
left: "32",
|
||||
top: "56",
|
||||
fontSize: "15",
|
||||
|
|
@ -18,12 +18,7 @@
|
|||
|
||||
function loadSettings() {
|
||||
try {
|
||||
let s = JSON.parse(localStorage.getItem("headlinerSettings"));
|
||||
if (s && /^Sanctum V 1\.0\.[0-9]+$/.test(s.content)) {
|
||||
s.content = defaults.content;
|
||||
saveSettings(s);
|
||||
}
|
||||
return s || { ...defaults };
|
||||
return JSON.parse(localStorage.getItem("headlinerSettings")) || { ...defaults };
|
||||
} catch {
|
||||
return { ...defaults };
|
||||
}
|
||||
|
|
@ -41,7 +36,6 @@
|
|||
}
|
||||
.flex-sh_0.h_29px.us_none.d_flex.ai_center.fill_var\\(--md-sys-color-on-surface\\).c_var\\(--md-sys-color-outline\\).bg_var\\(--md-sys-color-surface-container-high\\) {
|
||||
position: relative !important;
|
||||
color: transparent !important;
|
||||
}
|
||||
.flex-sh_0.h_29px.us_none.d_flex.ai_center.fill_var\\(--md-sys-color-on-surface\\).c_var\\(--md-sys-color-outline\\).bg_var\\(--md-sys-color-surface-container-high\\)::before {
|
||||
content: "${s.content}";
|
||||
|
|
@ -51,7 +45,7 @@
|
|||
transform: translateY(-50%);
|
||||
font-size: ${s.fontSize}px;
|
||||
font-weight: ${s.fontWeight};
|
||||
color: var(--md-sys-color-on-surface) !important;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
|
@ -86,7 +80,7 @@
|
|||
applyCSS();
|
||||
} else {
|
||||
clone.setAttribute("data-active", "false");
|
||||
if (desc) desc.textContent = "Modify the Sanctum name in the titlebar to say anything you want";
|
||||
if (desc) desc.textContent = "Modify the Stoat name in the titlebar to say anything you want";
|
||||
if (checkbox) checkbox.removeAttribute("checked");
|
||||
removeCSS();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@
|
|||
const linktreeBtn = appearanceBtn.cloneNode(true);
|
||||
linktreeBtn.id = 'stoat-fake-linktree';
|
||||
const textNode = Array.from(linktreeBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
|
||||
if (textNode) textNode.textContent = "(Sanctum) Ava's Linktree";
|
||||
if (textNode) textNode.textContent = "(Avia) Ava's Linktree";
|
||||
setIcon(linktreeBtn, "monitor");
|
||||
linktreeBtn.addEventListener('click', () => window.open(LINKTREE_URL, "_blank"));
|
||||
targetParent.appendChild(linktreeBtn);
|
||||
|
|
@ -288,7 +288,7 @@
|
|||
const stoatBtn = appearanceBtn.cloneNode(true);
|
||||
stoatBtn.id = 'stoat-fake-stoatserver';
|
||||
const stoatTextNode = Array.from(stoatBtn.querySelectorAll('div')).find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
|
||||
if (stoatTextNode) stoatTextNode.textContent = "(Sanctum) Stoat Server";
|
||||
if (stoatTextNode) stoatTextNode.textContent = "(Avia) Stoat Server";
|
||||
setIcon(stoatBtn, "monitor");
|
||||
stoatBtn.addEventListener('click', () => window.open(STOAT_SERVER_URL, "_blank"));
|
||||
targetParent.appendChild(stoatBtn);
|
||||
|
|
@ -298,7 +298,7 @@
|
|||
const newBtn = appearanceBtn.cloneNode(true);
|
||||
newBtn.id = 'stoat-fake-loadfont';
|
||||
const textNode = Array.from(newBtn.querySelectorAll('div')).find(d => d.children.length === 0);
|
||||
if (textNode) textNode.textContent = "(Sanctum) Font Loader";
|
||||
if (textNode) textNode.textContent = "(Avia) Font Loader";
|
||||
setIcon(newBtn, "upload");
|
||||
newBtn.addEventListener('click', showFontLoaderPopup);
|
||||
targetParent.appendChild(newBtn);
|
||||
|
|
@ -307,7 +307,7 @@
|
|||
const removeBtn = appearanceBtn.cloneNode(true);
|
||||
removeBtn.id = 'stoat-fake-removefont';
|
||||
const removeTextNode = Array.from(removeBtn.querySelectorAll('div')).find(d => d.children.length === 0);
|
||||
if (removeTextNode) removeTextNode.textContent = "(Sanctum) Remove selected font";
|
||||
if (removeTextNode) removeTextNode.textContent = "(Avia) Remove selected font";
|
||||
setIcon(removeBtn, "refresh");
|
||||
removeBtn.addEventListener('click', showRemoveFontPopup);
|
||||
targetParent.appendChild(removeBtn);
|
||||
|
|
@ -318,7 +318,7 @@
|
|||
const quickCssBtn = appearanceBtn.cloneNode(true);
|
||||
quickCssBtn.id = 'stoat-fake-quickcss';
|
||||
const quickCssTextNode = Array.from(quickCssBtn.querySelectorAll('div')).find(d => d.children.length === 0);
|
||||
if (quickCssTextNode) quickCssTextNode.textContent = "(Sanctum) QuickCSS";
|
||||
if (quickCssTextNode) quickCssTextNode.textContent = "(Avia) QuickCSS";
|
||||
setIcon(quickCssBtn, "code");
|
||||
quickCssBtn.addEventListener('click', toggleQuickCSSPanel);
|
||||
targetParent.appendChild(quickCssBtn);
|
||||
|
|
|
|||
|
|
@ -536,7 +536,7 @@
|
|||
pluginsBtn.id = 'stoat-fake-plugins';
|
||||
const textNode = [...pluginsBtn.querySelectorAll('div')]
|
||||
.find(d => d.children.length === 0 && d.textContent.trim() === 'Appearance');
|
||||
if (textNode) textNode.textContent = "(Sanctum) Plugins";
|
||||
if (textNode) textNode.textContent = "(Avia) Plugins";
|
||||
const svgNS = "http://www.w3.org/2000/svg";
|
||||
const oldSvg = pluginsBtn.querySelector('svg');
|
||||
if (oldSvg) oldSvg.remove();
|
||||
|
|
|
|||
|
|
@ -453,7 +453,7 @@
|
|||
clone.id = "avia-official-repo-btn-settings";
|
||||
|
||||
const label = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
|
||||
if (label) label.textContent = "(Sanctum) Plugins/Themes Repo";
|
||||
if (label) label.textContent = "(Avia) Plugins/Themes Repo";
|
||||
|
||||
const iconSpan = clone.querySelector("span.material-symbols-outlined");
|
||||
if (iconSpan) {
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@
|
|||
const clone = appearanceBtn.cloneNode(true);
|
||||
clone.id = "avia-themes-btn";
|
||||
const text = [...clone.querySelectorAll("div")].find(d => d.children.length === 0);
|
||||
if (text) text.textContent = "(Sanctum) Themes";
|
||||
if (text) text.textContent = "(Avia) Themes";
|
||||
clone.onclick = toggleThemesPanel;
|
||||
quickCSS.parentElement.insertBefore(clone, quickCSS.nextSibling);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Main window</caption>
|
||||
<image>https://raw.githubusercontent.com/stoatchat/for-desktop/b57faa2c59865fea15a879c9a9304271067d0020/screenshot.png</image>
|
||||
<image>screenshot.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
[Desktop Entry]
|
||||
Name=Sanctum
|
||||
Comment=Open source, user-first chat platform
|
||||
Exec=sanctum
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=cloud.mithraic.sanctum
|
||||
Categories=Network;InstantMessaging
|
||||
StartupWMClass=sanctum
|
||||
X-Desktop-File-Install-Version=0.26
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop">
|
||||
<id>cloud.mithraic.sanctum</id>
|
||||
<launchable type="desktop-id">cloud.mithraic.sanctum.desktop</launchable>
|
||||
<name>Sanctum</name>
|
||||
<developer id="cloud.mithraic">
|
||||
<name>izzy</name>
|
||||
</developer>
|
||||
<summary>Open source, user-first chat platform</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>AGPL-3.0</project_license>
|
||||
<description>
|
||||
<p>Sanctum is a self-hosted Stoat client with Avia Client mod support.</p>
|
||||
</description>
|
||||
<content_rating type="oars-1.1">
|
||||
<content_attribute id="social-chat">intense</content_attribute>
|
||||
<content_attribute id="social-info">intense</content_attribute>
|
||||
<content_attribute id="social-audio">intense</content_attribute>
|
||||
<content_attribute id="social-contacts">intense</content_attribute>
|
||||
</content_rating>
|
||||
<requires>
|
||||
<display_length compare="ge">940</display_length>
|
||||
<internet>always</internet>
|
||||
</requires>
|
||||
<supports>
|
||||
<control>keyboard</control>
|
||||
<control>pointing</control>
|
||||
</supports>
|
||||
<releases>
|
||||
<release date="2026-05-05" version="1.0.7">
|
||||
<description>
|
||||
<p>Fixed a main-process bootstrap race and improved VC sounds / game presence behavior.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release date="2026-04-22" version="1.0.0">
|
||||
<description>
|
||||
<p>Initial Sanctum release based on Avia Client with self-hosted instance support.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
|
|
@ -7,14 +7,15 @@ import { MakerZIP } from "@electron-forge/maker-zip";
|
|||
import { FusesPlugin } from "@electron-forge/plugin-fuses";
|
||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||
import { VitePluginBuildConfig } from "@electron-forge/plugin-vite/dist/Config";
|
||||
import { PublisherGithub } from "@electron-forge/publisher-github";
|
||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import { FuseV1Options, FuseVersion } from "@electron/fuses";
|
||||
import * as fs from "fs";
|
||||
|
||||
const STRINGS = {
|
||||
author: "izzy",
|
||||
name: "Sanctum",
|
||||
execName: "sanctum",
|
||||
author: "Revolt Platforms LTD",
|
||||
name: "AviaClient",
|
||||
execName: "aviaclient-desktop",
|
||||
description: "Open source user-first chat platform.",
|
||||
};
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ if (!process.env.PLATFORM) {
|
|||
}),
|
||||
new MakerFlatpak({
|
||||
options: {
|
||||
id: "cloud.mithraic.sanctum",
|
||||
id: "chat.stoat.stoat-desktop",
|
||||
description: STRINGS.description,
|
||||
productName: STRINGS.name,
|
||||
productDescription: STRINGS.description,
|
||||
|
|
@ -156,6 +157,14 @@ const config: ForgeConfig = {
|
|||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
publishers: [
|
||||
new PublisherGithub({
|
||||
repository: {
|
||||
owner: "AvaLilac",
|
||||
name: "for-desktop",
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "sanctum",
|
||||
"productName": "Sanctum",
|
||||
"version": "1.0.7",
|
||||
"aviaVersion": "1.0.7",
|
||||
"name": "stoat-desktop",
|
||||
"productName": "stoat-desktop",
|
||||
"version": "1.3.0",
|
||||
"aviaVersion": "1.6.0",
|
||||
"main": ".vite/build/main.js",
|
||||
"repository": "https://git.mithraic.cloud/ad3laid3/sanctum",
|
||||
"repository": "stoatchat/desktop",
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
|
|
@ -54,6 +54,7 @@
|
|||
"discord-rpc": "^4.0.1",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^10.1.0",
|
||||
"update-electron-app": "^3.1.2",
|
||||
"utf-8-validate": "^6.0.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
|
|
|
|||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
|
|
@ -29,6 +29,9 @@ importers:
|
|||
electron-store:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
update-electron-app:
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.2
|
||||
utf-8-validate:
|
||||
specifier: ^6.0.5
|
||||
version: 6.0.5
|
||||
|
|
@ -907,67 +910,56 @@ packages:
|
|||
resolution: {integrity: sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.52.2':
|
||||
resolution: {integrity: sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.52.2':
|
||||
resolution: {integrity: sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.52.2':
|
||||
resolution: {integrity: sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.52.2':
|
||||
resolution: {integrity: sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.52.2':
|
||||
resolution: {integrity: sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.52.2':
|
||||
resolution: {integrity: sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.52.2':
|
||||
resolution: {integrity: sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.52.2':
|
||||
resolution: {integrity: sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.52.2':
|
||||
resolution: {integrity: sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.52.2':
|
||||
resolution: {integrity: sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.52.2':
|
||||
resolution: {integrity: sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==}
|
||||
|
|
@ -1985,6 +1977,9 @@ packages:
|
|||
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
github-url-to-object@4.0.6:
|
||||
resolution: {integrity: sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -2272,6 +2267,9 @@ packages:
|
|||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
is-weakmap@2.0.2:
|
||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3304,6 +3302,9 @@ packages:
|
|||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
update-electron-app@3.1.2:
|
||||
resolution: {integrity: sha512-htLyPJv7mEoCpaSzCg0W3Hxz7ID0GC7BIhhpK32/ITG7McrWak4aOkLEOjJheKAI94AxtBVTjCk4EFIvyttw2w==}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
|
|
@ -5834,6 +5835,10 @@ snapshots:
|
|||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
github-url-to-object@4.0.6:
|
||||
dependencies:
|
||||
is-url: 1.2.4
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -6141,6 +6146,8 @@ snapshots:
|
|||
|
||||
is-unicode-supported@0.1.0: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
|
||||
is-weakref@1.1.1:
|
||||
|
|
@ -7211,6 +7218,11 @@ snapshots:
|
|||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
update-electron-app@3.1.2:
|
||||
dependencies:
|
||||
github-url-to-object: 4.0.6
|
||||
ms: 2.1.3
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
|
|
|||
65
src/config.d.ts
vendored
65
src/config.d.ts
vendored
|
|
@ -7,9 +7,6 @@ declare type DesktopConfig = {
|
|||
spellchecker: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
discordRpc: boolean;
|
||||
gamePresenceEnabled: boolean;
|
||||
gamePresenceRestrictToAllowList: boolean;
|
||||
gamePresenceAllowList: string;
|
||||
windowState: {
|
||||
x: number;
|
||||
y: number;
|
||||
|
|
@ -18,65 +15,3 @@ declare type DesktopConfig = {
|
|||
isMaximised: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
declare type VoiceOverlayMember = {
|
||||
name: string;
|
||||
speaking?: boolean;
|
||||
muted?: boolean;
|
||||
deafened?: boolean;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
declare type VoiceOverlayState = {
|
||||
channelName?: string;
|
||||
isInCall: boolean;
|
||||
members: VoiceOverlayMember[];
|
||||
selfMuted?: boolean;
|
||||
selfDeafened?: boolean;
|
||||
source?: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
declare type SanctumGamePresence = {
|
||||
title: string;
|
||||
processName: string;
|
||||
startedAt: number;
|
||||
source: string;
|
||||
};
|
||||
|
||||
declare type SanctumActivityState = {
|
||||
game: SanctumGamePresence | null;
|
||||
voice: VoiceOverlayState | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
native: {
|
||||
versions: {
|
||||
node: () => string;
|
||||
chrome: () => string;
|
||||
electron: () => string;
|
||||
desktop: () => string;
|
||||
aviaClient: () => string;
|
||||
};
|
||||
overlay: {
|
||||
setVoiceState: (state: VoiceOverlayState | null) => void;
|
||||
};
|
||||
activity: {
|
||||
getState: () => Promise<SanctumActivityState>;
|
||||
onUpdate: (callback: (state: SanctumActivityState) => void) => () => void;
|
||||
debugSetState: (state: SanctumActivityState) => Promise<SanctumActivityState>;
|
||||
};
|
||||
minimise: () => void;
|
||||
maximise: () => void;
|
||||
close: () => void;
|
||||
setBadgeCount: (count: number) => void;
|
||||
};
|
||||
desktopConfig: {
|
||||
get: () => DesktopConfig;
|
||||
set: (config: DesktopConfig) => void;
|
||||
getAutostart: () => Promise<boolean>;
|
||||
setAutostart: (value: boolean) => Promise<boolean>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
src/main.ts
71
src/main.ts
|
|
@ -1,7 +1,8 @@
|
|||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { updateElectronApp } from "update-electron-app";
|
||||
|
||||
import { BrowserWindow, app, shell } from "electron";
|
||||
import { BrowserWindow, Notification, app, shell } from "electron";
|
||||
import started from "electron-squirrel-startup";
|
||||
|
||||
import { aviaVersion } from "../package.json";
|
||||
|
|
@ -10,23 +11,15 @@ import { autoLaunch } from "./native/autoLaunch";
|
|||
import { setBadgeCount } from "./native/badges";
|
||||
import { config } from "./native/config";
|
||||
import { initDiscordRpc } from "./native/discordRpc";
|
||||
import { startGamePresenceMonitor } from "./native/gamePresence";
|
||||
import { checkForUpdates } from "./native/updater";
|
||||
import { initTray } from "./native/tray";
|
||||
import { BUILD_URL, createMainWindow, mainWindow } from "./native/window";
|
||||
|
||||
if (process.platform === "linux") {
|
||||
app.commandLine.appendSwitch("disable-gpu-sandbox");
|
||||
app.commandLine.appendSwitch("no-zygote");
|
||||
app.commandLine.appendSwitch("use-gl", "desktop");
|
||||
}
|
||||
|
||||
const applyAppName = () => {
|
||||
try {
|
||||
app.setName("Sanctum");
|
||||
app.name = "Sanctum";
|
||||
app.setName("AviaClient");
|
||||
app.name = "AviaClient";
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("cloud.mithraic.sanctum");
|
||||
app.setAppUserModelId("AviaClient");
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
|
|
@ -43,34 +36,21 @@ if (!config.hardwareAcceleration) {
|
|||
|
||||
const acquiredLock = app.requestSingleInstanceLock();
|
||||
|
||||
const onNotifyUser = () => {
|
||||
const notification = new Notification({
|
||||
title: "Update Available",
|
||||
body: "Restart the app to install the update.",
|
||||
silent: true,
|
||||
});
|
||||
|
||||
notification.show();
|
||||
};
|
||||
|
||||
const loadInject = () => {
|
||||
if (!mainWindow) return;
|
||||
|
||||
const wc = mainWindow.webContents;
|
||||
wc.removeAllListeners("dom-ready");
|
||||
wc.once("dom-ready", async () => {
|
||||
mainWindow.webContents.on("dom-ready", async () => {
|
||||
try {
|
||||
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
|
||||
|
||||
const builtInLocalPlugins = [
|
||||
{
|
||||
id: "sanctum-vcsounds",
|
||||
name: "VCSounds",
|
||||
code: fs.readFileSync(path.join(__dirname, "VCSounds.js"), "utf8"),
|
||||
enabled: true,
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
|
||||
await wc.executeJavaScript(
|
||||
`window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__ = ${JSON.stringify(
|
||||
builtInLocalPlugins,
|
||||
)};`,
|
||||
true,
|
||||
);
|
||||
|
||||
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
|
||||
|
||||
const plugins: string[] = [
|
||||
"inject.js",
|
||||
"LocalPlugins.js",
|
||||
|
|
@ -81,19 +61,18 @@ const loadInject = () => {
|
|||
"aviaversion.js",
|
||||
"repofrontend.js",
|
||||
"ButtonFix.js",
|
||||
"headliner.js",
|
||||
"aviadesktopversion.js",
|
||||
"customFrameNativeMenu.js",
|
||||
"disableTrayIcon.js",
|
||||
"gamePresenceSettings.js",
|
||||
"clientBackup.js",
|
||||
"LoginWithToken.js",
|
||||
];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (mainWindow.isDestroyed() || wc.isDestroyed()) return;
|
||||
const pluginPath: string = path.join(__dirname, plugin);
|
||||
const pluginCode: string = fs.readFileSync(pluginPath, "utf8");
|
||||
await wc.executeJavaScript(pluginCode, true);
|
||||
await mainWindow.webContents.executeJavaScript(pluginCode, true);
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
|
|
@ -102,14 +81,16 @@ const loadInject = () => {
|
|||
};
|
||||
|
||||
if (acquiredLock) {
|
||||
updateElectronApp({ onNotifyUser });
|
||||
|
||||
app.whenReady().then(() => {
|
||||
applyAppName();
|
||||
createMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.setTitle("Sanctum");
|
||||
mainWindow.setTitle("AviaClient");
|
||||
mainWindow.on("page-title-updated", (e) => {
|
||||
e.preventDefault();
|
||||
mainWindow.setTitle("Sanctum");
|
||||
mainWindow.setTitle("AviaClient");
|
||||
});
|
||||
}
|
||||
loadInject();
|
||||
|
|
@ -123,12 +104,10 @@ if (acquiredLock) {
|
|||
|
||||
initTray();
|
||||
initDiscordRpc();
|
||||
startGamePresenceMonitor();
|
||||
checkForUpdates();
|
||||
setBadgeCount(0);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("cloud.mithraic.sanctum");
|
||||
app.setAppUserModelId("AviaClient");
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
|
|
@ -154,10 +133,10 @@ if (acquiredLock) {
|
|||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.setTitle("Sanctum");
|
||||
mainWindow.setTitle("AviaClient");
|
||||
mainWindow.on("page-title-updated", (e) => {
|
||||
e.preventDefault();
|
||||
mainWindow.setTitle("Sanctum");
|
||||
mainWindow.setTitle("AviaClient");
|
||||
});
|
||||
}
|
||||
loadInject();
|
||||
|
|
|
|||
|
|
@ -34,15 +34,6 @@ const schema = {
|
|||
discordRpc: {
|
||||
type: "boolean",
|
||||
} as JSONSchema.Boolean,
|
||||
gamePresenceEnabled: {
|
||||
type: "boolean",
|
||||
} as JSONSchema.Boolean,
|
||||
gamePresenceRestrictToAllowList: {
|
||||
type: "boolean",
|
||||
} as JSONSchema.Boolean,
|
||||
gamePresenceAllowList: {
|
||||
type: "string",
|
||||
} as JSONSchema.String,
|
||||
windowState: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -77,9 +68,6 @@ const store = new Store({
|
|||
spellchecker: true,
|
||||
hardwareAcceleration: true,
|
||||
discordRpc: true,
|
||||
gamePresenceEnabled: true,
|
||||
gamePresenceRestrictToAllowList: true,
|
||||
gamePresenceAllowList: "",
|
||||
windowState: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
|
@ -105,9 +93,6 @@ class Config {
|
|||
spellchecker: this.spellchecker,
|
||||
hardwareAcceleration: this.hardwareAcceleration,
|
||||
discordRpc: this.discordRpc,
|
||||
gamePresenceEnabled: this.gamePresenceEnabled,
|
||||
gamePresenceRestrictToAllowList: this.gamePresenceRestrictToAllowList,
|
||||
gamePresenceAllowList: this.gamePresenceAllowList,
|
||||
windowState: this.windowState,
|
||||
});
|
||||
}
|
||||
|
|
@ -245,47 +230,6 @@ class Config {
|
|||
this.sync();
|
||||
}
|
||||
|
||||
get gamePresenceEnabled() {
|
||||
return (store as never as { get(k: string): boolean }).get("gamePresenceEnabled");
|
||||
}
|
||||
|
||||
set gamePresenceEnabled(value: boolean) {
|
||||
(store as never as { set(k: string, value: boolean): void }).set(
|
||||
"gamePresenceEnabled",
|
||||
value,
|
||||
);
|
||||
|
||||
this.sync();
|
||||
}
|
||||
|
||||
get gamePresenceRestrictToAllowList() {
|
||||
return (store as never as { get(k: string): boolean }).get(
|
||||
"gamePresenceRestrictToAllowList",
|
||||
);
|
||||
}
|
||||
|
||||
set gamePresenceRestrictToAllowList(value: boolean) {
|
||||
(store as never as { set(k: string, value: boolean): void }).set(
|
||||
"gamePresenceRestrictToAllowList",
|
||||
value,
|
||||
);
|
||||
|
||||
this.sync();
|
||||
}
|
||||
|
||||
get gamePresenceAllowList() {
|
||||
return (store as never as { get(k: string): string }).get("gamePresenceAllowList");
|
||||
}
|
||||
|
||||
set gamePresenceAllowList(value: string) {
|
||||
(store as never as { set(k: string, value: string): void }).set(
|
||||
"gamePresenceAllowList",
|
||||
value,
|
||||
);
|
||||
|
||||
this.sync();
|
||||
}
|
||||
|
||||
get windowState() {
|
||||
return (
|
||||
store as never as { get(k: string): DesktopConfig["windowState"] }
|
||||
|
|
|
|||
|
|
@ -3,32 +3,7 @@ import { Client } from "discord-rpc";
|
|||
import { config } from "./config";
|
||||
|
||||
// internal state
|
||||
let rpc: Client | undefined;
|
||||
type RpcActivity = Parameters<Client["setActivity"]>[0];
|
||||
|
||||
const defaultActivity: RpcActivity = {
|
||||
details: "Chatting with others on Sanctum",
|
||||
state: "stoat.chat",
|
||||
largeImageKey: "qr",
|
||||
largeImageText: "Join Stoat!",
|
||||
buttons: [
|
||||
{
|
||||
label: "Join Stoat",
|
||||
url: "https://stoat.chat/",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let pendingActivity: RpcActivity = defaultActivity;
|
||||
|
||||
function applyActivity() {
|
||||
if (!rpc) return;
|
||||
try {
|
||||
rpc.setActivity(pendingActivity);
|
||||
} catch {
|
||||
/* ignore transient RPC failures */
|
||||
}
|
||||
}
|
||||
let rpc: Client;
|
||||
|
||||
export async function initDiscordRpc() {
|
||||
if (!config.discordRpc) return;
|
||||
|
|
@ -39,7 +14,20 @@ export async function initDiscordRpc() {
|
|||
try {
|
||||
rpc = new Client({ transport: "ipc" });
|
||||
|
||||
rpc.on("ready", applyActivity);
|
||||
rpc.on("ready", () =>
|
||||
rpc.setActivity({
|
||||
details: "Chatting with others on AviaClient",
|
||||
state: "stoat.chat",
|
||||
largeImageKey: "qr",
|
||||
largeImageText: "Join Stoat!",
|
||||
buttons: [
|
||||
{
|
||||
label: "Join Stoat",
|
||||
url: "https://stoat.chat/",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
rpc.on("disconnected", reconnect);
|
||||
|
||||
|
|
@ -49,14 +37,8 @@ export async function initDiscordRpc() {
|
|||
}
|
||||
}
|
||||
|
||||
export function setDiscordActivity(activity: RpcActivity | null) {
|
||||
pendingActivity = activity ?? defaultActivity;
|
||||
applyActivity();
|
||||
}
|
||||
|
||||
const reconnect = () => setTimeout(() => initDiscordRpc(), 1e4);
|
||||
|
||||
export async function destroyDiscordRpc() {
|
||||
rpc?.destroy();
|
||||
rpc = undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
type CandidateLike = {
|
||||
processName: string;
|
||||
title: string;
|
||||
commandLine?: string;
|
||||
};
|
||||
|
||||
type GameCatalogEntry = {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
};
|
||||
|
||||
const GAME_CATALOG: GameCatalogEntry[] = [
|
||||
{ name: "Apex Legends", aliases: ["apex", "r5apex"] },
|
||||
{ name: "Among Us" },
|
||||
{ name: "Assassin's Creed Mirage" },
|
||||
{ name: "Assassin's Creed Valhalla" },
|
||||
{ name: "Armored Core VI: Fires of Rubicon", aliases: ["armored core 6"] },
|
||||
{ name: "Baldur's Gate 3", aliases: ["bg3", "baldurs gate 3", "baldursgate3"] },
|
||||
{ name: "Black Myth: Wukong", aliases: ["blackmythwukong", "wukong"] },
|
||||
{ name: "Brawlhalla" },
|
||||
{ name: "Call of Duty: Black Ops 6", aliases: ["black ops 6", "codbo6"] },
|
||||
{ name: "Call of Duty: Modern Warfare III", aliases: ["modern warfare 3", "mw3", "codmw3"] },
|
||||
{ name: "Call of Duty: Warzone", aliases: ["warzone", "cod warzone"] },
|
||||
{ name: "Celeste" },
|
||||
{ name: "Cities: Skylines II", aliases: ["cities skylines 2", "skylines 2"] },
|
||||
{ name: "Civilization VI", aliases: ["civ6", "civilization 6"] },
|
||||
{ name: "Counter-Strike 2", aliases: ["cs2", "counter strike 2", "csgo", "counter strike global offensive"] },
|
||||
{ name: "Cuphead" },
|
||||
{ name: "Cyberpunk 2077", aliases: ["cyberpunk"] },
|
||||
{ name: "Dark Souls III", aliases: ["dark souls 3"] },
|
||||
{ name: "Dave the Diver" },
|
||||
{ name: "Days Gone" },
|
||||
{ name: "Dead by Daylight" },
|
||||
{ name: "Dead Cells" },
|
||||
{ name: "Deep Rock Galactic" },
|
||||
{ name: "Destiny 2" },
|
||||
{ name: "Diablo IV", aliases: ["diablo 4"] },
|
||||
{ name: "Dota 2" },
|
||||
{ name: "Dragon's Dogma 2", aliases: ["dragons dogma 2"] },
|
||||
{ name: "Elden Ring" },
|
||||
{ name: "Enshrouded" },
|
||||
{ name: "Escape from Tarkov" },
|
||||
{ name: "Euro Truck Simulator 2" },
|
||||
{ name: "EVE Online" },
|
||||
{ name: "Fall Guys" },
|
||||
{ name: "Fallout 4" },
|
||||
{ name: "Fallout 76" },
|
||||
{ name: "Factorio" },
|
||||
{ name: "F1 24" },
|
||||
{ name: "Final Fantasy XIV", aliases: ["ffxiv"] },
|
||||
{ name: "Forza Horizon 5" },
|
||||
{ name: "Fortnite", aliases: ["fortniteclient", "fortniteclientwin64shipping"] },
|
||||
{ name: "Genshin Impact", aliases: ["genshin", "genshinimpact", "yuanshen"] },
|
||||
{ name: "Ghost of Tsushima" },
|
||||
{ name: "God of War" },
|
||||
{ name: "Grand Theft Auto V", aliases: ["gta5", "gta v"] },
|
||||
{ name: "Grounded" },
|
||||
{ name: "Guild Wars 2" },
|
||||
{ name: "Hades" },
|
||||
{ name: "Hades II" },
|
||||
{ name: "Helldivers 2" },
|
||||
{ name: "Hogwarts Legacy" },
|
||||
{ name: "Hollow Knight" },
|
||||
{ name: "Honkai: Star Rail", aliases: ["hkrpg", "hsr", "star rail"] },
|
||||
{ name: "Honkai Impact 3rd" },
|
||||
{ name: "Hunt: Showdown" },
|
||||
{ name: "It Takes Two" },
|
||||
{ name: "Kingdom Come: Deliverance" },
|
||||
{ name: "League of Legends", aliases: ["leagueclient", "league of legends", "lolclient"] },
|
||||
{ name: "Lethal Company" },
|
||||
{ name: "Left 4 Dead 2" },
|
||||
{ name: "Last Epoch" },
|
||||
{ name: "Marvel Rivals" },
|
||||
{ name: "Minecraft", aliases: ["minecraftlauncher", "minecraft java edition", "javaw"] },
|
||||
{ name: "Monster Hunter: World", aliases: ["monster hunter world", "mhw"] },
|
||||
{ name: "Monster Hunter Rise", aliases: ["monster hunter rise", "mhr"] },
|
||||
{ name: "Mortal Kombat 1", aliases: ["mk1"] },
|
||||
{ name: "Metaphor: ReFantazio" },
|
||||
{ name: "No Man's Sky" },
|
||||
{ name: "Once Human" },
|
||||
{ name: "Overwatch 2", aliases: ["overwatch", "ow2"] },
|
||||
{ name: "Palworld" },
|
||||
{ name: "Path of Exile", aliases: ["poe", "pathofexile"] },
|
||||
{ name: "Path of Exile 2", aliases: ["poe2", "pathofexile2"] },
|
||||
{ name: "Persona 5 Royal" },
|
||||
{ name: "Phasmophobia" },
|
||||
{ name: "PUBG: Battlegrounds", aliases: ["pubg"] },
|
||||
{ name: "Paladins" },
|
||||
{ name: "Rainbow Six Siege", aliases: ["r6 siege", "r6siege", "siege"] },
|
||||
{ name: "Red Dead Redemption 2", aliases: ["rdr2"] },
|
||||
{ name: "Resident Evil 4", aliases: ["re4 remake", "resident evil 4 remake"] },
|
||||
{ name: "Resident Evil Village", aliases: ["re8", "resident evil 8"] },
|
||||
{ name: "Rocket League", aliases: ["rocketleague"] },
|
||||
{ name: "Rust" },
|
||||
{ name: "Satisfactory" },
|
||||
{ name: "Sea of Thieves", aliases: ["seaofthieves"] },
|
||||
{ name: "Skyrim Special Edition", aliases: ["skyrimse", "tesv special edition"] },
|
||||
{ name: "Slay the Spire" },
|
||||
{ name: "Sons of the Forest" },
|
||||
{ name: "Spider-Man Remastered", aliases: ["spidermanremastered", "marvel spiderman remastered"] },
|
||||
{ name: "Split Fiction" },
|
||||
{ name: "Star Citizen" },
|
||||
{ name: "Starfield" },
|
||||
{ name: "Stardew Valley" },
|
||||
{ name: "Street Fighter 6", aliases: ["sf6"] },
|
||||
{ name: "Subnautica" },
|
||||
{ name: "Team Fortress 2", aliases: ["tf2"] },
|
||||
{ name: "Tekken 8", aliases: ["tekken8"] },
|
||||
{ name: "Terraria" },
|
||||
{ name: "The Elder Scrolls Online", aliases: ["eso"] },
|
||||
{ name: "The Finals" },
|
||||
{ name: "The Last of Us Part I", aliases: ["the last of us", "tlou"] },
|
||||
{ name: "The Witcher 3", aliases: ["witcher 3", "witcher3"] },
|
||||
{ name: "Titanfall 2" },
|
||||
{ name: "VALORANT", aliases: ["valorant-win64-shipping", "valorant-win64", "valorant"] },
|
||||
{ name: "V Rising" },
|
||||
{ name: "Valheim" },
|
||||
{ name: "Warframe" },
|
||||
{ name: "War Thunder" },
|
||||
{ name: "Wuthering Waves", aliases: ["wutheringwaves", "wuwa"] },
|
||||
{ name: "World of Warcraft", aliases: ["wow", "wowclassic", "worldofwarcraft"] },
|
||||
{ name: "World of Tanks", aliases: ["wot"] },
|
||||
{ name: "World of Warships", aliases: ["wowships"] },
|
||||
{ name: "Zenless Zone Zero", aliases: ["zzz"] },
|
||||
];
|
||||
|
||||
const GAME_MATCHERS = GAME_CATALOG.flatMap((entry) =>
|
||||
[entry.name, ...(entry.aliases || [])].flatMap((value) => buildNeedles(value)),
|
||||
);
|
||||
|
||||
export function parseGameAllowList(raw: string) {
|
||||
return String(raw || "")
|
||||
.split(/[\n,]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeGameText(value: string) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
.replace(/\.(exe|app|bat|sh)$/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
export function matchesKnownGame(candidate: CandidateLike, allowListRaw: string) {
|
||||
const haystack = normalizeGameText(
|
||||
`${candidate.processName} ${candidate.title} ${candidate.commandLine || ""}`,
|
||||
);
|
||||
|
||||
if (!haystack) return false;
|
||||
if (GAME_MATCHERS.some((matcher) => haystack.includes(matcher))) return true;
|
||||
|
||||
return parseGameAllowList(allowListRaw).some((item) =>
|
||||
haystack.includes(normalizeGameText(item)),
|
||||
);
|
||||
}
|
||||
|
||||
function buildNeedles(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return [];
|
||||
|
||||
const collapsed = raw.replace(/[’']/g, "");
|
||||
const variants = [
|
||||
raw,
|
||||
raw.toLowerCase(),
|
||||
collapsed,
|
||||
collapsed.toLowerCase(),
|
||||
normalizeGameText(raw),
|
||||
normalizeGameText(collapsed),
|
||||
];
|
||||
|
||||
return Array.from(new Set(variants.map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
|
||||
import { config } from "./config";
|
||||
import { mainWindow } from "./window";
|
||||
|
||||
type GamePresence = {
|
||||
title: string;
|
||||
processName: string;
|
||||
startedAt: number;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type OverlayState = {
|
||||
game: GamePresence | null;
|
||||
voice: VoiceOverlayState | null;
|
||||
};
|
||||
|
||||
let overlayWindow: BrowserWindow | null = null;
|
||||
let currentState: OverlayState = {
|
||||
game: null,
|
||||
voice: null,
|
||||
};
|
||||
|
||||
function publishActivityState() {
|
||||
const state = currentState;
|
||||
mainWindow?.webContents.send("sanctum-activity:update", state);
|
||||
overlayWindow?.webContents.send("sanctum-activity:update", state);
|
||||
}
|
||||
|
||||
const HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: rgba(18, 20, 28, 0.88);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: rgba(255, 255, 255, 0.96);
|
||||
--muted: rgba(255, 255, 255, 0.58);
|
||||
--accent: #8fb2ff;
|
||||
--speaking: #59f2a3;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
.shell {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.shell.is-flashing {
|
||||
opacity: 1;
|
||||
}
|
||||
.voice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
}
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.94);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
position: relative;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
opacity: 0.22;
|
||||
transition: opacity 90ms linear;
|
||||
}
|
||||
.avatar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
clip-path: circle(50% at 50% 50%);
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.member.speaking {
|
||||
opacity: 1;
|
||||
}
|
||||
.member.self.speaking {
|
||||
opacity: 1;
|
||||
}
|
||||
.member .initials {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.35);
|
||||
}
|
||||
.member.has-avatar {
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
}
|
||||
.member.has-avatar .initials {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="members" id="members"></div>
|
||||
</div>
|
||||
<script>
|
||||
const { ipcRenderer } = require("electron");
|
||||
|
||||
const state = { game: null, voice: null };
|
||||
const membersEl = document.getElementById("members");
|
||||
const shellEl = document.querySelector(".shell");
|
||||
let previousVoiceSignature = "";
|
||||
let flashTimeout = null;
|
||||
|
||||
function getInitials(name) {
|
||||
const value = String(name || "").trim();
|
||||
if (!value) return "?";
|
||||
const parts = value.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
function isSpeakingMember(member) {
|
||||
return Boolean(member?.speaking);
|
||||
}
|
||||
|
||||
function voiceSignature(voice) {
|
||||
if (!voice || !voice.members) return "";
|
||||
return voice.members
|
||||
.map((member) => [
|
||||
String(member?.name || "").trim().toLowerCase(),
|
||||
String(member?.avatarUrl || "").trim(),
|
||||
].join(":"))
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function flashShell() {
|
||||
if (!shellEl) return;
|
||||
shellEl.classList.add("is-flashing");
|
||||
clearTimeout(flashTimeout);
|
||||
flashTimeout = setTimeout(() => {
|
||||
shellEl.classList.remove("is-flashing");
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const voice = state.voice;
|
||||
const hasSpeaking = Boolean(voice?.members?.some((member) => member?.speaking));
|
||||
membersEl.innerHTML = "";
|
||||
|
||||
if (!voice || !voice.members || !voice.members.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const member of voice.members.slice(0, 5)) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "member" + (isSpeakingMember(member) ? " speaking" : "") + (member.name === "You" ? " self" : "");
|
||||
row.title = member.name || "Unknown";
|
||||
const initials = document.createElement("span");
|
||||
initials.className = "initials";
|
||||
initials.textContent = getInitials(member.name);
|
||||
if (member.avatarUrl) {
|
||||
row.classList.add("has-avatar");
|
||||
const img = document.createElement("img");
|
||||
img.className = "avatar";
|
||||
img.alt = member.name || "Avatar";
|
||||
img.draggable = false;
|
||||
img.src = String(member.avatarUrl);
|
||||
row.appendChild(img);
|
||||
} else {
|
||||
row.classList.remove("has-avatar");
|
||||
}
|
||||
row.appendChild(initials);
|
||||
membersEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on("overlay-state", (_, next) => {
|
||||
state.game = next?.game || null;
|
||||
state.voice = next?.voice || null;
|
||||
const nextSignature = voiceSignature(state.voice);
|
||||
if (nextSignature && nextSignature !== previousVoiceSignature) {
|
||||
flashShell();
|
||||
}
|
||||
previousVoiceSignature = nextSignature;
|
||||
if (!state.voice || !state.voice.members || !state.voice.members.length) {
|
||||
flashShell();
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function getOverlayBounds() {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
return { ...display.workArea };
|
||||
}
|
||||
|
||||
function ensureOverlayWindow() {
|
||||
if (overlayWindow) return overlayWindow;
|
||||
|
||||
const bounds = getOverlayBounds();
|
||||
|
||||
overlayWindow = new BrowserWindow({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
skipTaskbar: true,
|
||||
focusable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
hasShadow: false,
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
|
||||
overlayWindow.setMenu(null);
|
||||
overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
overlayWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
overlayWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(HTML));
|
||||
overlayWindow.webContents.on("did-finish-load", () => {
|
||||
syncOverlayWindow();
|
||||
});
|
||||
|
||||
overlayWindow.on("closed", () => {
|
||||
overlayWindow = null;
|
||||
});
|
||||
|
||||
return overlayWindow;
|
||||
}
|
||||
|
||||
function shouldShowOverlay() {
|
||||
if (!config.gamePresenceEnabled) return false;
|
||||
return Boolean(
|
||||
currentState.game &&
|
||||
currentState.voice &&
|
||||
currentState.voice.isInCall,
|
||||
);
|
||||
}
|
||||
|
||||
function syncOverlayWindow() {
|
||||
if (!overlayWindow) return;
|
||||
|
||||
if (!shouldShowOverlay()) {
|
||||
if (overlayWindow.isVisible()) overlayWindow.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
overlayWindow.showInactive();
|
||||
overlayWindow.webContents.send("overlay-state", currentState);
|
||||
}
|
||||
|
||||
export function setGamePresence(game: GamePresence | null) {
|
||||
if (!config.gamePresenceEnabled) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
game: null,
|
||||
};
|
||||
syncOverlayWindow();
|
||||
publishActivityState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (game && /^(sanctum|stoat|electron)$/i.test(game.processName)) {
|
||||
game = null;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
game,
|
||||
};
|
||||
|
||||
ensureOverlayWindow();
|
||||
syncOverlayWindow();
|
||||
publishActivityState();
|
||||
}
|
||||
|
||||
export function setVoiceOverlayState(voice: VoiceOverlayState | null) {
|
||||
if (!config.gamePresenceEnabled) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
voice,
|
||||
};
|
||||
publishActivityState();
|
||||
return;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
voice,
|
||||
};
|
||||
|
||||
ensureOverlayWindow();
|
||||
syncOverlayWindow();
|
||||
publishActivityState();
|
||||
}
|
||||
|
||||
export function debugSetActivityState(state: OverlayState) {
|
||||
currentState = {
|
||||
game: state.game || null,
|
||||
voice: state.voice || null,
|
||||
};
|
||||
|
||||
ensureOverlayWindow();
|
||||
syncOverlayWindow();
|
||||
publishActivityState();
|
||||
}
|
||||
|
||||
ipcMain.on("overlay:set-voice-state", (_event, state: VoiceOverlayState | null) => {
|
||||
setVoiceOverlayState(state);
|
||||
});
|
||||
|
||||
ipcMain.handle("sanctum-activity:get-state", () => currentState);
|
||||
ipcMain.handle("sanctum-activity:debug-set-state", (_event, state: OverlayState) => {
|
||||
debugSetActivityState(state);
|
||||
return currentState;
|
||||
});
|
||||
|
||||
export function getCurrentGamePresence() {
|
||||
return currentState.game;
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { config } from "./config";
|
||||
import { matchesKnownGame } from "./gameCatalog";
|
||||
import { getCurrentGamePresence, setGamePresence } from "./gameOverlay";
|
||||
|
||||
type Candidate = {
|
||||
title: string;
|
||||
processName: string;
|
||||
source: string;
|
||||
commandLine?: string;
|
||||
};
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let monitorTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
/^sanctum$/i,
|
||||
/^cloud\.mithraic\.sanctum$/i,
|
||||
/^mithral$/i,
|
||||
/^stoat$/i,
|
||||
/^electron$/i,
|
||||
/^chrome$/i,
|
||||
/^google chrome$/i,
|
||||
/^msedge$/i,
|
||||
/^microsoft edge$/i,
|
||||
/^firefox$/i,
|
||||
/^brave$/i,
|
||||
/^brave browser$/i,
|
||||
/^vivaldi$/i,
|
||||
/^opera$/i,
|
||||
/^opera gx$/i,
|
||||
/^arc$/i,
|
||||
/^safari$/i,
|
||||
/^finder$/i,
|
||||
/^launchpad$/i,
|
||||
/^terminal$/i,
|
||||
/^iterm2$/i,
|
||||
/^steam$/i,
|
||||
/^steamwebhelper$/i,
|
||||
/^discord$/i,
|
||||
/^slack$/i,
|
||||
/^teams$/i,
|
||||
/^zoom$/i,
|
||||
/^notion$/i,
|
||||
/^obsidian$/i,
|
||||
/^spotify$/i,
|
||||
/^telegram$/i,
|
||||
/^whatsapp$/i,
|
||||
/^code$/i,
|
||||
/^visual studio code$/i,
|
||||
/^node$/i,
|
||||
/^explorer$/i,
|
||||
/^file explorer$/i,
|
||||
/^system$/i,
|
||||
/^systemsettings$/i,
|
||||
/^settings$/i,
|
||||
/^textedit$/i,
|
||||
/^notes$/i,
|
||||
/^preview$/i,
|
||||
/^activity monitor$/i,
|
||||
/^app store$/i,
|
||||
/^messages$/i,
|
||||
/^mail$/i,
|
||||
/^outlook$/i,
|
||||
/^word$/i,
|
||||
/^excel$/i,
|
||||
/^powerpoint$/i,
|
||||
/^python$/i,
|
||||
/^bash$/i,
|
||||
/^zsh$/i,
|
||||
/^sh$/i,
|
||||
/^ps$/i,
|
||||
/^tasklist$/i,
|
||||
/^powershell$/i,
|
||||
/^pwsh$/i,
|
||||
/^xprop$/i,
|
||||
/^xdotool$/i,
|
||||
/^osascript$/i,
|
||||
];
|
||||
|
||||
const SELF_PATTERNS = [
|
||||
/sanctum/i,
|
||||
/stoat/i,
|
||||
/cloud\.mithraic\.sanctum/i,
|
||||
/mithraic\.space/i,
|
||||
/stoat\.chat/i,
|
||||
/electron-forge/i,
|
||||
/\/home\/[^/]+\/sanctum/i,
|
||||
/[A-Z]:\\.*\\sanctum/i,
|
||||
];
|
||||
|
||||
export function startGamePresenceMonitor() {
|
||||
if (monitorTimer) return;
|
||||
void refreshGamePresence();
|
||||
monitorTimer = setInterval(() => {
|
||||
void refreshGamePresence();
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function refreshGamePresence() {
|
||||
if (!config.gamePresenceEnabled) {
|
||||
if (getCurrentGamePresence()) {
|
||||
setGamePresence(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = await detectGameCandidate();
|
||||
const current = getCurrentGamePresence();
|
||||
const knownMatch = next ? matchesKnownGame(next, config.gamePresenceAllowList) : false;
|
||||
const accepted = next && !isSelfAppCandidate(next) && knownMatch ? next : null;
|
||||
|
||||
const same =
|
||||
(!!current &&
|
||||
!!accepted &&
|
||||
current.processName === accepted.processName &&
|
||||
current.title === accepted.title) ||
|
||||
(!current && !accepted);
|
||||
|
||||
if (same) return;
|
||||
|
||||
if (accepted) {
|
||||
console.info("[gamePresence] detected", accepted.processName, accepted.title, accepted.source);
|
||||
} else if (current) {
|
||||
console.info("[gamePresence] cleared");
|
||||
}
|
||||
|
||||
setGamePresence(accepted);
|
||||
}
|
||||
|
||||
async function detectGameCandidate(): Promise<Candidate | null> {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
return await detectWindowsGame();
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return await detectMacGame();
|
||||
}
|
||||
|
||||
return await detectUnixGame();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectWindowsGame(): Promise<Candidate | null> {
|
||||
const script = [
|
||||
"$sig=@'",
|
||||
"using System;",
|
||||
"using System.Text;",
|
||||
"using System.Runtime.InteropServices;",
|
||||
"public static class Win32 {",
|
||||
" [DllImport(\"user32.dll\")] public static extern IntPtr GetForegroundWindow();",
|
||||
" [DllImport(\"user32.dll\", SetLastError=true)] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);",
|
||||
" [DllImport(\"user32.dll\", CharSet=CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);",
|
||||
"}",
|
||||
"'@;",
|
||||
"Add-Type $sig | Out-Null;",
|
||||
"$h=[Win32]::GetForegroundWindow();",
|
||||
"$pid=0;",
|
||||
"[void][Win32]::GetWindowThreadProcessId($h,[ref]$pid);",
|
||||
"$p=Get-Process -Id $pid -ErrorAction SilentlyContinue;",
|
||||
"$sb=New-Object System.Text.StringBuilder 512;",
|
||||
"[void][Win32]::GetWindowText($h,$sb,$sb.Capacity);",
|
||||
"if ($p) { Write-Output ($p.ProcessName + '|' + $sb.ToString()) }",
|
||||
].join(" ");
|
||||
|
||||
const { stdout } = await execFileAsync("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
]);
|
||||
|
||||
const parsed = parseCandidateLine(stdout.trim(), "foreground-window");
|
||||
return parsed && !isIgnoredCandidate(parsed.processName, parsed.title, parsed.commandLine || "")
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
async function detectMacGame(): Promise<Candidate | null> {
|
||||
const { stdout } = await execFileAsync("osascript", [
|
||||
"-e",
|
||||
'tell application "System Events" to get name of first process whose frontmost is true',
|
||||
]);
|
||||
|
||||
const processName = stdout.trim();
|
||||
if (!processName || isIgnoredCandidate(processName, processName, "")) return null;
|
||||
|
||||
return {
|
||||
processName,
|
||||
title: formatGameTitle(processName),
|
||||
source: "macOS frontmost app",
|
||||
};
|
||||
}
|
||||
|
||||
async function detectUnixGame(): Promise<Candidate | null> {
|
||||
const xpropCandidate = await detectLinuxX11Game();
|
||||
if (xpropCandidate) return xpropCandidate;
|
||||
|
||||
const { stdout } = await execFileAsync("sh", [
|
||||
"-lc",
|
||||
[
|
||||
"if command -v xdotool >/dev/null 2>&1; then",
|
||||
" title=$(xdotool getactivewindow getwindowname 2>/dev/null || true)",
|
||||
" pid=$(xdotool getactivewindow getwindowpid 2>/dev/null || true)",
|
||||
" if [ -n \"$pid\" ]; then",
|
||||
" name=$(ps -p \"$pid\" -o comm= 2>/dev/null | head -n 1 | tr -d '\\n')",
|
||||
" args=$(ps -p \"$pid\" -o args= 2>/dev/null | head -n 1 | tr -d '\\n')",
|
||||
" printf '%s|%s|%s\\n' \"$name\" \"$title\" \"$args\"",
|
||||
" exit 0",
|
||||
" fi",
|
||||
"fi",
|
||||
"exit 0",
|
||||
].join("\n"),
|
||||
]);
|
||||
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parsed = parseCandidateLine(trimmed, "foreground-window");
|
||||
if (parsed && !isIgnoredCandidate(parsed.processName, parsed.title, parsed.commandLine || "")) return parsed;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function detectLinuxX11Game(): Promise<Candidate | null> {
|
||||
try {
|
||||
const { stdout: activeWindow } = await execFileAsync("xprop", [
|
||||
"-root",
|
||||
"_NET_ACTIVE_WINDOW",
|
||||
]);
|
||||
|
||||
const match = activeWindow.match(/0x[0-9a-fA-F]+/);
|
||||
if (!match) return null;
|
||||
|
||||
const windowId = match[0];
|
||||
const { stdout } = await execFileAsync("xprop", [
|
||||
"-id",
|
||||
windowId,
|
||||
"WM_CLASS",
|
||||
"WM_NAME",
|
||||
"_NET_WM_NAME",
|
||||
]);
|
||||
|
||||
const parts = stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const className = extractQuotedValue(parts.find((line) => line.startsWith("WM_CLASS")) || "");
|
||||
const name =
|
||||
extractQuotedValue(parts.find((line) => line.startsWith("_NET_WM_NAME")) || "") ||
|
||||
extractQuotedValue(parts.find((line) => line.startsWith("WM_NAME")) || "");
|
||||
|
||||
const processName = (className || name || "unknown").trim();
|
||||
const title = (name || className || "").trim();
|
||||
|
||||
if (!processName) return null;
|
||||
if (isIgnoredCandidate(processName, title || processName, "")) return null;
|
||||
|
||||
return {
|
||||
processName,
|
||||
title: title || formatGameTitle(processName),
|
||||
source: "xprop foreground window",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractQuotedValue(line: string) {
|
||||
const quoted = line.match(/"([^"]+)"/g);
|
||||
if (!quoted || !quoted.length) return "";
|
||||
return quoted.map((value) => value.replace(/^"|"$/g, "")).join(" ");
|
||||
}
|
||||
|
||||
function parseCandidateLine(line: string, source: string): Candidate | null {
|
||||
if (!line) return null;
|
||||
|
||||
const [processNameRaw, titleRaw = "", commandLineRaw = ""] = line.split("|");
|
||||
const processName = (processNameRaw || "").trim();
|
||||
const title =
|
||||
source === "process scan"
|
||||
? formatGameTitle(processName)
|
||||
: (titleRaw || "").trim() || formatGameTitle(processName);
|
||||
const commandLine = (commandLineRaw || "").trim();
|
||||
|
||||
if (!processName) return null;
|
||||
|
||||
return {
|
||||
title,
|
||||
processName,
|
||||
source,
|
||||
commandLine: commandLine || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isIgnoredCandidate(processName: string, title: string, commandLine = "") {
|
||||
if (isSelfString(processName) || isSelfString(title) || isSelfString(commandLine)) return true;
|
||||
if (IGNORE_PATTERNS.some((pattern) => pattern.test(processName))) return true;
|
||||
if (IGNORE_PATTERNS.some((pattern) => pattern.test(title))) return true;
|
||||
if (commandLine && IGNORE_PATTERNS.some((pattern) => pattern.test(commandLine))) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSelfAppCandidate(candidate: Candidate) {
|
||||
return isSelfString(candidate.processName) || isSelfString(candidate.title) || isSelfString(candidate.commandLine || "");
|
||||
}
|
||||
|
||||
function isSelfString(value: string) {
|
||||
const normalized = String(value || "");
|
||||
return SELF_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function formatGameTitle(raw: string) {
|
||||
const cleaned = raw
|
||||
.replace(/\.(exe|app|bat|sh)$/i, "")
|
||||
.replace(/[_.-]+/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.trim();
|
||||
|
||||
if (!cleaned) return raw || "Unknown Game";
|
||||
|
||||
return cleaned
|
||||
.split(/\s+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ export function initTray() {
|
|||
const trayIcon = createTrayIcon();
|
||||
tray = new Tray(trayIcon);
|
||||
updateTrayMenu();
|
||||
tray.setToolTip("Sanctum for Desktop");
|
||||
tray.setToolTip("AviaClient for Desktop");
|
||||
tray.setImage(trayIcon);
|
||||
tray.on("click", () => {
|
||||
config.sync();
|
||||
|
|
@ -46,7 +46,7 @@ export function initTray() {
|
|||
export function updateTrayMenu() {
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ label: "Sanctum for Desktop", type: "normal", enabled: false },
|
||||
{ label: "AviaClient for Desktop", type: "normal", enabled: false },
|
||||
{
|
||||
label: "Versions",
|
||||
type: "submenu",
|
||||
|
|
@ -57,7 +57,7 @@ export function updateTrayMenu() {
|
|||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: `Sanctum: ${aviaVersion}`,
|
||||
label: `AviaClient: ${aviaVersion}`,
|
||||
type: "normal",
|
||||
enabled: false,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import { BrowserWindow, app, ipcMain } from "electron";
|
||||
|
||||
let win: BrowserWindow | null = null;
|
||||
|
||||
const HTML = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:system-ui,sans-serif;background:#1e1e2e;color:#cdd6f4;
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
height:100vh;padding:28px 32px;gap:14px;user-select:none;-webkit-app-region:drag}
|
||||
h2{font-size:15px;font-weight:600;letter-spacing:.3px}
|
||||
#status{font-size:13px;color:#a6adc8}
|
||||
.track{width:100%;height:6px;background:#313244;border-radius:3px}
|
||||
#bar{height:6px;background:#89b4fa;border-radius:3px;width:0%;transition:width .4s ease}
|
||||
#btn{display:none;margin-top:6px;padding:9px 24px;background:#89b4fa;color:#1e1e2e;
|
||||
border:none;border-radius:6px;font-size:13px;font-weight:700;cursor:pointer;
|
||||
-webkit-app-region:no-drag}
|
||||
#btn:hover{background:#b4befe}
|
||||
</style></head><body>
|
||||
<h2 id="title">Updating Sanctum</h2>
|
||||
<div id="status">Downloading…</div>
|
||||
<div class="track"><div id="bar"></div></div>
|
||||
<button id="btn">Restart Now</button>
|
||||
<script>
|
||||
const {ipcRenderer}=require('electron');
|
||||
ipcRenderer.on('upd-progress',(_,p)=>{
|
||||
document.getElementById('bar').style.width=p+'%';
|
||||
document.getElementById('status').textContent=p<100?'Downloading… '+p+'%':'Installing…';
|
||||
});
|
||||
ipcRenderer.on('upd-ready',(_,v)=>{
|
||||
document.getElementById('title').textContent='Sanctum '+v+' installed';
|
||||
document.getElementById('bar').style.width='100%';
|
||||
var s=document.getElementById('status');
|
||||
var n=2;
|
||||
s.textContent='Restarting in '+n+'…';
|
||||
var t=setInterval(function(){
|
||||
n--;
|
||||
if(n<=0){clearInterval(t);s.textContent='Restarting…';}
|
||||
else s.textContent='Restarting in '+n+'…';
|
||||
},1000);
|
||||
});
|
||||
ipcRenderer.on('upd-error',(_,msg)=>{
|
||||
document.getElementById('title').textContent='Update Failed';
|
||||
document.getElementById('status').textContent=msg;
|
||||
document.getElementById('bar').style.background='#f38ba8';
|
||||
});
|
||||
</script></body></html>`;
|
||||
|
||||
export function showUpdateWindow() {
|
||||
if (win) { win.focus(); return; }
|
||||
win = new BrowserWindow({
|
||||
width: 400,
|
||||
height: 180,
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
title: "Sanctum Update",
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(HTML));
|
||||
win.on("closed", () => { win = null; });
|
||||
}
|
||||
|
||||
export function setUpdateProgress(percent: number) {
|
||||
win?.webContents.send("upd-progress", Math.round(percent));
|
||||
}
|
||||
|
||||
export function setUpdateReady(version: string, relaunch: boolean) {
|
||||
win?.webContents.send("upd-ready", version);
|
||||
setTimeout(() => {
|
||||
if (relaunch) app.relaunch();
|
||||
app.exit(0);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function setUpdateError(msg: string) {
|
||||
win?.webContents.send("upd-error", msg);
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
import { Notification, app, ipcMain } from "electron";
|
||||
import { exec, spawn } from "child_process";
|
||||
import { createWriteStream, mkdirSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
import { showUpdateWindow, setUpdateProgress, setUpdateReady, setUpdateError } from "./update-window";
|
||||
|
||||
ipcMain.handle("checkForUpdates", () => checkForUpdates());
|
||||
|
||||
const RELEASES_URL =
|
||||
"https://git.mithraic.cloud/api/v1/repos/ad3laid3/sanctum/releases/latest";
|
||||
|
||||
interface Asset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
interface Release {
|
||||
tag_name: string;
|
||||
html_url: string;
|
||||
assets: Asset[];
|
||||
}
|
||||
|
||||
export async function checkForUpdates() {
|
||||
try {
|
||||
console.log("[updater] checking:", RELEASES_URL);
|
||||
|
||||
const res = await fetch(RELEASES_URL);
|
||||
if (!res.ok) {
|
||||
console.error("[updater] releases API returned", res.status, res.statusText);
|
||||
notify("Update Check Failed", `API returned ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const release = (await res.json()) as Release;
|
||||
const latest = release.tag_name.replace(/^v/, "");
|
||||
const current = app.getVersion();
|
||||
|
||||
console.log(`[updater] current=${current} latest=${latest}`);
|
||||
|
||||
if (!isNewer(latest, current)) {
|
||||
notify("Already Up to Date", `You're on Sanctum ${current}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = findAsset(release.assets);
|
||||
if (!asset) {
|
||||
const names = release.assets.map(a => a.name).join(", ");
|
||||
console.error("[updater] no matching asset for platform:", process.platform, names);
|
||||
notify("Update Failed", `No ${process.platform} asset found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[updater] update available: ${current} → ${latest}, downloading ${asset.name}`);
|
||||
showUpdateWindow();
|
||||
await downloadAndInstall(asset.browser_download_url, latest);
|
||||
} catch (err) {
|
||||
console.error("[updater] update check failed:", err);
|
||||
setUpdateError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function findAsset(assets: Asset[]): Asset | undefined {
|
||||
if (process.platform === "linux")
|
||||
return assets.find((a) => a.name.includes("linux") && a.name.endsWith(".zip"));
|
||||
if (process.platform === "win32")
|
||||
return assets.find((a) => a.name.includes("win32") && a.name.endsWith(".zip"));
|
||||
}
|
||||
|
||||
async function downloadAndInstall(url: string, version: string) {
|
||||
const tmpDir = join(tmpdir(), `sanctum-update-${version}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const zipPath = join(tmpDir, "update.zip");
|
||||
const extractDir = join(tmpDir, "extracted");
|
||||
const installDir = dirname(process.execPath);
|
||||
|
||||
console.log(`[updater] downloading from ${url}`);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
|
||||
const total = Number(res.headers.get("content-length")) || 0;
|
||||
let downloaded = 0;
|
||||
const writer = createWriteStream(zipPath);
|
||||
const reader = res.body.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
writer.write(value);
|
||||
downloaded += value.length;
|
||||
if (total > 0) setUpdateProgress(Math.round((downloaded / total) * 90));
|
||||
}
|
||||
await new Promise<void>(resolve => writer.end(resolve));
|
||||
|
||||
setUpdateProgress(95);
|
||||
console.log(`[updater] download complete, extracting to ${installDir}`);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Extract zip while the app is still running (no locked files yet)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cmd = `powershell -Command "Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${extractDir}'"`;
|
||||
exec(cmd, (err, _stdout, stderr) => {
|
||||
if (err) { console.error("[updater] extract failed:", stderr); reject(err); }
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Write a batch script that runs after we exit: waits, copies, relaunches
|
||||
const batchPath = join(tmpDir, "apply-update.bat");
|
||||
const bat = [
|
||||
"@echo off",
|
||||
"timeout /t 3 /nobreak >nul",
|
||||
`for /d %%D in ("${extractDir}\\*") do set SUB=%%D`,
|
||||
`xcopy /E /Y /I "%SUB%\\*" "${installDir}\\"`,
|
||||
`start "" "${join(installDir, "sanctum.exe")}"`,
|
||||
`del "%~f0"`,
|
||||
].join("\r\n");
|
||||
writeFileSync(batchPath, bat);
|
||||
|
||||
// Spawn batch truly detached so it outlives this process
|
||||
const child = spawn("cmd.exe", ["/C", batchPath], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: false,
|
||||
});
|
||||
child.unref();
|
||||
setUpdateReady(version, false); // batch script handles relaunch
|
||||
} else {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cmd = `unzip -o "${zipPath}" -d "${extractDir}" && SUBDIR=$(ls "${extractDir}" | head -1) && rm -f "${installDir}/sanctum" && cp -rT "${extractDir}/$SUBDIR" "${installDir}"`;
|
||||
exec(cmd, { shell: "/bin/bash" }, (err, _stdout, stderr) => {
|
||||
if (err) { console.error("[updater] extract failed:", stderr); reject(err); }
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
setUpdateReady(version, true);
|
||||
}
|
||||
}
|
||||
|
||||
function notify(title: string, body: string) {
|
||||
const n = new Notification({ title, body, silent: true });
|
||||
n.show();
|
||||
return n;
|
||||
}
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
const parse = (v: string) => v.split(".").map(p => Number(p) || 0);
|
||||
const [lA, lB, lC] = parse(latest);
|
||||
const [cA, cB, cC] = parse(current);
|
||||
if (lA !== cA) return lA > cA;
|
||||
if (lB !== cB) return lB > cB;
|
||||
return lC > cC;
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ export let mainWindow: BrowserWindow;
|
|||
export const BUILD_URL = new URL(
|
||||
app.commandLine.hasSwitch("force-server")
|
||||
? app.commandLine.getSwitchValue("force-server")
|
||||
: /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://mithraic.space/app",
|
||||
: /*MAIN_WINDOW_VITE_DEV_SERVER_URL ??*/ "https://beta.revolt.chat",
|
||||
);
|
||||
|
||||
// internal window state
|
||||
|
|
|
|||
|
|
@ -11,22 +11,6 @@ contextBridge.exposeInMainWorld("native", {
|
|||
aviaClient: () => aviaVersion,
|
||||
},
|
||||
|
||||
overlay: {
|
||||
setVoiceState: (state: VoiceOverlayState | null) =>
|
||||
ipcRenderer.send("overlay:set-voice-state", state),
|
||||
},
|
||||
|
||||
activity: {
|
||||
getState: () => ipcRenderer.invoke("sanctum-activity:get-state"),
|
||||
onUpdate: (callback: (state: SanctumActivityState) => void) => {
|
||||
const listener = (_event: unknown, state: SanctumActivityState) => callback(state);
|
||||
ipcRenderer.on("sanctum-activity:update", listener);
|
||||
return () => ipcRenderer.removeListener("sanctum-activity:update", listener);
|
||||
},
|
||||
debugSetState: (state: SanctumActivityState) =>
|
||||
ipcRenderer.invoke("sanctum-activity:debug-set-state", state) as Promise<SanctumActivityState>,
|
||||
},
|
||||
|
||||
minimise: () => ipcRenderer.send("minimise"),
|
||||
maximise: () => ipcRenderer.send("maximise"),
|
||||
close: () => ipcRenderer.send("close"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue