Last active
November 23, 2025 09:39
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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." |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "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 | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
qc-config.jsongoes in~/Library/Application Support/DiscordRPC/qc-config.json