@lotics/app-sdk 0.40.0 → 0.41.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.
- package/AGENTS.md +15 -10
- package/dist/src/router.js +53 -100
- package/dist/src/rpc.d.ts +8 -13
- package/dist/src/rpc.js +26 -39
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -78,8 +78,9 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
|
|
|
78
78
|
keeping a declared slice of state (filters, search, sort, the active tab) in the **address bar** so a
|
|
79
79
|
view survives refresh and is shareable/bookmarkable. `setValues` replaces in place. Build `shape` from
|
|
80
80
|
**`urlParam`** codecs. See *Save view-state to the URL*.
|
|
81
|
-
- **In-app routing** — **`AppRouter`** (from `@lotics/app-sdk/router`) → wrap a react-router route config
|
|
82
|
-
|
|
81
|
+
- **In-app routing** — **`AppRouter`** (from `@lotics/app-sdk/router`) → wrap a react-router route config;
|
|
82
|
+
the app owns its own url (embedded, that's the iframe's own url — never the host's), so navigation never
|
|
83
|
+
reloads the app, browser back/forward walk app screens, and screens stay shareable + refresh-survivable.
|
|
83
84
|
See *In-app navigation*.
|
|
84
85
|
- **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
|
|
85
86
|
query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
|
|
@@ -301,14 +302,18 @@ export default function App() {
|
|
|
301
302
|
|
|
302
303
|
Inside, use react-router normally — `useNavigate`, `useParams`, `<Link>`.
|
|
303
304
|
|
|
304
|
-
- **Embedded** (in the Lotics host): the
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
305
|
+
- **Embedded** (in the Lotics host): the app drives the **iframe's own url** (same-origin to itself) via
|
|
306
|
+
`pushState`. The user sees the host's address bar, never the iframe's, so the screen url is invisible — and
|
|
307
|
+
the host never sees it, so navigation never reloads the app. The iframe's history participates in the
|
|
308
|
+
session history, so the **browser Back/Forward buttons walk app screens** (then leave the app). `AppRouter`
|
|
309
|
+
also mirrors the current screen into the host url (a non-remounting write) and the host bakes it back into
|
|
310
|
+
the iframe src on load, so a screen is **shareable and survives a full refresh** — no reload, no extra code.
|
|
311
|
+
- **Standalone** (`<slug>.lotics.app`): a normal browser router — real path URLs, native browser back/forward,
|
|
312
|
+
refresh-survivable.
|
|
313
|
+
|
|
314
|
+
`useUrlState` is the complement: in-app *routing* (screens) is automatic via `AppRouter`; reach for
|
|
315
|
+
`useUrlState` for *filters/search* you want as first-class, typed, shareable query keys. `react-router-dom` is
|
|
316
|
+
an optional peer — only apps that import `@lotics/app-sdk/router` pull it in.
|
|
312
317
|
|
|
313
318
|
---
|
|
314
319
|
|
package/dist/src/router.js
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* In-app routing for custom-code apps — `AppRouter` lets an app use react-router
|
|
4
4
|
* normally (`useNavigate`, `useParams`, `<Link>`) while its screens become real,
|
|
5
|
-
* addressable URLs in both
|
|
5
|
+
* addressable URLs. The app owns its OWN url (a plain browser history) in both
|
|
6
|
+
* modes; the host url only ever *mirrors* the screen, it never drives the router:
|
|
6
7
|
*
|
|
7
|
-
* - **Standalone** (`<slug>.lotics.app`):
|
|
8
|
-
* URLs, native browser back/forward, deep-link/refresh via the app host's
|
|
9
|
-
* fallback.
|
|
10
|
-
* - **Embedded** (inside the Lotics host): the app
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
8
|
+
* - **Standalone** (`<slug>.lotics.app`): the page's own browser history — real
|
|
9
|
+
* path URLs, native browser back/forward, deep-link/refresh via the app host's
|
|
10
|
+
* SPA fallback.
|
|
11
|
+
* - **Embedded** (inside the Lotics host): the app drives the IFRAME's own url
|
|
12
|
+
* (the iframe is same-origin to itself) via `pushState`. The user sees the
|
|
13
|
+
* host's address bar, never the iframe's, and the host never sees the iframe's
|
|
14
|
+
* url — so it never navigates and never remounts the iframe. (Reflecting the
|
|
15
|
+
* screen into the host url with a *navigation* is what used to remount the app
|
|
16
|
+
* iframe and reload the whole app on every in-app navigation.) The iframe's
|
|
17
|
+
* history still participates in the session history, so browser Back/Forward
|
|
18
|
+
* walk app screens (then leave the app).
|
|
19
|
+
*
|
|
20
|
+
* To stay shareable + refresh-survivable, `AppRouter` *mirrors* the current
|
|
21
|
+
* screen into the host url under `_loc` via `setUrlParams` — a non-remounting
|
|
22
|
+
* `history.replaceState` on the host, never a navigation. The READ half is the
|
|
23
|
+
* host's job: on (re)load it bakes `_loc` into the iframe src, so the app boots
|
|
24
|
+
* *directly* at the saved screen. There's deliberately no async seed here —
|
|
25
|
+
* that would race the app's own first navigation; the iframe's initial url is
|
|
26
|
+
* the source of truth, set synchronously by the host.
|
|
15
27
|
*
|
|
16
28
|
* Shipped as a separate entry (`@lotics/app-sdk/router`) so apps that don't route
|
|
17
29
|
* never pull react-router into their bundle:
|
|
@@ -24,103 +36,44 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
24
36
|
* ]} />;
|
|
25
37
|
* }
|
|
26
38
|
*/
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
/** Host query key carrying the app's current screen
|
|
31
|
-
*
|
|
39
|
+
import { useEffect } from "react";
|
|
40
|
+
import { BrowserRouter, useLocation, useRoutes, } from "react-router-dom";
|
|
41
|
+
import { isEmbedded, setUrlParams } from "./rpc.js";
|
|
42
|
+
/** Host query key carrying the app's current screen, so it's shareable and the
|
|
43
|
+
* host can restore it on refresh. */
|
|
32
44
|
const LOC_KEY = "_loc";
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return { pathname: u.pathname, search: u.search, hash: u.hash, state: null, key: String(++locKey) };
|
|
45
|
+
/** The host handshake param the host puts on the iframe src — present on the
|
|
46
|
+
* initial url only, never part of a route, so it's stripped from the mirror. */
|
|
47
|
+
const HOST_KEY = "lotics_host";
|
|
48
|
+
function screenHref(loc) {
|
|
49
|
+
const search = new URLSearchParams(loc.search);
|
|
50
|
+
search.delete(HOST_KEY);
|
|
51
|
+
const qs = search.toString();
|
|
52
|
+
return loc.pathname + (qs ? `?${qs}` : "") + loc.hash;
|
|
42
53
|
}
|
|
43
54
|
/**
|
|
44
|
-
* Embedded
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
55
|
+
* Embedded only: mirror the current screen into the host url under `_loc` — a
|
|
56
|
+
* non-remounting `setUrlParams` → `history.replaceState`, never a navigation, so
|
|
57
|
+
* it never reloads the app. Write-only by design: the host reads `_loc` back and
|
|
58
|
+
* bakes it into the iframe src on (re)load, so the app already boots at the saved
|
|
59
|
+
* screen — no async read here, hence no seed-vs-navigation race. Standalone needs
|
|
60
|
+
* none of this — the app's own url already IS the screen.
|
|
49
61
|
*/
|
|
50
|
-
function
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
let seeded = false;
|
|
58
|
-
// `delta` is history metadata (scroll restoration / blockers), not used for
|
|
59
|
-
// rendering — best-effort per action, since the host doesn't report the exact
|
|
60
|
-
// index change on a broadcast.
|
|
61
|
-
function set(href, act, delta) {
|
|
62
|
-
seeded = true;
|
|
63
|
-
location = toLocation(href);
|
|
64
|
-
action = act;
|
|
65
|
-
for (const l of listeners)
|
|
66
|
-
l({ action, location, delta });
|
|
67
|
-
}
|
|
68
|
-
// Host back/forward (browser Back or our own `go`) → re-render to the broadcast
|
|
69
|
-
// location. Guarded so an already-current location doesn't re-fire.
|
|
70
|
-
subscribeUrlParams((params) => {
|
|
71
|
-
const raw = params[LOC_KEY];
|
|
72
|
-
const href = typeof raw === "string" ? raw : "/";
|
|
73
|
-
if (href !== hrefOf(location))
|
|
74
|
-
set(href, NavigationType.Pop, -1);
|
|
75
|
-
});
|
|
76
|
-
// Boot: seed from the host URL (resolves a tick after mount — first paint is
|
|
77
|
-
// "/"). Skip if the location was already driven, so a navigation that races the
|
|
78
|
-
// bridge read isn't clobbered.
|
|
79
|
-
void getUrlParams().then((params) => {
|
|
80
|
-
if (seeded)
|
|
81
|
-
return;
|
|
82
|
-
const raw = params[LOC_KEY];
|
|
83
|
-
if (typeof raw === "string" && raw !== hrefOf(location))
|
|
84
|
-
set(raw, NavigationType.Pop, 0);
|
|
85
|
-
});
|
|
86
|
-
return {
|
|
87
|
-
get action() {
|
|
88
|
-
return action;
|
|
89
|
-
},
|
|
90
|
-
get location() {
|
|
91
|
-
return location;
|
|
92
|
-
},
|
|
93
|
-
createHref: (to) => hrefOf(to),
|
|
94
|
-
createURL: (to) => new URL(hrefOf(to), window.location.origin),
|
|
95
|
-
encodeLocation: (to) => {
|
|
96
|
-
const u = new URL(hrefOf(to), "http://x");
|
|
97
|
-
return { pathname: u.pathname, search: u.search, hash: u.hash };
|
|
98
|
-
},
|
|
99
|
-
push: (to) => {
|
|
100
|
-
const href = hrefOf(to);
|
|
101
|
-
set(href, NavigationType.Push, 1);
|
|
102
|
-
void setUrlParams({ [LOC_KEY]: href }, true);
|
|
103
|
-
},
|
|
104
|
-
replace: (to) => {
|
|
105
|
-
const href = hrefOf(to);
|
|
106
|
-
set(href, NavigationType.Replace, 0);
|
|
107
|
-
void setUrlParams({ [LOC_KEY]: href }, false);
|
|
108
|
-
},
|
|
109
|
-
go: (delta) => {
|
|
110
|
-
void goUrlHistory(delta);
|
|
111
|
-
},
|
|
112
|
-
listen: (listener) => {
|
|
113
|
-
listeners.add(listener);
|
|
114
|
-
return () => listeners.delete(listener);
|
|
115
|
-
},
|
|
116
|
-
};
|
|
62
|
+
function HostScreenMirror() {
|
|
63
|
+
const location = useLocation();
|
|
64
|
+
const href = screenHref(location);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
void setUrlParams({ [LOC_KEY]: href });
|
|
67
|
+
}, [href]);
|
|
68
|
+
return null;
|
|
117
69
|
}
|
|
118
70
|
function RoutedRoutes({ routes }) {
|
|
119
71
|
return useRoutes(routes);
|
|
120
72
|
}
|
|
121
73
|
export function AppRouter({ routes }) {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
const
|
|
125
|
-
return (
|
|
74
|
+
// `isEmbedded()` reads the `?lotics_host=` the host puts on the iframe src, so
|
|
75
|
+
// it's known synchronously at first render.
|
|
76
|
+
const embedded = isEmbedded();
|
|
77
|
+
return (_jsxs(BrowserRouter, { children: [embedded ? _jsx(HostScreenMirror, {}) : null, _jsx(RoutedRoutes, { routes: routes })
|
|
78
|
+
] }));
|
|
126
79
|
}
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
|
19
19
|
* app → host: { id, op, payload }
|
|
20
20
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
21
21
|
*/
|
|
22
|
-
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "urlState.get" | "urlState.set" | "
|
|
22
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
23
23
|
/** Payload for starting a streaming agent run. */
|
|
24
24
|
export interface AgentRunPayload {
|
|
25
25
|
alias: string;
|
|
@@ -60,8 +60,8 @@ export interface AppContext {
|
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
62
|
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
|
63
|
-
* own `<slug>.lotics.app`).
|
|
64
|
-
*
|
|
63
|
+
* own `<slug>.lotics.app`). `rpc()`, `useUrlState`, and `AppRouter` use this to
|
|
64
|
+
* pick the transport / behaviour; an app rarely needs it directly.
|
|
65
65
|
*/
|
|
66
66
|
export declare function isEmbedded(): boolean;
|
|
67
67
|
export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
|
|
@@ -69,20 +69,15 @@ export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T
|
|
|
69
69
|
* the page's own query string. */
|
|
70
70
|
export declare function getUrlParams(): Promise<UrlParams>;
|
|
71
71
|
/** Merge `patch` into the host's address bar (each key set, or cleared when its
|
|
72
|
-
* value is `undefined`), preserving every other param.
|
|
73
|
-
* history
|
|
74
|
-
|
|
75
|
-
export declare function setUrlParams(patch: UrlParamsPatch, push?: boolean): Promise<void>;
|
|
76
|
-
/** Move the host's history by `delta` (the router adapter's back/forward). The
|
|
77
|
-
* resulting location arrives via the `url-state` broadcast, not the return. */
|
|
78
|
-
export declare function goUrlHistory(delta: number): Promise<void>;
|
|
72
|
+
* value is `undefined`), preserving every other param. The host writes in place
|
|
73
|
+
* (`history.replaceState`) — view-state changes don't add history entries. */
|
|
74
|
+
export declare function setUrlParams(patch: UrlParamsPatch): Promise<void>;
|
|
79
75
|
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
80
76
|
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
81
77
|
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
82
78
|
export declare function peekUrlParams(): UrlParams;
|
|
83
|
-
/** Subscribe to external query changes — back/forward
|
|
84
|
-
*
|
|
85
|
-
* `popstate`. */
|
|
79
|
+
/** Subscribe to external query changes — browser back/forward and edited URLs.
|
|
80
|
+
* Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
86
81
|
export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
|
|
87
82
|
/**
|
|
88
83
|
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
package/dist/src/rpc.js
CHANGED
|
@@ -20,8 +20,8 @@ function getHostOrigin() {
|
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
|
23
|
-
* own `<slug>.lotics.app`).
|
|
24
|
-
*
|
|
23
|
+
* own `<slug>.lotics.app`). `rpc()`, `useUrlState`, and `AppRouter` use this to
|
|
24
|
+
* pick the transport / behaviour; an app rarely needs it directly.
|
|
25
25
|
*/
|
|
26
26
|
export function isEmbedded() {
|
|
27
27
|
// Truthiness, not `!== null`: a present-but-empty `?lotics_host=` yields "",
|
|
@@ -35,31 +35,28 @@ export function rpc(op, payload) {
|
|
|
35
35
|
? rpcBridged(op, payload, hostOrigin)
|
|
36
36
|
: rpcStandalone(op, payload);
|
|
37
37
|
}
|
|
38
|
-
// ── URL state (the address bar as app view-state +
|
|
38
|
+
// ── URL state (the host address bar as shareable app view-state + screen) ────
|
|
39
39
|
//
|
|
40
|
-
// Two consumers
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// host URL directly, so reads/writes
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
40
|
+
// Two consumers: `useUrlState` (a declared, typed slice of filters/search) and
|
|
41
|
+
// `AppRouter`'s screen mirror (the current screen under `_loc`). Both keep app
|
|
42
|
+
// state in the host's address bar so it survives refresh and is shareable. An
|
|
43
|
+
// embedded app can't touch the cross-origin host URL directly, so reads/writes
|
|
44
|
+
// flow over the bridge: the host writes the params in place (a non-remounting
|
|
45
|
+
// `history.replaceState` — never a navigation, which would reload the iframe) and
|
|
46
|
+
// pushes external changes (browser back/forward) back via the `url-state`
|
|
47
|
+
// broadcast. A standalone app owns its top-level URL and the same ops resolve
|
|
48
|
+
// against `window.location`. (In-app *routing* is not here — the app owns its own
|
|
49
|
+
// url via `@lotics/app-sdk/router`; see that module.)
|
|
47
50
|
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
48
51
|
* the page's own query string. */
|
|
49
52
|
export function getUrlParams() {
|
|
50
53
|
return rpc("urlState.get", {});
|
|
51
54
|
}
|
|
52
55
|
/** Merge `patch` into the host's address bar (each key set, or cleared when its
|
|
53
|
-
* value is `undefined`), preserving every other param.
|
|
54
|
-
* history
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return rpc("urlState.set", { params: patch, push });
|
|
58
|
-
}
|
|
59
|
-
/** Move the host's history by `delta` (the router adapter's back/forward). The
|
|
60
|
-
* resulting location arrives via the `url-state` broadcast, not the return. */
|
|
61
|
-
export function goUrlHistory(delta) {
|
|
62
|
-
return rpc("urlState.go", { delta });
|
|
56
|
+
* value is `undefined`), preserving every other param. The host writes in place
|
|
57
|
+
* (`history.replaceState`) — view-state changes don't add history entries. */
|
|
58
|
+
export function setUrlParams(patch) {
|
|
59
|
+
return rpc("urlState.set", { params: patch });
|
|
63
60
|
}
|
|
64
61
|
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
65
62
|
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
@@ -67,9 +64,8 @@ export function goUrlHistory(delta) {
|
|
|
67
64
|
export function peekUrlParams() {
|
|
68
65
|
return isEmbedded() ? {} : parseSearch(window.location.search);
|
|
69
66
|
}
|
|
70
|
-
/** Subscribe to external query changes — back/forward
|
|
71
|
-
*
|
|
72
|
-
* `popstate`. */
|
|
67
|
+
/** Subscribe to external query changes — browser back/forward and edited URLs.
|
|
68
|
+
* Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
73
69
|
export function subscribeUrlParams(cb) {
|
|
74
70
|
if (isEmbedded()) {
|
|
75
71
|
ensureListener();
|
|
@@ -84,8 +80,8 @@ export function subscribeUrlParams(cb) {
|
|
|
84
80
|
}
|
|
85
81
|
const pending = new Map();
|
|
86
82
|
const streaming = new Map();
|
|
87
|
-
/** `useUrlState`
|
|
88
|
-
*
|
|
83
|
+
/** `useUrlState` subscribers — notified when the host broadcasts new params
|
|
84
|
+
* after browser back/forward. */
|
|
89
85
|
const urlStateSubscribers = new Set();
|
|
90
86
|
let nextRpcId = 0;
|
|
91
87
|
let listenerInstalled = false;
|
|
@@ -100,8 +96,8 @@ function ensureListener() {
|
|
|
100
96
|
const msg = event.data;
|
|
101
97
|
if (!msg)
|
|
102
98
|
return;
|
|
103
|
-
// Broadcast (no id): the host pushes new params after browser/
|
|
104
|
-
//
|
|
99
|
+
// Broadcast (no id): the host pushes new params after browser back/forward
|
|
100
|
+
// so `useUrlState` subscribers re-hydrate.
|
|
105
101
|
if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
|
|
106
102
|
for (const cb of urlStateSubscribers)
|
|
107
103
|
cb(msg.params);
|
|
@@ -404,12 +400,6 @@ function rpcStandalone(op, payload) {
|
|
|
404
400
|
return Promise.resolve(parseSearch(window.location.search));
|
|
405
401
|
case "urlState.set":
|
|
406
402
|
return standaloneUrlStateSet(payload);
|
|
407
|
-
case "urlState.go": {
|
|
408
|
-
// Standalone owns its own history — move it; popstate re-hydrates subscribers.
|
|
409
|
-
const { delta } = payload;
|
|
410
|
-
window.history.go(delta);
|
|
411
|
-
return Promise.resolve();
|
|
412
|
-
}
|
|
413
403
|
case "comments.list":
|
|
414
404
|
case "comments.create":
|
|
415
405
|
case "comments.update":
|
|
@@ -461,12 +451,9 @@ async function standaloneOpenExternal(p) {
|
|
|
461
451
|
async function standaloneUrlStateSet(p) {
|
|
462
452
|
const search = serializeMerge(window.location.search, p.params ?? {});
|
|
463
453
|
const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
window.history.pushState(null, "", url);
|
|
468
|
-
else
|
|
469
|
-
window.history.replaceState(null, "", url);
|
|
454
|
+
// View-state writes never add a history entry; replaceState doesn't fire
|
|
455
|
+
// popstate, so there's no echo — the caller already updated optimistically.
|
|
456
|
+
window.history.replaceState(null, "", url);
|
|
470
457
|
}
|
|
471
458
|
async function standaloneContext() {
|
|
472
459
|
const info = await resolveAppInfo();
|