Skip to content

Instantly share code, notes, and snippets.

@g-l-i-t-c-h-o-r-s-e
Last active November 23, 2025 09:39
Show Gist options
  • Select an option

  • Save g-l-i-t-c-h-o-r-s-e/0c2305414c6ef16203afbf2e33cb2c69 to your computer and use it in GitHub Desktop.

Select an option

Save g-l-i-t-c-h-o-r-s-e/0c2305414c6ef16203afbf2e33cb2c69 to your computer and use it in GitHub Desktop.
Quartz Composer Discord 0.0.297 RPC Plugin (MacOS Mojave) | sudo port install discord-rpc
#!/usr/bin/env bash
set -euo pipefail
# --- config (override via env) ---
NAME="${NAME:-DiscordRPC}"
CLASS="${CLASS:-DiscordRPCPlugIn}"
SRC="${SRC:-${CLASS}.m}"
PLUG="$NAME.plugin"
OUT="$(pwd)/build-discordrpc"
INST="$HOME/Library/Graphics/Quartz Composer Plug-Ins"
XCODE_APP="${XCODE_APP:-/Applications/Xcode_9.4.1.app}"
DEV="$XCODE_APP/Contents/Developer"
SDKDIR="$DEV/Platforms/MacOSX.platform/Developer/SDKs"
# Arch toggles
# Default: no i386 (modern MacPorts discord-rpc is x86_64-only)
DO_I386="${DO_I386:-0}"
DO_X64="${DO_X64:-1}"
# Clean output directory first (set CLEAN=0 to keep)
CLEAN="${CLEAN:-1}"
# Prefer 10.14, fall back to 10.13, else xcrun
SDK="${SDK:-}"
if [[ -z "${SDK}" ]]; then
if [[ -d "$SDKDIR/MacOSX10.14.sdk" ]]; then SDK="$SDKDIR/MacOSX10.14.sdk"
elif [[ -d "$SDKDIR/MacOSX10.13.sdk" ]]; then SDK="$SDKDIR/MacOSX10.13.sdk"
else SDK="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || true)"
fi
fi
[[ -d "$DEV" ]] || { echo "Xcode not found: $XCODE_APP"; exit 1; }
[[ -f "$SRC" ]] || { echo "Source not found: $SRC"; exit 1; }
[[ -n "${SDK:-}" && -d "$SDK" ]] || { echo "macOS SDK not found."; exit 1; }
export DEVELOPER_DIR="$DEV"
echo "Using SDK: $SDK"
# Clean build output so there are no stale slices
if [[ "$CLEAN" == "1" ]]; then
echo "Cleaning: rm -rf '$OUT'"
rm -rf "$OUT"
fi
mkdir -p "$OUT/i386" "$OUT/x86_64" "$OUT/universal/$PLUG/Contents/MacOS"
# Arch toggles
# Default: skip i386 on 10.14 SDK; allow override with DO_I386=1
if [[ -z "${DO_I386:-}" ]]; then
if [[ "$SDK" == *"10.14.sdk"* ]]; then DO_I386=0; else DO_I386=1; fi
fi
DO_X64="${DO_X64:-1}"
# Minimum OS (override with DEPLOY=10.10, etc.)
DEPLOY="${DEPLOY:-10.14}"
COMMON_CFLAGS=(
-bundle -fobjc-arc -fobjc-link-runtime
-isysroot "$SDK"
-mmacosx-version-min="$DEPLOY"
-I .
# MacPorts includes for discord-rpc
-I /opt/local/include
)
COMMON_LIBS=(
-framework Foundation
-framework Quartz
-framework OpenGL
-framework AppKit
-framework QuartzCore
-framework CoreMIDI
-framework CoreFoundation
# MacPorts discord-rpc
-L /opt/local/lib
-ldiscord-rpc
)
# ---- compile (track what we actually built) ----
BUILT_I386=0
BUILT_X64=0
if [[ "$DO_I386" == "1" ]]; then
echo "Compiling i386…"
clang -arch i386 "${COMMON_CFLAGS[@]}" "$SRC" "${COMMON_LIBS[@]}" -o "$OUT/i386/$NAME" || {
echo "i386 build failed (likely unsupported by this SDK/clang). Set DO_I386=0 to skip."; exit 1; }
BUILT_I386=1
else
echo "Skipping i386 (set DO_I386=1 to attempt, e.g. with 10.13 SDK)."
fi
if [[ "$DO_X64" == "1" ]]; then
echo "Compiling x86_64…"
clang -arch x86_64 "${COMMON_CFLAGS[@]}" "$SRC" "${COMMON_LIBS[@]}" -o "$OUT/x86_64/$NAME"
BUILT_X64=1
fi
# ---- gather only slices from this run ----
BINARIES=()
[[ "$BUILT_I386" == "1" && -f "$OUT/i386/$NAME" ]] && BINARIES+=("$OUT/i386/$NAME")
[[ "$BUILT_X64" == "1" && -f "$OUT/x86_64/$NAME" ]] && BINARIES+=("$OUT/x86_64/$NAME")
if [[ "${#BINARIES[@]}" -eq 0 ]]; then
echo "No binaries were built. Aborting."
exit 1
fi
echo "Creating bundle…"
mkdir -p "$OUT/universal/$PLUG/Contents/MacOS"
lipo -create "${BINARIES[@]}" -output "$OUT/universal/$PLUG/Contents/MacOS/$NAME"
# ---- Info.plist ----
cat >"$OUT/universal/$PLUG/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDevelopmentRegion</key> <string>English</string>
<key>CFBundleExecutable</key> <string>${NAME}</string>
<key>CFBundleIdentifier</key> <string>com.yourdomain.${NAME}</string>
<key>CFBundleInfoDictionaryVersion</key> <string>6.0</string>
<key>CFBundleName</key> <string>${NAME}</string>
<key>CFBundlePackageType</key> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <string>1.0</string>
<key>CFBundleSupportedPlatforms</key> <array><string>MacOSX</string></array>
<key>CFBundleVersion</key> <string>1</string>
<key>QCPlugInClasses</key>
<array>
<string>${CLASS}</string>
</array>
<key>NSPrincipalClass</key> <string>QCPlugIn</string>
<key>NSRequiresAquaSystemAppearance</key> <false/>
</dict></plist>
PLIST
echo "Final binary slices:"
lipo -info "$OUT/universal/$PLUG/Contents/MacOS/$NAME" || true
echo "Signing…"
codesign --force -s - "$OUT/universal/$PLUG" >/dev/null || true
echo "Installing to: $INST"
mkdir -p "$INST"
rsync -a "$OUT/universal/$PLUG" "$INST/"
echo "Installed: $INST/$PLUG"
echo "Restart Quartz Composer to load the new plug-in."
echo
echo "Notes:"
echo " • CLEAN=$CLEAN (set CLEAN=0 to keep previous builds)."
echo " • i386 default depends on SDK (10.14 → OFF). Override with DO_I386=0/1."
echo " • DEPLOY=${DEPLOY} (override with DEPLOY=10.10, etc.)."
echo " • Uses MacPorts discord-rpc headers/libs: /opt/local/include, /opt/local/lib."
// DiscordRPCPlugIn.h
// Quartz Composer plug-in for Discord Rich Presence
#import <Quartz/Quartz.h>
@interface DiscordRPCPlugIn : QCPlugIn
// ---- Control / config ----
// If YES (default), read values from config file first, then fall back to QC ports.
@property (assign) BOOL inputUseConfig;
// If YES, override "State" with the composition name (.qtz filename without extension)
@property (assign) BOOL inputUseCompositionNameForState;
// ---- Core presence inputs ----
@property (copy) NSString *inputApplicationId;
@property (copy) NSString *inputDetails;
@property (copy) NSString *inputState;
@property (copy) NSString *inputLargeImageKey;
@property (copy) NSString *inputLargeImageText;
@property (copy) NSString *inputSmallImageKey;
@property (copy) NSString *inputSmallImageText;
// Enable / disable sending presence
@property (assign) BOOL inputEnabled;
// Clear presence explicitly (one-shot trigger; presence is cleared when this becomes true)
@property (assign) BOOL inputClear;
// Optional timestamps (Unix time, seconds)
@property (assign) double inputStartTimestamp;
@property (assign) double inputEndTimestamp;
// Optional party size / max
@property (assign) double inputPartySize;
@property (assign) double inputPartyMax;
// ---- Extra Rich Presence inputs ----
@property (copy) NSString *inputPartyId; // partyId
@property (copy) NSString *inputMatchSecret; // matchSecret
@property (copy) NSString *inputJoinSecret; // joinSecret
@property (copy) NSString *inputSpectateSecret; // spectateSecret
@property (assign) BOOL inputInstance; // instance = 0/1
// Auto-accept join requests (if enabled)
@property (assign) BOOL inputAutoAcceptJoinRequests;
@end
// DiscordRPCPlugIn.m
//
// Quartz Composer plug-in for Discord Rich Presence, with logging, config override,
// composition-name State override, and extra features.
//
// Uses MacPorts discord-rpc:
// headers: /opt/local/include/discord_rpc.h
// library: /opt/local/lib/libdiscord-rpc.dylib
//
// Config file (JSON):
// ~/Library/Application Support/DiscordRPC/qc-config.json
//
// Look for logs in Console.app as [DiscordRPC] …
//
#import "DiscordRPCPlugIn.h"
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <CoreFoundation/CoreFoundation.h>
#import <Quartz/Quartz.h>
#import <discord_rpc.h>
#define DRPC_LOG(fmt, ...) NSLog(@"[DiscordRPC] " fmt, ##__VA_ARGS__)
// Plug-in UI strings
#define kQCPlugIn_Name @"Discord RPC"
#define kQCPlugIn_Description @"Send Discord Rich Presence updates from Quartz Composer"
// Global state shared per-process
static BOOL gDiscordInitialized = NO;
static BOOL gPresenceCleared = NO;
// --- Config state ---
static NSDictionary *gConfigDict = nil;
static NSDate *gConfigMTime = nil;
// --- Event state for logging / auto-accept ---
typedef struct {
BOOL hasUser;
char userId[128];
char username[128];
char discriminator[8];
} DrpcUserInfo;
typedef struct {
BOOL hasValue;
int code;
char message[256];
} DrpcCodeMessage;
typedef struct {
BOOL hasSecret;
char secret[128];
} DrpcSecret;
typedef struct {
BOOL hasRequest;
char userId[128];
char username[128];
char discriminator[8];
} DrpcJoinRequest;
static DrpcUserInfo gReadyUser;
static DrpcCodeMessage gLastError;
static DrpcCodeMessage gLastDisconnect;
static DrpcSecret gLastJoinGame;
static DrpcSecret gLastSpectateGame;
static DrpcJoinRequest gLastJoinRequest;
#pragma mark - Helpers
static void drpc_strncpy(char *dst, size_t dstSize, const char *src)
{
if (!dst || dstSize == 0) return;
if (!src) {
dst[0] = '\0';
return;
}
strncpy(dst, src, dstSize - 1);
dst[dstSize - 1] = '\0';
}
static void drpc_reset_state(void)
{
memset(&gReadyUser, 0, sizeof(gReadyUser));
memset(&gLastError, 0, sizeof(gLastError));
memset(&gLastDisconnect, 0, sizeof(gLastDisconnect));
memset(&gLastJoinGame, 0, sizeof(gLastJoinGame));
memset(&gLastSpectateGame, 0, sizeof(gLastSpectateGame));
memset(&gLastJoinRequest, 0, sizeof(gLastJoinRequest));
}
static NSString *DrpcConfigPath(void)
{
NSString *home = NSHomeDirectory();
if (!home) return nil;
NSString *dir = [home stringByAppendingPathComponent:@"Library/Application Support/DiscordRPC"];
return [dir stringByAppendingPathComponent:@"qc-config.json"];
}
static id DrpcConfigValueForKey(NSString *key)
{
if (!gConfigDict) return nil;
id v = [gConfigDict objectForKey:key];
if (v == [NSNull null]) return nil;
return v;
}
#pragma mark - Discord RPC event handlers (C callbacks)
static void drpc_handle_ready(const DiscordUser *user)
{
if (!user) {
DRPC_LOG(@"Discord ready (no user struct?)");
gReadyUser.hasUser = NO;
return;
}
DRPC_LOG(@"Discord ready: %s#%s (%s)", user->username, user->discriminator, user->userId);
gReadyUser.hasUser = YES;
drpc_strncpy(gReadyUser.userId, sizeof(gReadyUser.userId), user->userId);
drpc_strncpy(gReadyUser.username, sizeof(gReadyUser.username), user->username);
drpc_strncpy(gReadyUser.discriminator, sizeof(gReadyUser.discriminator), user->discriminator);
}
static void drpc_handle_disconnected(int errorCode, const char *message)
{
DRPC_LOG(@"Discord disconnected: code=%d, message=%s", errorCode, message ? message : "(null)");
gLastDisconnect.hasValue = YES;
gLastDisconnect.code = errorCode;
drpc_strncpy(gLastDisconnect.message, sizeof(gLastDisconnect.message), message ? message : "");
}
static void drpc_handle_errored(int errorCode, const char *message)
{
DRPC_LOG(@"Discord errored: code=%d, message=%s", errorCode, message ? message : "(null)");
gLastError.hasValue = YES;
gLastError.code = errorCode;
drpc_strncpy(gLastError.message, sizeof(gLastError.message), message ? message : "");
}
static void drpc_handle_join_game(const char *joinSecret)
{
DRPC_LOG(@"Discord joinGame: secret=%s", joinSecret ? joinSecret : "(null)");
gLastJoinGame.hasSecret = (joinSecret && joinSecret[0] != '\0');
drpc_strncpy(gLastJoinGame.secret, sizeof(gLastJoinGame.secret), joinSecret ? joinSecret : "");
}
static void drpc_handle_spectate_game(const char *spectateSecret)
{
DRPC_LOG(@"Discord spectateGame: secret=%s", spectateSecret ? spectateSecret : "(null)");
gLastSpectateGame.hasSecret = (spectateSecret && spectateSecret[0] != '\0');
drpc_strncpy(gLastSpectateGame.secret, sizeof(gLastSpectateGame.secret), spectateSecret ? spectateSecret : "");
}
static void drpc_handle_join_request(const DiscordUser *user)
{
if (!user) {
DRPC_LOG(@"Discord joinRequest: (null user)");
gLastJoinRequest.hasRequest = NO;
return;
}
DRPC_LOG(@"Discord joinRequest: %s#%s (%s)", user->username, user->discriminator, user->userId);
gLastJoinRequest.hasRequest = YES;
drpc_strncpy(gLastJoinRequest.userId, sizeof(gLastJoinRequest.userId), user->userId);
drpc_strncpy(gLastJoinRequest.username, sizeof(gLastJoinRequest.username), user->username);
drpc_strncpy(gLastJoinRequest.discriminator, sizeof(gLastJoinRequest.discriminator), user->discriminator);
}
static void drpc_log_presence(const DiscordRichPresence *p)
{
if (!p) {
DRPC_LOG(@"Presence: (null pointer)");
return;
}
DRPC_LOG(@"Presence update:"
@" details='%s' state='%s'"
@" largeKey='%s' largeText='%s'"
@" smallKey='%s' smallText='%s'"
@" start=%lld end=%lld"
@" partyId='%s' partySize=%d partyMax=%d"
@" matchSecret='%s' joinSecret='%s' spectateSecret='%s' instance=%d",
p->details ? p->details : "(null)",
p->state ? p->state : "(null)",
p->largeImageKey ? p->largeImageKey : "(null)",
p->largeImageText ? p->largeImageText : "(null)",
p->smallImageKey ? p->smallImageKey : "(null)",
p->smallImageText ? p->smallImageText : "(null)",
(long long)p->startTimestamp,
(long long)p->endTimestamp,
p->partyId ? p->partyId : "(null)",
p->partySize,
p->partyMax,
p->matchSecret ? p->matchSecret : "(null)",
p->joinSecret ? p->joinSecret : "(null)",
p->spectateSecret? p->spectateSecret: "(null)",
p->instance);
}
#pragma mark - Implementation
@implementation DiscordRPCPlugIn
@dynamic inputUseConfig,
inputUseCompositionNameForState,
inputApplicationId,
inputDetails,
inputState,
inputLargeImageKey,
inputLargeImageText,
inputSmallImageKey,
inputSmallImageText,
inputEnabled,
inputClear,
inputStartTimestamp,
inputEndTimestamp,
inputPartySize,
inputPartyMax,
inputPartyId,
inputMatchSecret,
inputJoinSecret,
inputSpectateSecret,
inputInstance,
inputAutoAcceptJoinRequests;
#pragma mark - QCPlugIn metadata
+ (NSDictionary *)attributes
{
return @{
QCPlugInAttributeNameKey : kQCPlugIn_Name,
QCPlugInAttributeDescriptionKey : kQCPlugIn_Description
};
}
+ (NSDictionary *)attributesForPropertyPortWithKey:(NSString *)key
{
// ---- Control / config ----
if ([key isEqualToString:@"inputUseConfig"]) {
return @{
QCPortAttributeNameKey : @"Use Config",
QCPortAttributeTypeKey : QCPortTypeBoolean,
QCPortAttributeDefaultValueKey: @YES
};
}
if ([key isEqualToString:@"inputUseCompositionNameForState"]) {
return @{
QCPortAttributeNameKey : @"Use .qtz Name",
QCPortAttributeTypeKey : QCPortTypeBoolean,
QCPortAttributeDefaultValueKey: @YES
};
}
// ---- Inputs ----
if ([key isEqualToString:@"inputApplicationId"]) {
return @{
QCPortAttributeNameKey : @"Application ID",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputDetails"]) {
return @{
QCPortAttributeNameKey : @"Details",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputState"]) {
return @{
QCPortAttributeNameKey : @"State",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputLargeImageKey"]) {
return @{
QCPortAttributeNameKey : @"Large Image Key",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputLargeImageText"]) {
return @{
QCPortAttributeNameKey : @"Large Image Text",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputSmallImageKey"]) {
return @{
QCPortAttributeNameKey : @"Small Image Key",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputSmallImageText"]) {
return @{
QCPortAttributeNameKey : @"Small Image Text",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputEnabled"]) {
return @{
QCPortAttributeNameKey : @"Enabled",
QCPortAttributeTypeKey : QCPortTypeBoolean,
QCPortAttributeDefaultValueKey: @YES
};
}
if ([key isEqualToString:@"inputClear"]) {
return @{
QCPortAttributeNameKey : @"Clear Presence",
QCPortAttributeTypeKey : QCPortTypeBoolean,
QCPortAttributeDefaultValueKey: @NO
};
}
if ([key isEqualToString:@"inputStartTimestamp"]) {
return @{
QCPortAttributeNameKey : @"Start Timestamp",
QCPortAttributeTypeKey : QCPortTypeNumber,
QCPortAttributeDefaultValueKey: @0.0
};
}
if ([key isEqualToString:@"inputEndTimestamp"]) {
return @{
QCPortAttributeNameKey : @"End Timestamp",
QCPortAttributeTypeKey : QCPortTypeNumber,
QCPortAttributeDefaultValueKey: @0.0
};
}
if ([key isEqualToString:@"inputPartySize"]) {
return @{
QCPortAttributeNameKey : @"Party Size",
QCPortAttributeTypeKey : QCPortTypeNumber,
QCPortAttributeDefaultValueKey: @0.0
};
}
if ([key isEqualToString:@"inputPartyMax"]) {
return @{
QCPortAttributeNameKey : @"Party Max",
QCPortAttributeTypeKey : QCPortTypeNumber,
QCPortAttributeDefaultValueKey: @0.0
};
}
if ([key isEqualToString:@"inputPartyId"]) {
return @{
QCPortAttributeNameKey : @"Party ID",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputMatchSecret"]) {
return @{
QCPortAttributeNameKey : @"Match Secret",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputJoinSecret"]) {
return @{
QCPortAttributeNameKey : @"Join Secret",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputSpectateSecret"]) {
return @{
QCPortAttributeNameKey : @"Spectate Secret",
QCPortAttributeTypeKey : QCPortTypeString,
QCPortAttributeDefaultValueKey: @""
};
}
if ([key isEqualToString:@"inputInstance"]) {
return @{
QCPortAttributeNameKey : @"Instance",
QCPortAttributeTypeKey : QCPortTypeBoolean,
QCPortAttributeDefaultValueKey: @YES
};
}
if ([key isEqualToString:@"inputAutoAcceptJoinRequests"]) {
return @{
QCPortAttributeNameKey : @"Auto-Accept Join Requests",
QCPortAttributeTypeKey : QCPortTypeBoolean,
QCPortAttributeDefaultValueKey: @YES
};
}
return nil;
}
+ (NSArray *)sortedPropertyPortKeys
{
return @[
@"inputUseConfig",
@"inputUseCompositionNameForState",
@"inputEnabled",
@"inputApplicationId",
@"inputDetails",
@"inputState",
@"inputLargeImageKey",
@"inputLargeImageText",
@"inputSmallImageKey",
@"inputSmallImageText",
@"inputStartTimestamp",
@"inputEndTimestamp",
@"inputPartySize",
@"inputPartyMax",
@"inputPartyId",
@"inputMatchSecret",
@"inputJoinSecret",
@"inputSpectateSecret",
@"inputInstance",
@"inputAutoAcceptJoinRequests",
@"inputClear"
];
}
+ (QCPlugInExecutionMode)executionMode
{
// This is a consumer: no outputs, side-effects only.
return kQCPlugInExecutionModeConsumer;
}
+ (QCPlugInTimeMode)timeMode
{
return kQCPlugInTimeModeIdle;
}
#pragma mark - Lifecycle
- (id)init
{
if (self = [super init]) {
DRPC_LOG(@"init");
gDiscordInitialized = NO;
gPresenceCleared = NO;
drpc_reset_state();
}
return self;
}
+ (BOOL)needsTimeForExecution:(id<QCPlugInContext>)context
{
return YES;
}
- (BOOL)startExecution:(id<QCPlugInContext>)context
{
DRPC_LOG(@"startExecution");
gDiscordInitialized = NO;
gPresenceCleared = NO;
drpc_reset_state();
gConfigDict = nil;
gConfigMTime = nil;
return YES;
}
- (void)stopExecution:(id<QCPlugInContext>)context
{
DRPC_LOG(@"stopExecution");
if (gDiscordInitialized) {
DRPC_LOG(@"Shutting down Discord RPC on stopExecution");
Discord_ClearPresence();
Discord_Shutdown();
gDiscordInitialized = NO;
}
gPresenceCleared = NO;
drpc_reset_state();
gConfigDict = nil;
gConfigMTime = nil;
}
#pragma mark - Config helpers (instance methods)
- (BOOL)_reloadConfigIfNeeded
{
BOOL changed = NO;
BOOL useConfig = self.inputUseConfig;
if (!useConfig) {
if (gConfigDict || gConfigMTime) {
DRPC_LOG(@"Config disabled via port; clearing cached config");
gConfigDict = nil;
gConfigMTime = nil;
changed = YES;
}
return changed;
}
NSString *path = DrpcConfigPath();
if (!path) {
DRPC_LOG(@"Config path could not be constructed");
return changed;
}
NSFileManager *fm = [NSFileManager defaultManager];
NSError *attrError = nil;
NSDictionary *attrs = [fm attributesOfItemAtPath:path error:&attrError];
if (!attrs) {
if (gConfigDict) {
DRPC_LOG(@"Config file missing (was previously loaded): %s", [path UTF8String]);
gConfigDict = nil;
gConfigMTime = nil;
changed = YES;
}
return changed;
}
NSDate *mod = [attrs fileModificationDate];
if (!mod) {
mod = [attrs fileCreationDate];
}
if (!gConfigDict || !gConfigMTime || [mod compare:gConfigMTime] == NSOrderedDescending) {
NSError *readError = nil;
NSData *data = [NSData dataWithContentsOfFile:path options:0 error:&readError];
if (!data) {
DRPC_LOG(@"Failed to read config at %s: %@", [path UTF8String], readError);
return changed;
}
NSError *jsonError = nil;
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError];
if (!json || ![json isKindOfClass:[NSDictionary class]]) {
DRPC_LOG(@"Failed to parse JSON config at %s: %@", [path UTF8String], jsonError);
return changed;
}
gConfigDict = (NSDictionary *)json;
gConfigMTime = mod;
changed = YES;
DRPC_LOG(@"Config loaded from %s (keys: %lu)", [path UTF8String], (unsigned long)[gConfigDict count]);
}
return changed;
}
- (NSString *)_effectiveStringForKey:(NSString *)cfgKey fallback:(NSString *)fallback
{
if (!self.inputUseConfig || !gConfigDict) {
return fallback;
}
id v = DrpcConfigValueForKey(cfgKey);
if (!v || v == [NSNull null]) return fallback;
if (![v isKindOfClass:[NSString class]]) return fallback;
NSString *s = (NSString *)v;
if (s.length == 0) return fallback;
return s;
}
- (double)_effectiveDoubleForKey:(NSString *)cfgKey fallback:(double)fallback
{
if (!self.inputUseConfig || !gConfigDict) {
return fallback;
}
id v = DrpcConfigValueForKey(cfgKey);
if (!v || v == [NSNull null]) return fallback;
if ([v isKindOfClass:[NSNumber class]]) {
return [(NSNumber *)v doubleValue];
}
if ([v isKindOfClass:[NSString class]]) {
NSString *s = (NSString *)v;
if (s.length == 0) return fallback;
return [s doubleValue];
}
return fallback;
}
- (BOOL)_effectiveBoolForKey:(NSString *)cfgKey fallback:(BOOL)fallback
{
if (!self.inputUseConfig || !gConfigDict) {
return fallback;
}
id v = DrpcConfigValueForKey(cfgKey);
if (!v || v == [NSNull null]) return fallback;
if ([v isKindOfClass:[NSNumber class]]) {
return [(NSNumber *)v boolValue];
}
if ([v isKindOfClass:[NSString class]]) {
NSString *s = [(NSString *)v stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (s.length == 0) return fallback;
NSString *lower = [s lowercaseString];
if ([lower isEqualToString:@"true"] || [lower isEqualToString:@"yes"] || [lower isEqualToString:@"on"]) return YES;
if ([lower isEqualToString:@"false"] || [lower isEqualToString:@"no"] || [lower isEqualToString:@"off"]) return NO;
double d = [s doubleValue];
if (d == 0.0) return fallback;
return YES;
}
return fallback;
}
#pragma mark - Discord RPC helpers
- (void)_initializeDiscordIfNeeded
{
if (gDiscordInitialized)
return;
// Application ID may come from config or QC port.
NSString *portAppId = self.inputApplicationId;
NSString *appId = [self _effectiveStringForKey:@"applicationId" fallback:portAppId];
if (appId == nil || appId.length == 0) {
DRPC_LOG(@"_initializeDiscordIfNeeded: no Application ID (config + port both empty)");
return;
}
const char *appIdC = [appId UTF8String];
if (!appIdC || !appIdC[0]) {
DRPC_LOG(@"_initializeDiscordIfNeeded: Application ID UTF8 conversion failed");
return;
}
DRPC_LOG(@"Initializing Discord RPC with Application ID: %s", appIdC);
DiscordEventHandlers handlers;
memset(&handlers, 0, sizeof(handlers));
handlers.ready = drpc_handle_ready;
handlers.disconnected = drpc_handle_disconnected;
handlers.errored = drpc_handle_errored;
handlers.joinGame = drpc_handle_join_game;
handlers.spectateGame = drpc_handle_spectate_game;
handlers.joinRequest = drpc_handle_join_request;
// autoRegister = 1, no Steam ID
Discord_Initialize(appIdC, &handlers, 1, NULL);
gDiscordInitialized = YES;
gPresenceCleared = NO;
DRPC_LOG(@"Discord_Initialize called");
}
- (void)_shutdownDiscord
{
if (gDiscordInitialized) {
DRPC_LOG(@"_shutdownDiscord: shutting down");
Discord_ClearPresence();
Discord_Shutdown();
gDiscordInitialized = NO;
} else {
DRPC_LOG(@"_shutdownDiscord: not initialized, nothing to do");
}
gPresenceCleared = NO;
drpc_reset_state();
}
- (BOOL)_applicationIdChanged
{
BOOL changed = [self didValueForInputKeyChange:@"inputApplicationId"];
if (changed) {
DRPC_LOG(@"Application ID port changed");
}
return changed;
}
- (void)_updatePresenceIfNeededForce:(BOOL)force context:(id<QCPlugInContext>)context
{
if (!gDiscordInitialized)
return;
BOOL anyChanged =
force ||
[self didValueForInputKeyChange:@"inputDetails"] ||
[self didValueForInputKeyChange:@"inputState"] ||
[self didValueForInputKeyChange:@"inputLargeImageKey"] ||
[self didValueForInputKeyChange:@"inputLargeImageText"] ||
[self didValueForInputKeyChange:@"inputSmallImageKey"] ||
[self didValueForInputKeyChange:@"inputSmallImageText"] ||
[self didValueForInputKeyChange:@"inputStartTimestamp"] ||
[self didValueForInputKeyChange:@"inputEndTimestamp"] ||
[self didValueForInputKeyChange:@"inputPartySize"] ||
[self didValueForInputKeyChange:@"inputPartyMax"] ||
[self didValueForInputKeyChange:@"inputPartyId"] ||
[self didValueForInputKeyChange:@"inputMatchSecret"] ||
[self didValueForInputKeyChange:@"inputJoinSecret"] ||
[self didValueForInputKeyChange:@"inputSpectateSecret"] ||
[self didValueForInputKeyChange:@"inputInstance"];
if (!anyChanged)
return;
DiscordRichPresence presence;
memset(&presence, 0, sizeof(presence));
// Resolve effective values: config first, then QC ports.
NSString *detailsStr = [self _effectiveStringForKey:@"details"
fallback:self.inputDetails];
// --- STATE LOGIC WITH COMPOSITION NAME OVERRIDE ---
// 1) base state from config + port
NSString *stateFromCfg = [self _effectiveStringForKey:@"state"
fallback:self.inputState];
NSString *stateStr = stateFromCfg;
// 2) optional override with composition name (or "Untitled.qtz" if none)
if (self.inputUseCompositionNameForState && context) {
NSURL *compURL = [context compositionURL];
DRPC_LOG(@"compositionURL = %@", compURL);
NSString *baseName = nil;
if (compURL) {
NSString *file = [compURL lastPathComponent];
// strip existing extension (if any)
baseName = [file stringByDeletingPathExtension];
}
DRPC_LOG(@"composition base name from URL = '%@'", baseName);
if (baseName.length == 0) {
baseName = @"Untitled";
DRPC_LOG(@"No valid composition name; using base '%@'", baseName);
}
// Add .qtz explicitly
NSString *nameWithExt = [baseName stringByAppendingPathExtension:@"qtz"];
DRPC_LOG(@"Overriding State with composition-derived name: %@", nameWithExt);
stateStr = nameWithExt;
}
DRPC_LOG(@"Final State string being sent: '%@'", stateStr ?: @"(null)");
// --- rest of presence fields as before ---
NSString *largeKey = [self _effectiveStringForKey:@"largeImageKey" fallback:self.inputLargeImageKey];
NSString *largeText = [self _effectiveStringForKey:@"largeImageText" fallback:self.inputLargeImageText];
NSString *smallKey = [self _effectiveStringForKey:@"smallImageKey" fallback:self.inputSmallImageKey];
NSString *smallText = [self _effectiveStringForKey:@"smallImageText" fallback:self.inputSmallImageText];
NSString *partyId = [self _effectiveStringForKey:@"partyId" fallback:self.inputPartyId];
NSString *matchSec = [self _effectiveStringForKey:@"matchSecret" fallback:self.inputMatchSecret];
NSString *joinSec = [self _effectiveStringForKey:@"joinSecret" fallback:self.inputJoinSecret];
NSString *spectSec = [self _effectiveStringForKey:@"spectateSecret" fallback:self.inputSpectateSecret];
double startTs = [self _effectiveDoubleForKey:@"startTimestamp" fallback:self.inputStartTimestamp];
double endTs = [self _effectiveDoubleForKey:@"endTimestamp" fallback:self.inputEndTimestamp];
double partySize = [self _effectiveDoubleForKey:@"partySize" fallback:self.inputPartySize];
double partyMax = [self _effectiveDoubleForKey:@"partyMax" fallback:self.inputPartyMax];
BOOL instance = [self _effectiveBoolForKey:@"instance" fallback:self.inputInstance];
presence.details = (detailsStr.length > 0) ? [detailsStr UTF8String] : NULL;
presence.state = (stateStr.length > 0) ? [stateStr UTF8String] : NULL;
presence.largeImageKey = (largeKey.length > 0) ? [largeKey UTF8String] : NULL;
presence.largeImageText = (largeText.length > 0) ? [largeText UTF8String] : NULL;
presence.smallImageKey = (smallKey.length > 0) ? [smallKey UTF8String] : NULL;
presence.smallImageText = (smallText.length > 0) ? [smallText UTF8String] : NULL;
if (startTs > 0.0)
presence.startTimestamp = (int64_t)startTs;
if (endTs > 0.0)
presence.endTimestamp = (int64_t)endTs;
if (partySize > 0.0 && partyMax >= partySize) {
presence.partySize = (int)partySize;
presence.partyMax = (int)partyMax;
}
presence.partyId = (partyId.length > 0) ? [partyId UTF8String] : NULL;
presence.matchSecret = (matchSec.length > 0) ? [matchSec UTF8String] : NULL;
presence.joinSecret = (joinSec.length > 0) ? [joinSec UTF8String] : NULL;
presence.spectateSecret = (spectSec.length > 0) ? [spectSec UTF8String] : NULL;
presence.instance = instance ? 1 : 0;
drpc_log_presence(&presence);
Discord_UpdatePresence(&presence);
gPresenceCleared = NO;
}
#pragma mark - Execution
- (BOOL)execute:(id<QCPlugInContext>)context
atTime:(NSTimeInterval)time
withArguments:(NSDictionary *)arguments
{
// Pump callbacks
if (gDiscordInitialized) {
Discord_RunCallbacks();
}
// Check toggles that influence effective values globally
BOOL useConfigChanged = [self didValueForInputKeyChange:@"inputUseConfig"];
BOOL useCompNameForStateChanged = [self didValueForInputKeyChange:@"inputUseCompositionNameForState"];
// Reload config if needed (mtime changed, file added/removed, etc.)
BOOL configChanged = [self _reloadConfigIfNeeded];
BOOL forcePresenceUpdate = (useConfigChanged || configChanged || useCompNameForStateChanged);
// Enabled may come from config or port
BOOL enabled = [self _effectiveBoolForKey:@"enabled" fallback:self.inputEnabled];
// Optional: auto-accept join requests (config or port)
BOOL autoAccept = [self _effectiveBoolForKey:@"autoAcceptJoinRequests"
fallback:self.inputAutoAcceptJoinRequests];
// Auto-accept join requests
if (autoAccept &&
gLastJoinRequest.hasRequest &&
gLastJoinRequest.userId[0] != '\0') {
DRPC_LOG(@"Auto-accepting join request from %s", gLastJoinRequest.userId);
// reply: 1 == yes, 0 == no, 2 == ignore (per Discord RPC docs)
Discord_Respond(gLastJoinRequest.userId, 1);
gLastJoinRequest.hasRequest = NO; // consume
}
// Enabled / disabled presence
if (!enabled) {
if (gDiscordInitialized && !gPresenceCleared) {
DRPC_LOG(@"execute: enabled=NO → clearing presence");
Discord_ClearPresence();
gPresenceCleared = YES;
}
return YES;
}
if ([self _applicationIdChanged]) {
DRPC_LOG(@"execute: App ID port changed → shutdown + re-init");
[self _shutdownDiscord];
}
[self _initializeDiscordIfNeeded];
if (!gDiscordInitialized) {
// Nothing to do until we have a valid Application ID
return YES;
}
if ([self didValueForInputKeyChange:@"inputClear"] && self.inputClear) {
DRPC_LOG(@"execute: inputClear triggered → Discord_ClearPresence");
Discord_ClearPresence();
gPresenceCleared = YES;
return YES;
}
[self _updatePresenceIfNeededForce:forcePresenceUpdate context:context];
return YES;
}
@end
{
"applicationId": "123456789012345678",
"details": "From config",
"state": "Still from config",
"largeImageKey": "https://imagehost.com/image.gif",
"largeImageText": "Big image",
"smallImageKey": "https://imagehost.com/image.gif",
"smallImageText": "Small image",
"startTimestamp": 0,
"endTimestamp": 0,
"partySize": 1,
"partyMax": 4,
"partyId": "party-1",
"matchSecret": "match-xyz",
"joinSecret": "join-xyz",
"spectateSecret": "spec-xyz",
"instance": true,
"enabled": true,
"autoAcceptJoinRequests": false
}
@g-l-i-t-c-h-o-r-s-e
Copy link
Author

qc-config.json goes in ~/Library/Application Support/DiscordRPC/qc-config.json

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment