@hosty-sdk/app 0.22.1 → 0.23.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/README.md +12 -1
- package/dist/install-react.d.ts +8 -2
- package/dist/install-react.js +120 -39
- package/dist/install.d.ts +37 -3
- package/dist/install.js +31 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -176,23 +176,34 @@ Set `publicOrigin` to the Core-injected public origin for your UI endpoint when
|
|
|
176
176
|
exposes an internal request URL. The adapter checks browser `Origin` against this configured
|
|
177
177
|
value, or against the request URL when it is omitted; forwarded-host headers are never trusted.
|
|
178
178
|
|
|
179
|
-
The default dialog
|
|
179
|
+
The default dialog prepares installation and links to Core confirmation:
|
|
180
180
|
|
|
181
181
|
```tsx
|
|
182
182
|
"use client";
|
|
183
183
|
import { createInstallationClient } from "@hosty-sdk/app/install";
|
|
184
184
|
import { InstallDialog } from "@hosty-sdk/app/install/react";
|
|
185
|
+
import { openInstallationConfirmation } from "@hosty-sdk/app/install";
|
|
185
186
|
const client = createInstallationClient();
|
|
186
187
|
|
|
188
|
+
// In the Install click handler, before awaiting source preparation:
|
|
189
|
+
const confirmationWindow = openInstallationConfirmation();
|
|
187
190
|
// Render conditionally while open; unmount it when closed.
|
|
188
191
|
<InstallDialog
|
|
189
192
|
client={client}
|
|
193
|
+
confirmationWindow={confirmationWindow}
|
|
190
194
|
source={{ feedsUrl: "https://example.com/feeds.json", feedId: "stable" }}
|
|
191
195
|
onClose={() => setOpen(false)}
|
|
192
196
|
onInstalled={() => { setOpen(false); refreshInstalledApps(); }}
|
|
193
197
|
/>;
|
|
194
198
|
```
|
|
195
199
|
|
|
200
|
+
The default dialog immediately prepares the selected source and submits defaults. Core alone
|
|
201
|
+
asks for runtime, automatic startup and permissions. Choose the channel before opening the
|
|
202
|
+
dialog; changing the source creates a new request. Missing required configuration does not
|
|
203
|
+
prevent installation: configure the stopped app afterwards. The dialog shows preparation,
|
|
204
|
+
errors, status and an explicit confirmation link when the popup is blocked. An uncertain
|
|
205
|
+
submission retains its ID and offers status-only recovery.
|
|
206
|
+
|
|
196
207
|
For a custom UI, use `useInstallation(client)` or the framework-independent `InstallationFlow`.
|
|
197
208
|
For direct API use, the same client provides the full request sequence:
|
|
198
209
|
|
package/dist/install-react.d.ts
CHANGED
|
@@ -5,12 +5,18 @@ export declare function useInstallation(client: InstallationClient): {
|
|
|
5
5
|
request: InstallationRequest | null;
|
|
6
6
|
busy: boolean;
|
|
7
7
|
error: string | null;
|
|
8
|
+
uncertainSubmit: boolean;
|
|
8
9
|
};
|
|
9
10
|
export interface InstallDialogProps {
|
|
10
11
|
client: InstallationClient;
|
|
12
|
+
/** Choose the source/channel before opening. Core freezes that selection during preparation. */
|
|
11
13
|
source?: InstallationSource;
|
|
14
|
+
/** Open synchronously in the Install click handler, before mounting or fetching a source. */
|
|
15
|
+
confirmationWindow?: Window | null;
|
|
12
16
|
onClose: () => void;
|
|
17
|
+
/** Return to the caller's source/connection picker after a known pre-confirmation error. */
|
|
18
|
+
onChooseSource?: () => void;
|
|
13
19
|
onInstalled?: (request: InstallationRequest) => void;
|
|
14
20
|
}
|
|
15
|
-
/**
|
|
16
|
-
export declare function InstallDialog(
|
|
21
|
+
/** One Core confirmation, with app-local preparation/progress only. No installation questionnaire. */
|
|
22
|
+
export declare function InstallDialog(props: InstallDialogProps): import("react").JSX.Element;
|
package/dist/install-react.js
CHANGED
|
@@ -15,29 +15,83 @@ export function useInstallation(client) {
|
|
|
15
15
|
}, [flow, state.request?.id, state.request?.status]);
|
|
16
16
|
return { ...state, flow };
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const popupOwners = new WeakMap();
|
|
19
|
+
/** One Core confirmation, with app-local preparation/progress only. No installation questionnaire. */
|
|
20
|
+
export function InstallDialog(props) {
|
|
21
|
+
// A changed source is a new review. Never apply an earlier channel's confirmation to it.
|
|
22
|
+
return _jsx(InstallationSession, { ...props }, JSON.stringify(props.source ?? null));
|
|
23
|
+
}
|
|
24
|
+
function InstallationSession({ client, source, confirmationWindow, onClose, onChooseSource, onInstalled }) {
|
|
25
|
+
const { request, busy, error, uncertainSubmit, flow } = useInstallation(client);
|
|
21
26
|
const dialog = useRef(null);
|
|
22
27
|
const titleId = useId();
|
|
23
|
-
const [manifestPath, setManifestPath] = useState(
|
|
24
|
-
const [
|
|
25
|
-
const [autostart, setAutostart] = useState(true);
|
|
26
|
-
const [draftPlanId, setDraftPlanId] = useState(null);
|
|
28
|
+
const [manifestPath, setManifestPath] = useState("");
|
|
29
|
+
const [presentationError, setPresentationError] = useState(null);
|
|
27
30
|
const notified = useRef(null);
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
31
|
+
const [initialSource] = useState(source);
|
|
32
|
+
const activePopup = useRef(confirmationWindow ?? null);
|
|
33
|
+
const popupOwner = useRef({});
|
|
34
|
+
const popupShown = useRef(false);
|
|
35
|
+
const mountLease = useRef(0);
|
|
36
|
+
const hasSource = Boolean(initialSource?.manifestPath || initialSource?.feedsUrl);
|
|
37
|
+
const frozen = uncertainSubmit || (request !== null && request.status !== "draft");
|
|
38
|
+
const claimPopup = (popup) => {
|
|
39
|
+
activePopup.current = popup;
|
|
40
|
+
popupShown.current = false;
|
|
41
|
+
if (popup)
|
|
42
|
+
popupOwners.set(popup, popupOwner.current);
|
|
43
|
+
};
|
|
44
|
+
const closeUnusedPopup = (popup = activePopup.current) => {
|
|
45
|
+
if (!popup || popupShown.current || popupOwners.get(popup) !== popupOwner.current)
|
|
46
|
+
return;
|
|
47
|
+
popupOwners.delete(popup);
|
|
48
|
+
popup.close();
|
|
49
|
+
};
|
|
50
|
+
const showConfirmation = (popup, submitted) => {
|
|
51
|
+
// A newer source session may now own the caller's preopened window.
|
|
52
|
+
if (popup && popupOwners.get(popup) !== popupOwner.current)
|
|
53
|
+
return;
|
|
54
|
+
if (!submitted || submitted.status !== "pending") {
|
|
55
|
+
closeUnusedPopup(popup);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
showInstallationConfirmation(popup, submitted);
|
|
60
|
+
popupShown.current = Boolean(popup && !popup.closed);
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
closeUnusedPopup(popup);
|
|
64
|
+
setPresentationError(cause instanceof Error ? cause.message : "Core confirmation could not be opened.");
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const lease = ++mountLease.current;
|
|
69
|
+
claimPopup(activePopup.current);
|
|
70
|
+
return () => {
|
|
71
|
+
// StrictMode immediately replays setup. Defer cleanup so that replay, or a new source
|
|
72
|
+
// session adopting the same window, can retain the reservation.
|
|
73
|
+
queueMicrotask(() => { if (mountLease.current === lease)
|
|
74
|
+
closeUnusedPopup(); });
|
|
75
|
+
};
|
|
76
|
+
}, [flow]);
|
|
37
77
|
useEffect(() => {
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
78
|
+
if (!initialSource?.manifestPath && !initialSource?.feedsUrl)
|
|
79
|
+
return;
|
|
80
|
+
let cancelled = false;
|
|
81
|
+
void (async () => {
|
|
82
|
+
await flow.review(initialSource);
|
|
83
|
+
if (cancelled)
|
|
84
|
+
return;
|
|
85
|
+
if (flow.snapshot().request?.status !== "draft") {
|
|
86
|
+
closeUnusedPopup();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const submitted = await flow.submit();
|
|
90
|
+
if (!cancelled)
|
|
91
|
+
showConfirmation(activePopup.current, submitted);
|
|
92
|
+
})();
|
|
93
|
+
return () => { cancelled = true; };
|
|
94
|
+
}, [flow, initialSource]);
|
|
41
95
|
useEffect(() => {
|
|
42
96
|
const element = dialog.current;
|
|
43
97
|
element?.showModal();
|
|
@@ -49,28 +103,55 @@ export function InstallDialog({ client, source, onClose, onInstalled }) {
|
|
|
49
103
|
onInstalled?.(request);
|
|
50
104
|
}
|
|
51
105
|
}, [request, onInstalled]);
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
});
|
|
57
|
-
const submit = async () => {
|
|
106
|
+
const install = async () => {
|
|
107
|
+
if (busy || frozen)
|
|
108
|
+
return;
|
|
109
|
+
// The browser gesture is still active here, before any async preparation.
|
|
58
110
|
const popup = openInstallationConfirmation();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
claimPopup(popup);
|
|
112
|
+
setPresentationError(null);
|
|
113
|
+
await flow.review({ manifestPath: manifestPath.trim() });
|
|
114
|
+
if (flow.snapshot().request?.status !== "draft") {
|
|
115
|
+
closeUnusedPopup(popup);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
showConfirmation(popup, await flow.submit());
|
|
66
119
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
120
|
+
const retry = async () => {
|
|
121
|
+
setPresentationError(null);
|
|
122
|
+
if (uncertainSubmit) {
|
|
123
|
+
await flow.refresh();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const popup = openInstallationConfirmation();
|
|
127
|
+
claimPopup(popup);
|
|
128
|
+
// A known draft submit failure retries its existing identity, without resolving the feed again.
|
|
129
|
+
if (!request)
|
|
130
|
+
await flow.review(initialSource ?? { manifestPath: manifestPath.trim() });
|
|
131
|
+
if (flow.snapshot().request?.status !== "draft") {
|
|
132
|
+
closeUnusedPopup(popup);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
showConfirmation(popup, await flow.submit());
|
|
136
|
+
};
|
|
137
|
+
const close = () => {
|
|
138
|
+
closeUnusedPopup();
|
|
139
|
+
onClose();
|
|
140
|
+
};
|
|
141
|
+
const confirmationUrl = (() => {
|
|
142
|
+
if (request?.status !== "pending")
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
const url = new URL(request.approvalUrl);
|
|
146
|
+
return ["http:", "https:"].includes(url.protocol) ? url.href : null;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
return _jsxs("dialog", { ref: dialog, className: "hosty-install", "aria-labelledby": titleId, onCancel: event => { event.preventDefault(); close(); }, children: [_jsx("style", { children: styles }), _jsxs("header", { children: [_jsx("h2", { id: titleId, children: "Install app" }), _jsx("button", { type: "button", onClick: close, "aria-label": "Close installation", children: "\u00D7" })] }), !hasSource && !request && _jsxs("form", { onSubmit: event => { event.preventDefault(); void install(); }, children: [_jsxs("label", { children: ["Manifest, app directory, or URL", _jsx("input", { value: manifestPath, onChange: event => setManifestPath(event.target.value), required: true, disabled: busy })] }), _jsx("button", { className: "primary", disabled: busy || !manifestPath.trim(), children: "Install" })] }), busy && _jsx("p", { role: "status", children: "Preparing Core confirmation\u2026" }), (error || presentationError) && _jsxs("div", { role: "alert", children: [_jsx("p", { children: error || presentationError }), (!frozen || uncertainSubmit) && _jsx("button", { type: "button", disabled: busy, onClick: () => void retry(), children: uncertainSubmit ? "Check request status" : "Retry" }), !frozen && onChooseSource && _jsx("button", { type: "button", disabled: busy, onClick: () => { closeUnusedPopup(); onChooseSource(); }, children: "Change source" })] }), uncertainSubmit && _jsx("p", { role: "status", children: "Core may have received this request. Check its status before continuing." }), request && request.status !== "draft" && _jsxs("section", { role: "status", children: [_jsx("p", { children: request.status === "pending" ? "Confirm runtime, automatic startup and permissions in Hosty Core." : request.status === "executing" ? "Core is installing your app…" : request.status === "succeeded" ? "App installed." : request.status === "denied" ? "Installation cancelled." : "Installation failed. Close this dialog to begin a new request." }), confirmationUrl && _jsx("a", { href: confirmationUrl, target: "_blank", rel: "noopener noreferrer", children: "Open Core confirmation" }), _jsx("p", { children: _jsx("small", { children: "Closing this dialog does not cancel an operation already confirmed in Core." }) })] })] });
|
|
72
153
|
}
|
|
73
154
|
const styles = `
|
|
74
|
-
.hosty-install{box-sizing:border-box;width:min(
|
|
75
|
-
.hosty-install::backdrop{background:#0008}.hosty-install header{display:flex;align-items:center;justify-content:space-between;gap:16px}.hosty-install h2{font-size:1.25rem;margin:0}.hosty-install
|
|
155
|
+
.hosty-install{box-sizing:border-box;width:min(480px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:24px;border:1px solid var(--border,#aaa);border-radius:12px;background:var(--background,Canvas);color:var(--foreground,CanvasText);font:inherit;overflow:auto;margin:auto}
|
|
156
|
+
.hosty-install::backdrop{background:#0008}.hosty-install header{display:flex;align-items:center;justify-content:space-between;gap:16px}.hosty-install h2{font-size:1.25rem;margin:0}.hosty-install p{margin:12px 0;font-size:.9rem}.hosty-install small{font-size:.8rem;opacity:.75}.hosty-install label{display:flex;flex-direction:column;gap:6px;margin:16px 0;font-size:.9rem}.hosty-install input{box-sizing:border-box;width:100%;border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 10px;background:transparent;color:inherit;font:inherit}.hosty-install button{border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 12px;font:inherit;background:transparent;color:inherit;cursor:pointer}.hosty-install button:disabled{opacity:.5;cursor:default}.hosty-install .primary{background:#2563eb;color:white;border-color:#2563eb}.hosty-install [role=alert]{color:#d33}.hosty-install a{color:#2563eb;text-decoration:underline}.hosty-install :focus-visible{outline:2px solid #60a5fa;outline-offset:3px}
|
|
76
157
|
`;
|
package/dist/install.d.ts
CHANGED
|
@@ -12,6 +12,16 @@ export interface InstallSetting {
|
|
|
12
12
|
label: string;
|
|
13
13
|
}[] | null;
|
|
14
14
|
}
|
|
15
|
+
export interface InstallationConfigurationReadiness {
|
|
16
|
+
required: boolean;
|
|
17
|
+
missingSettings: string[];
|
|
18
|
+
mounts: {
|
|
19
|
+
key: string;
|
|
20
|
+
label?: string | null;
|
|
21
|
+
reason: string;
|
|
22
|
+
}[];
|
|
23
|
+
error?: string | null;
|
|
24
|
+
}
|
|
15
25
|
export interface InstallPlan {
|
|
16
26
|
appId: string;
|
|
17
27
|
displayName: string;
|
|
@@ -37,6 +47,14 @@ export interface InstallPlan {
|
|
|
37
47
|
development?: boolean;
|
|
38
48
|
}[];
|
|
39
49
|
settings: InstallSetting[];
|
|
50
|
+
runtimeChoices?: {
|
|
51
|
+
key: string;
|
|
52
|
+
type: string;
|
|
53
|
+
default: boolean;
|
|
54
|
+
available: boolean;
|
|
55
|
+
error?: string | null;
|
|
56
|
+
}[];
|
|
57
|
+
configurationReadiness?: InstallationConfigurationReadiness | null;
|
|
40
58
|
}
|
|
41
59
|
export interface InstallationSource {
|
|
42
60
|
manifestPath?: string;
|
|
@@ -97,10 +115,22 @@ export interface InstallationRequest {
|
|
|
97
115
|
plan: InstallPlan | null;
|
|
98
116
|
updatePlan?: {
|
|
99
117
|
appId: string;
|
|
100
|
-
displayName
|
|
118
|
+
displayName?: string | null;
|
|
101
119
|
targetVersion: string;
|
|
102
120
|
targetRuntime: string;
|
|
103
121
|
changes: string[];
|
|
122
|
+
previousRequiredCorePermissions?: string[] | null;
|
|
123
|
+
previousOptionalCorePermissions?: string[] | null;
|
|
124
|
+
feedsUrl?: string | null;
|
|
125
|
+
feedId?: string | null;
|
|
126
|
+
targetRuntimeType?: string | null;
|
|
127
|
+
settingChanges?: {
|
|
128
|
+
key: string;
|
|
129
|
+
label?: string | null;
|
|
130
|
+
change: string;
|
|
131
|
+
detail?: string | null;
|
|
132
|
+
}[];
|
|
133
|
+
configurationReadiness?: InstallationConfigurationReadiness | null;
|
|
104
134
|
} | null;
|
|
105
135
|
permissionPlan?: {
|
|
106
136
|
appId: string;
|
|
@@ -128,7 +158,7 @@ export interface InstallationRequest {
|
|
|
128
158
|
}
|
|
129
159
|
export interface InstallationClient {
|
|
130
160
|
prepare(source: InstallationSource): Promise<InstallationRequest>;
|
|
131
|
-
submit(id: string, settings
|
|
161
|
+
submit(id: string, settings?: Record<string, string | null>, autostart?: boolean): Promise<InstallationRequest>;
|
|
132
162
|
status(id: string): Promise<InstallationRequest>;
|
|
133
163
|
}
|
|
134
164
|
export declare class InstallationError extends Error {
|
|
@@ -145,6 +175,8 @@ export interface InstallationState {
|
|
|
145
175
|
request: InstallationRequest | null;
|
|
146
176
|
busy: boolean;
|
|
147
177
|
error: string | null;
|
|
178
|
+
/** A submit may have succeeded; resolve the existing identity before another mutation. */
|
|
179
|
+
uncertainSubmit: boolean;
|
|
148
180
|
}
|
|
149
181
|
/** Reusable state flow for custom UIs, independent of React. Never automatically retries a mutation. */
|
|
150
182
|
export declare class InstallationFlow {
|
|
@@ -153,6 +185,8 @@ export declare class InstallationFlow {
|
|
|
153
185
|
private listeners;
|
|
154
186
|
private generation;
|
|
155
187
|
private disposed;
|
|
188
|
+
private submitting;
|
|
189
|
+
private refreshing;
|
|
156
190
|
constructor(client: InstallationClient);
|
|
157
191
|
snapshot: () => InstallationState;
|
|
158
192
|
subscribe: (listener: () => void) => (() => void);
|
|
@@ -161,7 +195,7 @@ export declare class InstallationFlow {
|
|
|
161
195
|
cancelPending(): void;
|
|
162
196
|
dispose(): void;
|
|
163
197
|
review(source: InstallationSource): Promise<void>;
|
|
164
|
-
submit(settings
|
|
198
|
+
submit(settings?: Record<string, string | null>, autostart?: boolean): Promise<InstallationRequest | null>;
|
|
165
199
|
refresh(): Promise<void>;
|
|
166
200
|
}
|
|
167
201
|
/** Open synchronously in a click handler, before awaiting plan/submit requests. */
|
package/dist/install.js
CHANGED
|
@@ -33,10 +33,12 @@ export function createInstallationClient(options = {}) {
|
|
|
33
33
|
/** Reusable state flow for custom UIs, independent of React. Never automatically retries a mutation. */
|
|
34
34
|
export class InstallationFlow {
|
|
35
35
|
client;
|
|
36
|
-
state = { request: null, busy: false, error: null };
|
|
36
|
+
state = { request: null, busy: false, error: null, uncertainSubmit: false };
|
|
37
37
|
listeners = new Set();
|
|
38
38
|
generation = 0;
|
|
39
39
|
disposed = false;
|
|
40
|
+
submitting = false;
|
|
41
|
+
refreshing = false;
|
|
40
42
|
constructor(client) {
|
|
41
43
|
this.client = client;
|
|
42
44
|
}
|
|
@@ -50,72 +52,88 @@ export class InstallationFlow {
|
|
|
50
52
|
listener();
|
|
51
53
|
}
|
|
52
54
|
clearReview() {
|
|
53
|
-
|
|
55
|
+
const status = this.state.request?.status;
|
|
56
|
+
if (this.submitting || this.state.uncertainSubmit || (status && !["draft", "succeeded", "denied", "failed"].includes(status)))
|
|
54
57
|
return;
|
|
55
58
|
this.generation++;
|
|
56
|
-
this.set({ request: null, busy: false, error: null });
|
|
59
|
+
this.set({ request: null, busy: false, error: null, uncertainSubmit: false });
|
|
57
60
|
}
|
|
58
61
|
cancelPending() { this.generation++; }
|
|
59
62
|
dispose() { this.disposed = true; this.generation++; this.listeners.clear(); }
|
|
60
63
|
async review(source) {
|
|
61
|
-
if (this.state.request && this.state.request.status !== "draft")
|
|
64
|
+
if (this.submitting || this.state.uncertainSubmit || (this.state.request && this.state.request.status !== "draft"))
|
|
62
65
|
return;
|
|
63
66
|
const generation = ++this.generation;
|
|
64
|
-
this.set({ request: null, busy: true, error: null });
|
|
67
|
+
this.set({ request: null, busy: true, error: null, uncertainSubmit: false });
|
|
65
68
|
try {
|
|
66
69
|
const request = await this.client.prepare(source);
|
|
67
70
|
if (generation === this.generation)
|
|
68
|
-
this.set({ request, busy: false, error: null });
|
|
71
|
+
this.set({ request, busy: false, error: null, uncertainSubmit: false });
|
|
69
72
|
}
|
|
70
73
|
catch (error) {
|
|
71
74
|
if (generation === this.generation)
|
|
72
|
-
this.set({ request: null, busy: false, error: message(error) });
|
|
75
|
+
this.set({ request: null, busy: false, error: message(error), uncertainSubmit: false });
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
async submit(settings, autostart) {
|
|
76
|
-
if (this.state.busy || this.state.request?.status !== "draft")
|
|
79
|
+
if (this.state.busy || this.state.uncertainSubmit || this.state.request?.status !== "draft")
|
|
77
80
|
return null;
|
|
78
81
|
const id = this.state.request.id;
|
|
79
82
|
const generation = this.generation;
|
|
83
|
+
this.submitting = true;
|
|
80
84
|
this.set({ ...this.state, busy: true, error: null });
|
|
81
85
|
try {
|
|
82
86
|
const request = await this.client.submit(id, settings, autostart);
|
|
83
87
|
if (generation !== this.generation)
|
|
84
88
|
return null;
|
|
85
|
-
this.set({ request, busy: false, error: null });
|
|
89
|
+
this.set({ request, busy: false, error: null, uncertainSubmit: false });
|
|
86
90
|
return request;
|
|
87
91
|
}
|
|
88
92
|
catch (error) {
|
|
93
|
+
if (error instanceof InstallationError && error.status >= 400 && error.status < 500) {
|
|
94
|
+
if (generation === this.generation)
|
|
95
|
+
this.set({ ...this.state, busy: false, error: message(error), uncertainSubmit: false });
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
89
98
|
// A lost response may mean the request was already frozen. Recover by reading its status,
|
|
90
99
|
// not by sending another submit or creating a second installation.
|
|
91
100
|
try {
|
|
92
101
|
const request = await this.client.status(id);
|
|
93
102
|
if (generation !== this.generation)
|
|
94
103
|
return null;
|
|
95
|
-
this.set({ request, busy: false, error: request.status === "draft" ? message(error) : null });
|
|
104
|
+
this.set({ request, busy: false, error: request.status === "draft" ? message(error) : null, uncertainSubmit: false });
|
|
96
105
|
return request.status === "draft" ? null : request;
|
|
97
106
|
}
|
|
98
107
|
catch {
|
|
99
108
|
if (generation === this.generation)
|
|
100
|
-
this.set({ ...this.state, busy: false, error: message(error) });
|
|
109
|
+
this.set({ ...this.state, busy: false, error: message(error), uncertainSubmit: true });
|
|
101
110
|
return null;
|
|
102
111
|
}
|
|
103
112
|
}
|
|
113
|
+
finally {
|
|
114
|
+
this.submitting = false;
|
|
115
|
+
}
|
|
104
116
|
}
|
|
105
117
|
async refresh() {
|
|
106
|
-
if (!this.state.request || this.state.busy)
|
|
118
|
+
if (!this.state.request || this.state.busy || this.refreshing)
|
|
107
119
|
return;
|
|
120
|
+
this.refreshing = true;
|
|
108
121
|
const id = this.state.request.id;
|
|
109
122
|
const generation = this.generation;
|
|
110
123
|
try {
|
|
111
124
|
const request = await this.client.status(id);
|
|
112
125
|
if (generation === this.generation)
|
|
113
|
-
this.set({ request, busy: false,
|
|
126
|
+
this.set({ request, busy: false,
|
|
127
|
+
error: request.error ?? (this.state.uncertainSubmit && request.status === "draft" ? "Core has not received the submission. Retry this request." : null),
|
|
128
|
+
uncertainSubmit: false });
|
|
114
129
|
}
|
|
115
130
|
catch (error) {
|
|
116
131
|
if (generation === this.generation)
|
|
117
132
|
this.set({ ...this.state, error: message(error) });
|
|
118
133
|
}
|
|
134
|
+
finally {
|
|
135
|
+
this.refreshing = false;
|
|
136
|
+
}
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
139
|
function message(error) { return error instanceof Error ? error.message : "Installation request failed."; }
|
package/package.json
CHANGED