@hosty-sdk/app 0.22.1 → 0.24.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 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 includes runtime selection, settings and a link to Core confirmation:
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
 
@@ -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
- /** Mount when opened; unmount on close. Styling is self-contained and has no Next/Tailwind dependency. */
16
- export declare function InstallDialog({ client, source, onClose, onInstalled }: InstallDialogProps): import("react").JSX.Element;
21
+ /** One Core confirmation, with app-local preparation/progress only. No installation questionnaire. */
22
+ export declare function InstallDialog(props: InstallDialogProps): import("react").JSX.Element;
@@ -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
- /** Mount when opened; unmount on close. Styling is self-contained and has no Next/Tailwind dependency. */
19
- export function InstallDialog({ client, source, onClose, onInstalled }) {
20
- const { request, busy, error, flow } = useInstallation(client);
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(source?.manifestPath ?? "");
24
- const [settings, setSettings] = useState({});
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 plan = request?.plan ?? null;
29
- if (plan && request?.id !== draftPlanId) {
30
- setDraftPlanId(request.id);
31
- setSettings(Object.fromEntries(plan.settings.map(setting => [setting.key, setting.secret ? "" : setting.defaultValue ?? ""])));
32
- setAutostart(plan.defaultAutostart ?? true);
33
- }
34
- const initialManifest = source?.manifestPath;
35
- const initialFeed = source?.feedsUrl;
36
- const initialFeedId = source?.feedId;
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 (initialManifest || initialFeed)
39
- void flow.review({ manifestPath: initialManifest, feedsUrl: initialFeed, feedId: initialFeedId });
40
- }, [flow, initialManifest, initialFeed, initialFeedId]);
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 frozen = request !== null && request.status !== "draft";
53
- const review = (runtime) => flow.review({
54
- ...(source?.feedsUrl ? { feedsUrl: source.feedsUrl, feedId: source.feedId } : { manifestPath: manifestPath.trim() }),
55
- selectedRuntime: runtime,
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
- const values = Object.fromEntries((plan?.settings ?? []).filter(setting => !setting.secret || settings[setting.key])
60
- .map(setting => [setting.key, settings[setting.key] ?? ""]));
61
- const submitted = await flow.submit(values, autostart);
62
- if (submitted)
63
- showInstallationConfirmation(popup, submitted);
64
- else
65
- popup?.close();
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
- return _jsxs("dialog", { ref: dialog, className: "hosty-install", "aria-labelledby": titleId, onCancel: event => { event.preventDefault(); onClose(); }, children: [_jsx("style", { children: styles }), _jsxs("header", { children: [_jsx("h2", { id: titleId, children: "Install app" }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close installation", children: "\u00D7" })] }), _jsx("p", { children: "Prepare the installation here, then confirm it securely in Hosty Core." }), !source?.feedsUrl && !source?.manifestPath && _jsxs("form", { onSubmit: event => { event.preventDefault(); void review(); }, children: [_jsxs("label", { children: ["Manifest, app directory, or URL", _jsx("input", { value: manifestPath, onChange: event => { setManifestPath(event.target.value); flow.clearReview(); }, required: true, disabled: busy || frozen })] }), _jsx("button", { disabled: busy || frozen || !manifestPath.trim(), children: "Review" })] }), busy && _jsx("p", { role: "status", children: "Preparing installation\u2026" }), error && _jsxs("div", { role: "alert", children: [_jsx("p", { children: error }), !frozen && _jsx("button", { type: "button", disabled: busy, onClick: () => void review(), children: "Retry review" })] }), plan && _jsxs("form", { onSubmit: event => { event.preventDefault(); void submit(); }, children: [_jsxs("h3", { children: [plan.displayName, " ", _jsx("small", { children: plan.targetVersion })] }), plan.description && _jsx("p", { children: plan.description }), _jsxs("label", { children: ["Runtime", _jsx("select", { value: plan.targetRuntime, disabled: busy || frozen, onChange: event => void review(event.target.value), children: (plan.runtimeProfiles ?? [{ key: plan.targetRuntime, type: plan.targetRuntimeType }]).map(runtime => _jsxs("option", { value: runtime.key, children: [runtime.key, " (", runtime.type, ")"] }, runtime.key)) })] }), plan.targetRuntimeType === "localCommand" && _jsx("p", { className: "warning", children: "This runtime runs commands directly on your host, outside a container. Only install code you trust." }), plan.system && _jsx("p", { className: "warning", children: "This is a system app. User access is controlled by Hosty assignments." }), !!plan.requestedRoles?.length && _jsxs("section", { children: [_jsx("h3", { children: "Requested provider roles" }), _jsx("ul", { children: plan.requestedRoles.map(role => _jsx("li", { children: plan.roleDescriptions?.[role] ?? role }, role)) })] }), !!plan.corePermissions?.length && _jsxs("section", { children: [_jsx("h3", { children: "Requested Core permissions" }), _jsx("ul", { children: plan.corePermissions.map(permission => _jsx("li", { children: plan.permissionDescriptions?.[permission] ?? permission }, permission)) })] }), plan.settings.map(setting => _jsxs("label", { children: [setting.label || setting.key, setting.required ? " (required)" : "", setting.type === "boolean" && !setting.secret
68
- ? _jsx("input", { type: "checkbox", checked: ["true", "1", "yes", "on", "enabled"].includes((settings[setting.key] ?? "").toLowerCase()), disabled: busy || frozen, onChange: event => setSettings(current => ({ ...current, [setting.key]: String(event.target.checked) })) })
69
- : setting.type === "select" && setting.options?.length && !setting.secret
70
- ? _jsxs("select", { value: settings[setting.key] ?? "", disabled: busy || frozen, onChange: event => setSettings(current => ({ ...current, [setting.key]: event.target.value })), children: [_jsx("option", { value: "", children: "Select a value" }), setting.options.map(option => _jsx("option", { value: option.value, children: option.label }, option.value))] })
71
- : _jsx("input", { type: setting.secret ? "password" : setting.type === "number" ? "number" : setting.type === "url" ? "url" : "text", step: setting.type === "number" ? "any" : undefined, autoComplete: setting.secret ? "new-password" : "off", required: setting.required && !setting.secret, disabled: busy || frozen, value: settings[setting.key] ?? "", onChange: event => setSettings(current => ({ ...current, [setting.key]: event.target.value })) }), setting.description && _jsx("small", { children: setting.description })] }, setting.key)), _jsxs("label", { className: "check", children: [_jsx("input", { type: "checkbox", checked: autostart, disabled: busy || frozen, onChange: event => setAutostart(event.target.checked) }), " Start automatically (now and on Core startup)"] }), !frozen && _jsx("footer", { children: _jsx("button", { className: "primary", disabled: busy, children: "Continue to Core confirmation" }) })] }), frozen && _jsxs("section", { role: "status", children: [_jsx("p", { children: request.status === "pending" ? "Waiting for your confirmation in Hosty Core." : request.status === "executing" ? "Core is applying your confirmed request…" : request.status === "succeeded" ? "App installed." : request.status === "denied" ? "Installation cancelled." : "Installation failed. Close this dialog and review a new plan to retry." }), request.status === "pending" && _jsx("a", { href: request.approvalUrl, 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." }) })] })] });
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(640px,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}
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 h3{font-size:1rem;margin:20px 0 8px}.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:not([type=checkbox]),.hosty-install select{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 .check{flex-direction:row;align-items:center;gap:10px}.hosty-install input[type=checkbox]{width:16px;height:16px;accent-color:#2563eb}.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 footer{display:flex;justify-content:flex-end;margin-top:24px}.hosty-install .warning{padding:12px;border:1px solid #b7791f;border-radius:6px}.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}
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: string;
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: Record<string, string | null>, autostart: boolean): Promise<InstallationRequest>;
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: Record<string, string | null>, autostart: boolean): Promise<InstallationRequest | null>;
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
- if (this.state.request && this.state.request.status !== "draft")
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, error: request.error ?? null });
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."; }
@@ -9,6 +9,12 @@ export type ProviderDescriptor = {
9
9
  capabilities: string[];
10
10
  url: string | null;
11
11
  available: boolean;
12
+ /** Declared browser surfaces for resolving assistant handoffs; absent on older Core versions. */
13
+ uiSurfaces?: Array<{
14
+ endpoint: string | null;
15
+ path: string;
16
+ url: string | null;
17
+ }> | null;
12
18
  };
13
19
  export type AppPermissionState = {
14
20
  required: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",