@comergehq/studio 0.1.12 → 0.1.15
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/dist/index.d.mts +26 -3
- package/dist/index.d.ts +26 -3
- package/dist/index.js +538 -307
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +544 -313
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
- package/src/components/{floating-draggable-button/FloatingDraggableButton.tsx → bubble/Bubble.tsx} +3 -3
- package/src/components/{floating-draggable-button → bubble}/constants.ts +2 -2
- package/src/components/bubble/index.ts +4 -0
- package/src/components/{floating-draggable-button → bubble}/types.ts +3 -3
- package/src/components/index.ts +2 -2
- package/src/core/services/http/baseUrl.ts +1 -1
- package/src/core/services/http/public.ts +7 -7
- package/src/data/apps/bundles/remote.ts +17 -0
- package/src/data/apps/bundles/repository.ts +14 -0
- package/src/data/apps/bundles/types.ts +15 -0
- package/src/studio/ComergeStudio.tsx +17 -9
- package/src/studio/bootstrap/StudioBootstrap.tsx +2 -2
- package/src/studio/bootstrap/useStudioBootstrap.ts +7 -7
- package/src/studio/hooks/useBundleManager.ts +323 -19
- package/src/studio/ui/RuntimeRenderer.tsx +24 -1
- package/src/studio/ui/StudioOverlay.tsx +6 -6
- package/src/components/floating-draggable-button/index.ts +0 -4
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
3
|
+
import { Asset } from 'expo-asset';
|
|
4
|
+
import { unzip } from 'react-native-zip-archive';
|
|
3
5
|
|
|
4
|
-
import type { Platform as BundlePlatform, Bundle } from '../../data/apps/bundles/types';
|
|
6
|
+
import type { Platform as BundlePlatform, Bundle, BundleAsset } from '../../data/apps/bundles/types';
|
|
5
7
|
import { bundlesRepository } from '../../data/apps/bundles/repository';
|
|
6
8
|
|
|
7
9
|
function sleep(ms: number): Promise<void> {
|
|
@@ -60,6 +62,7 @@ export type UseBundleManagerParams = {
|
|
|
60
62
|
* Test bundles (merge request previews) are NOT gated by this.
|
|
61
63
|
*/
|
|
62
64
|
canRequestLatest?: boolean;
|
|
65
|
+
embeddedBaseBundles?: EmbeddedBaseBundles;
|
|
63
66
|
};
|
|
64
67
|
|
|
65
68
|
export type BundleLoadState = {
|
|
@@ -85,6 +88,23 @@ export type UseBundleManagerResult = BundleLoadState & {
|
|
|
85
88
|
restoreBase: () => Promise<void>;
|
|
86
89
|
};
|
|
87
90
|
|
|
91
|
+
export type EmbeddedBaseBundle = {
|
|
92
|
+
module: number;
|
|
93
|
+
meta?: BaseBundleMeta | null;
|
|
94
|
+
assetsModule?: number;
|
|
95
|
+
assetsMeta?: EmbeddedAssetsMeta | null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type EmbeddedBaseBundles = {
|
|
99
|
+
ios?: EmbeddedBaseBundle;
|
|
100
|
+
android?: EmbeddedBaseBundle;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
type EmbeddedAssetsMeta = {
|
|
104
|
+
checksumSha256: string | null;
|
|
105
|
+
size: number | null;
|
|
106
|
+
};
|
|
107
|
+
|
|
88
108
|
function safeName(s: string) {
|
|
89
109
|
return s.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
90
110
|
}
|
|
@@ -102,6 +122,11 @@ async function ensureDir(path: string) {
|
|
|
102
122
|
await FileSystem.makeDirectoryAsync(path, { intermediates: true });
|
|
103
123
|
}
|
|
104
124
|
|
|
125
|
+
async function ensureBundleDir(key: string) {
|
|
126
|
+
await ensureDir(bundlesCacheDir());
|
|
127
|
+
await ensureDir(bundleDir(key));
|
|
128
|
+
}
|
|
129
|
+
|
|
105
130
|
function baseBundleKey(appId: string, platform: BundlePlatform): string {
|
|
106
131
|
return `base:${appId}:${platform}`;
|
|
107
132
|
}
|
|
@@ -110,16 +135,37 @@ function testBundleKey(appId: string, commitId: string | null | undefined, platf
|
|
|
110
135
|
return `test:${appId}:${commitId ?? 'head'}:${platform}:${bundleId}`;
|
|
111
136
|
}
|
|
112
137
|
|
|
113
|
-
function
|
|
138
|
+
function legacyBundleFileUri(key: string): string {
|
|
114
139
|
const dir = bundlesCacheDir();
|
|
115
140
|
return `${dir}${safeName(key)}.jsbundle`;
|
|
116
141
|
}
|
|
117
142
|
|
|
118
|
-
function
|
|
143
|
+
function legacyBundleMetaFileUri(key: string): string {
|
|
119
144
|
const dir = bundlesCacheDir();
|
|
120
145
|
return `${dir}${safeName(key)}.meta.json`;
|
|
121
146
|
}
|
|
122
147
|
|
|
148
|
+
function bundleDir(key: string): string {
|
|
149
|
+
const dir = bundlesCacheDir();
|
|
150
|
+
return `${dir}${safeName(key)}/`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toBundleFileUri(key: string, platform: BundlePlatform): string {
|
|
154
|
+
return `${bundleDir(key)}index.${platform}.jsbundle`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function toBundleMetaFileUri(key: string): string {
|
|
158
|
+
return `${bundleDir(key)}bundle.meta.json`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toAssetsMetaFileUri(key: string): string {
|
|
162
|
+
return `${bundleDir(key)}assets.meta.json`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function toAssetsDir(key: string): string {
|
|
166
|
+
return `${bundleDir(key)}assets/`;
|
|
167
|
+
}
|
|
168
|
+
|
|
123
169
|
type BaseBundleMeta = {
|
|
124
170
|
fingerprint: string;
|
|
125
171
|
bundleId: string;
|
|
@@ -144,7 +190,7 @@ async function writeJsonFile(fileUri: string, value: unknown): Promise<void> {
|
|
|
144
190
|
try {
|
|
145
191
|
await (FileSystem as any).writeAsStringAsync(fileUri, JSON.stringify(value));
|
|
146
192
|
} catch {
|
|
147
|
-
|
|
193
|
+
|
|
148
194
|
}
|
|
149
195
|
}
|
|
150
196
|
|
|
@@ -158,6 +204,15 @@ async function getExistingNonEmptyFileUri(fileUri: string): Promise<string | nul
|
|
|
158
204
|
}
|
|
159
205
|
}
|
|
160
206
|
|
|
207
|
+
async function getExistingBundleFileUri(key: string, platform: BundlePlatform): Promise<string | null> {
|
|
208
|
+
const nextPath = toBundleFileUri(key, platform);
|
|
209
|
+
const next = await getExistingNonEmptyFileUri(nextPath);
|
|
210
|
+
if (next) return next;
|
|
211
|
+
const legacyPath = legacyBundleFileUri(key);
|
|
212
|
+
const legacy = await getExistingNonEmptyFileUri(legacyPath);
|
|
213
|
+
return legacy;
|
|
214
|
+
}
|
|
215
|
+
|
|
161
216
|
async function downloadIfMissing(url: string, fileUri: string): Promise<string> {
|
|
162
217
|
const existing = await getExistingNonEmptyFileUri(fileUri);
|
|
163
218
|
if (existing) return existing;
|
|
@@ -177,14 +232,103 @@ async function deleteFileIfExists(fileUri: string) {
|
|
|
177
232
|
try {
|
|
178
233
|
const info = await FileSystem.getInfoAsync(fileUri);
|
|
179
234
|
if (!info.exists) return;
|
|
180
|
-
await FileSystem.deleteAsync(fileUri).catch(() => {});
|
|
235
|
+
await FileSystem.deleteAsync(fileUri).catch(() => { });
|
|
236
|
+
} catch {
|
|
237
|
+
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function hydrateBaseFromEmbeddedAsset(
|
|
242
|
+
appId: string,
|
|
243
|
+
platform: BundlePlatform,
|
|
244
|
+
embedded: EmbeddedBaseBundle | undefined
|
|
245
|
+
): Promise<{ bundlePath: string; meta?: BaseBundleMeta | null } | null> {
|
|
246
|
+
if (!embedded?.module) return null;
|
|
247
|
+
const key = baseBundleKey(appId, platform);
|
|
248
|
+
const existing = await getExistingBundleFileUri(key, platform);
|
|
249
|
+
if (existing) {
|
|
250
|
+
return { bundlePath: existing, meta: embedded.meta ?? null };
|
|
251
|
+
}
|
|
252
|
+
await ensureBundleDir(key);
|
|
253
|
+
const targetUri = toBundleFileUri(key, platform);
|
|
254
|
+
|
|
255
|
+
const asset = Asset.fromModule(embedded.module);
|
|
256
|
+
await asset.downloadAsync();
|
|
257
|
+
const sourceUri = asset.localUri ?? asset.uri;
|
|
258
|
+
if (!sourceUri) return null;
|
|
259
|
+
const info = await FileSystem.getInfoAsync(sourceUri);
|
|
260
|
+
if (!info.exists) return null;
|
|
261
|
+
|
|
262
|
+
await deleteFileIfExists(targetUri);
|
|
263
|
+
await FileSystem.copyAsync({ from: sourceUri, to: targetUri });
|
|
264
|
+
const finalUri = await getExistingNonEmptyFileUri(targetUri);
|
|
265
|
+
if (!finalUri) return null;
|
|
266
|
+
return { bundlePath: finalUri, meta: embedded.meta ?? null };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function hydrateAssetsFromEmbeddedAsset(
|
|
270
|
+
appId: string,
|
|
271
|
+
platform: BundlePlatform,
|
|
272
|
+
key: string,
|
|
273
|
+
embedded: EmbeddedBaseBundle | undefined
|
|
274
|
+
): Promise<boolean> {
|
|
275
|
+
const moduleId = embedded?.assetsModule;
|
|
276
|
+
if (!moduleId) return false;
|
|
277
|
+
|
|
278
|
+
const assetsMeta = embedded?.assetsMeta ?? null;
|
|
279
|
+
const assetsDir = toAssetsDir(key);
|
|
280
|
+
const metaUri = toAssetsMetaFileUri(key);
|
|
281
|
+
|
|
282
|
+
const existingMeta = await readJsonFile<AssetsMeta>(metaUri);
|
|
283
|
+
const assetsDirInfo = await FileSystem.getInfoAsync(assetsDir);
|
|
284
|
+
const assetsDirExists = assetsDirInfo.exists && assetsDirInfo.isDirectory;
|
|
285
|
+
const checksumMatches =
|
|
286
|
+
Boolean(existingMeta?.checksumSha256) &&
|
|
287
|
+
Boolean(assetsMeta?.checksumSha256) &&
|
|
288
|
+
existingMeta?.checksumSha256 === assetsMeta?.checksumSha256;
|
|
289
|
+
const embeddedMetaMatches = existingMeta?.storageKey?.startsWith('embedded:');
|
|
290
|
+
if (assetsDirExists && checksumMatches && embeddedMetaMatches) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await ensureBundleDir(key);
|
|
295
|
+
await ensureDir(assetsDir);
|
|
296
|
+
|
|
297
|
+
const asset = Asset.fromModule(moduleId);
|
|
298
|
+
await asset.downloadAsync();
|
|
299
|
+
const sourceUri = asset.localUri ?? asset.uri;
|
|
300
|
+
if (!sourceUri) return false;
|
|
301
|
+
const info = await FileSystem.getInfoAsync(sourceUri);
|
|
302
|
+
if (!info.exists) return false;
|
|
303
|
+
|
|
304
|
+
const zipUri = `${bundleDir(key)}assets.zip`;
|
|
305
|
+
await deleteFileIfExists(zipUri);
|
|
306
|
+
await FileSystem.copyAsync({ from: sourceUri, to: zipUri });
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await FileSystem.deleteAsync(assetsDir, { idempotent: true }).catch(() => { });
|
|
181
310
|
} catch {
|
|
182
|
-
|
|
183
311
|
}
|
|
312
|
+
await ensureDir(assetsDir);
|
|
313
|
+
await unzipArchive(zipUri, assetsDir);
|
|
314
|
+
|
|
315
|
+
await writeJsonFile(metaUri, {
|
|
316
|
+
checksumSha256: assetsMeta?.checksumSha256 ?? null,
|
|
317
|
+
storageKey: `embedded:${assetsMeta?.checksumSha256 ?? 'unknown'}`,
|
|
318
|
+
updatedAt: new Date().toISOString(),
|
|
319
|
+
} satisfies AssetsMeta);
|
|
320
|
+
return true;
|
|
184
321
|
}
|
|
185
322
|
|
|
186
|
-
async function safeReplaceFileFromUrl(
|
|
187
|
-
|
|
323
|
+
async function safeReplaceFileFromUrl(
|
|
324
|
+
url: string,
|
|
325
|
+
targetUri: string,
|
|
326
|
+
tmpKey: string,
|
|
327
|
+
platform: BundlePlatform
|
|
328
|
+
): Promise<string> {
|
|
329
|
+
const tmpKeySafe = `tmp:${tmpKey}:${Date.now()}`;
|
|
330
|
+
const tmpUri = toBundleFileUri(tmpKeySafe, platform);
|
|
331
|
+
await ensureDir(bundleDir(tmpKeySafe));
|
|
188
332
|
try {
|
|
189
333
|
await withRetry(
|
|
190
334
|
async () => {
|
|
@@ -207,6 +351,107 @@ async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: st
|
|
|
207
351
|
}
|
|
208
352
|
}
|
|
209
353
|
|
|
354
|
+
async function safeReplaceFileFromUrlToPath(url: string, targetUri: string, tmpKey: string): Promise<string> {
|
|
355
|
+
const tmpDir = `${bundlesCacheDir()}tmp/`;
|
|
356
|
+
await ensureDir(tmpDir);
|
|
357
|
+
const tmpUri = `${tmpDir}${safeName(tmpKey)}.tmp`;
|
|
358
|
+
try {
|
|
359
|
+
await withRetry(
|
|
360
|
+
async () => {
|
|
361
|
+
await deleteFileIfExists(tmpUri);
|
|
362
|
+
await FileSystem.downloadAsync(url, tmpUri);
|
|
363
|
+
const tmpOk = await getExistingNonEmptyFileUri(tmpUri);
|
|
364
|
+
if (!tmpOk) throw new Error('Downloaded file is empty.');
|
|
365
|
+
},
|
|
366
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
367
|
+
);
|
|
368
|
+
await deleteFileIfExists(targetUri);
|
|
369
|
+
await FileSystem.moveAsync({ from: tmpUri, to: targetUri });
|
|
370
|
+
const finalOk = await getExistingNonEmptyFileUri(targetUri);
|
|
371
|
+
if (!finalOk) throw new Error('File replacement failed.');
|
|
372
|
+
return targetUri;
|
|
373
|
+
} finally {
|
|
374
|
+
await deleteFileIfExists(tmpUri);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function getMetroAssets(bundle: Bundle): BundleAsset | null {
|
|
379
|
+
const assets = bundle.assets ?? [];
|
|
380
|
+
return assets.find((asset) => asset.kind === 'metro-assets') ?? null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
type AssetsMeta = {
|
|
384
|
+
checksumSha256: string | null;
|
|
385
|
+
storageKey: string;
|
|
386
|
+
updatedAt: string;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
async function ensureAssetsForBundle(
|
|
390
|
+
appId: string,
|
|
391
|
+
bundle: Bundle,
|
|
392
|
+
key: string,
|
|
393
|
+
platform: BundlePlatform
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
const asset = getMetroAssets(bundle);
|
|
396
|
+
if (!asset?.storageKey) return;
|
|
397
|
+
|
|
398
|
+
await ensureBundleDir(key);
|
|
399
|
+
const assetsDir = toAssetsDir(key);
|
|
400
|
+
await ensureDir(assetsDir);
|
|
401
|
+
|
|
402
|
+
const metaUri = toAssetsMetaFileUri(key);
|
|
403
|
+
const existingMeta = await readJsonFile<AssetsMeta>(metaUri);
|
|
404
|
+
const assetsDirInfo = await FileSystem.getInfoAsync(assetsDir);
|
|
405
|
+
const assetsDirExists = assetsDirInfo.exists && assetsDirInfo.isDirectory;
|
|
406
|
+
if (
|
|
407
|
+
existingMeta?.checksumSha256 &&
|
|
408
|
+
asset.checksumSha256 &&
|
|
409
|
+
existingMeta.checksumSha256 === asset.checksumSha256 &&
|
|
410
|
+
(existingMeta.storageKey === asset.storageKey || existingMeta.storageKey?.startsWith('embedded:')) &&
|
|
411
|
+
assetsDirExists
|
|
412
|
+
) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const signed = await withRetry(
|
|
417
|
+
async () => {
|
|
418
|
+
return await bundlesRepository.getSignedAssetsDownloadUrl(appId, bundle.id, {
|
|
419
|
+
redirect: false,
|
|
420
|
+
kind: asset.kind,
|
|
421
|
+
});
|
|
422
|
+
},
|
|
423
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const zipUri = `${bundleDir(key)}assets.zip`;
|
|
427
|
+
await safeReplaceFileFromUrlToPath(signed.url, zipUri, `${appId}:${bundle.id}:${platform}:${asset.kind}`);
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
await FileSystem.deleteAsync(assetsDir, { idempotent: true }).catch(() => { });
|
|
431
|
+
} catch {
|
|
432
|
+
|
|
433
|
+
}
|
|
434
|
+
await ensureDir(assetsDir);
|
|
435
|
+
await unzipArchive(zipUri, assetsDir);
|
|
436
|
+
await writeJsonFile(metaUri, {
|
|
437
|
+
checksumSha256: asset.checksumSha256 ?? null,
|
|
438
|
+
storageKey: asset.storageKey,
|
|
439
|
+
updatedAt: new Date().toISOString(),
|
|
440
|
+
} satisfies AssetsMeta);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function unzipArchive(sourceUri: string, destDir: string) {
|
|
444
|
+
try {
|
|
445
|
+
await unzip(sourceUri, destDir);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`Failed to extract assets archive. Ensure 'react-native-zip-archive' is installed in the host app. ${String(
|
|
449
|
+
(e as Error)?.message ?? e
|
|
450
|
+
)}`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
210
455
|
async function pollBundle(appId: string, bundleId: string, opts: { timeoutMs: number; intervalMs: number }): Promise<Bundle> {
|
|
211
456
|
const start = Date.now();
|
|
212
457
|
while (true) {
|
|
@@ -254,27 +499,48 @@ async function resolveBundlePath(
|
|
|
254
499
|
throw new Error('Bundle build failed.');
|
|
255
500
|
}
|
|
256
501
|
|
|
502
|
+
let bundleWithAssets = finalBundle;
|
|
503
|
+
if (finalBundle.status === 'succeeded' && (!finalBundle.assets || finalBundle.assets.length === 0)) {
|
|
504
|
+
try {
|
|
505
|
+
bundleWithAssets = await bundlesRepository.getById(appId, finalBundle.id);
|
|
506
|
+
} catch {
|
|
507
|
+
bundleWithAssets = finalBundle;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
257
511
|
const signed = await withRetry(
|
|
258
512
|
async () => {
|
|
259
513
|
return await bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
|
|
260
514
|
},
|
|
261
515
|
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
262
516
|
);
|
|
517
|
+
const key =
|
|
518
|
+
mode === 'base'
|
|
519
|
+
? baseBundleKey(appId, platform)
|
|
520
|
+
: testBundleKey(appId, commitId, platform, finalBundle.id);
|
|
521
|
+
await ensureBundleDir(key);
|
|
263
522
|
const bundlePath =
|
|
264
523
|
mode === 'base'
|
|
265
524
|
? await safeReplaceFileFromUrl(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
525
|
+
signed.url,
|
|
526
|
+
toBundleFileUri(key, platform),
|
|
527
|
+
`${appId}:${commitId ?? 'head'}:${platform}:${finalBundle.id}`,
|
|
528
|
+
platform
|
|
529
|
+
)
|
|
530
|
+
: await downloadIfMissing(signed.url, toBundleFileUri(key, platform));
|
|
531
|
+
try {
|
|
532
|
+
await ensureAssetsForBundle(appId, bundleWithAssets, key, platform);
|
|
533
|
+
} catch {
|
|
534
|
+
|
|
535
|
+
}
|
|
536
|
+
return { bundlePath, label: 'Ready', bundle: bundleWithAssets };
|
|
272
537
|
}
|
|
273
538
|
|
|
274
539
|
export function useBundleManager({
|
|
275
540
|
base,
|
|
276
541
|
platform,
|
|
277
542
|
canRequestLatest = true,
|
|
543
|
+
embeddedBaseBundles,
|
|
278
544
|
}: UseBundleManagerParams): UseBundleManagerResult {
|
|
279
545
|
const [bundlePath, setBundlePath] = React.useState<string | null>(null);
|
|
280
546
|
const [renderToken, setRenderToken] = React.useState(0);
|
|
@@ -287,6 +553,9 @@ export function useBundleManager({
|
|
|
287
553
|
const baseRef = React.useRef(base);
|
|
288
554
|
baseRef.current = base;
|
|
289
555
|
|
|
556
|
+
const embeddedBaseBundlesRef = React.useRef<EmbeddedBaseBundles | undefined>(embeddedBaseBundles);
|
|
557
|
+
embeddedBaseBundlesRef.current = embeddedBaseBundles;
|
|
558
|
+
|
|
290
559
|
// Monotonic operation ids to prevent stale async loads from overwriting newer ones.
|
|
291
560
|
const baseOpIdRef = React.useRef(0);
|
|
292
561
|
const testOpIdRef = React.useRef(0);
|
|
@@ -319,12 +588,38 @@ export function useBundleManager({
|
|
|
319
588
|
const dir = bundlesCacheDir();
|
|
320
589
|
await ensureDir(dir);
|
|
321
590
|
const key = baseBundleKey(appId, platform);
|
|
322
|
-
|
|
323
|
-
|
|
591
|
+
let existing = await getExistingBundleFileUri(key, platform);
|
|
592
|
+
let embeddedMeta: BaseBundleMeta | null = null;
|
|
593
|
+
if (!existing) {
|
|
594
|
+
const embedded = embeddedBaseBundlesRef.current?.[platform];
|
|
595
|
+
const hydrated = await hydrateBaseFromEmbeddedAsset(appId, platform, embedded);
|
|
596
|
+
if (hydrated?.bundlePath) {
|
|
597
|
+
existing = hydrated.bundlePath;
|
|
598
|
+
embeddedMeta = hydrated.meta ?? null;
|
|
599
|
+
if (embeddedMeta) {
|
|
600
|
+
await ensureBundleDir(key);
|
|
601
|
+
await writeJsonFile(toBundleMetaFileUri(key), embeddedMeta);
|
|
602
|
+
await writeJsonFile(legacyBundleMetaFileUri(key), embeddedMeta);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
324
606
|
if (existing) {
|
|
325
607
|
lastBaseBundlePathRef.current = existing;
|
|
326
608
|
setBundlePath(existing);
|
|
327
|
-
const meta =
|
|
609
|
+
const meta =
|
|
610
|
+
embeddedMeta ??
|
|
611
|
+
(await readJsonFile<BaseBundleMeta>(toBundleMetaFileUri(key))) ??
|
|
612
|
+
(await readJsonFile<BaseBundleMeta>(legacyBundleMetaFileUri(key)));
|
|
613
|
+
const embedded = embeddedBaseBundlesRef.current?.[platform];
|
|
614
|
+
const embeddedFingerprint = embedded?.meta?.fingerprint ?? null;
|
|
615
|
+
const actualFingerprint = meta?.fingerprint ?? embeddedMeta?.fingerprint ?? null;
|
|
616
|
+
if (embedded?.assetsModule && embeddedFingerprint && actualFingerprint === embeddedFingerprint) {
|
|
617
|
+
try {
|
|
618
|
+
await hydrateAssetsFromEmbeddedAsset(appId, platform, key, embedded);
|
|
619
|
+
} catch {
|
|
620
|
+
|
|
621
|
+
}
|
|
622
|
+
}
|
|
328
623
|
if (meta?.fingerprint) {
|
|
329
624
|
lastBaseFingerprintRef.current = meta.fingerprint;
|
|
330
625
|
}
|
|
@@ -334,7 +629,7 @@ export function useBundleManager({
|
|
|
334
629
|
}
|
|
335
630
|
}
|
|
336
631
|
} catch {
|
|
337
|
-
|
|
632
|
+
|
|
338
633
|
}
|
|
339
634
|
},
|
|
340
635
|
[platform]
|
|
@@ -408,7 +703,16 @@ export function useBundleManager({
|
|
|
408
703
|
lastBaseFingerprintRef.current = fingerprint;
|
|
409
704
|
hasCompletedFirstNetworkBaseLoadRef.current = true;
|
|
410
705
|
initialHydratedBaseFromDiskRef.current = false;
|
|
411
|
-
|
|
706
|
+
const metaKey = baseBundleKey(src.appId, platform);
|
|
707
|
+
await ensureBundleDir(metaKey);
|
|
708
|
+
void writeJsonFile(toBundleMetaFileUri(metaKey), {
|
|
709
|
+
fingerprint,
|
|
710
|
+
bundleId: bundle.id,
|
|
711
|
+
checksumSha256: bundle.checksumSha256 ?? null,
|
|
712
|
+
size: bundle.size ?? null,
|
|
713
|
+
updatedAt: new Date().toISOString(),
|
|
714
|
+
} satisfies BaseBundleMeta);
|
|
715
|
+
void writeJsonFile(legacyBundleMetaFileUri(metaKey), {
|
|
412
716
|
fingerprint,
|
|
413
717
|
bundleId: bundle.id,
|
|
414
718
|
checksumSha256: bundle.checksumSha256 ?? null,
|
|
@@ -13,6 +13,10 @@ export type RuntimeRendererProps = {
|
|
|
13
13
|
* Used to avoid briefly rendering an outdated bundle during post-edit base refresh.
|
|
14
14
|
*/
|
|
15
15
|
forcePreparing?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* When false, suppress "Preparing app…" on the very first load.
|
|
18
|
+
*/
|
|
19
|
+
allowInitialPreparing?: boolean;
|
|
16
20
|
/**
|
|
17
21
|
* Used to force a runtime remount even when bundlePath stays constant
|
|
18
22
|
* (e.g. base bundle replaced in-place).
|
|
@@ -21,8 +25,27 @@ export type RuntimeRendererProps = {
|
|
|
21
25
|
style?: ViewStyle;
|
|
22
26
|
};
|
|
23
27
|
|
|
24
|
-
export function RuntimeRenderer({
|
|
28
|
+
export function RuntimeRenderer({
|
|
29
|
+
appKey,
|
|
30
|
+
bundlePath,
|
|
31
|
+
forcePreparing,
|
|
32
|
+
renderToken,
|
|
33
|
+
style,
|
|
34
|
+
allowInitialPreparing = true,
|
|
35
|
+
}: RuntimeRendererProps) {
|
|
36
|
+
const [hasRenderedOnce, setHasRenderedOnce] = React.useState(false);
|
|
37
|
+
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
if (bundlePath) {
|
|
40
|
+
setHasRenderedOnce(true);
|
|
41
|
+
}
|
|
42
|
+
}, [bundlePath]);
|
|
43
|
+
|
|
25
44
|
if (!bundlePath || forcePreparing) {
|
|
45
|
+
if (!hasRenderedOnce && !forcePreparing && !allowInitialPreparing) {
|
|
46
|
+
return <View style={[{ flex: 1 }, style]} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
return (
|
|
27
50
|
<View style={[{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }, style]}>
|
|
28
51
|
<Text variant="bodyMuted">Preparing app…</Text>
|
|
@@ -5,7 +5,7 @@ import type { App } from '../../data/apps/types';
|
|
|
5
5
|
import type { MergeRequest } from '../../data/merge-requests/types';
|
|
6
6
|
import { StudioBottomSheet } from '../../components/studio-sheet/StudioBottomSheet';
|
|
7
7
|
import { StudioSheetPager } from '../../components/studio-sheet/StudioSheetPager';
|
|
8
|
-
import {
|
|
8
|
+
import { Bubble } from '../../components/bubble/Bubble';
|
|
9
9
|
import { EdgeGlowFrame } from '../../components/overlays/EdgeGlowFrame';
|
|
10
10
|
import { DrawModeOverlay } from '../../components/draw/DrawModeOverlay';
|
|
11
11
|
import { AppCommentsSheet } from '../../components/comments/AppCommentsSheet';
|
|
@@ -61,7 +61,7 @@ export type StudioOverlayProps = {
|
|
|
61
61
|
|
|
62
62
|
// Navigation callbacks
|
|
63
63
|
onNavigateHome?: () => void;
|
|
64
|
-
|
|
64
|
+
showBubble: boolean;
|
|
65
65
|
studioControlOptions?: StudioControlOptions;
|
|
66
66
|
};
|
|
67
67
|
|
|
@@ -94,7 +94,7 @@ export function StudioOverlay({
|
|
|
94
94
|
chatShowTypingIndicator,
|
|
95
95
|
onSendChat,
|
|
96
96
|
onNavigateHome,
|
|
97
|
-
|
|
97
|
+
showBubble,
|
|
98
98
|
studioControlOptions,
|
|
99
99
|
}: StudioOverlayProps) {
|
|
100
100
|
const theme = useTheme();
|
|
@@ -270,8 +270,8 @@ export function StudioOverlay({
|
|
|
270
270
|
/>
|
|
271
271
|
</StudioBottomSheet>
|
|
272
272
|
|
|
273
|
-
{
|
|
274
|
-
<
|
|
273
|
+
{showBubble && (
|
|
274
|
+
<Bubble
|
|
275
275
|
visible={!sheetOpen && !drawing}
|
|
276
276
|
ariaLabel={sheetOpen ? 'Hide studio' : 'Show studio'}
|
|
277
277
|
badgeCount={incomingMergeRequests.length}
|
|
@@ -281,7 +281,7 @@ export function StudioOverlay({
|
|
|
281
281
|
<View style={{ width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }}>
|
|
282
282
|
<MergeIcon width={24} height={24} color={theme.colors.floatingContent} />
|
|
283
283
|
</View>
|
|
284
|
-
</
|
|
284
|
+
</Bubble>
|
|
285
285
|
)}
|
|
286
286
|
|
|
287
287
|
<DrawModeOverlay
|