@appspacer/react-native 1.0.0

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.
@@ -0,0 +1,81 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { AppSpacer } from "./AppSpacer";
3
+ import { UpdateStatus } from "./types";
4
+ /**
5
+ * Production-grade React hook for AppSpacer OTA updates.
6
+ */
7
+ export function useAppSpacerUpdate() {
8
+ const [status, setStatus] = useState(AppSpacer.getStatus());
9
+ const [update, setUpdate] = useState(null);
10
+ const [error, setError] = useState(null);
11
+ const [downloadProgress, setDownloadProgress] = useState(0);
12
+ useEffect(() => {
13
+ const unsubscribeStatus = AppSpacer.onStatusChange((newStatus, detail) => {
14
+ setStatus(newStatus);
15
+ if (newStatus === UpdateStatus.ERROR) {
16
+ setError(detail ?? "Unknown error");
17
+ }
18
+ else {
19
+ setError(null);
20
+ }
21
+ // Reset progress when not downloading
22
+ if (newStatus !== UpdateStatus.DOWNLOADING) {
23
+ setDownloadProgress(0);
24
+ }
25
+ });
26
+ const unsubscribeProgress = AppSpacer.onDownloadProgress((progress) => {
27
+ setDownloadProgress(progress);
28
+ });
29
+ return () => {
30
+ unsubscribeStatus();
31
+ unsubscribeProgress();
32
+ };
33
+ }, []);
34
+ const checkForUpdate = useCallback(async () => {
35
+ try {
36
+ const result = await AppSpacer.checkForUpdate();
37
+ setUpdate(result);
38
+ }
39
+ catch (err) {
40
+ setError(err.message);
41
+ }
42
+ }, []);
43
+ const downloadUpdate = useCallback(async () => {
44
+ try {
45
+ await AppSpacer.downloadUpdate();
46
+ }
47
+ catch (err) {
48
+ setError(err.message);
49
+ }
50
+ }, []);
51
+ const restartApp = useCallback(() => {
52
+ AppSpacer.restartApp();
53
+ }, []);
54
+ const sync = useCallback(async (options) => {
55
+ try {
56
+ const result = await AppSpacer.sync(options);
57
+ setUpdate(result);
58
+ }
59
+ catch (err) {
60
+ setError(err.message);
61
+ }
62
+ }, []);
63
+ const clearOldUpdates = useCallback(async () => {
64
+ return AppSpacer.clearOldUpdates();
65
+ }, []);
66
+ const getUpdateMetadata = useCallback(async () => {
67
+ return AppSpacer.getUpdateMetadata();
68
+ }, []);
69
+ return {
70
+ status,
71
+ update,
72
+ error,
73
+ downloadProgress,
74
+ checkForUpdate,
75
+ downloadUpdate,
76
+ restartApp,
77
+ sync,
78
+ clearOldUpdates,
79
+ getUpdateMetadata,
80
+ };
81
+ }
@@ -0,0 +1,4 @@
1
+ import React from "react";
2
+ import type { AppSpacerConfig, SyncOptions } from "./types";
3
+ export type AppSpacerOptions = AppSpacerConfig & SyncOptions;
4
+ export declare function withAppSpacer(options: AppSpacerOptions): <P extends object>(WrappedComponent: React.ComponentType<P>) => React.ComponentType<P>;
@@ -0,0 +1,487 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState, useRef, useCallback } from "react";
3
+ import { AppState, Modal, View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions, ActivityIndicator,
4
+ // eslint-disable-next-line deprecation/deprecation
5
+ SafeAreaView, } from "react-native";
6
+ import { AppSpacer } from "./AppSpacer";
7
+ import { UpdateStatus, InstallMode, UpdateUIType } from "./types";
8
+ const { width } = Dimensions.get("window");
9
+ export function withAppSpacer(options) {
10
+ const selectedUI = options.updateUI || UpdateUIType.PREMIUM_DARK;
11
+ return function (WrappedComponent) {
12
+ return function AppSpacerWrapper(props) {
13
+ const [showModal, setShowModal] = useState(false);
14
+ const [update, setUpdate] = useState(null);
15
+ const [status, setStatus] = useState(UpdateStatus.IDLE);
16
+ const [progress, setProgress] = useState(0);
17
+ useEffect(() => {
18
+ AppSpacer.init({
19
+ deploymentKey: options.deploymentKey,
20
+ appVersion: options.appVersion,
21
+ checkInterval: options.checkInterval,
22
+ enableCrashReporting: options.enableCrashReporting,
23
+ });
24
+ return () => AppSpacer.destroy();
25
+ }, []);
26
+ // ✅ Use a ref so AppState listener always reads current status
27
+ // without needing to be re-registered on every status change
28
+ const statusRef = useRef(UpdateStatus.IDLE);
29
+ useEffect(() => {
30
+ statusRef.current = status;
31
+ }, [status]);
32
+ // ✅ Resolve the correct install mode given a specific update
33
+ const resolveInstallMode = useCallback((currentUpdate) => {
34
+ if (currentUpdate.mandatory) {
35
+ return (options.mandatoryInstallMode ??
36
+ options.installMode ??
37
+ InstallMode.IMMEDIATE);
38
+ }
39
+ return options.installMode ?? InstallMode.ON_NEXT_RESTART;
40
+ }, []);
41
+ // ✅ Accept currentUpdate directly — avoids stale state closure
42
+ const handleSilentDownload = useCallback(async (currentUpdate) => {
43
+ try {
44
+ await AppSpacer.downloadUpdate();
45
+ const mode = resolveInstallMode(currentUpdate);
46
+ if (mode === InstallMode.IMMEDIATE) {
47
+ AppSpacer.restartApp();
48
+ }
49
+ }
50
+ catch {
51
+ // Silent background download failures are non-fatal
52
+ }
53
+ }, [resolveInstallMode]);
54
+ const runSync = useCallback(async () => {
55
+ try {
56
+ const result = await AppSpacer.checkForUpdate();
57
+ if (!result.updateAvailable)
58
+ return;
59
+ setUpdate(result);
60
+ if (options.updateDialog) {
61
+ setShowModal(true);
62
+ }
63
+ else {
64
+ // ✅ Pass result directly — no stale state risk
65
+ await handleSilentDownload(result);
66
+ }
67
+ }
68
+ catch {
69
+ // Check failures are non-fatal
70
+ }
71
+ }, [handleSilentDownload]);
72
+ useEffect(() => {
73
+ const unsubscribeStatus = AppSpacer.onStatusChange((s) => setStatus(s));
74
+ const unsubscribeProgress = AppSpacer.onDownloadProgress((p) => setProgress(p));
75
+ runSync();
76
+ const subscription = AppState.addEventListener("change", (nextState) => {
77
+ // ✅ Guard: don't re-trigger check if already downloading/installing
78
+ if (nextState === "active" &&
79
+ statusRef.current === UpdateStatus.IDLE) {
80
+ runSync();
81
+ }
82
+ });
83
+ return () => {
84
+ subscription.remove();
85
+ unsubscribeStatus();
86
+ unsubscribeProgress();
87
+ };
88
+ }, [runSync]);
89
+ // ✅ Respects installMode — no longer always calls restartApp()
90
+ const confirmInstall = useCallback(async () => {
91
+ try {
92
+ await AppSpacer.downloadUpdate();
93
+ const mode = resolveInstallMode(update ?? { updateAvailable: true });
94
+ if (mode === InstallMode.IMMEDIATE) {
95
+ AppSpacer.restartApp();
96
+ }
97
+ else {
98
+ // ON_NEXT_RESTART or ON_NEXT_RESUME — close modal, update applies on next launch
99
+ setShowModal(false);
100
+ }
101
+ }
102
+ catch {
103
+ setShowModal(false);
104
+ }
105
+ }, [update, resolveInstallMode]);
106
+ const ignoreInstall = useCallback(() => setShowModal(false), []);
107
+ const isProcessing = status === UpdateStatus.DOWNLOADING ||
108
+ status === UpdateStatus.INSTALLING;
109
+ const renderUI = () => {
110
+ switch (selectedUI) {
111
+ // ─────────────────────────────────────────────────────────────
112
+ case UpdateUIType.GLASS:
113
+ return (_jsxs(View, { style: [
114
+ styles.glassCard,
115
+ { backgroundColor: "rgba(255,255,255,0.15)" },
116
+ ], children: [_jsx(Text, { style: styles.glassTitle, children: update?.mandatory ? "Urgent Patch" : "App Upgrade" }), _jsx(Text, { style: styles.glassDesc, children: update?.description || "Stability and performance improvements." }), isProcessing ? (_jsx(ActivityIndicator, { color: "#FFF", size: "large" })) : (_jsxs(View, { style: styles.glassActions, children: [!update?.mandatory && (_jsx(TouchableOpacity, { onPress: ignoreInstall, style: styles.glassBtnSecondary, children: _jsx(Text, { style: styles.glassBtnTextSecondary, children: "Later" }) })), _jsx(TouchableOpacity, { onPress: confirmInstall, style: styles.glassBtnPrimary, children: _jsx(Text, { style: styles.glassBtnTextPrimary, children: "Update Now" }) })] }))] }));
117
+ // ─────────────────────────────────────────────────────────────
118
+ case UpdateUIType.MINIMAL:
119
+ return (_jsxs(View, { style: styles.minimalCard, children: [_jsxs(View, { style: styles.minimalHeader, children: [_jsx(Text, { style: styles.minimalTitle, children: "Update Required" }), update?.label ? (_jsxs(Text, { style: styles.minimalVersion, children: ["v", update.label] })) : null] }), _jsx(Text, { style: styles.minimalDesc, children: update?.description || "Release notes not provided." }), isProcessing ? (
120
+ // ✅ Added track container so bar is visible at 0%
121
+ _jsx(View, { style: styles.minimalProgressTrack, children: _jsx(View, { style: [
122
+ styles.minimalBar,
123
+ { width: `${Math.round(progress * 100)}%` },
124
+ ] }) })) : (_jsxs(View, { style: styles.minimalFooter, children: [!update?.mandatory && (_jsx(TouchableOpacity, { onPress: ignoreInstall, children: _jsx(Text, { style: styles.minimalLink, children: "Postpone" }) })), _jsx(TouchableOpacity, { onPress: confirmInstall, style: styles.minimalBtn, children: _jsx(Text, { style: styles.minimalBtnText, children: "Update" }) })] }))] }));
125
+ // ─────────────────────────────────────────────────────────────
126
+ case UpdateUIType.BOTTOM_SHEET:
127
+ return (
128
+ // ✅ Sheet manages its own bottom alignment — not relying on overlay
129
+ _jsx(View, { style: styles.sheetWrapper, children: _jsxs(View, { style: styles.sheet, children: [_jsx(View, { style: styles.sheetHandle }), _jsx(Text, { style: styles.sheetTitle, children: "New Version Available" }), _jsx(Text, { style: styles.sheetDesc, children: "Get the latest features and bug fixes. The app will restart after install." }), isProcessing ? (_jsxs(View, { style: styles.sheetLoading, children: [_jsx(ActivityIndicator, { color: "#6366F1" }), _jsxs(Text, { style: styles.sheetLoadingText, children: ["Downloading... ", Math.round(progress * 100), "%"] })] })) : (_jsxs(View, { style: styles.sheetActions, children: [_jsx(TouchableOpacity, { onPress: confirmInstall, style: styles.sheetBtnPrimary, children: _jsx(Text, { style: styles.sheetBtnTextPrimary, children: "Install Update" }) }), !update?.mandatory && (_jsx(TouchableOpacity, { onPress: ignoreInstall, style: styles.sheetBtnSecondary, children: _jsx(Text, { style: styles.sheetBtnTextSecondary, children: "Not Now" }) }))] }))] }) }));
130
+ // ─────────────────────────────────────────────────────────────
131
+ case UpdateUIType.FULL_SCREEN:
132
+ return (_jsxs(SafeAreaView, { style: styles.fullScreen, children: [_jsx(View, { style: styles.fullScreenGlow }), _jsx(Text, { style: styles.fullScreenEmoji, children: "\u2728" }), _jsx(Text, { style: styles.fullScreenTitle, children: "Time to Expand" }), _jsx(Text, { style: styles.fullScreenDesc, children: "A new build is ready. We've optimized the core engine and added new capabilities." }), _jsx(View, { style: styles.fullScreenFooter, children: isProcessing ? (
133
+ // ✅ Added track behind the fill bar
134
+ _jsxs(View, { style: styles.fullScreenProgressWrapper, children: [_jsx(View, { style: styles.fullScreenProgressTrack, children: _jsx(View, { style: [
135
+ styles.fullScreenBar,
136
+ { width: `${Math.round(progress * 100)}%` },
137
+ ] }) }), _jsxs(Text, { style: styles.fullScreenProgressText, children: [Math.round(progress * 100), "% Complete"] })] })) : (_jsxs(_Fragment, { children: [_jsx(TouchableOpacity, { onPress: confirmInstall, style: styles.fullScreenBtn, children: _jsx(Text, { style: styles.fullScreenBtnText, children: "Deploy Update" }) }), !update?.mandatory && (_jsx(TouchableOpacity, { onPress: ignoreInstall, style: styles.fullScreenMinor, children: _jsx(Text, { style: styles.fullScreenMinorText, children: "Skip for now" }) }))] })) })] }));
138
+ // ─────────────────────────────────────────────────────────────
139
+ case UpdateUIType.PREMIUM_DARK:
140
+ default:
141
+ return (_jsxs(View, { style: styles.card, children: [_jsx(View, { style: styles.glow }), _jsxs(View, { style: styles.content, children: [_jsx(View, { style: styles.iconBox, children: _jsx(Text, { style: styles.icon, children: "\u26A1" }) }), _jsx(Text, { style: styles.title, children: update?.mandatory ? "Mandatory Update" : "Update Available" }), update?.label ? (_jsxs(Text, { style: styles.version, children: ["AppSpacer v", update.label] })) : null, _jsx(View, { style: styles.descBox, children: _jsx(Text, { style: styles.description, children: update?.description || "Improvements and patches." }) }), isProcessing ? (_jsxs(View, { style: styles.progressContainer, children: [_jsx(View, { style: styles.progressBarBg, children: _jsx(View, { style: [
142
+ styles.progressBarFill,
143
+ { width: `${Math.round(progress * 100)}%` },
144
+ ] }) }), _jsxs(Text, { style: styles.progressText, children: [status === UpdateStatus.DOWNLOADING
145
+ ? "Downloading..."
146
+ : "Applying...", " ", Math.round(progress * 100), "%"] })] })) : (_jsxs(View, { style: styles.footer, children: [!update?.mandatory && (_jsx(TouchableOpacity, { style: styles.ignoreBtn, onPress: ignoreInstall, children: _jsx(Text, { style: styles.ignoreText, children: "Later" }) })), _jsx(TouchableOpacity, { style: styles.installBtn, onPress: confirmInstall, children: _jsx(Text, { style: styles.installText, children: "Install Now" }) })] }))] })] }));
147
+ }
148
+ };
149
+ // ✅ Overlay justifyContent is conditional per UI type
150
+ const overlayStyle = [
151
+ styles.overlay,
152
+ selectedUI === UpdateUIType.FULL_SCREEN && styles.overlayFullScreen,
153
+ selectedUI === UpdateUIType.BOTTOM_SHEET && styles.overlayBottomSheet,
154
+ ];
155
+ return (_jsxs(View, { style: { flex: 1 }, children: [_jsx(WrappedComponent, { ...props }), _jsx(Modal, { visible: showModal, transparent: true, animationType: selectedUI === UpdateUIType.BOTTOM_SHEET ? "slide" : "fade", statusBarTranslucent: true, children: _jsx(View, { style: overlayStyle, children: renderUI() }) })] }));
156
+ };
157
+ };
158
+ }
159
+ // ─── Styles ──────────────────────────────────────────────────────────────────
160
+ const styles = StyleSheet.create({
161
+ // OVERLAY — base (centered)
162
+ overlay: {
163
+ flex: 1,
164
+ backgroundColor: "rgba(0,0,0,0.85)",
165
+ justifyContent: "center",
166
+ alignItems: "center",
167
+ },
168
+ // ✅ Full-screen variant — no backdrop, fills screen
169
+ overlayFullScreen: {
170
+ backgroundColor: "#080B14",
171
+ justifyContent: "center",
172
+ alignItems: "center",
173
+ },
174
+ // ✅ Bottom sheet variant — aligns to bottom, not center
175
+ overlayBottomSheet: {
176
+ justifyContent: "flex-end",
177
+ alignItems: "stretch",
178
+ backgroundColor: "rgba(0,0,0,0.5)",
179
+ },
180
+ // ── PREMIUM_DARK ────────────────────────────────────────────────────────
181
+ card: {
182
+ width: width * 0.85,
183
+ backgroundColor: "#161E2E",
184
+ borderRadius: 32,
185
+ borderWidth: 1,
186
+ borderColor: "rgba(255,255,255,0.1)",
187
+ overflow: "hidden",
188
+ paddingTop: 12,
189
+ },
190
+ glow: {
191
+ position: "absolute",
192
+ top: -100,
193
+ left: "25%",
194
+ right: "25%",
195
+ height: 150,
196
+ backgroundColor: "#6366F1",
197
+ opacity: 0.1,
198
+ borderRadius: 100,
199
+ transform: [{ scaleX: 2 }],
200
+ },
201
+ content: { padding: 24, alignItems: "center" },
202
+ iconBox: {
203
+ width: 64,
204
+ height: 64,
205
+ borderRadius: 22,
206
+ backgroundColor: "rgba(99,102,241,0.15)",
207
+ justifyContent: "center",
208
+ alignItems: "center",
209
+ marginBottom: 20,
210
+ borderWidth: 1,
211
+ borderColor: "rgba(99,102,241,0.3)",
212
+ },
213
+ icon: { fontSize: 32 },
214
+ title: {
215
+ color: "#FFFFFF",
216
+ fontSize: 22,
217
+ fontWeight: "900",
218
+ textAlign: "center",
219
+ marginBottom: 6,
220
+ },
221
+ version: {
222
+ color: "#6366F1",
223
+ fontSize: 14,
224
+ fontWeight: "800",
225
+ marginBottom: 20,
226
+ backgroundColor: "rgba(99,102,241,0.1)",
227
+ paddingHorizontal: 12,
228
+ paddingVertical: 4,
229
+ borderRadius: 12,
230
+ },
231
+ descBox: {
232
+ backgroundColor: "rgba(255,255,255,0.03)",
233
+ padding: 16,
234
+ borderRadius: 18,
235
+ width: "100%",
236
+ marginBottom: 32,
237
+ },
238
+ description: {
239
+ color: "#94A3B8",
240
+ fontSize: 14,
241
+ textAlign: "center",
242
+ lineHeight: 22,
243
+ },
244
+ footer: { flexDirection: "row", width: "100%", gap: 12 },
245
+ ignoreBtn: {
246
+ flex: 1,
247
+ height: 58,
248
+ borderRadius: 18,
249
+ justifyContent: "center",
250
+ alignItems: "center",
251
+ backgroundColor: "rgba(255,255,255,0.03)",
252
+ },
253
+ ignoreText: { color: "#94A3B8", fontSize: 16, fontWeight: "700" },
254
+ installBtn: {
255
+ flex: 2,
256
+ height: 58,
257
+ borderRadius: 18,
258
+ justifyContent: "center",
259
+ alignItems: "center",
260
+ backgroundColor: "#6366F1",
261
+ },
262
+ installText: { color: "#FFFFFF", fontSize: 16, fontWeight: "900" },
263
+ progressContainer: { width: "100%", alignItems: "center" },
264
+ progressBarBg: {
265
+ width: "100%",
266
+ height: 8,
267
+ backgroundColor: "rgba(255,255,255,0.05)",
268
+ borderRadius: 4,
269
+ overflow: "hidden",
270
+ marginBottom: 12,
271
+ },
272
+ progressBarFill: {
273
+ height: "100%",
274
+ backgroundColor: "#6366F1",
275
+ borderRadius: 4,
276
+ },
277
+ progressText: {
278
+ color: "#6366F1",
279
+ fontSize: 12,
280
+ fontWeight: "800",
281
+ textTransform: "uppercase",
282
+ letterSpacing: 1,
283
+ },
284
+ // ── GLASS ───────────────────────────────────────────────────────────────
285
+ glassCard: {
286
+ width: width * 0.88,
287
+ borderRadius: 36,
288
+ padding: 32,
289
+ borderWidth: 1,
290
+ borderColor: "rgba(255,255,255,0.2)",
291
+ alignItems: "center",
292
+ },
293
+ glassTitle: {
294
+ color: "#FFF",
295
+ fontSize: 24,
296
+ fontWeight: "900",
297
+ marginBottom: 16,
298
+ },
299
+ glassDesc: {
300
+ color: "rgba(255,255,255,0.7)",
301
+ fontSize: 16,
302
+ textAlign: "center",
303
+ marginBottom: 32,
304
+ },
305
+ glassActions: { width: "100%", gap: 12 },
306
+ glassBtnPrimary: {
307
+ backgroundColor: "#FFF",
308
+ height: 60,
309
+ borderRadius: 20,
310
+ alignItems: "center",
311
+ justifyContent: "center",
312
+ },
313
+ glassBtnSecondary: {
314
+ backgroundColor: "rgba(255,255,255,0.1)",
315
+ height: 60,
316
+ borderRadius: 20,
317
+ alignItems: "center",
318
+ justifyContent: "center",
319
+ },
320
+ // ✅ Split into two styles — primary is dark text on white, secondary is white text
321
+ glassBtnTextPrimary: { color: "#000", fontSize: 16, fontWeight: "800" },
322
+ glassBtnTextSecondary: { color: "#FFF", fontSize: 16, fontWeight: "700" },
323
+ // ── MINIMAL ─────────────────────────────────────────────────────────────
324
+ minimalCard: {
325
+ width: width * 0.9,
326
+ backgroundColor: "#FFF",
327
+ borderRadius: 24,
328
+ padding: 24,
329
+ },
330
+ minimalHeader: {
331
+ flexDirection: "row",
332
+ justifyContent: "space-between",
333
+ alignItems: "center",
334
+ marginBottom: 12,
335
+ },
336
+ minimalTitle: { fontSize: 18, fontWeight: "800", color: "#111" },
337
+ minimalVersion: {
338
+ fontSize: 12,
339
+ fontWeight: "700",
340
+ color: "#6366F1",
341
+ backgroundColor: "#EEF2FF",
342
+ paddingHorizontal: 8,
343
+ paddingVertical: 2,
344
+ borderRadius: 6,
345
+ },
346
+ minimalDesc: {
347
+ fontSize: 14,
348
+ color: "#666",
349
+ lineHeight: 20,
350
+ marginBottom: 24,
351
+ },
352
+ minimalFooter: {
353
+ flexDirection: "row",
354
+ justifyContent: "space-between",
355
+ alignItems: "center",
356
+ },
357
+ minimalLink: { color: "#999", fontSize: 14, fontWeight: "600" },
358
+ minimalBtn: {
359
+ backgroundColor: "#111",
360
+ paddingHorizontal: 20,
361
+ paddingVertical: 12,
362
+ borderRadius: 12,
363
+ },
364
+ minimalBtnText: { color: "#FFF", fontWeight: "800" },
365
+ // ✅ Track added so fill is visible at 0%
366
+ minimalProgressTrack: {
367
+ height: 4,
368
+ backgroundColor: "#F3F4F6",
369
+ borderRadius: 2,
370
+ overflow: "hidden",
371
+ marginBottom: 16,
372
+ },
373
+ minimalBar: { height: "100%", backgroundColor: "#6366F1", borderRadius: 2 },
374
+ // ── BOTTOM SHEET ────────────────────────────────────────────────────────
375
+ // ✅ Wrapper fills width and pushes sheet to bottom via overlay's flex-end
376
+ sheetWrapper: { width: "100%" },
377
+ sheet: {
378
+ backgroundColor: "#FFF",
379
+ borderTopLeftRadius: 32,
380
+ borderTopRightRadius: 32,
381
+ padding: 32,
382
+ paddingBottom: Platform.OS === "ios" ? 48 : 32,
383
+ },
384
+ sheetHandle: {
385
+ width: 40,
386
+ height: 5,
387
+ backgroundColor: "#E5E7EB",
388
+ borderRadius: 3,
389
+ alignSelf: "center",
390
+ marginBottom: 24,
391
+ },
392
+ sheetTitle: {
393
+ fontSize: 22,
394
+ fontWeight: "900",
395
+ color: "#111",
396
+ marginBottom: 12,
397
+ },
398
+ sheetDesc: {
399
+ fontSize: 15,
400
+ color: "#4B5563",
401
+ lineHeight: 22,
402
+ marginBottom: 32,
403
+ },
404
+ sheetActions: { gap: 12 },
405
+ sheetBtnPrimary: {
406
+ backgroundColor: "#6366F1",
407
+ height: 60,
408
+ borderRadius: 20,
409
+ alignItems: "center",
410
+ justifyContent: "center",
411
+ },
412
+ sheetBtnSecondary: {
413
+ backgroundColor: "#F3F4F6",
414
+ height: 60,
415
+ borderRadius: 20,
416
+ alignItems: "center",
417
+ justifyContent: "center",
418
+ },
419
+ sheetBtnTextPrimary: { color: "#FFF", fontSize: 16, fontWeight: "800" },
420
+ sheetBtnTextSecondary: {
421
+ color: "#4B5563",
422
+ fontSize: 16,
423
+ fontWeight: "700",
424
+ },
425
+ sheetLoading: { alignItems: "center", gap: 12 },
426
+ sheetLoadingText: { color: "#6366F1", fontWeight: "800" },
427
+ // ── FULL SCREEN ─────────────────────────────────────────────────────────
428
+ fullScreen: {
429
+ flex: 1,
430
+ width: "100%",
431
+ backgroundColor: "#080B14",
432
+ alignItems: "center",
433
+ justifyContent: "center",
434
+ padding: 40,
435
+ },
436
+ fullScreenGlow: {
437
+ position: "absolute",
438
+ width: width * 1.5,
439
+ height: width * 1.5,
440
+ backgroundColor: "#6366F1",
441
+ opacity: 0.05,
442
+ borderRadius: width,
443
+ },
444
+ fullScreenEmoji: { fontSize: 64, marginBottom: 40 },
445
+ fullScreenTitle: {
446
+ color: "#FFF",
447
+ fontSize: 32,
448
+ fontWeight: "900",
449
+ textAlign: "center",
450
+ marginBottom: 20,
451
+ },
452
+ fullScreenDesc: {
453
+ color: "#94A3B8",
454
+ fontSize: 17,
455
+ textAlign: "center",
456
+ lineHeight: 26,
457
+ marginBottom: 60,
458
+ },
459
+ fullScreenFooter: { width: "100%", alignItems: "center" },
460
+ fullScreenBtn: {
461
+ backgroundColor: "#6366F1",
462
+ width: "100%",
463
+ height: 64,
464
+ borderRadius: 22,
465
+ alignItems: "center",
466
+ justifyContent: "center",
467
+ },
468
+ fullScreenBtnText: { color: "#FFF", fontSize: 18, fontWeight: "900" },
469
+ fullScreenMinor: { marginTop: 24 },
470
+ fullScreenMinorText: { color: "#475569", fontSize: 16, fontWeight: "600" },
471
+ // ✅ Proper progress track + fill structure
472
+ fullScreenProgressWrapper: { width: "100%", alignItems: "center" },
473
+ fullScreenProgressTrack: {
474
+ width: "100%",
475
+ height: 6,
476
+ backgroundColor: "rgba(255,255,255,0.08)",
477
+ borderRadius: 3,
478
+ overflow: "hidden",
479
+ marginBottom: 16,
480
+ },
481
+ fullScreenBar: {
482
+ height: "100%",
483
+ backgroundColor: "#6366F1",
484
+ borderRadius: 3,
485
+ },
486
+ fullScreenProgressText: { color: "#6366F1", fontWeight: "900" },
487
+ });
@@ -0,0 +1,12 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ @interface AppSpacerCrashHandler : NSObject
6
+
7
+ + (void)start;
8
+ + (void)saveCrashToDiskSyncWithId:(NSString *)crashId payload:(NSString *)payload;
9
+
10
+ @end
11
+
12
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,90 @@
1
+ #import "AppSpacerCrashHandler.h"
2
+ #import <UIKit/UIKit.h>
3
+
4
+ static NSUncaughtExceptionHandler *defaultUncaughtExceptionHandler;
5
+ static BOOL isInitialized = NO;
6
+
7
+ // Function to synchronously format and save the crash
8
+ static void SaveNativeCrashToDisk(NSString *reason, NSArray *stackSymbols) {
9
+ NSString *crashId = [NSString stringWithFormat:@"native-%lld-%05d", (long long)([[NSDate date] timeIntervalSince1970] * 1000), arc4random_uniform(100000)];
10
+
11
+ NSMutableDictionary *crashData = [NSMutableDictionary dictionary];
12
+ crashData[@"id"] = crashId;
13
+ crashData[@"type"] = @"native";
14
+
15
+ NSMutableDictionary *payload = [NSMutableDictionary dictionary];
16
+ payload[@"message"] = reason;
17
+ payload[@"stack"] = [stackSymbols componentsJoinedByString:@"\n"];
18
+ payload[@"isFatal"] = @YES;
19
+
20
+ crashData[@"payload"] = [[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:payload options:0 error:nil] encoding:NSUTF8StringEncoding];
21
+
22
+ NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
23
+ formatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'";
24
+ formatter.timeZone = [NSTimeZone timeZoneWithName:@"UTC"];
25
+ crashData[@"timestamp"] = [formatter stringFromDate:[NSDate date]];
26
+
27
+ NSData *jsonData = [NSJSONSerialization dataWithJSONObject:crashData options:0 error:nil];
28
+ if (jsonData) {
29
+ NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
30
+ [AppSpacerCrashHandler saveCrashToDiskSyncWithId:crashId payload:jsonString];
31
+ }
32
+ }
33
+
34
+ // Exception Handler
35
+ static void AppSpacerUncaughtExceptionHandler(NSException *exception) {
36
+ NSString *reason = [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason];
37
+ SaveNativeCrashToDisk(reason, [exception callStackSymbols]);
38
+
39
+ if (defaultUncaughtExceptionHandler != NULL) {
40
+ defaultUncaughtExceptionHandler(exception);
41
+ }
42
+ }
43
+
44
+ // Signal Handler
45
+ static void AppSpacerSignalHandler(int signal) {
46
+ NSString *reason = [NSString stringWithFormat:@"Signal %d received", signal];
47
+ SaveNativeCrashToDisk(reason, [NSThread callStackSymbols]);
48
+
49
+ // We intentionally don't chain standard BSD signals here to avoid loops,
50
+ // the system will terminate the process anyway because we don't catch the signal
51
+ // a second time (SA_RESETHAND could be used natively, but for now we just exit)
52
+ exit(signal);
53
+ }
54
+
55
+ @implementation AppSpacerCrashHandler
56
+
57
+ + (void)start {
58
+ if (isInitialized) return;
59
+ isInitialized = YES;
60
+
61
+ defaultUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
62
+ NSSetUncaughtExceptionHandler(&AppSpacerUncaughtExceptionHandler);
63
+
64
+ signal(SIGABRT, AppSpacerSignalHandler);
65
+ signal(SIGILL, AppSpacerSignalHandler);
66
+ signal(SIGSEGV, AppSpacerSignalHandler);
67
+ signal(SIGFPE, AppSpacerSignalHandler);
68
+ signal(SIGBUS, AppSpacerSignalHandler);
69
+ signal(SIGPIPE, AppSpacerSignalHandler);
70
+ }
71
+
72
+ + (NSString *)crashDirectory {
73
+ NSString *cachesDirectory = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
74
+ NSString *crashPath = [cachesDirectory stringByAppendingPathComponent:@"appspacer_crashes"];
75
+
76
+ NSFileManager *fm = [NSFileManager defaultManager];
77
+ if (![fm fileExistsAtPath:crashPath]) {
78
+ [fm createDirectoryAtPath:crashPath withIntermediateDirectories:YES attributes:nil error:nil];
79
+ }
80
+ return crashPath;
81
+ }
82
+
83
+ + (void)saveCrashToDiskSyncWithId:(NSString *)crashId payload:(NSString *)payload {
84
+ NSString *dir = [self crashDirectory];
85
+ NSString *filePath = [dir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.json", crashId]];
86
+
87
+ [payload writeToFile:filePath atomically:YES encoding:NSUTF8StringEncoding error:nil];
88
+ }
89
+
90
+ @end