Files
gen1recomp/src/core/DiscordPresence.lua
T
bryanthaboi 5d8009096a other update
2026-09-28 10:30:30 -04:00

856 lines
26 KiB
Lua

-- Discord Rich Presence via the local Discord IPC pipe. Shows the player's
-- current map (and battle status) on their Discord profile. Desktop only;
-- no-ops on mobile, headless runs, or when Discord is not running.
--
-- Application ID is the only credential needed for presence. The Discord
-- Interactions public key is unused here (that is for verifying bot HTTP
-- payloads, not Rich Presence).
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local DiscordPresence = {
APP_ID = "1529183141267374262",
}
local OP_HANDSHAKE, OP_FRAME, OP_CLOSE = 0, 1, 2
local MIN_UPDATE_INTERVAL = 2.0
local RECONNECT_INTERVAL = 30.0
local state = {
enabled = false,
connected = false,
socket = nil, -- Windows: file handle; Unix: ffi sockfd
isWindows = false,
ffi = nil,
ffiReady = false,
startedAt = 0,
dirty = false,
lastSentKey = nil,
lastSendAt = -1e9,
nextReconnectAt = 0,
location = nil, -- display name
mapId = nil,
activity = "menu", -- menu | exploring | battle
battleLabel = nil,
unsubs = {},
pid = nil,
loggedAbsent = false, -- one quiet note when Discord isn't running
game = nil, -- kept for the "friend clicked join" -> push a screen path
joinSecret = nil,
partySize = 1, -- current headcount for the party.size Discord shows
partyMax = 2,
subscribedJoin = false, -- SUBSCRIBE ACTIVITY_JOIN sent on this connection?
}
-- MSG_DONTWAIT differs by OS (Darwin 0x80, Linux 0x40); only used for the
-- ongoing per-frame inbound poll, never for the one-time connect/handshake
local MSG_DONTWAIT_BY_OS = { ["OS X"] = 0x80, Linux = 0x40 }
local function disabledByEnv()
return os.getenv("POKEPORT_NO_DISCORD") == "1"
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
or os.getenv("POKEPORT_DRIVER") ~= nil
end
local function isDesktop()
if not love or not love.system or not love.system.getOS then return false end
local osName = love.system.getOS()
return osName == "OS X" or osName == "Windows" or osName == "Linux"
end
local function packU32(n)
n = math.floor(n) % 0x100000000
local b1 = n % 256; n = math.floor(n / 256)
local b2 = n % 256; n = math.floor(n / 256)
local b3 = n % 256; n = math.floor(n / 256)
local b4 = n % 256
return string.char(b1, b2, b3, b4)
end
local function unpackU32(s)
local b1, b2, b3, b4 = s:byte(1, 4)
return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
end
local function nonce()
return string.format("%08x-%04x-4%03x-%04x-%04x%08x",
math.random(0, 0xffffffff),
math.random(0, 0xffff),
math.random(0, 0xfff),
math.random(0, 0x3fff) + 0x8000,
math.random(0, 0xffff),
math.random(0, 0xffffffff))
end
local function resolvePid()
if state.pid then return state.pid end
local ffi = state.ffi
if ffi then
if state.isWindows then
pcall(ffi.cdef, "unsigned long GetCurrentProcessId(void);")
local ok, p = pcall(function() return ffi.C.GetCurrentProcessId() end)
if ok and p and tonumber(p) and tonumber(p) > 0 then
state.pid = tonumber(p)
return state.pid
end
else
pcall(ffi.cdef, "int getpid(void);")
local ok, p = pcall(function() return ffi.C.getpid() end)
if ok and p and tonumber(p) and tonumber(p) > 0 then
state.pid = tonumber(p)
return state.pid
end
end
end
state.pid = 1000 + math.random(1, 8999)
return state.pid
end
local function ensureFfi()
if state.ffiReady then return state.ffi end
if state.ffi == false then return nil end
local ok, ffi = pcall(require, "ffi")
if not ok or not ffi then
state.ffi = false
return nil
end
state.ffi = ffi
if state.isWindows then
state.ffiReady = true
return ffi
end
-- Darwin sockaddr_un has sun_len + uint8_t family; Linux uses u16 family.
local osName = love.system.getOS()
local cdef
if osName == "OS X" then
cdef = [[
typedef unsigned int socklen_t;
typedef int ssize_t;
struct sockaddr_un {
uint8_t sun_len;
uint8_t sun_family;
char sun_path[104];
};
struct timeval { long tv_sec; int tv_usec; };
int socket(int domain, int type, int protocol);
int connect(int sockfd, const void *addr, socklen_t addrlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
int close(int fd);
]]
else
cdef = [[
typedef unsigned int socklen_t;
typedef int ssize_t;
struct sockaddr_un {
unsigned short sun_family;
char sun_path[108];
};
struct timeval { long tv_sec; long tv_usec; };
int socket(int domain, int type, int protocol);
int connect(int sockfd, const void *addr, socklen_t addrlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
int close(int fd);
]]
end
-- cdef may already be registered from a prior boot; either way we need
-- sockaddr_un usable before claiming readiness
pcall(ffi.cdef, cdef)
local ready = pcall(function() return ffi.new("struct sockaddr_un") end)
if not ready then
state.ffi = false
state.ffiReady = false
return nil
end
state.ffiReady = true
return ffi
end
local function closeSocket()
local sock = state.socket
state.socket = nil
state.connected = false
if not sock then return end
if state.isWindows then
pcall(function() sock:close() end)
elseif state.ffi then
pcall(state.ffi.C.close, sock)
end
end
local function writeRaw(bytes)
if not state.socket then return false end
if state.isWindows then
local ok = pcall(function()
state.socket:seek("end")
local _, writeErr = state.socket:write(bytes)
state.socket:flush()
if writeErr then error(writeErr) end
end)
return ok
end
local ffi = state.ffi
if not ffi then return false end
local ok, sent = pcall(ffi.C.send, state.socket, bytes, #bytes, 0)
return ok and sent == #bytes
end
local function sendFrame(opcode, payload)
local body = payload or ""
local ok, frame = pcall(function()
return packU32(opcode) .. packU32(#body) .. body
end)
if not ok or not frame then return false end
return writeRaw(frame)
end
local function readExact(n)
if not state.socket or n <= 0 then return nil end
if state.isWindows then
local ok, data = pcall(function() return state.socket:read(n) end)
if not ok or not data or #data < n then return nil end
return data
end
local ffi = state.ffi
if not ffi then return nil end
local ok, data = pcall(function()
local buf = ffi.new("char[?]", n)
local got = 0
while got < n do
local nread = ffi.C.recv(state.socket, buf + got, n - got, 0)
if not nread or nread <= 0 then return nil end
got = got + tonumber(nread)
end
return ffi.string(buf, n)
end)
if not ok then return nil end
return data
end
local function receiveFrame()
local header = readExact(8)
if not header then return nil end
local ok, opcode, length = pcall(function()
return unpackU32(header:sub(1, 4)), unpackU32(header:sub(5, 8))
end)
if not ok or not opcode or not length or length < 0 or length > 65536 then
return nil
end
local data = length > 0 and readExact(length) or ""
if data == nil then return nil end
return opcode, data
end
local function unixSocketPaths()
local paths = {}
local envs = { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" }
local bases = {}
for _, key in ipairs(envs) do
local v = os.getenv(key)
if v and v ~= "" then
if v:sub(-1) == "/" then v = v:sub(1, -2) end
bases[#bases + 1] = v
end
end
bases[#bases + 1] = "/tmp"
local suffixes = {
"",
"/app/com.discordapp.Discord",
"/app/com.discordapp.DiscordCanary",
"/app/com.discordapp.DiscordPTB",
"/snap.discord",
"/snap.discord-canary",
}
local seen = {}
for i = 0, 9 do
local name = "discord-ipc-" .. i
for _, base in ipairs(bases) do
for _, suffix in ipairs(suffixes) do
local path = base .. suffix .. "/" .. name
if not seen[path] then
seen[path] = true
paths[#paths + 1] = path
end
end
end
end
return paths
end
local function setSocketTimeout(fd, seconds)
local ffi = state.ffi
if not ffi or not fd then return end
pcall(function()
local tv = ffi.new("struct timeval")
tv.tv_sec = seconds
tv.tv_usec = 0
-- SOL_SOCKET / SO_RCVTIMEO / SO_SNDTIMEO differ by OS
local sol, rcv, snd
if love.system.getOS() == "OS X" then
sol, rcv, snd = 0xffff, 0x1006, 0x1005
else
sol, rcv, snd = 1, 20, 21
end
ffi.C.setsockopt(fd, sol, rcv, tv, ffi.sizeof(tv))
ffi.C.setsockopt(fd, sol, snd, tv, ffi.sizeof(tv))
end)
end
local function connectUnix()
local ffi = ensureFfi()
if not ffi then return false end
local darwin = love.system.getOS() == "OS X"
local maxPath = darwin and 103 or 107
-- AF_UNIX=1, SOCK_STREAM=1 on Linux/macOS
local pathsOk, paths = pcall(unixSocketPaths)
if not pathsOk or not paths then return false end
for _, path in ipairs(paths) do
if #path <= maxPath then
local ok, connected = pcall(function()
local fd = ffi.C.socket(1, 1, 0)
if not fd or fd < 0 then return false end
local addr = ffi.new("struct sockaddr_un")
addr.sun_family = 1
if darwin then
-- SUN_LEN: sizeof(sun_len+sun_family) + path + NUL
addr.sun_len = 2 + #path + 1
end
ffi.copy(addr.sun_path, path)
local rc = ffi.C.connect(fd, ffi.cast("void*", addr), ffi.sizeof(addr))
if rc == 0 then
setSocketTimeout(fd, 1) -- handshake must not freeze the game
state.socket = fd
return true
end
ffi.C.close(fd)
return false
end)
if ok and connected then return true end
end
end
return false
end
local function connectWindows()
for i = 0, 9 do
local path = "\\\\.\\pipe\\discord-ipc-" .. i
local ok, file = pcall(io.open, path, "r+")
if ok and file then
state.socket = file
return true
end
end
return false
end
local function handshake()
local ok, payload = pcall(Json.encode, { v = 1, client_id = DiscordPresence.APP_ID })
if not ok or not payload then return false end
if not sendFrame(OP_HANDSHAKE, payload) then return false end
-- Discord echoes READY as a FRAME. A timeout/missing client is a soft miss.
local opcode = receiveFrame()
if opcode == OP_FRAME then return true end
-- Windows named pipes have no easy read timeout: if the write succeeded
-- and Discord is present, proceed optimistically so boot never blocks.
if state.isWindows then return true end
return false
end
local function noteAbsent()
if state.loggedAbsent then return end
state.loggedAbsent = true
-- info, not warn: missing Discord is normal for many players
Logger.info("discord: rich presence unavailable (Discord not running)")
end
local function connect()
local ok, result = pcall(function()
closeSocket()
local linked
if state.isWindows then
linked = connectWindows()
else
linked = connectUnix()
end
if not linked then return false end
if not handshake() then
closeSocket()
return false
end
state.connected = true
state.dirty = true
state.lastSentKey = nil
state.loggedAbsent = false
state.subscribedJoin = false
-- ask for "friend clicked join" dispatches; a plain outbound write, so
-- it's harmless to send even on Windows, where the inbound poll below
-- isn't implemented yet (see pollJoinRequest)
if sendFrame(OP_FRAME, Json.encode({
cmd = "SUBSCRIBE", evt = "ACTIVITY_JOIN", nonce = nonce(),
})) then
state.subscribedJoin = true
end
Logger.info("discord: rich presence connected")
return true
end)
if not ok then
closeSocket()
return false
end
return result and true or false
end
local function isGen3(game)
if type(game) ~= "table" then return false end
if tonumber(game.generation) == 3 then return true end
local data = rawget(game, "data")
if type(data) == "table" and tonumber(data.generation) == 3 then return true end
local ok, GameVersion = pcall(require, "src.core.GameVersion")
return ok and GameVersion.generation() == 3 and game.stack == nil
end
local function titleCase(s)
return (tostring(s):lower():gsub("(%a)([%w']*)", function(a, b) return a:upper() .. b end))
end
local function gen3LocationName(mapId, def)
local ok, SaveMenu = pcall(require, "src.ui.game3.save_menu")
if ok then
local sec = type(def) == "table" and (def.regionMapSectionId or def.region_map_section_id or def.mapsec) or nil
local got, name = pcall(SaveMenu.locationName, { map = mapId, regionMapSectionId = sec })
if got and type(name) == "string" and name ~= "" then return titleCase(name) end
end
local s = tostring(mapId):gsub("^MAP_", "")
local okP, Profile = pcall(require, "src.core.game3.profile")
local row = okP and Profile.forSession(nil) or nil
for _, prefix in ipairs(row and row.map and row.map.prefixes or {}) do
if s:sub(1, #prefix) == prefix then s = s:sub(#prefix + 1) break end
end
return titleCase(s:gsub("_", " "))
end
local function locationName(game, mapId, def)
if not mapId then return nil end
if isGen3(game) then return gen3LocationName(mapId, def) end
local world = game and game.world
if world and world.landmarkName then
local ok, name = pcall(world.landmarkName, world)
if ok and type(name) == "string" and name ~= "" then return name end
end
local field = game and game.data and game.data.field
local townMap = field and field.townMap
local locations = townMap and (townMap.locations or townMap)
local entry = type(locations) == "table" and locations[mapId]
if type(entry) == "table" then
local name = entry.name or entry.label
if type(name) == "string" and name ~= "" then return name end
end
local def = game and game.data and game.data.maps and game.data.maps[mapId]
if def and type(def.label) == "string" and def.label ~= "" then
-- PalletTown -> Pallet Town
return def.label:gsub("(%l)(%u)", "%1 %2")
end
return tostring(mapId):gsub("_", " ")
end
local function speciesName(game, species)
if not species or not game or not game.data or not game.data.pokemon then
return nil
end
local def = game.data.pokemon[species]
return def and def.name or tostring(species):gsub("_", " ")
end
local function buildActivity()
local details, activityState
if state.activity == "launcher" then
details = Strings("In the launcher")
activityState = state.joinSecret and Strings("Waiting in a lobby")
or Strings("Browsing the online lobby")
elseif state.activity == "battle" then
details = state.battleLabel or Strings("In battle")
activityState = state.location and (state.location) or "Kanto"
elseif state.activity == "exploring" and state.location then
details = state.location
activityState = "Exploring the overworld"
else
details = state.location or "Kanto"
activityState = "At the title screen"
end
local activity = {
details = details,
state = activityState,
timestamps = { start = state.startedAt },
assets = {
large_image = "logo",
large_text = "Pokemon Gen1Recomp",
},
}
if state.joinSecret then
activity.party = { id = "pokeport-" .. state.joinSecret:sub(1, 16),
size = { state.partySize, state.partyMax } }
activity.secrets = { join = "join:i:" .. state.joinSecret }
end
return activity
end
local function activityKey(activity)
local partyKey = activity.party
and (activity.party.size[1] .. "/" .. activity.party.size[2]) or ""
return (activity.details or "") .. "|" .. (activity.state or "") .. "|"
.. (activity.secrets and activity.secrets.join or "") .. "|" .. partyKey
end
local function flush(force)
if not state.enabled or not state.connected then return end
local ok = pcall(function()
local now = love.timer.getTime()
if not force and (now - state.lastSendAt) < MIN_UPDATE_INTERVAL then
state.dirty = true
return
end
local activity = buildActivity()
local key = activityKey(activity)
if not force and key == state.lastSentKey then
state.dirty = false
return
end
local payload = Json.encode({
cmd = "SET_ACTIVITY",
args = {
pid = resolvePid(),
activity = activity,
},
nonce = nonce(),
})
if not sendFrame(OP_FRAME, payload) then
closeSocket()
state.nextReconnectAt = now + RECONNECT_INTERVAL
return
end
state.lastSentKey = key
state.lastSendAt = now
state.dirty = false
end)
if not ok then
closeSocket()
local now = love and love.timer and love.timer.getTime and love.timer.getTime() or 0
state.nextReconnectAt = now + RECONNECT_INTERVAL
end
end
local function setPresence(fields)
-- event listeners must never throw into the overworld/battle path
pcall(function()
if fields.location ~= nil then state.location = fields.location end
if fields.mapId ~= nil then state.mapId = fields.mapId end
if fields.activity ~= nil then state.activity = fields.activity end
if fields.clearBattle then
state.battleLabel = nil
elseif fields.battleLabel ~= nil then
state.battleLabel = fields.battleLabel
end
state.dirty = true
flush(false)
end)
end
local function subscribe(game)
local events = Runtime.events
if not events or not events.on then return end
local function track(name, fn)
state.unsubs[#state.unsubs + 1] = events:on(name, fn, 0, "discord")
end
track("map.entered", function(ev)
local mapId = ev and ev.mapId
setPresence({
mapId = mapId,
location = locationName(game, mapId, ev and ev.map),
activity = "exploring",
clearBattle = true,
})
end)
track("battle.started", function(ev)
local battle = ev and ev.battle
local label = Strings("In battle")
if ev and ev.kind == "wild" then
local name = speciesName(game, ev.species)
if name then
label = "Battling wild " .. name
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
else
label = Strings("Wild battle")
end
elseif ev and ev.kind == "trainer" then
local tname = battle and battle.trainer and battle.trainer.name
label = tname and ("Battling " .. tname) or Strings("Trainer battle")
elseif ev and ev.kind == "link" then
label = Strings("Link battle")
end
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
setPresence({
activity = "battle",
battleLabel = label,
location = locationName(game, mapId) or state.location,
mapId = mapId,
})
end)
track("battle.ended", function()
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
setPresence({
activity = mapId and "exploring" or "menu",
clearBattle = true,
location = locationName(game, mapId) or state.location,
mapId = mapId,
})
end)
track("screen.pushed", function(ev)
local s = ev and ev.state
local id = s and s.screenId
if not id and s and s.onNewGame then id = "TitleState" end
if id == "TitleState" or id == "IntroMovie" then
state.mapId = nil
setPresence({
activity = "menu",
location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"),
clearBattle = true,
})
end
end)
end
local function tokenOf(value)
if type(value) ~= "string" then return nil end
value = value:lower()
if #value == 32 and value:match("^%x+$") then return value end
return nil
end
DiscordPresence.tokenOf = tokenOf
function DiscordPresence.setJoinSecret(token, size, max)
pcall(function()
state.joinSecret = tokenOf(token)
state.partySize = size or 1
state.partyMax = max or 2
state.dirty = true
flush(false)
end)
end
function DiscordPresence.setJoinCode(code)
if code == nil then DiscordPresence.setJoinSecret(nil) end
end
-- A friend clicked "Ask to Join" on our profile and Discord delivered the
-- secret back over this same connection. Only act from a safe spot (never
-- yank the player out of a battle or an already-running link session) --
-- "exploring"/"menu" are the coarse buckets subscribe() above already
-- tracks; anything more specific (a shop, a dialogue box) is a rarer miss
-- worth accepting rather than adding a second, finer-grained state tracker
-- just for this.
local function handleJoinRequest(secret)
if not secret or secret == "" then return end
if state.activity == "battle" then return end
local game = state.game
local top = game and type(game.stack) == "table"
and type(game.stack.top) == "function" and game.stack:top()
if top and top.stage and top.net then return end -- already in a link session
if game and tonumber(game.generation) == 3 then
local link = package.loaded["src.core.game3.link"]
if type(link) == "table" and link.link ~= nil then return end
end
local kindTag, code = secret:match("^(%a):(.+)$")
if not kindTag then kindTag, code = "i", secret end
local token = kindTag == "i" and tokenOf(code) or nil
Runtime.emit("discord.join_requested",
{ code = code, kind = kindTag, invite = token })
if not token then
print("[discord] not an invite token; ignoring join secret")
return
end
if game then
if not game.returnToLauncher then
print("[discord] no launcher to return to; ignoring the invite")
return
end
game.returnToLauncher({ tab = "online", invite = token })
return
end
local handler = DiscordPresence.joinHandler
if type(handler) == "function" then
pcall(handler, token)
return
end
print("[discord] online panel unavailable; ignoring the invite")
end
-- non-blocking peek for an incoming ACTIVITY_JOIN dispatch. Unix (FFI)
-- only for now: reading a Windows named pipe without risking a stall needs
-- a HANDLE-level PeekNamedPipe that the plain io.open() connection below
-- doesn't give us, so a Windows player's presence/party/join-secret still
-- all work (they can still be seen and clicked), the click just isn't
-- delivered back to their own game session yet.
local function pollJoinRequest()
if state.isWindows or not state.ffi or not state.socket or not state.subscribedJoin then
return
end
pcall(function()
local ffi = state.ffi
local dontwait = MSG_DONTWAIT_BY_OS[love.system.getOS()] or 0
local hbuf = ffi.new("char[8]")
local n = ffi.C.recv(state.socket, hbuf, 8, dontwait)
if not n or tonumber(n) <= 0 then return end -- nothing waiting right now
local header = ffi.string(hbuf, 8)
local opcode = unpackU32(header:sub(1, 4))
local length = unpackU32(header:sub(5, 8))
if not opcode or not length or length < 0 or length > 65536 then return end
-- the body may lag the header by a packet; the connection's normal
-- (short-timeout) blocking read is fine here since it has already
-- announced its exact length
local body = length > 0 and readExact(length) or ""
if body == nil then return end
if opcode == 3 then -- PING -> PONG, keeps Discord from closing on us
sendFrame(4, body)
return
end
if opcode ~= 1 then return end
local decoded = Json.decode(body)
if decoded and decoded.evt == "ACTIVITY_JOIN" then
local secret = decoded.data and decoded.data.secret
if type(secret) == "string" and secret ~= "" then
if secret:sub(1, 5) == "join:" then secret = secret:sub(6) end
handleJoinRequest(secret)
end
end
end)
end
function DiscordPresence.init(game)
local ok, err = pcall(function()
DiscordPresence.shutdown()
if disabledByEnv() or not isDesktop() then return end
state.enabled = true
state.isWindows = love.system.getOS() == "Windows"
state.startedAt = os.time()
state.activity = "menu"
state.location = "Title screen"
state.mapId = nil
state.battleLabel = nil
state.joinSecret = nil
state.game = game
DiscordPresence.launcherArmed = game == nil
state.dirty = true
state.nextReconnectAt = 0
state.loggedAbsent = false
if not state.isWindows and not ensureFfi() then
-- no FFI / broken cdef: stay disabled, never throw
state.enabled = false
return
end
subscribe(game)
if connect() then
flush(true)
else
noteAbsent()
local now = (love.timer and love.timer.getTime and love.timer.getTime()) or 0
state.nextReconnectAt = now + RECONNECT_INTERVAL
end
end)
if not ok then
closeSocket()
state.enabled = false
-- still soft: a broken presence stack must not abort boot
Logger.info("discord: rich presence disabled (%s)", tostring(err))
end
end
function DiscordPresence.update(_dt)
if not state.enabled then return end
pcall(function()
local now = love.timer.getTime()
if not state.connected then
if now >= state.nextReconnectAt then
state.nextReconnectAt = now + RECONNECT_INTERVAL
if connect() then
flush(true)
else
noteAbsent()
end
end
return
end
pollJoinRequest()
if state.dirty then flush(false) end
end)
end
function DiscordPresence.shutdown()
pcall(function()
for _, unsub in ipairs(state.unsubs) do
pcall(unsub)
end
state.unsubs = {}
if state.connected then
pcall(function()
-- omit activity to clear the presence
sendFrame(OP_FRAME, Json.encode({
cmd = "SET_ACTIVITY",
args = { pid = resolvePid() },
nonce = nonce(),
}))
sendFrame(OP_CLOSE, "{}")
end)
end
closeSocket()
end)
state.enabled = false
state.dirty = false
state.lastSentKey = nil
state.connected = false
state.socket = nil
state.subscribedJoin = false
state.joinSecret = nil
DiscordPresence.launcherArmed = false
end
function DiscordPresence.ensureLauncher()
if state.enabled and state.game == nil then return true end
if state.enabled then return false end
DiscordPresence.init(nil)
if state.enabled then
state.activity = "launcher"
state.dirty = true
end
return state.enabled
end
function DiscordPresence.enabled()
return state.enabled == true
end
-- test / debug helpers
DiscordPresence._state = state
DiscordPresence.locationName = locationName
DiscordPresence.handleJoinRequest = handleJoinRequest
return DiscordPresence