mirror of
https://github.com/Heretek-AI/GDevelop-Unlocked.git
synced 2026-11-07 10:55:58 -10:00
Replaces the 205-file fork delivery vehicle with patches/ + launcher/ + versioned packages, per the implementation plan: Phase 1: seam tripwires (27 tests) freezing every surgical target in upstream (Usage predicate, %16 gate, SubscriptionChecker, debugger, LoadingScreen branding, Electron hook surface, no-plugin-registry verdict). Phase 2: 5 generated Tier-1 hunks (round-trip verified; 3 files reach byte-identical fork parity) + apply driver (dry-run gate, marker verify, asar extract/repack). Phase 3: launcher hook (preload fetch/XHR spoof of the two usage GETs, BrowserWindow wrap, webRequest fallback, updater pin) + 06 loader patch. Module._load interception dropped: renderers run a webpack bundle, so the spoof rides the axios/XHR path the provider hydrates from instead. Phase 4: Tier-2 verbatim extraction (CustomAIClient + 12 Studio modules + offline mock harness, manifest-pinned) + 07/08 verbatim glue patches + pure generation shim with fail-closed leak guard. Phase 5: web userscript demo (Tier-1 only, documented CORS/LAN/churn limits). Phase 6 runbook + upstream provider-interface RFC draft included; fork archiving left to owners (destructive). Suite: 66 tests green (seams, patcher incl. re-apply refusal, launcher incl. vm-sandbox fallback eval, tier-2 provenance, web).
491 lines
16 KiB
JavaScript
491 lines
16 KiB
JavaScript
// Generates patches/*.patch (Tier-1 unlocks, Phase 2) from a stock upstream
|
|
// checkout by applying the exact Tier-1 replacements.
|
|
//
|
|
// node scripts/generate-tier1-patches.mjs --upstream review/GDevelop
|
|
//
|
|
// Every replacement asserts a single match (loud on drift). Non-Tier-1 fork
|
|
// deltas are deliberately NOT reproduced (Usage quota type fields,
|
|
// LocalPreviewLauncher previewContext/sourceGameId, the FirebaseUser
|
|
// `type` import tweak) — verify with: diff the patched tree against the
|
|
// fork and confirm only those exclusions remain.
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createRequire } from 'node:module';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(here, '..');
|
|
const requireCjs = createRequire(import.meta.url);
|
|
const payloads = requireCjs('../launcher/unlock-payloads.cjs');
|
|
|
|
/**
|
|
* Serialize a plain-JS payload object to prettier-style source
|
|
* (single quotes, 2-space indent, es5 trailing commas) — byte-identical
|
|
* to the fork's UNLOCKED_* blocks, which were written in that style.
|
|
*/
|
|
function serializeJs(value, indent) {
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) return '[]';
|
|
const pad = ' '.repeat(indent);
|
|
const inner = ' '.repeat(indent + 2);
|
|
return `[\n${value.map(v => `${inner}${serializeJs(v, indent + 2)}`).join(',\n')},\n${pad}]`;
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const keys = Object.keys(value);
|
|
if (keys.length === 0) return '{}';
|
|
const pad = ' '.repeat(indent);
|
|
const inner = ' '.repeat(indent + 2);
|
|
return `{\n${keys.map(k => `${inner}${k}: ${serializeJs(value[k], indent + 2)}`).join(',\n')},\n${pad}}`;
|
|
}
|
|
if (typeof value === 'string')
|
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
return String(value);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { upstream: path.join(ROOT, 'review', 'GDevelop') };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === '--upstream') out.upstream = path.resolve(argv[++i]);
|
|
else if (argv[i] === '--out') out.out = path.resolve(argv[++i]);
|
|
else out.out = path.join(ROOT, 'patches');
|
|
}
|
|
if (!out.out) out.out = path.join(ROOT, 'patches');
|
|
return out;
|
|
}
|
|
|
|
function replaceOnce(text, oldStr, newStr, label) {
|
|
const count = text.split(oldStr).length - 1;
|
|
if (count !== 1) {
|
|
throw new Error(
|
|
`GENERATOR DRIFT [${label}]: expected 1 match, found ${count}. ` +
|
|
`Upstream moved — update the replacement anchor.`
|
|
);
|
|
}
|
|
return text.replace(oldStr, newStr);
|
|
}
|
|
|
|
const UNLOCKED_SUBSCRIPTION_BLOCK =
|
|
'export const UNLOCKED_HERETEK_SUBSCRIPTION: Subscription = ' +
|
|
serializeJs(payloads.UNLOCKED_SUBSCRIPTION, 0) +
|
|
';\n\n' +
|
|
'export const UNLOCKED_HERETEK_CAPABILITIES: Capabilities = ' +
|
|
serializeJs(payloads.UNLOCKED_CAPABILITIES, 0) +
|
|
';';
|
|
|
|
const LIMITS_SEED = `limits: {
|
|
quotas: {},
|
|
capabilities: UNLOCKED_HERETEK_CAPABILITIES,
|
|
credits: {
|
|
userBalance: { amount: 999999 },
|
|
prices: {},
|
|
purchasableQuantities: [],
|
|
},
|
|
message: undefined,
|
|
},`;
|
|
|
|
// Each entry: files touched, and a transform(upstreamText) -> patchedText.
|
|
const PATCHES = [
|
|
{
|
|
name: '01-usage-predicate.patch',
|
|
files: ['newIDE/app/src/Utils/GDevelopServices/Usage.js'],
|
|
transform(text) {
|
|
let out = replaceOnce(
|
|
text,
|
|
` return !!subscription && subscription.pricingSystemId === 'TEAM_MEMBER';
|
|
};
|
|
|
|
export const hasValidSubscriptionPlan = (`,
|
|
` return !!subscription && subscription.pricingSystemId === 'TEAM_MEMBER';
|
|
};
|
|
|
|
${UNLOCKED_SUBSCRIPTION_BLOCK}
|
|
|
|
export const hasValidSubscriptionPlan = (`,
|
|
'01/seed-insert'
|
|
);
|
|
out = replaceOnce(
|
|
out,
|
|
`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 => {
|
|
// In GDevelop-Heretek, all client-side and offline features are unlocked for all users.
|
|
return true;
|
|
};`,
|
|
'01/predicate-stub'
|
|
);
|
|
return out;
|
|
},
|
|
},
|
|
{
|
|
name: '02-auth-context-seed.patch',
|
|
files: ['newIDE/app/src/Profile/AuthenticatedUserContext.js'],
|
|
transform(text) {
|
|
let out = replaceOnce(
|
|
text,
|
|
` type UserEarningsBalance,
|
|
} from '../Utils/GDevelopServices/Usage';`,
|
|
` type UserEarningsBalance,
|
|
UNLOCKED_HERETEK_SUBSCRIPTION,
|
|
UNLOCKED_HERETEK_CAPABILITIES,
|
|
} from '../Utils/GDevelopServices/Usage';`,
|
|
'02/import'
|
|
);
|
|
out = replaceOnce(
|
|
out,
|
|
` subscription: null,
|
|
subscriptionPricingSystem: null,
|
|
usages: null,
|
|
userEarningsBalance: null,
|
|
limits: null,
|
|
...authenticatedUserPropertiesLoadingState,`,
|
|
` subscription: UNLOCKED_HERETEK_SUBSCRIPTION,
|
|
subscriptionPricingSystem: null,
|
|
usages: null,
|
|
userEarningsBalance: null,
|
|
${LIMITS_SEED}
|
|
...authenticatedUserPropertiesLoadingState,`,
|
|
'02/logged-in-seed'
|
|
);
|
|
out = replaceOnce(
|
|
out,
|
|
` receivedBundles: ([]: Array<empty>), // Initialize to empty array to indicate that the loading is done.
|
|
subscription: null,
|
|
subscriptionPricingSystem: null,
|
|
userEarningsBalance: null,
|
|
usages: null,
|
|
limits: null,
|
|
};`,
|
|
` receivedBundles: ([]: Array<empty>), // Initialize to empty array to indicate that the loading is done.
|
|
subscription: UNLOCKED_HERETEK_SUBSCRIPTION,
|
|
subscriptionPricingSystem: null,
|
|
userEarningsBalance: null,
|
|
usages: null,
|
|
${LIMITS_SEED}
|
|
};`,
|
|
'02/logged-out-seed'
|
|
);
|
|
return out;
|
|
},
|
|
},
|
|
{
|
|
name: '03-subscription-checker.patch',
|
|
files: ['newIDE/app/src/Profile/Subscription/SubscriptionChecker.js'],
|
|
transform(text) {
|
|
let out = replaceOnce(
|
|
text,
|
|
`// @flow
|
|
|
|
import * as React from 'react';
|
|
import { Trans } from '@lingui/macro';
|
|
import FlatButton from '../../UI/FlatButton';
|
|
import Dialog from '../../UI/Dialog';
|
|
import AuthenticatedUserContext from '../AuthenticatedUserContext';
|
|
import { Column } from '../../UI/Grid';
|
|
import {
|
|
sendSubscriptionCheckDialogShown,
|
|
sendSubscriptionCheckDismiss,
|
|
} from '../../Utils/Analytics/EventSender';
|
|
import Text from '../../UI/Text';
|
|
import { hasValidSubscriptionPlan } from '../../Utils/GDevelopServices/Usage';
|
|
import { isNativeMobileApp } from '../../Utils/Platform';
|
|
import InAppTutorialContext from '../../InAppTutorial/InAppTutorialContext';
|
|
import GetSubscriptionCard from './GetSubscriptionCard';`,
|
|
`// @flow
|
|
import * as React from 'react';
|
|
import { Trans } from '@lingui/macro';
|
|
import FlatButton from '../../UI/FlatButton';
|
|
import Dialog from '../../UI/Dialog';
|
|
import { Column } from '../../UI/Grid';
|
|
import { sendSubscriptionCheckDismiss } from '../../Utils/Analytics/EventSender';
|
|
import Text from '../../UI/Text';
|
|
import GetSubscriptionCard from './GetSubscriptionCard';`,
|
|
'03/imports'
|
|
);
|
|
out = replaceOnce(
|
|
out,
|
|
`}> = React.forwardRef<Props, SubscriptionCheckerInterface>(
|
|
(
|
|
{
|
|
mode,
|
|
id,
|
|
title,
|
|
onChangeSubscription,
|
|
placementId,
|
|
isNotShownDuringInAppTutorial,
|
|
},
|
|
ref
|
|
) => {
|
|
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
|
const [dialogOpen, setDialogOpen] = React.useState<boolean>(false);`,
|
|
`}> = React.forwardRef<Props, SubscriptionCheckerInterface>(
|
|
({ mode, id, title, onChangeSubscription, placementId }, ref) => {
|
|
const [dialogOpen, setDialogOpen] = React.useState<boolean>(false);`,
|
|
'03/signature'
|
|
);
|
|
out = replaceOnce(
|
|
out,
|
|
` const { currentlyRunningInAppTutorial } = React.useContext(
|
|
InAppTutorialContext
|
|
);
|
|
|
|
const checkUserHasSubscription = () => {`,
|
|
` const checkUserHasSubscription = () => {`,
|
|
'03/tutorial-context'
|
|
);
|
|
out = replaceOnce(
|
|
out,
|
|
` const checkUserHasSubscription = () => {
|
|
if (
|
|
hasValidSubscriptionPlan(authenticatedUser.subscription) ||
|
|
(isNotShownDuringInAppTutorial && currentlyRunningInAppTutorial)
|
|
) {
|
|
setDialogOpen(false);
|
|
return true;
|
|
}
|
|
|
|
if (isNativeMobileApp()) {
|
|
// Would present App Store screen.
|
|
} else {
|
|
setDialogOpen(true);
|
|
sendSubscriptionCheckDialogShown({ mode, id });
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const hasUserSubscription = () => {
|
|
return hasValidSubscriptionPlan(authenticatedUser.subscription);
|
|
};`,
|
|
` const checkUserHasSubscription = () => {
|
|
setDialogOpen(false);
|
|
return true;
|
|
};
|
|
|
|
const hasUserSubscription = () => {
|
|
return true;
|
|
};`,
|
|
'03/closures'
|
|
);
|
|
return out;
|
|
},
|
|
},
|
|
{
|
|
name: '04-loading-screen.patch',
|
|
files: ['newIDE/app/src/ProjectManager/LoadingScreenEditor.js'],
|
|
transform(text) {
|
|
let out = replaceOnce(
|
|
text,
|
|
` onCheck={(e, checked) => {
|
|
if (
|
|
!checked &&
|
|
!watermark.isGDevelopWatermarkShown() &&
|
|
subscriptionChecker.current &&
|
|
!subscriptionChecker.current.checkUserHasSubscription()
|
|
) {
|
|
// If user wants to deactivate GDevelop splash screen although
|
|
// watermark is hidden, we don't allow it if they have no subscription.
|
|
return;
|
|
}
|
|
loadingScreen.showGDevelopLogoDuringLoadingScreen(checked);`,
|
|
` onCheck={(e, checked) => {
|
|
loadingScreen.showGDevelopLogoDuringLoadingScreen(checked);`,
|
|
'04/logo-guard'
|
|
);
|
|
// Watermark guard + forced-duration clamp: remove everything from the
|
|
// guard `if` through the clamp, keeping only the show + onUpdate lines.
|
|
const guardStartMarker = ` if (
|
|
!checked &&
|
|
!loadingScreen.isGDevelopLogoShownDuringLoadingScreen() &&`;
|
|
const showLine = ` watermark.showGDevelopWatermark(checked);`;
|
|
const clampTail = `
|
|
onUpdate();`;
|
|
const guardStartIdx = out.indexOf(guardStartMarker);
|
|
if (guardStartIdx === -1)
|
|
throw new Error('GENERATOR DRIFT [04/watermark-guard]: no match.');
|
|
if (out.indexOf(guardStartMarker, guardStartIdx + 1) !== -1)
|
|
throw new Error(
|
|
'GENERATOR DRIFT [04/watermark-guard]: anchor not unique.'
|
|
);
|
|
const tailIdx = out.indexOf(clampTail, guardStartIdx);
|
|
if (tailIdx === -1)
|
|
throw new Error('GENERATOR DRIFT [04/watermark-tail]: no match.');
|
|
const removed = out.slice(guardStartIdx, tailIdx);
|
|
for (const marker of [
|
|
'checkUserHasSubscription()',
|
|
'if (checked) {',
|
|
'forcedLogo.minDuration',
|
|
'setMinDuration(forcedLogo.minDuration)',
|
|
'hasUserSubscription()',
|
|
'watermark.showGDevelopWatermark(checked);',
|
|
]) {
|
|
if (!removed.includes(marker))
|
|
throw new Error(
|
|
`GENERATOR DRIFT [04/clamp-span]: missing ${JSON.stringify(marker)}.`
|
|
);
|
|
}
|
|
out = out.slice(0, guardStartIdx) + showLine + out.slice(tailIdx);
|
|
return out;
|
|
},
|
|
},
|
|
{
|
|
name: '05-preview-debugger.patch',
|
|
files: [
|
|
'newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js',
|
|
'newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js',
|
|
],
|
|
transform(text, relPath) {
|
|
if (relPath.endsWith('LocalPreviewLauncher/index.js')) {
|
|
let out = replaceOnce(
|
|
text,
|
|
` if (!previewOptions.isForInGameEdition) {
|
|
if (
|
|
this.state.hotReloadsCount % 16 === 0 &&
|
|
this._hotReloadSubscriptionChecker
|
|
) {
|
|
this._hotReloadSubscriptionChecker.checkUserHasSubscription();
|
|
}
|
|
this.setState(state => ({`,
|
|
` if (!previewOptions.isForInGameEdition) {
|
|
this.setState(state => ({`,
|
|
'05/hot-reload-gate'
|
|
);
|
|
out = replaceOnce(
|
|
out,
|
|
` _checkSubscriptionForNetworkPreview = (): any => {
|
|
if (!this._networkPreviewSubscriptionChecker) return true;
|
|
|
|
return this._networkPreviewSubscriptionChecker.checkUserHasSubscription();
|
|
};`,
|
|
` _checkSubscriptionForNetworkPreview = (): any => {
|
|
return true;
|
|
};`,
|
|
'05/network-stub'
|
|
);
|
|
return out;
|
|
}
|
|
return replaceOnce(
|
|
text,
|
|
` _checkUserHasSubscription() {
|
|
if (
|
|
this._subscriptionChecker &&
|
|
this.props.isActive &&
|
|
!this.state.subscriptionChecked
|
|
) {
|
|
this._subscriptionChecker.checkUserHasSubscription();
|
|
this.setState({
|
|
subscriptionChecked: true,
|
|
});
|
|
}
|
|
}`,
|
|
` _checkUserHasSubscription() {
|
|
// Unlocked in GDevelop-Heretek
|
|
}`,
|
|
'05/debugger-stub'
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: '06-electron-hook.patch',
|
|
files: ['newIDE/electron-app/app/main.js'],
|
|
transform(text) {
|
|
// The loader MUST precede the `electron.BrowserWindow` destructure
|
|
// (main.js:6): the hook wraps the export before any module captures
|
|
// the original reference (main.js:226, PreviewWindow.js:52).
|
|
return replaceOnce(
|
|
text,
|
|
`const electron = require('electron');
|
|
const path = require('path');`,
|
|
`// GDevelop-Unlocked (Phase 3, issue #3): install the Tier-1 runtime hook
|
|
// before any module destructures \`electron.BrowserWindow\` (main.js:6,
|
|
// PreviewWindow.js:1-6). Re-applied by scripts/apply-patches.mjs, which
|
|
// also syncs launcher/*.cjs next to this file. Updates drop the hook:
|
|
// see launcher/README.md ("auto-updater pin").
|
|
try {
|
|
require('./heretek-hook.cjs').installPreloadHook();
|
|
} catch (error) {
|
|
console.error(
|
|
'[heretek] hook install failed (Tier-1 runtime fallback only):',
|
|
error
|
|
);
|
|
}
|
|
const electron = require('electron');
|
|
const path = require('path');`,
|
|
'06/loader'
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const outDir = args.out;
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
for (const patch of PATCHES) {
|
|
let combined = '';
|
|
for (const rel of patch.files) {
|
|
const abs = path.join(args.upstream, rel);
|
|
if (!fs.existsSync(abs))
|
|
throw new Error(`Upstream file missing: ${abs}`);
|
|
const upstreamText = fs.readFileSync(abs, 'utf8');
|
|
const patchedText = patch.transform(upstreamText, rel);
|
|
if (patchedText === upstreamText)
|
|
throw new Error(`Patch ${patch.name} changed nothing in ${rel}.`);
|
|
const tmpOld = path.join('/tmp/opencode', `gen-old-${Date.now()}.txt`);
|
|
const tmpNew = path.join('/tmp/opencode', `gen-new-${Date.now()}.txt`);
|
|
fs.mkdirSync('/tmp/opencode', { recursive: true });
|
|
fs.writeFileSync(tmpOld, upstreamText);
|
|
fs.writeFileSync(tmpNew, patchedText);
|
|
const r = spawnSync(
|
|
'diff',
|
|
['-U3', `--label=a/${rel}`, `--label=b/${rel}`, tmpOld, tmpNew],
|
|
{ encoding: 'utf8' }
|
|
);
|
|
fs.unlinkSync(tmpOld);
|
|
fs.unlinkSync(tmpNew);
|
|
if (r.status !== 1 || !r.stdout)
|
|
throw new Error(
|
|
`diff failed for ${rel} (status ${r.status}): ${r.stderr}`
|
|
);
|
|
combined += r.stdout;
|
|
// Round-trip check: the hunk must apply cleanly to a pristine copy.
|
|
const checkDir = path.join('/tmp/opencode', `gen-rt-${Date.now()}`);
|
|
const checkFile = path.join(checkDir, rel);
|
|
fs.mkdirSync(path.dirname(checkFile), { recursive: true });
|
|
fs.writeFileSync(checkFile, upstreamText);
|
|
const tmpPatch = path.join('/tmp/opencode', `gen-rt-${Date.now()}.patch`);
|
|
fs.writeFileSync(tmpPatch, r.stdout);
|
|
const dry = spawnSync('patch', ['-p1', '--dry-run', '-i', tmpPatch], {
|
|
cwd: checkDir,
|
|
encoding: 'utf8',
|
|
});
|
|
fs.rmSync(checkDir, { recursive: true, force: true });
|
|
fs.unlinkSync(tmpPatch);
|
|
if (dry.status !== 0)
|
|
throw new Error(
|
|
`Round-trip FAILED for ${patch.name} :: ${rel}:\n${dry.stdout}\n${dry.stderr}`
|
|
);
|
|
}
|
|
fs.writeFileSync(path.join(outDir, patch.name), combined);
|
|
console.log(
|
|
`wrote ${patch.name} (${patch.files.length} file(s), round-trip clean)`
|
|
);
|
|
}
|
|
console.log('done.');
|