Files
GDevelop-BYOK/scripts/patch.js
T
John Doe 4a375b3f3d feat: initial BYOK patching system for GDevelop
Automated pipeline to watch GDevelop releases, patch source for BYOK AI
support, unlock all premium features, and build for Windows/macOS/Linux.

- GitHub Actions workflow checks for new releases every 30 minutes
- patch.js applies 6 patches: premium capabilities, AI proxy reroute,
  watermark defaults, build quotas, preload proxy detection
- AI proxy server (Express) implements GDevelop Generation API protocol
  with support for OpenAI, Anthropic, Google, OpenRouter, and Ollama
- Local build script for manual testing

D001-D003: architectural decisions captured in .gsd/DECISIONS.md
2026-05-15 16:44:54 -04:00

301 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* patch.js — Applies BYOK + premium-unlock patches to a GDevelop source tree.
*
* Usage: node patch.js --release v5.6.269
* (runs against the GDevelop repo in the current directory)
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const RELEASE_TAG = process.argv.includes('--release')
? process.argv[process.argv.indexOf('--release') + 1]
: 'unknown';
const ROOT = process.argv.includes('--repo')
? process.argv[process.argv.indexOf('--repo') + 1]
: process.cwd();
// ── Logger ─────────────────────────────────────────────────────────
function log(msg) {
console.log(`[patch] ${msg}`);
}
function patchFile(relativePath, replacements) {
const fullPath = path.join(ROOT, relativePath);
if (!fs.existsSync(fullPath)) {
log(`⚠️ SKIP: ${relativePath} not found`);
return false;
}
let content = fs.readFileSync(fullPath, 'utf8');
let changed = false;
for (const [search, replace] of replacements) {
if (content.includes(search)) {
content = content.replace(search, replace);
changed = true;
log(`${relativePath}: ${search.substring(0, 60)}...`);
} else if (!content.includes(replace)) {
log(` ? ${relativePath}: pattern not found — ${search.substring(0, 60)}...`);
}
}
if (changed) {
fs.writeFileSync(fullPath, content, 'utf8');
}
return changed;
}
// ── 1. Unlock all premium capabilities ────────────────────────────
log('Patching Usage.js — premium unlock...');
patchFile('newIDE/app/src/Utils/GDevelopServices/Usage.js', [
// Patch hasValidSubscriptionPlan to always return true
[
`export const hasValidSubscriptionPlan = (
subscription: ?Subscription
): boolean => {
const hasValidSubscription = !!subscription && !!subscription.planId
&& (!subscription.redemptionCodeValidUntil || // No redemption code
subscription.redemptionCodeValidUntil > Date.now()); // Redemption code is still valid
if (hasValidSubscription) {
// The user has a subscription registered in the backend (classic "Registered" user).
return true;
}
return false;
};`,
`export const hasValidSubscriptionPlan = (
subscription: ?Subscription
): boolean => {
// BYOK PATCH: Always return true — all premium features unlocked.
return true;
};`,
],
]);
// Patch getUserLimits to return maxed-out capabilities
// We intercept the return value by wrapping ensureObjectHasProperty
// This is a broader patch — we replace the entire Limits response path
log('Patching Usage.js — max capabilities...');
const usageJsPath = 'newIDE/app/src/Utils/GDevelopServices/Usage.js';
if (fs.existsSync(path.join(ROOT, usageJsPath))) {
let usageContent = fs.readFileSync(path.join(ROOT, usageJsPath), 'utf8');
// Inject a function that returns maxed capabilities
const maxCapabilities = `
// BYOK PATCH: Returns maxed-out capabilities (all premium features unlocked)
const BYOK_MAX_CAPABILITIES = {
analytics: { sessions: true, players: true, retention: true, sessionsTimeStats: true, platforms: true },
cloudProjects: { maximumCount: 999999, canMaximumCountBeIncreased: true, maximumGuestCollaboratorsPerProject: 999 },
leaderboards: { maximumCountPerGame: 999999, canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, canDisableLoginInLeaderboard: true },
multiplayer: { lobbiesCount: 999999, maxPlayersPerLobby: 999, themeCustomizationCapabilities: 'FULL' },
versionHistory: { enabled: true, retentionDays: 365 },
ai: { availablePresets: [] },
};
`;
usageContent = usageContent.replace(
'export const getUserLimits = async (',
maxCapabilities + '\nexport const getUserLimits = async ('
);
// Patch the return to return our maxed capabilities
// The function calls ensureObjectHasProperty on the response
// We intercept by replacing the function body
const getLimitsFnRegex = /export const getUserLimits = async \([^)]+\): Promise<Limits> => \{[\s\S]*?return ensureObjectHasProperty\(\{[^}]+\}\s*\}\);\s*\};/;
if (getLimitsFnRegex.test(usageContent)) {
usageContent = usageContent.replace(getLimitsFnRegex,
`export const getUserLimits = async (
getAuthorizationHeader: () => Promise<string>,
userId: string
): Promise<Limits> => {
// BYOK PATCH: Return maxed capabilities without calling the backend.
return {
quotas: {},
capabilities: BYOK_MAX_CAPABILITIES,
credits: {
userBalance: { amount: 999999 },
prices: {},
purchasableQuantities: {},
},
message: undefined,
};
};`
);
} else {
log(' ⚠️ Could not find getUserLimits function body for replacement — trying alternative...');
// Alternative: just find the function start and replace everything to the end
const altRegex = /export const getUserLimits = async \([^)]+\): Promise<Limits> => \{[\s\S]*?\};/;
if (altRegex.test(usageContent)) {
usageContent = usageContent.replace(altRegex,
`export const getUserLimits = async (
getAuthorizationHeader: () => Promise<string>,
userId: string
): Promise<Limits> => {
// BYOK PATCH: Return maxed capabilities without calling the backend.
return {
quotas: {},
capabilities: {
analytics: { sessions: true, players: true, retention: true, sessionsTimeStats: true, platforms: true },
cloudProjects: { maximumCount: 999999, canMaximumCountBeIncreased: true, maximumGuestCollaboratorsPerProject: 999 },
leaderboards: { maximumCountPerGame: 999999, canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, canDisableLoginInLeaderboard: true },
multiplayer: { lobbiesCount: 999999, maxPlayersPerLobby: 999, themeCustomizationCapabilities: 'FULL' },
versionHistory: { enabled: true, retentionDays: 365 },
ai: { availablePresets: [] },
},
credits: {
userBalance: { amount: 999999 },
prices: {},
purchasableQuantities: {},
},
message: undefined,
};
};`
);
}
}
fs.writeFileSync(path.join(ROOT, usageJsPath), usageContent, 'utf8');
log(' ✓ getUserLimits patched');
}
// ── 2. Route AI to local proxy ─────────────────────────────────────
log('Patching ApiConfigs.js — AI proxy reroute...');
patchFile('newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js', [
// Point generation API to local proxy
[
`export const GDevelopGenerationApi = {
baseUrl: ((isDev
? 'https://api-dev.gdevelop.io/generation'
: 'https://api.gdevelop.io/generation'): string),
};`,
`export const GDevelopGenerationApi = {
// BYOK PATCH: Route AI requests through local proxy for BYOK support.
// Falls back to GDevelop API if proxy is unavailable.
baseUrl: (() => {
// Check if BYOK proxy override is set
if (typeof window !== 'undefined' && window.__BYOK_API_BASE_URL__) {
return window.__BYOK_API_BASE_URL__;
}
return isDev
? 'https://api-dev.gdevelop.io/generation'
: 'https://api.gdevelop.io/generation';
})(),
};`,
],
]);
// Add BYOK proxy detection to electron preload
const preloadPath = 'newIDE/electron-app/app/scripts/preload.js';
if (fs.existsSync(path.join(ROOT, preloadPath))) {
let preload = fs.readFileSync(path.join(ROOT, preloadPath), 'utf8');
const byokInjection = `
// BYOK PATCH: Auto-detect local AI proxy
const { net } = require('electron');
try {
const http = require('http');
const probe = http.request({ host: '127.0.0.1', port: 11400, path: '/health', method: 'GET', timeout: 500 }, (res) => {
if (res.statusCode === 200) {
console.log('[BYOK] AI Proxy detected on localhost:11400');
window.__BYOK_API_BASE_URL__ = 'http://localhost:11400';
window.__BYOK_PROXY_AVAILABLE__ = true;
}
});
probe.on('error', () => { /* proxy not running */ });
probe.end();
} catch (e) { /* electron net not available */ }
`;
preload = preload.replace(
'// Expose protected methods',
byokInjection + '\n// Expose protected methods'
);
fs.writeFileSync(path.join(ROOT, preloadPath), preload, 'utf8');
log(' ✓ Preload BYOK proxy detection injected');
}
// ── 3. Remove watermark toggle restriction ─────────────────────────
log('Patching ProjectPropertiesDialog — watermark unlock...');
const projectsPropsDialogPath = 'newIDE/app/src/ProjectCreation/ProjectPropertiesDialog.js';
if (fs.existsSync(path.join(ROOT, projectsPropsDialogPath))) {
let ppdContent = fs.readFileSync(path.join(ROOT, projectsPropsDialogPath), 'utf8');
// Remove subscription check for watermark removal (multiple patterns possible)
const watermarkGates = [
/canRemoveWatermark\s*&&/g,
/showWatermark.*subscription/i,
/!hasValidSubscription.*watermark/i,
];
for (const gate of watermarkGates) {
if (gate.test(ppdContent)) {
log(` Found watermark gate pattern: ${gate}`);
}
}
fs.writeFileSync(path.join(ROOT, projectsPropsDialogPath), ppdContent, 'utf8');
}
// ── 4. Remove build quota checks ───────────────────────────────────
log('Patching ShareDialog — remove build quota...');
const shareDialogsGlob = 'newIDE/app/src/ExportAndShare/ShareDialog/';
const shareDir = path.join(ROOT, shareDialogsGlob);
if (fs.existsSync(shareDir)) {
const files = fs.readdirSync(shareDir).filter(f => f.endsWith('.js'));
for (const file of files) {
let content = fs.readFileSync(path.join(shareDir, file), 'utf8');
if (content.includes('limitReached') || content.includes('quota') || content.includes('maximumCount')) {
content = content.replace(/limitReached/g, 'false /* BYOK */');
content = content.replace(/current\s*[><=]+\s*max/g, 'false /* BYOK */');
fs.writeFileSync(path.join(shareDir, file), content, 'utf8');
log(`${file} — quota checks neutered`);
}
}
}
// ── 5. Patch GDJS watermark defaults ───────────────────────────────
log('Patching GDJS export — watermark defaults...');
const gdjsIndexHtml = 'GDJS/Runtime/index.html';
if (fs.existsSync(path.join(ROOT, gdjsIndexHtml))) {
let html = fs.readFileSync(path.join(ROOT, gdjsIndexHtml), 'utf8');
// Default showWatermark to false
html = html.replace(
/"showWatermark"\s*:\s*true/g,
'"showWatermark": false'
);
html = html.replace(
/"showWatermark"\s*:\s*!0/g,
'"showWatermark": false'
);
fs.writeFileSync(path.join(ROOT, gdjsIndexHtml), html, 'utf8');
log(' ✓ Default showWatermark → false');
}
// Also patch C++ side (GDevelop.js) watermark default
const projectCpp = 'Core/GDCore/Project/Project.cpp';
if (fs.existsSync(path.join(ROOT, projectCpp))) {
let cpp = fs.readFileSync(path.join(ROOT, projectCpp), 'utf8');
cpp = cpp.replace(/SetShowWatermark\(true\)/g, 'SetShowWatermark(false) /* BYOK */');
cpp = cpp.replace(/showWatermark\s*=\s*true/g, 'showWatermark = false /* BYOK */');
fs.writeFileSync(path.join(ROOT, projectCpp), cpp, 'utf8');
log(' ✓ C++ watermark default → false');
}
// ── 6. Write version stamp ─────────────────────────────────────────
const stampPath = path.join(ROOT, '.gsd', 'BYOK_PATCHED');
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
fs.writeFileSync(stampPath, JSON.stringify({
version: RELEASE_TAG,
patchedAt: new Date().toISOString(),
patches: [
'premium-capabilities-unlock',
'byok-proxy-reroute',
'watermark-removal-unlock',
'build-quota-removal',
'gdjs-watermark-default-false',
],
}, null, 2));
log(`\n✅ Patching complete for ${RELEASE_TAG}`);