@glassly/engine 0.3.7-beta.889 → 0.3.7-beta.896
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/build/engine.d.ts +6 -0
- package/build/engine.d.ts.map +1 -1
- package/build/engine.js +3 -0
- package/build/engine.js.map +1 -1
- package/build/facades/backup.d.ts +14 -0
- package/build/facades/backup.d.ts.map +1 -0
- package/build/facades/backup.js +22 -0
- package/build/facades/backup.js.map +1 -0
- package/build/generated/releaseMetadata.js +3 -3
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/services/AppRegistry.d.ts +2 -1
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js +9 -2
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/AppStateBackup.d.ts +98 -0
- package/build/services/AppStateBackup.d.ts.map +1 -0
- package/build/services/AppStateBackup.js +224 -0
- package/build/services/AppStateBackup.js.map +1 -0
- package/build/services/CloudClientService.d.ts +1 -0
- package/build/services/CloudClientService.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +18 -4
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MiniappEngine.d.ts +9 -0
- package/build/services/MiniappEngine.d.ts.map +1 -1
- package/build/services/MiniappEngine.js +60 -27
- package/build/services/MiniappEngine.js.map +1 -1
- package/build/services/NavigationService.d.ts +1 -0
- package/build/services/NavigationService.d.ts.map +1 -1
- package/build/services/NavigationService.js +20 -6
- package/build/services/NavigationService.js.map +1 -1
- package/build/services/appStateBackupFile.d.ts +32 -0
- package/build/services/appStateBackupFile.d.ts.map +1 -0
- package/build/services/appStateBackupFile.js +135 -0
- package/build/services/appStateBackupFile.js.map +1 -0
- package/build/utils/miniappGlobals.d.ts +13 -17
- package/build/utils/miniappGlobals.d.ts.map +1 -1
- package/build/utils/miniappGlobals.js +24 -56
- package/build/utils/miniappGlobals.js.map +1 -1
- package/package.json +7 -7
- package/src/engine.ts +3 -0
- package/src/facades/backup.ts +28 -0
- package/src/generated/releaseMetadata.ts +3 -3
- package/src/index.ts +1 -0
- package/src/services/AppRegistry.ts +8 -3
- package/src/services/AppStateBackup.ts +283 -0
- package/src/services/LocalMiniappRuntime.ts +21 -4
- package/src/services/MiniappEngine.ts +62 -27
- package/src/services/NavigationService.ts +28 -8
- package/src/services/appStateBackupFile.ts +171 -0
- package/src/utils/miniappGlobals.ts +32 -64
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App state backup files: the share-sheet export and file-picker restore
|
|
3
|
+
* behind Settings > Profile > Backup. What goes in the file is decided in
|
|
4
|
+
* AppStateBackup.ts; this side reads and writes the live stores.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {MemoryImportResult} from "@glassly/miniapp"
|
|
8
|
+
import {File, Paths} from "expo-file-system"
|
|
9
|
+
import {Platform} from "react-native"
|
|
10
|
+
import Share from "react-native-share"
|
|
11
|
+
|
|
12
|
+
import {SETTINGS, useSettingsStore} from "../stores/settings"
|
|
13
|
+
import {useAppStatusStore} from "../stores/apps"
|
|
14
|
+
import {storage} from "../utils/storage/storage"
|
|
15
|
+
import appRegistry, {saveAlwaysOnOverride, saveWidgetExpandChoice} from "./AppRegistry"
|
|
16
|
+
import {
|
|
17
|
+
AppStateBackupError,
|
|
18
|
+
collectAppStateBackup,
|
|
19
|
+
miniappPrefs,
|
|
20
|
+
miniappsWithStorage,
|
|
21
|
+
parseAppStateBackup,
|
|
22
|
+
restorableSettings,
|
|
23
|
+
restoreMiniappStorage,
|
|
24
|
+
type AppStateBackupBackend,
|
|
25
|
+
} from "./AppStateBackup"
|
|
26
|
+
import {cloudClientService} from "./CloudClientService"
|
|
27
|
+
import {memoryStore} from "./memoryStoreInstance"
|
|
28
|
+
import {respawnMiniapp, runningMiniappPackages, stopMiniapp} from "./MiniappEngine"
|
|
29
|
+
|
|
30
|
+
export interface AppStateExportResult {
|
|
31
|
+
success: boolean
|
|
32
|
+
cancelled?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AppStateRestoreResult {
|
|
36
|
+
/** Settings written. */
|
|
37
|
+
settings: number
|
|
38
|
+
/** Miniapps whose storage was restored. */
|
|
39
|
+
miniapps: number
|
|
40
|
+
memory: MemoryImportResult | null
|
|
41
|
+
/** Installed when the backup was made but not on this phone now. */
|
|
42
|
+
missing: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AppStateRestoreOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Install what the backup had installed and this phone lacks. Runs before
|
|
48
|
+
* the restored miniapps start again, so the dashboard comes back with every
|
|
49
|
+
* widget provider its saved layout names: it drops unknown widgets from the
|
|
50
|
+
* layout the next time it saves it.
|
|
51
|
+
*/
|
|
52
|
+
reinstall?: (packageNames: string[]) => Promise<void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PICK_MIME_TYPES =
|
|
56
|
+
Platform.OS === "android" ? ["application/json", "application/octet-stream"] : ["application/json"]
|
|
57
|
+
|
|
58
|
+
const backend: AppStateBackupBackend = {
|
|
59
|
+
get(key) {
|
|
60
|
+
const result = storage.load<unknown>(key)
|
|
61
|
+
return result.is_ok() ? result.value : null
|
|
62
|
+
},
|
|
63
|
+
set(key, value) {
|
|
64
|
+
const result = storage.save(key, value)
|
|
65
|
+
if (result.is_error()) throw result.error
|
|
66
|
+
},
|
|
67
|
+
keys: () => storage.getAllKeys(),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function exportAppStateFile(): Promise<AppStateExportResult> {
|
|
71
|
+
const userId = await cloudClientService.resolveUserId()
|
|
72
|
+
const installed: Array<{packageName: string; version: string; source?: string}> = []
|
|
73
|
+
for (const app of await appRegistry.getInstalledMiniapps()) {
|
|
74
|
+
const version = await appRegistry.getActiveVersion(app.packageName)
|
|
75
|
+
if (!version) continue
|
|
76
|
+
installed.push({
|
|
77
|
+
packageName: app.packageName,
|
|
78
|
+
version,
|
|
79
|
+
source: appRegistry.getReleaseIdentity(app.packageName, version)?.source,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
const backup = collectAppStateBackup({
|
|
83
|
+
backend,
|
|
84
|
+
userId,
|
|
85
|
+
settings: Object.values(SETTINGS),
|
|
86
|
+
installed,
|
|
87
|
+
memory: await memoryStore.exportBackup(),
|
|
88
|
+
now: Date.now(),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const filename = `glassly-backup-${localDate(backup.exportedAt)}.json`
|
|
92
|
+
const file = new File(Paths.cache, filename)
|
|
93
|
+
file.writeSync(JSON.stringify(backup))
|
|
94
|
+
try {
|
|
95
|
+
await Share.open({url: file.uri, type: "application/json", filename, title: "Glassly backup"})
|
|
96
|
+
return {success: true}
|
|
97
|
+
} catch (error: any) {
|
|
98
|
+
// react-native-share rejects when the user dismisses the sheet.
|
|
99
|
+
if (error?.message?.includes("User did not share")) return {success: false, cancelled: true}
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resolves null when the user cancels the picker. */
|
|
105
|
+
export async function importAppStateFile(opts: AppStateRestoreOptions = {}): Promise<AppStateRestoreResult | null> {
|
|
106
|
+
const picked = await File.pickFileAsync({mimeTypes: PICK_MIME_TYPES})
|
|
107
|
+
if (picked.canceled) return null
|
|
108
|
+
let data: unknown
|
|
109
|
+
try {
|
|
110
|
+
data = JSON.parse(await picked.result.text())
|
|
111
|
+
} catch {
|
|
112
|
+
throw new AppStateBackupError("Not a Glassly app state backup")
|
|
113
|
+
}
|
|
114
|
+
const backup = parseAppStateBackup(data)
|
|
115
|
+
const userId = await cloudClientService.resolveUserId()
|
|
116
|
+
|
|
117
|
+
// First, so a memory section this build rejects fails the restore before anything is written.
|
|
118
|
+
const memory = backup.memory ? await memoryStore.importBackup(backup.memory) : null
|
|
119
|
+
|
|
120
|
+
const {plain, indexed} = restorableSettings(backup, Object.values(SETTINGS))
|
|
121
|
+
const written = await useSettingsStore.getState().setManyLocally(plain)
|
|
122
|
+
if (written.is_error()) throw written.error
|
|
123
|
+
// setManyLocally only takes registry keys; `key:index` values go to storage and the store directly.
|
|
124
|
+
for (const [key, value] of Object.entries(indexed)) backend.set(key, value)
|
|
125
|
+
if (Object.keys(indexed).length > 0) {
|
|
126
|
+
useSettingsStore.setState((state) => ({settings: {...state.settings, ...indexed}}))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A running miniapp holds its old values in memory and could write them back over the
|
|
130
|
+
// restore, so the ones getting storage stop now and start again at the end.
|
|
131
|
+
const running = new Set(runningMiniappPackages())
|
|
132
|
+
const restarting = miniappsWithStorage(backup).filter((packageName) => running.has(packageName))
|
|
133
|
+
for (const packageName of restarting) await stopMiniapp(packageName)
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const restored = restoreMiniappStorage(backend, userId, backup)
|
|
137
|
+
|
|
138
|
+
const apps = useAppStatusStore.getState()
|
|
139
|
+
for (const [packageName, record] of Object.entries(backup.miniapps)) {
|
|
140
|
+
const prefs = miniappPrefs(record)
|
|
141
|
+
if (prefs.hidden !== undefined) apps.setHiddenStatus(packageName, prefs.hidden)
|
|
142
|
+
if (prefs.alwaysOn !== undefined) saveAlwaysOnOverride(packageName, prefs.alwaysOn)
|
|
143
|
+
for (const [widgetId, expand] of Object.entries(prefs.widgetExpand)) {
|
|
144
|
+
saveWidgetExpandChoice(packageName, widgetId, expand)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
appRegistry.markRefreshNeeded()
|
|
148
|
+
await apps.refresh()
|
|
149
|
+
|
|
150
|
+
const installedNow = new Set((await appRegistry.getInstalledMiniapps()).map((app) => app.packageName))
|
|
151
|
+
const missing = Object.entries(backup.miniapps)
|
|
152
|
+
.filter(([packageName, record]) => record.installed && !installedNow.has(packageName))
|
|
153
|
+
.map(([packageName]) => packageName)
|
|
154
|
+
if (missing.length > 0) await opts.reinstall?.(missing)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
settings: Object.keys(plain).length + Object.keys(indexed).length,
|
|
158
|
+
miniapps: restored.packages.length,
|
|
159
|
+
memory,
|
|
160
|
+
missing,
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
for (const packageName of restarting) await respawnMiniapp(packageName)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function localDate(ms: number): string {
|
|
168
|
+
const d = new Date(ms)
|
|
169
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
170
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
171
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* same API surface.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {Platform} from "react-native"
|
|
11
11
|
|
|
12
12
|
export interface MiniappSafeArea {
|
|
13
13
|
top: number
|
|
@@ -16,6 +16,11 @@ export interface MiniappSafeArea {
|
|
|
16
16
|
right: number
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The host's capsule menu in the WebView's own coordinate space. `corner`
|
|
21
|
+
* floats over the page's top-right; `rail` sits in the iPhone Duo's status
|
|
22
|
+
* rail, inside the side safe-area inset and clear of the page content.
|
|
23
|
+
*/
|
|
19
24
|
export interface CapsuleMenuRect {
|
|
20
25
|
top: number
|
|
21
26
|
right: number
|
|
@@ -23,36 +28,7 @@ export interface CapsuleMenuRect {
|
|
|
23
28
|
left: number
|
|
24
29
|
width: number
|
|
25
30
|
height: number
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The capsule menu bounding rect in the WebView's own coordinate space.
|
|
30
|
-
*
|
|
31
|
-
* Keep in sync with CapsuleMenu.tsx:
|
|
32
|
-
* - CapsuleButton height ≈ h-7.5 (30px), width ≈ 73px
|
|
33
|
-
* - Positioned at right-2 (8px), top = theme.spacing.s2 (8px) below insets.top
|
|
34
|
-
*/
|
|
35
|
-
const CAPSULE_MENU_HEIGHT = 30
|
|
36
|
-
const CAPSULE_MENU_WIDTH = 73
|
|
37
|
-
const CAPSULE_MENU_PADDING = 8
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* @param topInsetOffset extra top offset to add when the WebView container does
|
|
41
|
-
* NOT already pad by the safe-area inset (i.e. WebView fills edge-to-edge under
|
|
42
|
-
* the status bar). Pass `insets.top` in that case, or 0 when the container
|
|
43
|
-
* already applies top padding.
|
|
44
|
-
*/
|
|
45
|
-
export function getCapsuleMenuRect(topInsetOffset = 0): CapsuleMenuRect {
|
|
46
|
-
const screenWidth = Dimensions.get("window").width
|
|
47
|
-
const top = topInsetOffset + CAPSULE_MENU_PADDING
|
|
48
|
-
return {
|
|
49
|
-
top,
|
|
50
|
-
right: CAPSULE_MENU_PADDING,
|
|
51
|
-
bottom: top + CAPSULE_MENU_HEIGHT,
|
|
52
|
-
left: screenWidth - CAPSULE_MENU_PADDING - CAPSULE_MENU_WIDTH,
|
|
53
|
-
width: CAPSULE_MENU_WIDTH,
|
|
54
|
-
height: CAPSULE_MENU_HEIGHT,
|
|
55
|
-
}
|
|
31
|
+
placement: "corner" | "rail"
|
|
56
32
|
}
|
|
57
33
|
|
|
58
34
|
export type MiniappColorScheme = "light" | "dark"
|
|
@@ -63,37 +39,48 @@ export interface BuildMiniappGlobalsOptions {
|
|
|
63
39
|
miniappLocal?: boolean
|
|
64
40
|
miniappDeveloperMode?: boolean
|
|
65
41
|
safeAreaInsets: MiniappSafeArea
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
* the status bar). In that case the capsule menu rect needs to shift down by
|
|
69
|
-
* the top inset so its coords match where it's actually drawn on screen.
|
|
70
|
-
*/
|
|
71
|
-
webviewFillsStatusBar?: boolean
|
|
42
|
+
/** Where the host draws its capsule menu over this WebView. */
|
|
43
|
+
capsuleMenu: CapsuleMenuRect
|
|
72
44
|
/** Current host color scheme. Miniapps may follow this to match the phone. */
|
|
73
45
|
colorScheme?: MiniappColorScheme
|
|
74
46
|
}
|
|
75
47
|
|
|
76
|
-
|
|
48
|
+
// CSS custom properties that mirror the safe-area and capsule menu data, so
|
|
49
|
+
// miniapp CSS (or Tailwind arbitrary values) can read them without touching
|
|
50
|
+
// JS: e.g. `style="margin-top: var(--glassly-capsule-top)"`.
|
|
51
|
+
function layoutCssVars(insets: MiniappSafeArea, capsule: CapsuleMenuRect): Record<string, string> {
|
|
77
52
|
return {
|
|
78
53
|
"--glassly-safe-top": `${insets.top}px`,
|
|
79
54
|
"--glassly-safe-bottom": `${insets.bottom}px`,
|
|
80
55
|
"--glassly-safe-left": `${insets.left}px`,
|
|
81
56
|
"--glassly-safe-right": `${insets.right}px`,
|
|
57
|
+
"--glassly-capsule-top": `${capsule.top}px`,
|
|
58
|
+
"--glassly-capsule-bottom": `${capsule.bottom}px`,
|
|
59
|
+
"--glassly-capsule-left": `${capsule.left}px`,
|
|
60
|
+
"--glassly-capsule-right": `${capsule.right}px`,
|
|
61
|
+
"--glassly-capsule-width": `${capsule.width}px`,
|
|
62
|
+
"--glassly-capsule-height": `${capsule.height}px`,
|
|
63
|
+
"--glassly-capsule-center-y": `${capsule.top + capsule.height / 2}px`,
|
|
64
|
+
// Right-side gutter to reserve so content doesn't slide under the
|
|
65
|
+
// capsule: its width + 16px breathing room, none when it sits in the rail.
|
|
66
|
+
"--glassly-capsule-gutter": `${capsule.placement === "rail" ? 0 : capsule.width + 16}px`,
|
|
82
67
|
}
|
|
83
68
|
}
|
|
84
69
|
|
|
85
70
|
/**
|
|
86
|
-
* JS to inject into a running miniapp when the host's safe area
|
|
87
|
-
* iPhone Duo folding
|
|
88
|
-
* CSS variables, then fires
|
|
71
|
+
* JS to inject into a running miniapp when the host's safe area or capsule
|
|
72
|
+
* menu moves (an iPhone Duo folding, or its rail's reserved regions growing):
|
|
73
|
+
* updates window.Glassly and the CSS variables, then fires
|
|
74
|
+
* `glassly:safe-area-change`.
|
|
89
75
|
*/
|
|
90
|
-
export function buildSafeAreaUpdateScript(insets: MiniappSafeArea): string {
|
|
91
|
-
const sets = Object.entries(
|
|
76
|
+
export function buildSafeAreaUpdateScript(insets: MiniappSafeArea, capsuleMenu: CapsuleMenuRect): string {
|
|
77
|
+
const sets = Object.entries(layoutCssVars(insets, capsuleMenu))
|
|
92
78
|
.map(([k, v]) => `document.documentElement.style.setProperty(${JSON.stringify(k)}, ${JSON.stringify(v)});`)
|
|
93
79
|
.join("\n ")
|
|
94
80
|
return `
|
|
95
81
|
window.Glassly = window.Glassly || {};
|
|
96
82
|
window.Glassly.safeAreaInsets = ${JSON.stringify(insets)};
|
|
83
|
+
window.Glassly.capsuleMenu = ${JSON.stringify(capsuleMenu)};
|
|
97
84
|
try {
|
|
98
85
|
${sets}
|
|
99
86
|
} catch (e) { /* ignore */ }
|
|
@@ -115,37 +102,18 @@ export function buildSafeAreaUpdateScript(insets: MiniappSafeArea): string {
|
|
|
115
102
|
* assign to it before the native bridge wires in.
|
|
116
103
|
*/
|
|
117
104
|
export function buildMiniappGlobalsScript(opts: BuildMiniappGlobalsOptions): string {
|
|
118
|
-
const capsuleTopOffset = opts.webviewFillsStatusBar ? opts.safeAreaInsets.top : 0
|
|
119
105
|
const globals: Record<string, unknown> = {
|
|
120
106
|
platform: Platform.OS,
|
|
121
107
|
capabilities: opts.capabilities ?? ["share", "open_url", "copy_clipboard", "download"],
|
|
122
108
|
safeAreaInsets: opts.safeAreaInsets,
|
|
123
|
-
capsuleMenu:
|
|
109
|
+
capsuleMenu: opts.capsuleMenu,
|
|
124
110
|
}
|
|
125
111
|
if (opts.packageName) globals.packageName = opts.packageName
|
|
126
112
|
if (opts.miniappLocal) globals.miniappLocal = true
|
|
127
113
|
if (opts.miniappDeveloperMode) globals.miniappDeveloperMode = true
|
|
128
114
|
if (opts.colorScheme) globals.colorScheme = opts.colorScheme
|
|
129
115
|
|
|
130
|
-
|
|
131
|
-
// Miniapp CSS (or Tailwind arbitrary values) can read these without
|
|
132
|
-
// touching JS: e.g. `style="margin-top: var(--glassly-capsule-top)"`.
|
|
133
|
-
const capsule = globals.capsuleMenu as CapsuleMenuRect
|
|
134
|
-
const insets = opts.safeAreaInsets
|
|
135
|
-
const capsuleCenter = capsule.top + capsule.height / 2
|
|
136
|
-
const cssVars: Record<string, string> = {
|
|
137
|
-
...safeAreaCssVars(insets),
|
|
138
|
-
"--glassly-capsule-top": `${capsule.top}px`,
|
|
139
|
-
"--glassly-capsule-bottom": `${capsule.bottom}px`,
|
|
140
|
-
"--glassly-capsule-left": `${capsule.left}px`,
|
|
141
|
-
"--glassly-capsule-right": `${capsule.right}px`,
|
|
142
|
-
"--glassly-capsule-width": `${capsule.width}px`,
|
|
143
|
-
"--glassly-capsule-height": `${capsule.height}px`,
|
|
144
|
-
"--glassly-capsule-center-y": `${capsuleCenter}px`,
|
|
145
|
-
// Right-side gutter to reserve so content doesn't slide under the
|
|
146
|
-
// capsule: capsule width + 16px breathing room.
|
|
147
|
-
"--glassly-capsule-gutter": `${capsule.width + 16}px`,
|
|
148
|
-
}
|
|
116
|
+
const cssVars = layoutCssVars(opts.safeAreaInsets, opts.capsuleMenu)
|
|
149
117
|
const cssVarsBlock = Object.entries(cssVars)
|
|
150
118
|
.map(([k, v]) => `${k}: ${v};`)
|
|
151
119
|
.join(" ")
|