@scalebun/react-native 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/bin/lib/androidCodemod.js +370 -0
- package/bin/scalebun.js +262 -22
- package/dist/scalebun.full.js +1 -1
- package/dist/scalebun.slim.js +1 -1
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/module/core/constants/version.js +1 -1
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/package.json +2 -2
- package/src/core/constants/version.ts +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,39 @@ yarn add @scalebun/react-native
|
|
|
18
18
|
cd ios && pod install
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
To wire the AppDelegate for Direct APNs push (optional):
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx scalebun init ios # patch the AppDelegate (ObjC auto-patched; Swift prints steps)
|
|
25
|
+
npx scalebun init ios --check # verify hooks exist (exit 1 if missing)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Android
|
|
29
|
+
|
|
30
|
+
OTA updates need one wiring step in `MainApplication` so a downloaded bundle
|
|
31
|
+
actually loads — without it an update installs, reports success, and the app
|
|
32
|
+
silently keeps running the bundle baked into the APK. Run:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npx scalebun init android # wire MainApplication for OTA bundle loading
|
|
36
|
+
npx scalebun init android --check # verify (exit 1 if missing)
|
|
37
|
+
npx scalebun init android --dry-run # preview changes without writing
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This adds `ScaleBunOtaModule.getJSBundleFile(...)` in the form matching your
|
|
41
|
+
React Native version (Kotlin is auto-patched; Java prints manual instructions).
|
|
42
|
+
**Rebuild the native app afterwards** — a JS reload does not load the newly
|
|
43
|
+
wired native path.
|
|
44
|
+
|
|
45
|
+
For push on Android (optional): add `android/app/google-services.json`, apply
|
|
46
|
+
the Google Services Gradle plugin with `@react-native-firebase/messaging`, and
|
|
47
|
+
declare the `POST_NOTIFICATIONS` permission. Run `npx scalebun doctor` to see
|
|
48
|
+
exactly what's missing.
|
|
49
|
+
|
|
50
|
+
> Run `npx scalebun -h` to list every command — SDK setup (`doctor`, `init ios`,
|
|
51
|
+
> `init android`) plus the OTA publishing commands (`login`, `ota publish`,
|
|
52
|
+
> `rollout`, …).
|
|
53
|
+
|
|
21
54
|
### Optional peer dependencies
|
|
22
55
|
|
|
23
56
|
Some features activate only when their peer package is installed:
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Android MainApplication codemod for `npx scalebun init android`.
|
|
4
|
+
*
|
|
5
|
+
* Pure, dependency-free string transforms (no fs / process / RN imports) so they
|
|
6
|
+
* are unit-testable with plain fixtures and safe to run anywhere. The CLI
|
|
7
|
+
* (bin/scalebun.js) handles disk IO, flags, and reporting; this module only
|
|
8
|
+
* decides WHAT the patched source should be.
|
|
9
|
+
*
|
|
10
|
+
* It wires OTA bundle resolution into the host app's MainApplication so a
|
|
11
|
+
* downloaded update actually LOADS instead of the app silently running the
|
|
12
|
+
* bundle baked into the APK. The required call is the static
|
|
13
|
+
* `ScaleBunOtaModule.getJSBundleFile(context)`, wired in one of two shapes
|
|
14
|
+
* depending on the React Native version:
|
|
15
|
+
*
|
|
16
|
+
* - RN < 0.82 (ReactNativeHost still exists): add
|
|
17
|
+
* override fun getJSBundleFile(): String? =
|
|
18
|
+
* ScaleBunOtaModule.getJSBundleFile(this@MainApplication) ?: super.getJSBundleFile()
|
|
19
|
+
* inside the `object : *ReactNativeHost { … }` block.
|
|
20
|
+
*
|
|
21
|
+
* - RN >= 0.82 (ReactNativeHost removed): pass the path to getDefaultReactHost:
|
|
22
|
+
* getDefaultReactHost(
|
|
23
|
+
* …,
|
|
24
|
+
* jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(applicationContext),
|
|
25
|
+
* )
|
|
26
|
+
*
|
|
27
|
+
* The version decides the shape — mirroring the check in
|
|
28
|
+
* @scalebun/cli src/commands/doctor.ts. Every transform is idempotent (running
|
|
29
|
+
* twice is a no-op). Java MainApplication and unrecognized structures are NOT
|
|
30
|
+
* auto-patched — they return an exact manual snippet instead of a risky edit.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const OTA_IMPORT = 'import com.scalebun.rn.ota.ScaleBunOtaModule';
|
|
34
|
+
const BUNDLE_CALL = 'ScaleBunOtaModule.getJSBundleFile';
|
|
35
|
+
const MANAGED_TAG = 'ScaleBun OTA bundle resolution';
|
|
36
|
+
|
|
37
|
+
// ── MainApplication discovery ────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find the host MainApplication under <androidDir>. Returns { path, kind } where
|
|
41
|
+
* kind is 'kotlin' (.kt) or 'java' (.java), or null when none is found. Kotlin
|
|
42
|
+
* is preferred. `fsLike`/`pathLike` are injected for testability.
|
|
43
|
+
*/
|
|
44
|
+
function findMainApplication(androidDir, fsLike, pathLike) {
|
|
45
|
+
const fs = fsLike;
|
|
46
|
+
const path = pathLike;
|
|
47
|
+
const root = path.join(androidDir, 'app', 'src', 'main');
|
|
48
|
+
|
|
49
|
+
// Walk src/main/{java,kotlin}/** for MainApplication.kt/.java.
|
|
50
|
+
const found = { kotlin: null, java: null };
|
|
51
|
+
const walk = (dir) => {
|
|
52
|
+
let entries = [];
|
|
53
|
+
try {
|
|
54
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const p = path.join(dir, entry.name);
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
walk(p);
|
|
62
|
+
} else if (entry.name === 'MainApplication.kt' && !found.kotlin) {
|
|
63
|
+
found.kotlin = p;
|
|
64
|
+
} else if (entry.name === 'MainApplication.java' && !found.java) {
|
|
65
|
+
found.java = p;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
walk(root);
|
|
70
|
+
|
|
71
|
+
if (found.kotlin) return { path: found.kotlin, kind: 'kotlin' };
|
|
72
|
+
if (found.java) return { path: found.java, kind: 'java' };
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── version helper ────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* True when RN >= 0.82 (ReactNativeHost removed → must pass jsBundleFilePath to
|
|
80
|
+
* getDefaultReactHost). `minor` is [major, minor] or null when unknown; when
|
|
81
|
+
* unknown we assume the modern shape.
|
|
82
|
+
*/
|
|
83
|
+
function isReactHostEra(minor) {
|
|
84
|
+
if (!minor) return true;
|
|
85
|
+
return minor[0] > 0 || minor[1] >= 82;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── analysis ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** Report which OTA wiring pieces are already present in a Kotlin MainApplication. */
|
|
91
|
+
function analyzeKotlin(source) {
|
|
92
|
+
const s = String(source);
|
|
93
|
+
return {
|
|
94
|
+
hasImport: s.includes(OTA_IMPORT),
|
|
95
|
+
hasBundleWiring: s.includes(BUNDLE_CALL),
|
|
96
|
+
hasReactNativeHostObject: /object\s*:\s*\w*ReactNativeHost\s*\(/.test(s),
|
|
97
|
+
hasGetDefaultReactHost: s.includes('getDefaultReactHost('),
|
|
98
|
+
hasJsBundleFilePathArg: s.includes('jsBundleFilePath'),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* True when OTA bundle resolution is fully wired for this RN version (for
|
|
104
|
+
* --check). On RN >= 0.82 the wiring must go through the jsBundleFilePath arg;
|
|
105
|
+
* before that any getJSBundleFile call is enough.
|
|
106
|
+
*/
|
|
107
|
+
function isFullyWiredKotlin(source, minor) {
|
|
108
|
+
const a = analyzeKotlin(source);
|
|
109
|
+
if (!a.hasImport || !a.hasBundleWiring) return false;
|
|
110
|
+
if (isReactHostEra(minor)) return a.hasJsBundleFilePathArg;
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** Insert `lineToAdd` immediately after the last top-of-file Kotlin import. */
|
|
117
|
+
function insertAfterLastImport(source, lineToAdd) {
|
|
118
|
+
const lines = source.split('\n');
|
|
119
|
+
let lastImport = -1;
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
if (/^\s*import\b/.test(lines[i])) lastImport = i;
|
|
122
|
+
// Stop scanning once the class/object declaration begins.
|
|
123
|
+
if (/^\s*(class|object)\b/.test(lines[i])) break;
|
|
124
|
+
}
|
|
125
|
+
if (lastImport === -1) {
|
|
126
|
+
// No imports — put it after the package line, else prepend.
|
|
127
|
+
for (let i = 0; i < lines.length; i++) {
|
|
128
|
+
if (/^\s*package\b/.test(lines[i])) {
|
|
129
|
+
lines.splice(i + 1, 0, '', lineToAdd);
|
|
130
|
+
return lines.join('\n');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return lineToAdd + '\n' + source;
|
|
134
|
+
}
|
|
135
|
+
lines.splice(lastImport + 1, 0, lineToAdd);
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Leading whitespace of the first non-empty line at/after `fromIdx`. */
|
|
140
|
+
function bodyIndentAfter(source, fromIdx) {
|
|
141
|
+
const rest = source.slice(fromIdx).split('\n');
|
|
142
|
+
for (const line of rest) {
|
|
143
|
+
if (line.trim() === '') continue;
|
|
144
|
+
return (line.match(/^\s*/) || [''])[0];
|
|
145
|
+
}
|
|
146
|
+
return ' ';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Insert the getJSBundleFile() override into the `object : *ReactNativeHost(…) {`
|
|
151
|
+
* block (RN < 0.82). Returns { source, inserted, reason }.
|
|
152
|
+
*/
|
|
153
|
+
function insertIntoReactNativeHostObject(source, methodText) {
|
|
154
|
+
const objRe = /object\s*:\s*\w*ReactNativeHost\s*\([^)]*\)\s*\{/;
|
|
155
|
+
const m = objRe.exec(source);
|
|
156
|
+
if (!m) return { source, inserted: false, reason: 'no-object' };
|
|
157
|
+
const insertAt = m.index + m[0].length; // right after the opening `{`
|
|
158
|
+
const indent = bodyIndentAfter(source, insertAt);
|
|
159
|
+
const block = '\n' + methodText(indent);
|
|
160
|
+
return {
|
|
161
|
+
source: source.slice(0, insertAt) + block + source.slice(insertAt),
|
|
162
|
+
inserted: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Insert a named argument into a call, respecting single-line vs multi-line
|
|
168
|
+
* Kotlin argument style. Returns { source, inserted, reason }.
|
|
169
|
+
*/
|
|
170
|
+
function insertArgIntoCall(source, callName, argText) {
|
|
171
|
+
const callIdx = source.indexOf(callName + '(');
|
|
172
|
+
if (callIdx === -1) return { source, inserted: false, reason: 'no-call' };
|
|
173
|
+
const openIdx = source.indexOf('(', callIdx);
|
|
174
|
+
|
|
175
|
+
// Find the matching close paren (calls contain nested parens like
|
|
176
|
+
// PackageList(this).packages, so a naive indexOf(')') is wrong).
|
|
177
|
+
let depth = 0;
|
|
178
|
+
let closeIdx = -1;
|
|
179
|
+
for (let i = openIdx; i < source.length; i++) {
|
|
180
|
+
const c = source[i];
|
|
181
|
+
if (c === '(') depth++;
|
|
182
|
+
else if (c === ')') {
|
|
183
|
+
depth--;
|
|
184
|
+
if (depth === 0) {
|
|
185
|
+
closeIdx = i;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (closeIdx === -1) return { source, inserted: false, reason: 'no-close' };
|
|
191
|
+
|
|
192
|
+
const inner = source.slice(openIdx + 1, closeIdx);
|
|
193
|
+
const isMultiline = inner.includes('\n');
|
|
194
|
+
|
|
195
|
+
// Last meaningful (non-whitespace) char before the close paren.
|
|
196
|
+
let j = closeIdx - 1;
|
|
197
|
+
while (j > openIdx && /\s/.test(source[j])) j--;
|
|
198
|
+
const lastChar = source[j];
|
|
199
|
+
|
|
200
|
+
let insertText;
|
|
201
|
+
if (inner.trim() === '') {
|
|
202
|
+
insertText = argText;
|
|
203
|
+
} else if (isMultiline) {
|
|
204
|
+
// Align the new argument with the last existing argument line (the line
|
|
205
|
+
// where `j` sits) so the call reads consistently, not with the closing
|
|
206
|
+
// paren's indentation.
|
|
207
|
+
const argLineStart = source.lastIndexOf('\n', j) + 1;
|
|
208
|
+
const argIndent = (source.slice(argLineStart, j).match(/^\s*/) || [''])[0];
|
|
209
|
+
insertText =
|
|
210
|
+
lastChar === ','
|
|
211
|
+
? `\n${argIndent}${argText},`
|
|
212
|
+
: `,\n${argIndent}${argText}`;
|
|
213
|
+
} else {
|
|
214
|
+
insertText = lastChar === ',' ? ` ${argText},` : `, ${argText}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
source: source.slice(0, j + 1) + insertText + source.slice(j + 1),
|
|
219
|
+
inserted: true,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── the patch ─────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Patch a Kotlin MainApplication for OTA bundle resolution. `opts.minor` is the
|
|
227
|
+
* [major, minor] React Native version (or null). Returns:
|
|
228
|
+
* { changed, source, summary[], manual[] }
|
|
229
|
+
*/
|
|
230
|
+
function patchKotlin(source, opts = {}) {
|
|
231
|
+
let out = String(source);
|
|
232
|
+
const summary = [];
|
|
233
|
+
const manual = [];
|
|
234
|
+
const a = analyzeKotlin(out);
|
|
235
|
+
const reactHostEra = isReactHostEra(opts.minor);
|
|
236
|
+
|
|
237
|
+
// Already fully wired for this era → no-op.
|
|
238
|
+
if (isFullyWiredKotlin(out, opts.minor)) {
|
|
239
|
+
return { changed: false, source: out, summary, manual };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (reactHostEra) {
|
|
243
|
+
// RN >= 0.82: pass jsBundleFilePath to getDefaultReactHost(...).
|
|
244
|
+
if (!a.hasGetDefaultReactHost) {
|
|
245
|
+
manual.push(reactHostManual());
|
|
246
|
+
return { changed: false, source: out, summary, manual };
|
|
247
|
+
}
|
|
248
|
+
if (!a.hasImport) {
|
|
249
|
+
out = insertAfterLastImport(out, OTA_IMPORT);
|
|
250
|
+
summary.push(`Added import: ${OTA_IMPORT}`);
|
|
251
|
+
}
|
|
252
|
+
if (!a.hasJsBundleFilePathArg) {
|
|
253
|
+
const r = insertArgIntoCall(
|
|
254
|
+
out,
|
|
255
|
+
'getDefaultReactHost',
|
|
256
|
+
'jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(applicationContext)',
|
|
257
|
+
);
|
|
258
|
+
if (r.inserted) {
|
|
259
|
+
out = r.source;
|
|
260
|
+
summary.push(
|
|
261
|
+
'Patched getDefaultReactHost(...) → added jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(applicationContext)',
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
manual.push(reactHostManual());
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// RN < 0.82: add getJSBundleFile() override inside the ReactNativeHost object.
|
|
269
|
+
if (!a.hasReactNativeHostObject) {
|
|
270
|
+
manual.push(legacyManual());
|
|
271
|
+
return { changed: false, source: out, summary, manual };
|
|
272
|
+
}
|
|
273
|
+
if (!a.hasImport) {
|
|
274
|
+
out = insertAfterLastImport(out, OTA_IMPORT);
|
|
275
|
+
summary.push(`Added import: ${OTA_IMPORT}`);
|
|
276
|
+
}
|
|
277
|
+
if (!a.hasBundleWiring) {
|
|
278
|
+
const r = insertIntoReactNativeHostObject(
|
|
279
|
+
out,
|
|
280
|
+
(indent) =>
|
|
281
|
+
`${indent}// ${MANAGED_TAG} (added by \`npx scalebun init android\`)\n` +
|
|
282
|
+
`${indent}override fun getJSBundleFile(): String? =\n` +
|
|
283
|
+
`${indent} ScaleBunOtaModule.getJSBundleFile(this@MainApplication) ?: super.getJSBundleFile()\n`,
|
|
284
|
+
);
|
|
285
|
+
if (r.inserted) {
|
|
286
|
+
out = r.source;
|
|
287
|
+
summary.push(
|
|
288
|
+
'Added getJSBundleFile() override → ScaleBunOtaModule.getJSBundleFile(this@MainApplication)',
|
|
289
|
+
);
|
|
290
|
+
} else {
|
|
291
|
+
manual.push(legacyManual());
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { changed: out !== String(source), source: out, summary, manual };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── manual instructions ─────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
/** Manual snippet for RN >= 0.82 when the getDefaultReactHost call can't be found. */
|
|
302
|
+
function reactHostManual() {
|
|
303
|
+
return [
|
|
304
|
+
'Could not locate getDefaultReactHost(...) in MainApplication. Wire OTA bundle',
|
|
305
|
+
'resolution manually (RN >= 0.82):',
|
|
306
|
+
'',
|
|
307
|
+
` ${OTA_IMPORT}`,
|
|
308
|
+
'',
|
|
309
|
+
' override val reactHost: ReactHost by lazy {',
|
|
310
|
+
' getDefaultReactHost(',
|
|
311
|
+
' context = applicationContext,',
|
|
312
|
+
' packageList = PackageList(this).packages,',
|
|
313
|
+
' jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(applicationContext),',
|
|
314
|
+
' )',
|
|
315
|
+
' }',
|
|
316
|
+
].join('\n');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Manual snippet for RN < 0.82 when the ReactNativeHost object can't be found. */
|
|
320
|
+
function legacyManual() {
|
|
321
|
+
return [
|
|
322
|
+
'Could not locate the ReactNativeHost object in MainApplication. Add the OTA',
|
|
323
|
+
'bundle-resolution override manually (RN < 0.82), inside your ReactNativeHost:',
|
|
324
|
+
'',
|
|
325
|
+
` ${OTA_IMPORT}`,
|
|
326
|
+
'',
|
|
327
|
+
' override fun getJSBundleFile(): String? =',
|
|
328
|
+
' ScaleBunOtaModule.getJSBundleFile(this@MainApplication) ?: super.getJSBundleFile()',
|
|
329
|
+
].join('\n');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Manual instructions for a Java MainApplication (auto-patch not supported). */
|
|
333
|
+
function javaInstructions(minor) {
|
|
334
|
+
if (isReactHostEra(minor)) {
|
|
335
|
+
return [
|
|
336
|
+
'A Java MainApplication was detected. Automatic patching of Java is not',
|
|
337
|
+
'supported — wire OTA bundle resolution manually (RN >= 0.82):',
|
|
338
|
+
'',
|
|
339
|
+
' import com.scalebun.rn.ota.ScaleBunOtaModule;',
|
|
340
|
+
'',
|
|
341
|
+
' // where you build the ReactHost via DefaultReactHost.getDefaultReactHost(...),',
|
|
342
|
+
' // pass the OTA bundle path:',
|
|
343
|
+
' ScaleBunOtaModule.getJSBundleFile(getApplicationContext())',
|
|
344
|
+
].join('\n');
|
|
345
|
+
}
|
|
346
|
+
return [
|
|
347
|
+
'A Java MainApplication was detected. Automatic patching of Java is not',
|
|
348
|
+
'supported — add this override inside your ReactNativeHost (RN < 0.82):',
|
|
349
|
+
'',
|
|
350
|
+
' import com.scalebun.rn.ota.ScaleBunOtaModule;',
|
|
351
|
+
'',
|
|
352
|
+
' @Override',
|
|
353
|
+
' protected String getJSBundleFile() {',
|
|
354
|
+
' String ota = ScaleBunOtaModule.getJSBundleFile(getApplicationContext());',
|
|
355
|
+
' return ota != null ? ota : super.getJSBundleFile();',
|
|
356
|
+
' }',
|
|
357
|
+
].join('\n');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
module.exports = {
|
|
361
|
+
OTA_IMPORT,
|
|
362
|
+
BUNDLE_CALL,
|
|
363
|
+
MANAGED_TAG,
|
|
364
|
+
findMainApplication,
|
|
365
|
+
isReactHostEra,
|
|
366
|
+
analyzeKotlin,
|
|
367
|
+
isFullyWiredKotlin,
|
|
368
|
+
patchKotlin,
|
|
369
|
+
javaInstructions,
|
|
370
|
+
};
|
package/bin/scalebun.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
const fs = require('fs');
|
|
23
23
|
const path = require('path');
|
|
24
24
|
const codemod = require('./lib/iosCodemod');
|
|
25
|
+
const androidCodemod = require('./lib/androidCodemod');
|
|
25
26
|
|
|
26
27
|
const MIN_BRIDGELESS_RNFIREBASE_MAJOR = 18;
|
|
27
28
|
|
|
@@ -597,6 +598,215 @@ function runInitIos(argv) {
|
|
|
597
598
|
process.exit(0);
|
|
598
599
|
}
|
|
599
600
|
|
|
601
|
+
// ─── `init android` — MainApplication OTA wiring + checklist ──────────────────
|
|
602
|
+
|
|
603
|
+
function findAndroidDir() {
|
|
604
|
+
const androidDir = path.join(CWD, 'android');
|
|
605
|
+
return exists(androidDir) ? androidDir : null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** [major, minor] of the installed react-native, or null. */
|
|
609
|
+
function rnMinor() {
|
|
610
|
+
const v = pkgVersion('react-native');
|
|
611
|
+
if (!v) return null;
|
|
612
|
+
const parts = v.split('.').map((n) => parseInt(n, 10));
|
|
613
|
+
return [parts[0] || 0, parts[1] || 0];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Detect Android setup that the codemod cannot apply — the checklist items
|
|
618
|
+
* (Firebase config, gradle plugin, manifest permissions, the New-Arch OTA
|
|
619
|
+
* version gate). Mirrors detectIosCapabilities.
|
|
620
|
+
*/
|
|
621
|
+
function detectAndroidCapabilities(androidDir) {
|
|
622
|
+
const rnVersion = pkgVersion('react-native');
|
|
623
|
+
const minor = rnMinor();
|
|
624
|
+
|
|
625
|
+
const gradleProps = readText(path.join(androidDir, 'gradle.properties')) || '';
|
|
626
|
+
const newArch = /(^|\n)\s*newArchEnabled\s*=\s*true/.test(gradleProps);
|
|
627
|
+
|
|
628
|
+
// Bridgeless ignored getJSBundleFile() until RN 0.76.1 — an OTA update installs
|
|
629
|
+
// and silently never runs on 0.74/0.75/0.76.0 with New Arch on.
|
|
630
|
+
const otaDeadZone =
|
|
631
|
+
newArch &&
|
|
632
|
+
minor != null &&
|
|
633
|
+
minor[0] === 0 &&
|
|
634
|
+
(minor[1] === 74 || minor[1] === 75 || (rnVersion || '').startsWith('0.76.0'));
|
|
635
|
+
|
|
636
|
+
const hasGoogleServicesJson = exists(path.join(androidDir, 'app', 'google-services.json'));
|
|
637
|
+
|
|
638
|
+
const manifestPath = path.join(androidDir, 'app', 'src', 'main', 'AndroidManifest.xml');
|
|
639
|
+
const manifest = readText(manifestPath) || '';
|
|
640
|
+
const hasPostNotifications = /android\.permission\.POST_NOTIFICATIONS/.test(manifest);
|
|
641
|
+
const hasChannelMeta = /default_notification_channel_id/.test(manifest);
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
rnVersion,
|
|
645
|
+
newArch,
|
|
646
|
+
otaDeadZone,
|
|
647
|
+
hasGoogleServicesJson,
|
|
648
|
+
manifestExists: !!readText(manifestPath),
|
|
649
|
+
hasPostNotifications,
|
|
650
|
+
hasChannelMeta,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function printAndroidChecklist(caps) {
|
|
655
|
+
console.log('');
|
|
656
|
+
console.log(paint('Android setup (verify / apply by hand — cannot be safely automated)', C.bold));
|
|
657
|
+
|
|
658
|
+
if (caps.otaDeadZone) {
|
|
659
|
+
console.log(
|
|
660
|
+
' ' + FAIL() + ' ' +
|
|
661
|
+
paint(
|
|
662
|
+
`React Native ${caps.rnVersion} + New Architecture: OTA updates CANNOT LOAD.`,
|
|
663
|
+
C.red,
|
|
664
|
+
),
|
|
665
|
+
);
|
|
666
|
+
console.log(
|
|
667
|
+
paint(
|
|
668
|
+
' Bridgeless ignored getJSBundleFile() until RN 0.76.1. Upgrade to >= 0.76.1,\n' +
|
|
669
|
+
' or set newArchEnabled=false. Until then updates install but never run.',
|
|
670
|
+
C.dim,
|
|
671
|
+
),
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
line(caps.hasGoogleServicesJson ? OK() : WARN(), 'google-services.json', caps.hasGoogleServicesJson ? 'present' : null);
|
|
676
|
+
line(caps.hasPostNotifications ? OK() : WARN(), 'POST_NOTIFICATIONS perm', caps.hasPostNotifications ? 'declared' : null);
|
|
677
|
+
line(caps.hasChannelMeta ? OK() : WARN(), 'default channel meta', caps.hasChannelMeta ? 'declared' : null);
|
|
678
|
+
|
|
679
|
+
const todo = [];
|
|
680
|
+
if (!caps.hasGoogleServicesJson) {
|
|
681
|
+
todo.push(
|
|
682
|
+
'For push: add ' + paint('android/app/google-services.json', C.cyan) +
|
|
683
|
+
' (Firebase console), apply the Google Services gradle plugin, and add ' +
|
|
684
|
+
paint('@react-native-firebase/messaging', C.cyan) + ' for real tokens.',
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
if (caps.manifestExists && !caps.hasPostNotifications) {
|
|
688
|
+
todo.push(
|
|
689
|
+
'Declare the Android 13+ permission in AndroidManifest.xml: ' +
|
|
690
|
+
paint('<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />', C.cyan),
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
if (caps.manifestExists && !caps.hasChannelMeta) {
|
|
694
|
+
todo.push(
|
|
695
|
+
'Declare a default FCM channel meta-data ' +
|
|
696
|
+
'(com.google.firebase.messaging.default_notification_channel_id) so backgrounded ' +
|
|
697
|
+
'notifications have a channel.',
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (todo.length) {
|
|
702
|
+
console.log('');
|
|
703
|
+
console.log(paint('Push follow-ups (skip if you only use OTA)', C.bold + C.yellow));
|
|
704
|
+
todo.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function runInitAndroid(argv) {
|
|
709
|
+
const flags = new Set(argv);
|
|
710
|
+
const isCheck = flags.has('--check');
|
|
711
|
+
const isDryRun = flags.has('--dry-run');
|
|
712
|
+
|
|
713
|
+
const androidDir = findAndroidDir();
|
|
714
|
+
if (!androidDir) {
|
|
715
|
+
console.error(paint('✗ No android/ directory found.', C.red));
|
|
716
|
+
console.error(' Run this from a React Native project root (the folder containing android/).');
|
|
717
|
+
process.exit(2);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const found = androidCodemod.findMainApplication(androidDir, fs, path);
|
|
721
|
+
if (!found) {
|
|
722
|
+
console.error(paint('✗ Could not find MainApplication.kt or .java under android/app/src/main.', C.red));
|
|
723
|
+
console.error(' This project uses a non-standard layout — wire OTA bundle resolution manually.');
|
|
724
|
+
process.exit(2);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const minor = rnMinor();
|
|
728
|
+
const rel = path.relative(CWD, found.path);
|
|
729
|
+
console.log('');
|
|
730
|
+
console.log(paint('ScaleBun init android', C.bold + C.cyan));
|
|
731
|
+
console.log(` MainApplication: ${paint(rel, C.cyan)} (${found.kind})`);
|
|
732
|
+
|
|
733
|
+
// Java: not auto-patched — print clear manual instructions, fail non-zero.
|
|
734
|
+
if (found.kind === 'java') {
|
|
735
|
+
console.log('');
|
|
736
|
+
console.log(androidCodemod.javaInstructions(minor));
|
|
737
|
+
printAndroidChecklist(detectAndroidCapabilities(androidDir));
|
|
738
|
+
process.exit(isCheck ? 1 : 2);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const original = readText(found.path) || '';
|
|
742
|
+
|
|
743
|
+
// --check: verify, never write.
|
|
744
|
+
if (isCheck) {
|
|
745
|
+
const a = androidCodemod.analyzeKotlin(original);
|
|
746
|
+
const wired = androidCodemod.isFullyWiredKotlin(original, minor);
|
|
747
|
+
console.log('');
|
|
748
|
+
console.log(paint('OTA wiring status', C.bold));
|
|
749
|
+
line(a.hasImport ? OK() : FAIL(), 'ScaleBunOtaModule import', a.hasImport ? 'present' : null);
|
|
750
|
+
line(a.hasBundleWiring ? OK() : FAIL(), 'getJSBundleFile wiring', a.hasBundleWiring ? 'present' : null);
|
|
751
|
+
if (androidCodemod.isReactHostEra(minor)) {
|
|
752
|
+
line(a.hasJsBundleFilePathArg ? OK() : FAIL(), 'jsBundleFilePath arg (RN >= 0.82)', a.hasJsBundleFilePathArg ? 'present' : null);
|
|
753
|
+
}
|
|
754
|
+
printAndroidChecklist(detectAndroidCapabilities(androidDir));
|
|
755
|
+
console.log('');
|
|
756
|
+
if (wired) {
|
|
757
|
+
console.log(paint('✓ MainApplication is wired for ScaleBun OTA bundle resolution.', C.green));
|
|
758
|
+
process.exit(0);
|
|
759
|
+
}
|
|
760
|
+
console.log(paint('✗ MainApplication is missing OTA bundle wiring. Run `npx scalebun init android`.', C.red));
|
|
761
|
+
process.exit(1);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const result = androidCodemod.patchKotlin(original, { minor });
|
|
765
|
+
|
|
766
|
+
if (!result.changed) {
|
|
767
|
+
console.log('');
|
|
768
|
+
if (result.manual.length) {
|
|
769
|
+
console.log(paint('Could not auto-apply — wire it by hand:', C.bold + C.yellow));
|
|
770
|
+
result.manual.forEach((m) => console.log(m));
|
|
771
|
+
} else {
|
|
772
|
+
console.log(paint('✓ Already wired — no changes needed (idempotent).', C.green));
|
|
773
|
+
}
|
|
774
|
+
printAndroidChecklist(detectAndroidCapabilities(androidDir));
|
|
775
|
+
process.exit(result.manual.length ? 1 : 0);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
console.log('');
|
|
779
|
+
console.log(paint(isDryRun ? 'Planned changes (--dry-run, nothing written)' : 'Changes', C.bold));
|
|
780
|
+
result.summary.forEach((sline, i) => console.log(` ${i + 1}. ${sline}`));
|
|
781
|
+
if (result.manual.length) {
|
|
782
|
+
console.log('');
|
|
783
|
+
console.log(paint('Could not auto-apply (edit by hand)', C.bold + C.yellow));
|
|
784
|
+
result.manual.forEach((m) => console.log(m));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (isDryRun) {
|
|
788
|
+
console.log('');
|
|
789
|
+
console.log(paint('Dry run — no files written. Re-run without --dry-run to apply.', C.dim));
|
|
790
|
+
printAndroidChecklist(detectAndroidCapabilities(androidDir));
|
|
791
|
+
process.exit(0);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Apply. Never stage / commit — only edits the working-tree file.
|
|
795
|
+
fs.writeFileSync(found.path, result.source, 'utf8');
|
|
796
|
+
console.log('');
|
|
797
|
+
console.log(paint(`✓ Patched ${rel}`, C.green));
|
|
798
|
+
console.log(paint(' (working-tree edit only — nothing staged or committed)', C.dim));
|
|
799
|
+
|
|
800
|
+
printAndroidChecklist(detectAndroidCapabilities(androidDir));
|
|
801
|
+
|
|
802
|
+
console.log('');
|
|
803
|
+
console.log(paint('Next:', C.bold));
|
|
804
|
+
console.log(' Rebuild the native app (a JS reload does not load the newly-wired native path):');
|
|
805
|
+
console.log(paint(' npx react-native run-android # or your gradle build', C.dim));
|
|
806
|
+
console.log('');
|
|
807
|
+
process.exit(0);
|
|
808
|
+
}
|
|
809
|
+
|
|
600
810
|
// ─── entry ──────────────────────────────────────────────────────────────────
|
|
601
811
|
|
|
602
812
|
function main() {
|
|
@@ -610,9 +820,11 @@ function main() {
|
|
|
610
820
|
const target = argv[1];
|
|
611
821
|
if (target === 'ios') {
|
|
612
822
|
runInitIos(argv.slice(2));
|
|
823
|
+
} else if (target === 'android') {
|
|
824
|
+
runInitAndroid(argv.slice(2));
|
|
613
825
|
} else {
|
|
614
826
|
console.error(`Unknown init target: ${target ?? '(none)'}`);
|
|
615
|
-
console.error('Usage: npx scalebun init ios [--check | --dry-run]');
|
|
827
|
+
console.error('Usage: npx scalebun init <ios|android> [--check | --dry-run]');
|
|
616
828
|
process.exit(2);
|
|
617
829
|
}
|
|
618
830
|
break;
|
|
@@ -621,19 +833,7 @@ function main() {
|
|
|
621
833
|
case 'help':
|
|
622
834
|
case '--help':
|
|
623
835
|
case '-h':
|
|
624
|
-
|
|
625
|
-
console.log(paint('ScaleBun SDK CLI', C.bold));
|
|
626
|
-
console.log('');
|
|
627
|
-
console.log('Usage:');
|
|
628
|
-
console.log(' npx scalebun doctor Diagnose push-notification setup');
|
|
629
|
-
console.log(' npx scalebun init ios Wire the iOS AppDelegate for Direct APNs');
|
|
630
|
-
console.log(' npx scalebun init ios --check Verify hooks exist (exit 1 if missing)');
|
|
631
|
-
console.log(' npx scalebun init ios --dry-run Preview changes without writing');
|
|
632
|
-
console.log('');
|
|
633
|
-
console.log('OTA commands (login, quickstart, ota publish, releases, rollout…)');
|
|
634
|
-
console.log('ship with this package (via the bundled @scalebun/cli) and are');
|
|
635
|
-
console.log('forwarded automatically — no extra install needed.');
|
|
636
|
-
console.log('');
|
|
836
|
+
printFullHelp();
|
|
637
837
|
break;
|
|
638
838
|
default:
|
|
639
839
|
// TWO PACKAGES, ONE COMMAND NAME.
|
|
@@ -651,19 +851,23 @@ function main() {
|
|
|
651
851
|
}
|
|
652
852
|
}
|
|
653
853
|
|
|
654
|
-
/**
|
|
655
|
-
|
|
656
|
-
* Falls back to an actionable install instruction when it is not present.
|
|
657
|
-
*/
|
|
658
|
-
function delegateToOtaCli(argv) {
|
|
659
|
-
let cliEntry = null;
|
|
854
|
+
/** Resolve the bundled @scalebun/cli compiled entry, or null when absent. */
|
|
855
|
+
function resolveOtaCli() {
|
|
660
856
|
try {
|
|
661
|
-
|
|
857
|
+
return require.resolve('@scalebun/cli/lib/index.js', {
|
|
662
858
|
paths: [process.cwd(), __dirname],
|
|
663
859
|
});
|
|
664
860
|
} catch {
|
|
665
|
-
|
|
861
|
+
return null;
|
|
666
862
|
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Hand the invocation to @scalebun/cli, preserving argv and exit code.
|
|
867
|
+
* Falls back to an actionable install instruction when it is not present.
|
|
868
|
+
*/
|
|
869
|
+
function delegateToOtaCli(argv) {
|
|
870
|
+
const cliEntry = resolveOtaCli();
|
|
667
871
|
|
|
668
872
|
if (!cliEntry) {
|
|
669
873
|
const cmd = argv[0];
|
|
@@ -690,4 +894,40 @@ function delegateToOtaCli(argv) {
|
|
|
690
894
|
process.exit(res.status ?? 1);
|
|
691
895
|
}
|
|
692
896
|
|
|
897
|
+
/**
|
|
898
|
+
* Print the full command surface: the SDK-setup commands this package handles
|
|
899
|
+
* directly, followed by the complete @scalebun/cli command tree.
|
|
900
|
+
*
|
|
901
|
+
* The CLI tree is rendered by delegating to `@scalebun/cli --help` rather than a
|
|
902
|
+
* hardcoded list, so it is a single source of truth and cannot drift. When the
|
|
903
|
+
* CLI cannot be resolved (should not happen — it is a dependency) we fall back to
|
|
904
|
+
* a prose note instead of an error, since this is a help screen.
|
|
905
|
+
*/
|
|
906
|
+
function printFullHelp() {
|
|
907
|
+
console.log('');
|
|
908
|
+
console.log(paint('ScaleBun SDK CLI', C.bold));
|
|
909
|
+
console.log('');
|
|
910
|
+
console.log(paint('SDK setup (bundled with @scalebun/react-native):', C.bold));
|
|
911
|
+
console.log(' npx scalebun doctor Diagnose push-notification setup');
|
|
912
|
+
console.log(' npx scalebun init ios Wire the iOS AppDelegate for Direct APNs');
|
|
913
|
+
console.log(' npx scalebun init android Wire Android MainApplication for OTA bundle loading');
|
|
914
|
+
console.log(paint(' add --check to verify (exit 1 if missing) or --dry-run to preview', C.dim));
|
|
915
|
+
console.log('');
|
|
916
|
+
|
|
917
|
+
const cliEntry = resolveOtaCli();
|
|
918
|
+
if (!cliEntry) {
|
|
919
|
+
console.log(paint('OTA & account commands:', C.bold));
|
|
920
|
+
console.log(' Provided by @scalebun/cli (login, quickstart, apps, ota publish,');
|
|
921
|
+
console.log(' releases, rollout…). It ships with this package but could not be');
|
|
922
|
+
console.log(' resolved — run `npm install`, then `npx scalebun --help` again.');
|
|
923
|
+
console.log('');
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
console.log(paint('OTA & account commands (from @scalebun/cli):', C.bold));
|
|
928
|
+
const { spawnSync } = require('child_process');
|
|
929
|
+
spawnSync(process.execPath, [cliEntry, '--help'], { stdio: 'inherit' });
|
|
930
|
+
console.log('');
|
|
931
|
+
}
|
|
932
|
+
|
|
693
933
|
main();
|
package/dist/scalebun.full.js
CHANGED
package/dist/scalebun.slim.js
CHANGED
|
@@ -9,5 +9,5 @@ exports.SDK_VERSION = void 0;
|
|
|
9
9
|
* can attribute telemetry to the SDK build that produced it.
|
|
10
10
|
* Keep in sync with package.json "version".
|
|
11
11
|
*/
|
|
12
|
-
const SDK_VERSION = exports.SDK_VERSION = '1.
|
|
12
|
+
const SDK_VERSION = exports.SDK_VERSION = '1.6.1';
|
|
13
13
|
//# sourceMappingURL=version.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalebun/react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Production-grade React Native SDK for ScaleBun",
|
|
5
5
|
"main": "lib/commonjs/index",
|
|
6
6
|
"module": "lib/module/index",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@babel/runtime": "^7.25.0",
|
|
106
106
|
"@jridgewell/sourcemap-codec": "1.5.5",
|
|
107
107
|
"@jridgewell/trace-mapping": "0.3.31",
|
|
108
|
-
"@scalebun/cli": "^1.
|
|
108
|
+
"@scalebun/cli": "^1.6.1"
|
|
109
109
|
},
|
|
110
110
|
"codegenConfig": {
|
|
111
111
|
"name": "ScaleBunSpec",
|