From 45a1525259a09a117e734efea96d684cd7455f6f Mon Sep 17 00:00:00 2001 From: ENVISION Date: Mon, 9 Mar 2026 03:13:25 -0400 Subject: [PATCH 1/9] Added padding to the settings panel and a scrollbar for actions in the injector --- nw/index.html | 25 +++++++++++++++++++++++-- scripts/lib/fluxer-injector-utils.js | 6 +++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/nw/index.html b/nw/index.html index 5bc695c..a2030bc 100644 --- a/nw/index.html +++ b/nw/index.html @@ -71,7 +71,7 @@ Injecting -
+

Actions

+
+ + When nothing is playing, publish system stats instead. +
+
+ + + Suggested poll interval for status sync. +
Status: idle

   
diff --git a/bridge-nw/scripts/local-bridge.js b/bridge-nw/scripts/local-bridge.js index 79b686b..94a04f4 100644 --- a/bridge-nw/scripts/local-bridge.js +++ b/bridge-nw/scripts/local-bridge.js @@ -31,6 +31,7 @@ const BRIDGE_BASE_DIR = getBridgeBaseDir(); const DATA_DIR = path.join(BRIDGE_BASE_DIR, "data"); const TOKEN_FILE = path.join(DATA_DIR, "bridge-token.txt"); const CACHE_FILE = path.join(DATA_DIR, "bridge-cache.json"); +const SETTINGS_FILE = path.join(DATA_DIR, "bridge-settings.json"); const HOST = "127.0.0.1"; const PORT = Number.parseInt(process.env.BF_BRIDGE_PORT || "21864", 10); @@ -43,6 +44,7 @@ const BRIDGE_VERSION = BRIDGE_VERSION_BY_OS[process.platform] || "2026-03-10-rpc const REQUEST_TIMEOUT_MS = Number.parseInt(process.env.BF_BRIDGE_TIMEOUT_MS || "12000", 10); const DEFAULT_TTL_SECONDS = Number.parseInt(process.env.BF_BRIDGE_DEFAULT_TTL || "120", 10); const MAX_TTL_SECONDS = Number.parseInt(process.env.BF_BRIDGE_MAX_TTL || "1800", 10); +const DEFAULT_UPDATE_INTERVAL_MS = Number.parseInt(process.env.BF_BRIDGE_UPDATE_INTERVAL_MS || "10000", 10); const WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; const WINDOWS_RUN_VALUE = "BetterFluxerBridge"; const LINUX_BIN_DIR = path.join(os.homedir(), ".local", "bin"); @@ -116,6 +118,12 @@ function normalizeTtlSeconds(input) { return Math.max(1, Math.min(MAX_TTL_SECONDS, n)); } +function normalizeUpdateIntervalMs(input) { + const n = Number.parseInt(String(input || ""), 10); + if (!Number.isFinite(n) || n <= 0) return DEFAULT_UPDATE_INTERVAL_MS; + return Math.max(1000, Math.min(60000, Math.round(n))); +} + function readJsonBody(req) { return new Promise((resolve) => { let raw = ""; @@ -153,6 +161,21 @@ function saveCache(cache) { fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), "utf8"); } +function loadBridgeSettings() { + if (!fs.existsSync(SETTINGS_FILE)) { + return { nerdModeEnabled: false, updateIntervalMs: DEFAULT_UPDATE_INTERVAL_MS }; + } + try { + const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8")); + return { + nerdModeEnabled: Boolean(parsed && parsed.nerdModeEnabled), + updateIntervalMs: normalizeUpdateIntervalMs(parsed && parsed.updateIntervalMs) + }; + } catch (_) { + return { nerdModeEnabled: false, updateIntervalMs: DEFAULT_UPDATE_INTERVAL_MS }; + } +} + function parseArgv(argv) { const out = {}; for (const item of argv || []) { @@ -392,6 +415,41 @@ function normalizeNowPlayingPayload(payload, source) { }; } +function formatDurationShort(totalSeconds) { + const sec = Math.max(0, Math.floor(Number(totalSeconds) || 0)); + const hours = Math.floor(sec / 3600); + const minutes = Math.floor((sec % 3600) / 60); + const seconds = sec % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function buildNerdModeNowPlaying() { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = Math.max(0, totalMem - freeMem); + const memPercent = totalMem > 0 ? Math.round((usedMem / totalMem) * 100) : 0; + const loadAvg = os.loadavg(); + const cpuCount = Array.isArray(os.cpus()) ? os.cpus().length : 0; + const hostname = String(os.hostname() || "").trim(); + const details = `CPU ${Number(loadAvg[0] || 0).toFixed(2)} | RAM ${memPercent}%`; + const state = `${hostname || process.platform} | up ${formatDurationShort(os.uptime())}`; + return normalizeNowPlayingPayload( + { + source: "nerd-mode", + kind: "system", + title: "Nerd Mode", + details, + state, + appId: hostname || process.platform, + playbackStatus: "idle", + name: `System Stats (${cpuCount} cores)` + }, + "nerd-mode" + ); +} + function makeRpcFrame(op, payloadObject) { const payload = Buffer.from(JSON.stringify(payloadObject || {}), "utf8"); const frame = Buffer.allocUnsafe(8 + payload.length); @@ -809,6 +867,7 @@ async function queryMacMedia() { async function queryUniversalNowPlaying(state) { const now = Date.now(); + let rpcFallback = null; if (state && state.lastRpcActivity && now - Number(state.lastRpcActivityAt || 0) < 180000) { const raw = state.lastRpcActivity.raw && typeof state.lastRpcActivity.raw === "object" ? state.lastRpcActivity.raw : {}; const base = state.lastRpcActivity.normalized && typeof state.lastRpcActivity.normalized === "object" @@ -823,31 +882,49 @@ async function queryUniversalNowPlaying(state) { if (startMs != null && endMs != null && endMs > startMs) { base.durationMs = endMs - startMs; } - return base; + if (base && base.ok && base.hasSession) { + return base; + } + rpcFallback = { + ok: true, + hasSession: false, + source: String(base.source || "discord-rpc-pipe"), + error: String(base.error || "No active RPC session") + }; } + let fallback = null; if (process.platform === "win32") { const tuna = await queryTunaNowPlaying(state); if (tuna && tuna.ok && tuna.hasSession) return tuna; - return tuna && tuna.ok + fallback = tuna && tuna.ok ? tuna : { ok: false, hasSession: false, source: "windows-now-playing", error: "No active RPC or Tuna session" }; - } - if (process.platform === "linux") { + } else if (process.platform === "linux") { const tuna = await queryTunaNowPlaying(state); if (tuna && tuna.ok && tuna.hasSession) return tuna; - return tuna && tuna.ok + fallback = tuna && tuna.ok ? tuna : { ok: false, hasSession: false, source: "linux-now-playing", error: "No active Tuna session" }; - } - if (process.platform === "darwin") { + } else if (process.platform === "darwin") { const mac = await queryMacMedia(); if (mac.ok && mac.hasSession) return mac; const tuna = await queryTunaNowPlaying(state); if (tuna && tuna.ok && tuna.hasSession) return tuna; - return mac.ok ? mac : tuna.ok ? tuna : mac; + fallback = mac.ok ? mac : tuna.ok ? tuna : mac; + } else { + fallback = { ok: false, error: `Unsupported platform: ${process.platform}` }; + } + + const preferredFallback = + fallback && fallback.hasSession + ? fallback + : rpcFallback || fallback; + + if (loadBridgeSettings().nerdModeEnabled && (!preferredFallback || !preferredFallback.hasSession)) { + return buildNerdModeNowPlaying(); } - return { ok: false, error: `Unsupported platform: ${process.platform}` }; + return preferredFallback; } function formatNowPlayingForLog(np) { @@ -988,6 +1065,7 @@ async function main() { const url = new URL(req.url || "/", `http://${HOST}:${PORT}`); if (url.pathname === "/health") { + const settings = loadBridgeSettings(); return sendJson( res, 200, @@ -999,6 +1077,8 @@ async function main() { rpcPipesListening: (bridgeState.rpcServers || []).length, rpcPipeErrors: (bridgeState.rpcBindErrors || []).length, tunaPath: bridgeState.tunaPath, + nerdModeEnabled: Boolean(settings.nerdModeEnabled), + updateIntervalMs: settings.updateIntervalMs, allowlist, uptimeSec: Math.floor(process.uptime()) }, diff --git a/do_not_edit/fluxer b/do_not_edit/fluxer new file mode 160000 index 0000000..03813bb --- /dev/null +++ b/do_not_edit/fluxer @@ -0,0 +1 @@ +Subproject commit 03813bbe17db008452f0f1be3090a7d2970a5447 diff --git a/nw/plugins/DiscordRPCEmu/index.js b/nw/plugins/DiscordRPCEmu/index.js index 917311f..d09ff8f 100644 --- a/nw/plugins/DiscordRPCEmu/index.js +++ b/nw/plugins/DiscordRPCEmu/index.js @@ -51,8 +51,10 @@ module.exports = class DiscordRPCEmuPlugin { this.bridgeSocket = null; this.bridgePort = null; this.bridgeReconnectTimer = null; + this.bridgeWatchdogTimer = null; this.bridgeStarted = false; this.bridgeNonce = 0; + this.lastBridgeSocketEventAt = 0; this.statusSyncEnabled = this.api.storage.get("statusSyncEnabled", true) !== false; this.statusPollTimer = null; this.lastBridgeActivity = null; @@ -65,10 +67,14 @@ module.exports = class DiscordRPCEmuPlugin { this.localBridgeEnabled = this.api.storage.get("localBridgeEnabled", true) !== false; this.localBridgePort = Number.parseInt(String(this.api.storage.get("localBridgePort", "21864")), 10) || 21864; this.localBridgeToken = String(this.api.storage.get("localBridgeToken", "") || ""); + this.statusSyncIntervalMs = Number.parseInt(String(this.api.storage.get("statusSyncIntervalMs", "10000")), 10) || 10000; this.lastWindowsMedia = null; this.lastWindowsMediaAt = 0; this.lastWindowsMediaError = ""; this.debugDetection = this.api.storage.get("debugDetection", false) === true; + this.savedUserCustomStatus = this.normalizeCustomStatusValue(this.api.storage.get("savedUserCustomStatus", null)); + this.statusSyncOwnsStatus = this.api.storage.get("statusSyncOwnsStatus", false) === true; + this.statusMutationDepth = 0; } start() { @@ -182,9 +188,12 @@ module.exports = class DiscordRPCEmuPlugin { }), getStatusSyncState: () => ({ enabled: Boolean(plugin.statusSyncEnabled), + intervalMs: plugin.statusSyncIntervalMs, lastAppliedStatusText: plugin.lastAppliedStatusText || "", lastStatusApplyAt: plugin.lastStatusApplyAt || 0, cachedEndpoint: plugin.cachedStatusEndpoint || null, + savedUserCustomStatus: plugin.savedUserCustomStatus, + statusSyncOwnsStatus: Boolean(plugin.statusSyncOwnsStatus), localBridgeEnabled: Boolean(plugin.localBridgeEnabled), localBridgePort: plugin.localBridgePort, hasRecentWindowsMedia: Boolean(plugin.lastWindowsMedia && Date.now() - plugin.lastWindowsMediaAt < 30000) @@ -234,6 +243,7 @@ module.exports = class DiscordRPCEmuPlugin { description: "Bridge and status sync behavior.", controls: [ { key: "statusSyncEnabled", type: "boolean", label: "Enable status sync", value: this.statusSyncEnabled }, + { key: "statusSyncIntervalMs", type: "range", label: "Update interval (ms)", min: 1000, max: 60000, step: 1000, value: this.statusSyncIntervalMs }, { key: "localBridgeEnabled", type: "boolean", label: "Enable local bridge", value: this.localBridgeEnabled }, { key: "debugDetection", type: "boolean", label: "Enable debug detection logs", value: this.debugDetection }, { key: "localBridgePort", type: "text", label: "Local bridge port (1024-65535)", value: String(this.localBridgePort) }, @@ -265,6 +275,16 @@ module.exports = class DiscordRPCEmuPlugin { if (this.statusSyncEnabled) this.startStatusSync(); else this.stopStatusSync(); } + if (k === "statusSyncIntervalMs") { + const n = Number(value); + if (Number.isFinite(n) && n >= 1000 && n <= 60000) { + this.statusSyncIntervalMs = Math.round(n); + if (this.statusSyncEnabled) { + this.stopStatusSync(); + this.startStatusSync(); + } + } + } if (k === "localBridgeEnabled") { this.localBridgeEnabled = Boolean(value); this.stopBridge(); @@ -286,6 +306,7 @@ module.exports = class DiscordRPCEmuPlugin { } try { this.api.storage.set("statusSyncEnabled", this.statusSyncEnabled); + this.api.storage.set("statusSyncIntervalMs", this.statusSyncIntervalMs); this.api.storage.set("localBridgeEnabled", this.localBridgeEnabled); this.api.storage.set("debugDetection", this.debugDetection); this.api.storage.set("localBridgePort", this.localBridgePort); @@ -293,6 +314,7 @@ module.exports = class DiscordRPCEmuPlugin { } catch (_e) {} return { statusSyncEnabled: this.statusSyncEnabled, + statusSyncIntervalMs: this.statusSyncIntervalMs, localBridgeEnabled: this.localBridgeEnabled, debugDetection: this.debugDetection, localBridgePort: this.localBridgePort, @@ -344,6 +366,7 @@ module.exports = class DiscordRPCEmuPlugin { } if (response && response.ok && bodyJson && typeof bodyJson === "object") { + const capturedCustomStatus = plugin.extractCustomStatusFromPayload(bodyJson); const hasCustomStatus = Object.prototype.hasOwnProperty.call(bodyJson, "custom_status") || (bodyJson.status && @@ -357,6 +380,12 @@ module.exports = class DiscordRPCEmuPlugin { plugin.captureStatusEndpointArmed = false; plugin.api.logger.info(`DiscordRPCEmu: captured status endpoint ${method} ${url}`); } + if (plugin.statusMutationDepth <= 0) { + plugin.savedUserCustomStatus = capturedCustomStatus; + plugin.api.storage.set("savedUserCustomStatus", capturedCustomStatus); + plugin.statusSyncOwnsStatus = false; + plugin.api.storage.set("statusSyncOwnsStatus", false); + } } } @@ -376,9 +405,12 @@ module.exports = class DiscordRPCEmuPlugin { startStatusSync() { if (!this.statusSyncEnabled) return; if (this.statusPollTimer) return; + const intervalMs = Number.isFinite(Number(this.statusSyncIntervalMs)) + ? Math.max(1000, Math.min(60000, Math.round(Number(this.statusSyncIntervalMs)))) + : 10000; this.statusPollTimer = setInterval(() => { this.applyNowPlayingFromSources().catch(() => {}); - }, 10000); + }, intervalMs); this.applyNowPlayingFromSources().catch(() => {}); } @@ -402,6 +434,102 @@ module.exports = class DiscordRPCEmuPlugin { return token; } + normalizeCustomStatusValue(value) { + if (!value || typeof value !== "object") return null; + const text = String(value.text || "").trim(); + const emojiName = String(value.emoji_name || value.emojiName || "").trim(); + const emojiId = + value.emoji_id != null + ? String(value.emoji_id).trim() + : value.emojiId != null + ? String(value.emojiId).trim() + : ""; + const expiresAt = + value.expires_at != null + ? String(value.expires_at).trim() + : value.expiresAt != null + ? String(value.expiresAt).trim() + : ""; + if (!text && !emojiName && !emojiId) return null; + const out = {}; + if (text) out.text = text; + if (emojiName) out.emoji_name = emojiName; + if (emojiId) out.emoji_id = emojiId; + if (expiresAt) out.expires_at = expiresAt; + return out; + } + + extractCustomStatusFromPayload(payload) { + const body = payload && typeof payload === "object" ? payload : null; + if (!body) return null; + if (Object.prototype.hasOwnProperty.call(body, "custom_status")) { + return this.normalizeCustomStatusValue(body.custom_status); + } + if (body.status && typeof body.status === "object" && Object.prototype.hasOwnProperty.call(body.status, "custom_status")) { + return this.normalizeCustomStatusValue(body.status.custom_status); + } + return null; + } + + withPluginStatusMutation(task) { + this.statusMutationDepth += 1; + return Promise.resolve() + .then(task) + .finally(() => { + this.statusMutationDepth = Math.max(0, this.statusMutationDepth - 1); + }); + } + + async readCurrentFluxerCustomStatus() { + const win = this.api.app.getWindow?.(); + if (!win || typeof win.fetch !== "function") return null; + const token = this.getAuthToken(); + const baseHeaders = { Accept: "application/json" }; + const headerVariants = [{ ...baseHeaders }]; + if (token) { + headerVariants.push({ ...baseHeaders, Authorization: token }); + headerVariants.push({ ...baseHeaders, Authorization: `Bearer ${token}` }); + } + const targets = [ + "/api/v1/users/@me/settings", + "/api/v1/users/@me", + "/api/v1/users/@me/profile", + "https://web.fluxer.app/api/v1/users/@me/settings", + "https://web.fluxer.app/api/v1/users/@me", + "https://web.fluxer.app/api/v1/users/@me/profile" + ]; + for (const url of targets) { + for (const headers of headerVariants) { + try { + const res = await win.fetch(url, { method: "GET", credentials: "include", headers }); + if (!res || !res.ok) continue; + const payload = await res.json().catch(() => null); + const customStatus = this.extractCustomStatusFromPayload(payload); + const hasStatusField = + Boolean(payload && typeof payload === "object" && Object.prototype.hasOwnProperty.call(payload, "custom_status")) || + Boolean( + payload && + payload.status && + typeof payload.status === "object" && + Object.prototype.hasOwnProperty.call(payload.status, "custom_status") + ); + if (customStatus || hasStatusField) { + return customStatus; + } + } catch (_) {} + } + } + return null; + } + + async ensureSavedUserCustomStatus() { + if (this.statusSyncOwnsStatus) return this.savedUserCustomStatus; + const currentStatus = await this.readCurrentFluxerCustomStatus(); + this.savedUserCustomStatus = currentStatus; + this.api.storage.set("savedUserCustomStatus", currentStatus); + return currentStatus; + } + formatClock(ms) { const n = Number(ms); if (!Number.isFinite(n) || n < 0) return ""; @@ -466,13 +594,14 @@ module.exports = class DiscordRPCEmuPlugin { if (!m || !m.ok || !m.hasSession) return ""; const kind = String(m.kind || "").toLowerCase(); const source = String(m.source || "").toLowerCase(); + const isNerdMode = source === "nerd-mode" || kind === "system"; const hasTrackMetadata = Boolean(String(m.title || "").trim() || String(m.artist || "").trim() || String(m.albumTitle || "").trim()); const hasRpcGameMarkers = kind === "game"; const hasExplicitGameType = (typeof m.activityType === "number" && Number.isFinite(m.activityType) && m.activityType === 0) || (typeof m.activityType === "string" && m.activityType.trim() !== "" && Number(m.activityType) === 0); const isGame = hasRpcGameMarkers || (hasExplicitGameType && !hasTrackMetadata); - const prefix = isGame ? "🎮 Playing " : "🎵 Listening to "; + const prefix = isNerdMode ? "⚙️ " : isGame ? "🎮 Playing " : "🎵 Listening to "; const name = String(m.name || "").trim(); const details = String(m.details || "").trim(); @@ -603,12 +732,29 @@ module.exports = class DiscordRPCEmuPlugin { async applyNowPlayingFromSources() { if (!this.statusSyncEnabled) return false; const text = await this.getPreferredNowPlayingText(); - if (!text) return false; + if (!text) { + if (!this.statusSyncOwnsStatus && !this.lastAppliedStatusText) return false; + const restored = this.savedUserCustomStatus + ? await this.restoreFluxerCustomStatus(this.savedUserCustomStatus) + : await this.clearFluxerCustomStatus(); + if (restored) { + this.lastAppliedStatusText = ""; + this.lastStatusApplyAt = Date.now(); + this.statusSyncOwnsStatus = false; + this.api.storage.set("statusSyncOwnsStatus", false); + } + return restored; + } if (text === this.lastAppliedStatusText) return true; + if (!this.statusSyncOwnsStatus) { + await this.ensureSavedUserCustomStatus(); + } const ok = await this.setFluxerCustomStatus(text); if (ok) { this.lastAppliedStatusText = text; this.lastStatusApplyAt = Date.now(); + this.statusSyncOwnsStatus = true; + this.api.storage.set("statusSyncOwnsStatus", true); } return ok; } @@ -675,12 +821,12 @@ module.exports = class DiscordRPCEmuPlugin { seen.add(key); for (const headers of headerVariants) { try { - const res = await win.fetch(def.url, { + const res = await this.withPluginStatusMutation(() => win.fetch(def.url, { method: def.method, credentials: "include", headers, body: JSON.stringify(def.body || {}) - }); + })); if (!res) continue; if (res.ok) { this.cachedStatusEndpoint = { method: def.method, url: def.url, body: def.body }; @@ -693,6 +839,144 @@ module.exports = class DiscordRPCEmuPlugin { return false; } + async clearFluxerCustomStatus() { + const win = this.api.app.getWindow?.(); + if (!win || typeof win.fetch !== "function") return false; + const token = this.getAuthToken(); + const baseHeaders = { + Accept: "application/json", + "Content-Type": "application/json" + }; + const headerVariants = []; + headerVariants.push({ ...baseHeaders }); + if (token) { + headerVariants.push({ ...baseHeaders, Authorization: token }); + headerVariants.push({ ...baseHeaders, Authorization: `Bearer ${token}` }); + } + + const requestDefs = []; + if (this.cachedStatusEndpoint && this.cachedStatusEndpoint.url && this.cachedStatusEndpoint.method) { + const cachedBody = this.cachedStatusEndpoint.body && typeof this.cachedStatusEndpoint.body === "object" + ? JSON.parse(JSON.stringify(this.cachedStatusEndpoint.body)) + : {}; + if (cachedBody.custom_status && typeof cachedBody.custom_status === "object") { + cachedBody.custom_status = null; + } else if (cachedBody.status && typeof cachedBody.status === "object") { + cachedBody.status.custom_status = null; + } else { + cachedBody.custom_status = null; + } + requestDefs.push({ + method: this.cachedStatusEndpoint.method, + url: this.cachedStatusEndpoint.url, + body: cachedBody + }); + } + requestDefs.push( + { method: "PATCH", url: "/api/v1/users/@me/settings", body: { custom_status: null } }, + { method: "PATCH", url: "/api/v1/users/@me", body: { custom_status: null } }, + { method: "PATCH", url: "/api/v1/users/@me/profile", body: { custom_status: null } }, + { method: "PATCH", url: "https://web.fluxer.app/api/v1/users/@me/settings", body: { custom_status: null } }, + { method: "PATCH", url: "https://web.fluxer.app/api/v1/users/@me", body: { custom_status: null } }, + { method: "PATCH", url: "https://web.fluxer.app/api/v1/users/@me/profile", body: { custom_status: null } }, + { method: "PATCH", url: "/api/v1/users/@me/settings", body: { status: { custom_status: null } } } + ); + + const seen = new Set(); + for (const def of requestDefs) { + if (!def || !def.url || !def.method) continue; + const key = `${def.method}|${def.url}|${JSON.stringify(def.body || {})}`; + if (seen.has(key)) continue; + seen.add(key); + for (const headers of headerVariants) { + try { + const res = await this.withPluginStatusMutation(() => win.fetch(def.url, { + method: def.method, + credentials: "include", + headers, + body: JSON.stringify(def.body || {}) + })); + if (res && res.ok) { + return true; + } + } catch (_) {} + } + } + return false; + } + + async restoreFluxerCustomStatus(customStatus) { + const restored = this.normalizeCustomStatusValue(customStatus); + if (!restored) return this.clearFluxerCustomStatus(); + const win = this.api.app.getWindow?.(); + if (!win || typeof win.fetch !== "function") return false; + const token = this.getAuthToken(); + const baseHeaders = { + Accept: "application/json", + "Content-Type": "application/json" + }; + const headerVariants = [{ ...baseHeaders }]; + if (token) { + headerVariants.push({ ...baseHeaders, Authorization: token }); + headerVariants.push({ ...baseHeaders, Authorization: `Bearer ${token}` }); + } + + const applyCustomStatus = (body, value) => { + const input = body && typeof body === "object" ? JSON.parse(JSON.stringify(body)) : {}; + const out = input && typeof input === "object" ? input : {}; + if (Object.prototype.hasOwnProperty.call(out, "custom_status")) { + out.custom_status = value; + return out; + } + if (out.status && typeof out.status === "object") { + out.status.custom_status = value; + return out; + } + out.custom_status = value; + return out; + }; + + const requestDefs = []; + if (this.cachedStatusEndpoint && this.cachedStatusEndpoint.url && this.cachedStatusEndpoint.method) { + requestDefs.push({ + method: this.cachedStatusEndpoint.method, + url: this.cachedStatusEndpoint.url, + body: applyCustomStatus(this.cachedStatusEndpoint.body, restored) + }); + } + requestDefs.push( + { method: "PATCH", url: "/api/v1/users/@me/settings", body: { custom_status: restored } }, + { method: "PATCH", url: "/api/v1/users/@me", body: { custom_status: restored } }, + { method: "PATCH", url: "/api/v1/users/@me/profile", body: { custom_status: restored } }, + { method: "PATCH", url: "https://web.fluxer.app/api/v1/users/@me/settings", body: { custom_status: restored } }, + { method: "PATCH", url: "https://web.fluxer.app/api/v1/users/@me", body: { custom_status: restored } }, + { method: "PATCH", url: "https://web.fluxer.app/api/v1/users/@me/profile", body: { custom_status: restored } }, + { method: "PATCH", url: "/api/v1/users/@me/settings", body: { status: { custom_status: restored } } } + ); + + const seen = new Set(); + for (const def of requestDefs) { + if (!def || !def.url || !def.method) continue; + const key = `${def.method}|${def.url}|${JSON.stringify(def.body || {})}`; + if (seen.has(key)) continue; + seen.add(key); + for (const headers of headerVariants) { + try { + const res = await this.withPluginStatusMutation(() => win.fetch(def.url, { + method: def.method, + credentials: "include", + headers, + body: JSON.stringify(def.body || {}) + })); + if (res && res.ok) { + return true; + } + } catch (_) {} + } + } + return false; + } + getBridgeState() { return { connected: Boolean(this.bridgeSocket && this.bridgeSocket.readyState === 1), @@ -707,6 +991,8 @@ module.exports = class DiscordRPCEmuPlugin { lastAppliedStatusText: this.lastAppliedStatusText || "", lastStatusApplyAt: this.lastStatusApplyAt || 0, cachedEndpoint: this.cachedStatusEndpoint || null, + savedUserCustomStatus: this.savedUserCustomStatus, + statusSyncOwnsStatus: Boolean(this.statusSyncOwnsStatus), hasRecentBridgeActivity: Boolean(this.lastBridgeActivity && Date.now() - this.lastBridgeActivityAt < 120000), captureArmed: Boolean(this.captureStatusEndpointArmed) }; @@ -810,6 +1096,7 @@ module.exports = class DiscordRPCEmuPlugin { startBridge() { this.bridgeStarted = true; + this.startBridgeWatchdog(); this.connectBridge(); } @@ -819,6 +1106,10 @@ module.exports = class DiscordRPCEmuPlugin { clearTimeout(this.bridgeReconnectTimer); this.bridgeReconnectTimer = null; } + if (this.bridgeWatchdogTimer) { + clearInterval(this.bridgeWatchdogTimer); + this.bridgeWatchdogTimer = null; + } if (this.bridgeSocket) { try { this.bridgeSocket.close(); @@ -826,6 +1117,26 @@ module.exports = class DiscordRPCEmuPlugin { this.bridgeSocket = null; } this.bridgePort = null; + this.lastBridgeSocketEventAt = 0; + } + + startBridgeWatchdog() { + if (this.bridgeWatchdogTimer) return; + this.bridgeWatchdogTimer = setInterval(() => { + if (!this.bridgeStarted) return; + const ws = this.bridgeSocket; + if (!ws || ws.readyState !== 1) return; + const lastEventAt = Number(this.lastBridgeSocketEventAt || 0); + if (!lastEventAt) return; + if (Date.now() - lastEventAt < 90000) return; + this.api.logger.warn("DiscordRPCEmu: Discord RPC bridge went stale, reconnecting."); + try { + ws.close(); + } catch (_) { + this.bridgeSocket = null; + } + this.scheduleBridgeReconnect(1000); + }, 15000); } scheduleBridgeReconnect(delayMs) { @@ -920,6 +1231,7 @@ module.exports = class DiscordRPCEmuPlugin { settled = true; this.bridgeSocket = ws; this.bridgePort = candidate.port; + this.lastBridgeSocketEventAt = Date.now(); this.api.logger.info(`DiscordRPCEmu: connected Discord RPC bridge on port ${candidate.port}`); try { ws.send( @@ -935,6 +1247,7 @@ module.exports = class DiscordRPCEmuPlugin { ws.addEventListener("message", (event) => { try { + this.lastBridgeSocketEventAt = Date.now(); const raw = typeof event.data === "string" ? event.data : ""; if (!raw) return; const msg = JSON.parse(raw); @@ -956,6 +1269,7 @@ module.exports = class DiscordRPCEmuPlugin { this.bridgeSocket = null; this.bridgePort = null; } + this.lastBridgeSocketEventAt = 0; this.scheduleBridgeReconnect(5000); }); diff --git a/nw/plugins/DisplaySourceFix/index.js b/nw/plugins/DisplaySourceFix/index.js deleted file mode 100644 index e11f608..0000000 --- a/nw/plugins/DisplaySourceFix/index.js +++ /dev/null @@ -1,407 +0,0 @@ -module.exports = class DisplaySourceFixPlugin { - constructor(api) { - this.api = api; - this.originalSelect = null; - this.originalGetDisplayMedia = null; - this.originalGetUserMedia = null; - this.onDisplayMediaRequestedUnsub = null; - this.lastGoodSourceId = null; - this.pendingRequest = null; - this.pendingPickerResolve = null; - this.overlayId = "betterfluxer-display-picker-overlay"; - this.styleId = "betterfluxer-display-picker-style"; - this.cache = { - types: ["screen", "window"], - sources: [], - at: 0 - }; - this.includeScreens = true; - this.includeWindows = true; - } - - getElectronApi() { - return this.api.app.getWindow?.()?.electron; - } - - async refreshSources() { - const electronApi = this.getElectronApi(); - if (!electronApi || typeof electronApi.getDesktopSources !== "function") { - return []; - } - try { - this.cache.types = this.getSourceTypes(); - const sources = await electronApi.getDesktopSources(this.cache.types); - if (Array.isArray(sources)) { - this.cache.sources = sources; - this.cache.at = Date.now(); - } - } catch (error) { - this.api.logger.warn("Failed to refresh desktop sources:", error?.message || error); - } - return this.cache.sources; - } - - findFallbackSourceId(requestedId) { - const id = String(requestedId || ""); - const sources = this.cache.sources || []; - if (sources.length === 0) { - if (this.lastGoodSourceId) return this.lastGoodSourceId; - if (id.startsWith("window:")) return "screen:0:0"; - return null; - } - - const exact = sources.find((item) => String(item?.id || "") === id); - if (exact) return exact.id; - - const prefix = id.includes(":") ? id.split(":")[0] : ""; - if (prefix) { - const samePrefix = sources.find((item) => String(item?.id || "").startsWith(`${prefix}:`)); - if (samePrefix) return samePrefix.id; - } - - const preferredScreen = sources.find((item) => String(item?.id || "").startsWith("screen:")); - if (preferredScreen) return String(preferredScreen.id || ""); - - return String(sources[0]?.id || this.lastGoodSourceId || ""); - } - - ensurePickerStyle(doc) { - if (doc.getElementById(this.styleId)) return; - const style = doc.createElement("style"); - style.id = this.styleId; - style.textContent = [ - ".bf-dsp-overlay{position:fixed;inset:0;background:rgba(0,0,0,.62);z-index:2147483647;display:flex;align-items:center;justify-content:center;}", - ".bf-dsp-modal{width:min(860px,96vw);max-height:88vh;overflow:auto;background:#15181d;border:1px solid #2b3139;border-radius:12px;color:#f4f6f8;padding:14px;box-shadow:0 28px 70px rgba(0,0,0,.5);font-family:Segoe UI,Tahoma,sans-serif;}", - ".bf-dsp-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;}", - ".bf-dsp-head h3{margin:0;font-size:16px;}", - ".bf-dsp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;}", - ".bf-dsp-card{background:#1b2027;border:1px solid #2b3139;border-radius:10px;padding:10px;display:grid;gap:8px;}", - ".bf-dsp-name{font-size:13px;color:#d7dee7;word-break:break-word;}", - ".bf-dsp-id{font-size:11px;color:#9cacbe;word-break:break-word;}", - ".bf-dsp-btn{border:1px solid #3c4754;background:#26313d;color:#fff;border-radius:8px;padding:6px 10px;cursor:pointer;}", - ".bf-dsp-btn:hover{background:#304050;}", - ".bf-dsp-foot{display:flex;justify-content:space-between;align-items:center;margin-top:10px;gap:10px;}", - ".bf-dsp-muted{font-size:12px;color:#9cacbe;}" - ].join(""); - doc.head.appendChild(style); - } - - closePicker() { - const doc = this.api.app.getDocument?.(); - if (!doc) return; - const overlay = doc.getElementById(this.overlayId); - if (overlay) overlay.remove(); - if (typeof this.pendingPickerResolve === "function") { - const resolve = this.pendingPickerResolve; - this.pendingPickerResolve = null; - resolve(null); - } - this.pendingRequest = null; - } - - selectForRequest(requestId, sourceId, withAudio) { - const chosen = String(sourceId || ""); - if (!chosen) return; - - if (requestId && this.originalSelect) { - this.originalSelect(requestId, chosen, withAudio); - } - - if (typeof this.pendingPickerResolve === "function") { - const resolve = this.pendingPickerResolve; - this.pendingPickerResolve = null; - const selected = (this.cache.sources || []).find((item) => String(item?.id || "") === chosen) || { id: chosen }; - resolve(selected); - } - - this.lastGoodSourceId = chosen; - const doc = this.api.app.getDocument?.(); - const overlay = doc?.getElementById(this.overlayId); - if (overlay) overlay.remove(); - this.pendingRequest = null; - } - - async showPicker(requestId, info) { - const doc = this.api.app.getDocument?.(); - if (!doc) return; - - await this.refreshSources(); - const sources = this.cache.sources || []; - if (!Array.isArray(sources) || sources.length === 0) { - this.api.logger.warn("No desktop sources available for custom picker."); - return; - } - - this.closePicker(); - this.ensurePickerStyle(doc); - - const overlay = doc.createElement("div"); - overlay.id = this.overlayId; - overlay.className = "bf-dsp-overlay"; - - const modal = doc.createElement("div"); - modal.className = "bf-dsp-modal"; - overlay.appendChild(modal); - - const head = doc.createElement("div"); - head.className = "bf-dsp-head"; - head.innerHTML = "

Select Window Or Screen

"; - const closeBtn = doc.createElement("button"); - closeBtn.className = "bf-dsp-btn"; - closeBtn.textContent = "Cancel"; - closeBtn.addEventListener("click", () => { - this.closePicker(); - }); - head.appendChild(closeBtn); - modal.appendChild(head); - - const grid = doc.createElement("div"); - grid.className = "bf-dsp-grid"; - modal.appendChild(grid); - - for (const source of sources) { - const id = String(source?.id || ""); - if (!id) continue; - const name = String(source?.name || id); - const card = doc.createElement("div"); - card.className = "bf-dsp-card"; - - const title = doc.createElement("div"); - title.className = "bf-dsp-name"; - title.textContent = name; - card.appendChild(title); - - const sub = doc.createElement("div"); - sub.className = "bf-dsp-id"; - sub.textContent = id; - card.appendChild(sub); - - const selectBtn = doc.createElement("button"); - selectBtn.className = "bf-dsp-btn"; - selectBtn.textContent = "Share This"; - selectBtn.addEventListener("click", () => { - this.selectForRequest(requestId, id, Boolean(info?.withAudio)); - }); - card.appendChild(selectBtn); - - grid.appendChild(card); - } - - const foot = doc.createElement("div"); - foot.className = "bf-dsp-foot"; - const muted = doc.createElement("div"); - muted.className = "bf-dsp-muted"; - muted.textContent = "Custom picker by BetterFluxer"; - foot.appendChild(muted); - const fallbackBtn = doc.createElement("button"); - fallbackBtn.className = "bf-dsp-btn"; - fallbackBtn.textContent = "Use Fallback"; - fallbackBtn.addEventListener("click", () => { - const fallback = this.findFallbackSourceId("screen:0:0") || "screen:0:0"; - this.selectForRequest(requestId, fallback, Boolean(info?.withAudio)); - }); - foot.appendChild(fallbackBtn); - modal.appendChild(foot); - - overlay.addEventListener("click", (event) => { - if (event.target === overlay) this.closePicker(); - }); - doc.body.appendChild(overlay); - - this.pendingRequest = { requestId, withAudio: Boolean(info?.withAudio) }; - } - - async pickSourceFromOverlay() { - await this.showPicker(null, { withAudio: false }); - return new Promise((resolve) => { - this.pendingPickerResolve = resolve; - }); - } - - async captureViaCustomPicker(constraints) { - const win = this.api.app.getWindow?.(); - const mediaDevices = win?.navigator?.mediaDevices; - if (!mediaDevices || typeof mediaDevices.getUserMedia !== "function") { - throw new Error("mediaDevices.getUserMedia unavailable"); - } - - const selected = await this.pickSourceFromOverlay(); - if (!selected || !selected.id) { - throw new Error("Display capture canceled"); - } - - const selectedId = String(selected.id); - const wantsAudio = Boolean(constraints && constraints.audio); - const videoTrack = { - mandatory: { - chromeMediaSource: "desktop", - chromeMediaSourceId: selectedId - } - }; - - const userMediaConstraints = { - video: videoTrack, - audio: false - }; - - // System audio capture support varies across Linux setups; keep it optional. - if (wantsAudio) { - userMediaConstraints.audio = { - mandatory: { - chromeMediaSource: "desktop", - chromeMediaSourceId: selectedId - } - }; - } - - return mediaDevices.getUserMedia(userMediaConstraints); - } - - start() { - const win = this.api.app.getWindow?.(); - this.loadConfig(); - const electronApi = this.getElectronApi(); - const mediaDevices = win?.navigator?.mediaDevices; - const hasSelectDisplayMediaSource = Boolean(electronApi && typeof electronApi.selectDisplayMediaSource === "function"); - const hasGetDisplayMedia = Boolean(mediaDevices && typeof mediaDevices.getDisplayMedia === "function"); - const hasGetDesktopSources = Boolean(electronApi && typeof electronApi.getDesktopSources === "function"); - - if (!hasGetDisplayMedia) { - this.api.logger.warn("navigator.mediaDevices.getDisplayMedia is unavailable; plugin inactive."); - return; - } - - if (!hasGetDesktopSources) { - this.api.logger.warn("electron.getDesktopSources is unavailable; custom picker cannot list sources."); - } - - if (hasSelectDisplayMediaSource) { - this.originalSelect = electronApi.selectDisplayMediaSource.bind(electronApi); - } - if (mediaDevices && typeof mediaDevices.getDisplayMedia === "function") { - this.originalGetDisplayMedia = mediaDevices.getDisplayMedia.bind(mediaDevices); - } - if (mediaDevices && typeof mediaDevices.getUserMedia === "function") { - this.originalGetUserMedia = mediaDevices.getUserMedia.bind(mediaDevices); - } - - this.refreshSources(); - - if (mediaDevices && this.originalGetDisplayMedia) { - mediaDevices.getDisplayMedia = async (constraints = { video: true, audio: false }) => { - try { - return await this.captureViaCustomPicker(constraints); - } catch (error) { - this.api.logger.warn("Custom getDisplayMedia failed, falling back:", error?.message || error); - return this.originalGetDisplayMedia(constraints); - } - }; - this.api.logger.info("Custom display source selector enabled (getDisplayMedia override)."); - } - - if (electronApi && typeof electronApi.onDisplayMediaRequested === "function" && this.originalSelect) { - this.onDisplayMediaRequestedUnsub = electronApi.onDisplayMediaRequested(async (requestId, info) => { - try { - await this.showPicker(requestId, info || {}); - } catch (error) { - this.api.logger.warn("Custom picker failed, falling back:", error?.message || error); - const fallback = this.findFallbackSourceId("screen:0:0") || "screen:0:0"; - this.selectForRequest(requestId, fallback, Boolean(info?.withAudio)); - } - }); - this.api.logger.info("Custom display source selector enabled."); - return; - } - - if (electronApi && this.originalSelect) { - this.api.logger.warn("onDisplayMediaRequested not available; using fallback-only mode."); - const patchedSelect = async (requestId, sourceId, withAudio) => { - const requested = String(sourceId || ""); - if (!requested) { - this.originalSelect(requestId, sourceId, withAudio); - return; - } - await this.refreshSources(); - const chosenId = this.findFallbackSourceId(requested) || requested; - this.originalSelect(requestId, chosenId, withAudio); - this.lastGoodSourceId = chosenId; - }; - electronApi.selectDisplayMediaSource = patchedSelect; - } - } - - stop() { - this.closePicker(); - const win = this.api.app.getWindow?.(); - const mediaDevices = win?.navigator?.mediaDevices; - const electronApi = this.getElectronApi(); - if (mediaDevices && this.originalGetDisplayMedia) { - try { - mediaDevices.getDisplayMedia = this.originalGetDisplayMedia; - } catch (_) {} - } - if (electronApi && this.originalSelect) { - try { - electronApi.selectDisplayMediaSource = this.originalSelect; - } catch (_) {} - try { - Object.defineProperty(electronApi, "selectDisplayMediaSource", { - configurable: true, - enumerable: true, - writable: true, - value: this.originalSelect - }); - } catch (_) {} - } - if (typeof this.onDisplayMediaRequestedUnsub === "function") { - this.onDisplayMediaRequestedUnsub(); - } - this.onDisplayMediaRequestedUnsub = null; - this.originalSelect = null; - this.originalGetDisplayMedia = null; - this.originalGetUserMedia = null; - this.api.logger.info("Display source fallback patch disabled."); - } - - getSourceTypes() { - const out = []; - if (this.includeScreens) out.push("screen"); - if (this.includeWindows) out.push("window"); - return out.length ? out : ["screen", "window"]; - } - - loadConfig() { - try { - this.includeScreens = this.api.storage.get("includeScreens", this.includeScreens) !== false; - this.includeWindows = this.api.storage.get("includeWindows", this.includeWindows) !== false; - this.cache.types = this.getSourceTypes(); - } catch (_e) {} - } - - getSettingsSchema() { - return { - title: "Display Source Fix", - description: "Desktop source picker and fallback source types.", - controls: [ - { key: "includeScreens", type: "boolean", label: "Include screens", value: this.includeScreens }, - { key: "includeWindows", type: "boolean", label: "Include windows", value: this.includeWindows } - ] - }; - } - - setSettingValue(key, value) { - const k = String(key || ""); - if (k === "includeScreens") this.includeScreens = Boolean(value); - if (k === "includeWindows") this.includeWindows = Boolean(value); - this.cache.types = this.getSourceTypes(); - try { - this.api.storage.set("includeScreens", this.includeScreens); - this.api.storage.set("includeWindows", this.includeWindows); - } catch (_e) {} - this.refreshSources(); - return { - includeScreens: this.includeScreens, - includeWindows: this.includeWindows - }; - } -}; diff --git a/nw/plugins/DisplaySourceFix/manifest.json b/nw/plugins/DisplaySourceFix/manifest.json deleted file mode 100644 index c032000..0000000 --- a/nw/plugins/DisplaySourceFix/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "DisplaySourceFix", - "version": "1.0.0", - "description": "Retries display source selection with a valid fallback when stale source IDs are used.", - "author": "BetterFluxer", - "main": "index.js" -} diff --git a/scripts/lib/fluxer-injector-utils.js b/scripts/lib/fluxer-injector-utils.js index a5bb912..73a4011 100644 --- a/scripts/lib/fluxer-injector-utils.js +++ b/scripts/lib/fluxer-injector-utils.js @@ -1659,6 +1659,59 @@ try { } } + class MembersClass extends BaseDOMClass { + getMemberItems() { + const nodes = this.queryAll( + "span[class*='MemberListItem'][class*='name'], [data-user-id] span[class*='name'], [data-user-id]" + ); + return nodes + .map((node) => { + const carrier = node.closest && typeof node.closest === "function" ? node.closest("[data-user-id]") : null; + const userId = String((carrier && carrier.getAttribute && carrier.getAttribute("data-user-id")) || (node.getAttribute && node.getAttribute("data-user-id")) || ""); + return { + id: userId, + label: this.text(node), + element: node + }; + }) + .filter((item) => item.label || item.id); + } + + clickMemberByName(name) { + const needle = String(name || "").trim().toLowerCase(); + if (!needle) return false; + const item = this.getMemberItems().find((it) => String(it.label || "").toLowerCase().includes(needle)); + if (!item || !item.element || typeof item.element.click !== "function") return false; + item.element.click(); + return true; + } + + getVisibleMemberIds() { + const out = []; + const seen = new Set(); + for (const item of this.getMemberItems()) { + const id = String(item.id || ""); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(id); + } + return out; + } + + getMemberById(userId) { + const id = String(userId || "").trim(); + if (!id) return null; + return this.getMemberItems().find((it) => String(it.id) === id) || null; + } + + clickMemberById(userId) { + const item = this.getMemberById(userId); + if (!item || !item.element || typeof item.element.click !== "function") return false; + item.element.click(); + return true; + } + } + function createUIClasses() { const settingsSidebar = new SettingsSidebarClass(); const userProfile = new UserProfileClass(settingsSidebar); @@ -1669,7 +1722,8 @@ try { userProfile, messages: new MessagesClass(), guildList: new GuildListClass(), - channels: new ChannelsClass() + channels: new ChannelsClass(), + members: new MembersClass() }; } @@ -1679,7 +1733,8 @@ try { UserProfileClass, MessagesClass, GuildListClass, - ChannelsClass + ChannelsClass, + MembersClass }; runtime.uiClasses = createUIClasses(); @@ -3088,6 +3143,13 @@ try { getChannelItems: () => runtime.uiClasses.channels.getChannelItems(), clickChannelByName: (name) => runtime.uiClasses.channels.clickChannelByName(name) }, + members: { + getMemberItems: () => runtime.uiClasses.members.getMemberItems(), + getVisibleMemberIds: () => runtime.uiClasses.members.getVisibleMemberIds(), + getMemberById: (userId) => runtime.uiClasses.members.getMemberById(userId), + clickMemberById: (userId) => runtime.uiClasses.members.clickMemberById(userId), + clickMemberByName: (name) => runtime.uiClasses.members.clickMemberByName(name) + }, settingsSidebar: { getItems: () => runtime.uiClasses.settingsSidebar.getItems(), clickById: (tabId) => runtime.uiClasses.settingsSidebar.clickById(tabId)