Skip to content

Commit 36b4293

Browse files
authored
Inspector v2: Handle extension install errors (#16807)
A few people recently got hung up on extension install errors not currently being handled, particularly in the case where a previously installed extension can no longer be installed (such as the name changing). This PR adds error handling for these scenarios that works for our dev workflow as well as users. Also unrelated, but I updated the theme with a color ramp generated from the color used for icons in inspector v1. We can continue to tune of course, I just wanted to put something in place that is slightly closer to the Babylon colors. ![image](https://github.com/user-attachments/assets/e3d66afd-c4f0-472d-82d6-92cf5cb56caa)
1 parent d0d97bb commit 36b4293

File tree

4 files changed

+152
-52
lines changed

4 files changed

+152
-52
lines changed

packages/dev/inspector-v2/src/extensibility/extensionManager.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import type { IExtensionFeed, ExtensionMetadata, ExtensionModule } from "./exten
55

66
import { Logger } from "core/Misc/logger";
77

8-
import { Assert } from "../misc/assert";
9-
108
/**
119
* Represents a loaded extension.
1210
*/
@@ -44,6 +42,21 @@ export interface IExtension {
4442
addStateChangedHandler(handler: () => void): IDisposable;
4543
}
4644

45+
/**
46+
* Provides information about an extension installation failure.
47+
*/
48+
export type InstallFailedInfo = {
49+
/**
50+
* The metadata of the extension that failed to install.
51+
*/
52+
extension: ExtensionMetadata;
53+
54+
/**
55+
* The error that occurred during the installation.
56+
*/
57+
error: unknown;
58+
};
59+
4760
type InstalledExtension = {
4861
metadata: ExtensionMetadata;
4962
feed: IExtensionFeed;
@@ -91,18 +104,24 @@ export class ExtensionManager implements IDisposable {
91104

92105
private constructor(
93106
private readonly _serviceContainer: ServiceContainer,
94-
private readonly _feeds: readonly IExtensionFeed[]
107+
private readonly _feeds: readonly IExtensionFeed[],
108+
private readonly _onInstallFailed: (info: InstallFailedInfo) => void
95109
) {}
96110

97111
/**
98112
* Creates a new instance of the ExtensionManager.
99113
* This will automatically rehydrate previously installed and enabled extensions.
100114
* @param serviceContainer The service container to use.
101115
* @param feeds The extension feeds to include.
116+
* @param onInstallFailed A callback that is called when an extension installation fails.
102117
* @returns A promise that resolves to the new instance of the ExtensionManager.
103118
*/
104-
public static async CreateAsync(serviceContainer: ServiceContainer, feeds: readonly IExtensionFeed[]) {
105-
const extensionManager = new ExtensionManager(serviceContainer, feeds);
119+
public static async CreateAsync(
120+
serviceContainer: ServiceContainer,
121+
feeds: readonly IExtensionFeed[],
122+
onInstallFailed: (info: InstallFailedInfo) => void
123+
): Promise<ExtensionManager> {
124+
const extensionManager = new ExtensionManager(serviceContainer, feeds, onInstallFailed);
106125

107126
// Rehydrate installed extensions.
108127
const installedExtensionNames = JSON.parse(localStorage.getItem(InstalledExtensionsKey) ?? "[]") as string[];
@@ -125,7 +144,17 @@ export class ExtensionManager implements IDisposable {
125144
// Load installed and enabled extensions.
126145
const enablePromises: Promise<void>[] = [];
127146
for (const extension of extensionManager._installedExtensions.values()) {
128-
enablePromises.push(extensionManager._enableAsync(extension.metadata, false));
147+
enablePromises.push(
148+
(async () => {
149+
try {
150+
await extensionManager._enableAsync(extension.metadata, false, false);
151+
} catch {
152+
// If enabling the extension fails, uninstall it. The extension install fail callback will still be called,
153+
// so the owner of the ExtensionManager instance can decide what to do with the error.
154+
await extensionManager._uninstallAsync(extension.metadata, false);
155+
}
156+
})()
157+
);
129158
}
130159

131160
await Promise.all(enablePromises);
@@ -215,6 +244,16 @@ export class ExtensionManager implements IDisposable {
215244
installedExtension.isStateChanging = true;
216245
this._installedExtensions.set(metadata.name, installedExtension);
217246

247+
try {
248+
// Enable the extension.
249+
await this._enableAsync(metadata, true, true);
250+
} catch (error) {
251+
this._installedExtensions.delete(metadata.name);
252+
throw error;
253+
} finally {
254+
!isNestedStateChange && (installedExtension.isStateChanging = false);
255+
}
256+
218257
// Mark the extension as being installed.
219258
localStorage.setItem(
220259
GetExtensionInstalledKey(GetExtensionIdentity(feed.name, metadata.name)),
@@ -227,16 +266,6 @@ export class ExtensionManager implements IDisposable {
227266
InstalledExtensionsKey,
228267
JSON.stringify(Array.from(this._installedExtensions.values()).map((extension) => GetExtensionIdentity(extension.feed.name, extension.metadata.name)))
229268
);
230-
231-
try {
232-
// Enable the extension.
233-
await this._enableAsync(metadata, true);
234-
} catch (error) {
235-
this._installedExtensions.delete(metadata.name);
236-
throw error;
237-
} finally {
238-
!isNestedStateChange && (installedExtension.isStateChanging = false);
239-
}
240269
}
241270

242271
return installedExtension;
@@ -263,7 +292,7 @@ export class ExtensionManager implements IDisposable {
263292
}
264293
}
265294

266-
private async _enableAsync(metadata: ExtensionMetadata, isNestedStateChange: boolean): Promise<void> {
295+
private async _enableAsync(metadata: ExtensionMetadata, isInitialInstall: boolean, isNestedStateChange: boolean): Promise<void> {
267296
const installedExtension = this._installedExtensions.get(metadata.name);
268297
if (installedExtension && (isNestedStateChange || !installedExtension.isStateChanging)) {
269298
try {
@@ -274,7 +303,9 @@ export class ExtensionManager implements IDisposable {
274303
installedExtension.extensionModule = await installedExtension.feed.getExtensionModuleAsync(metadata.name);
275304
}
276305

277-
Assert(installedExtension.extensionModule);
306+
if (!installedExtension.extensionModule) {
307+
throw new Error(`Unable to load extension module for "${metadata.name}" from feed "${installedExtension.feed.name}".`);
308+
}
278309

279310
// Register the ServiceDefinitions.
280311
let servicesRegistrationToken: Nullable<IDisposable> = null;
@@ -288,6 +319,12 @@ export class ExtensionManager implements IDisposable {
288319
servicesRegistrationToken?.dispose();
289320
},
290321
};
322+
} catch (error: unknown) {
323+
this._onInstallFailed({
324+
extension: metadata,
325+
error,
326+
});
327+
throw error;
291328
} finally {
292329
!isNestedStateChange && (installedExtension.isStateChanging = false);
293330
}

packages/dev/inspector-v2/src/modularTool.tsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,27 @@ import type { IDisposable } from "core/index";
33

44
import type { ComponentType, FunctionComponent } from "react";
55
import type { IExtensionFeed } from "./extensibility/extensionFeed";
6-
import type { IExtension } from "./extensibility/extensionManager";
6+
import type { IExtension, InstallFailedInfo } from "./extensibility/extensionManager";
77
import type { WeaklyTypedServiceDefinition } from "./modularity/serviceContainer";
88
import type { IRootComponentService, ShellServiceOptions } from "./services/shellService";
99

10-
import { Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, FluentProvider, makeStyles, Spinner } from "@fluentui/react-components";
10+
import {
11+
Body1,
12+
Button,
13+
Dialog,
14+
DialogActions,
15+
DialogBody,
16+
DialogContent,
17+
DialogSurface,
18+
DialogTitle,
19+
FluentProvider,
20+
List,
21+
ListItem,
22+
makeStyles,
23+
Spinner,
24+
tokens,
25+
} from "@fluentui/react-components";
26+
import { ErrorCircleRegular } from "@fluentui/react-icons";
1127
import { createElement, Suspense, useCallback, useEffect, useState } from "react";
1228
import { createRoot } from "react-dom/client";
1329
import { useTernaryDarkMode } from "usehooks-ts";
@@ -21,8 +37,7 @@ import { ServiceContainer } from "./modularity/serviceContainer";
2137
import { ExtensionListServiceDefinition } from "./services/extensionsListService";
2238
import { MakeShellServiceDefinition, RootComponentServiceIdentity } from "./services/shellService";
2339
import { ThemeSelectorServiceDefinition } from "./services/themeSelectorService";
24-
//import { DarkTheme, LightTheme } from "./themes/babylonTheme";
25-
import { webDarkTheme as DarkTheme, webLightTheme as LightTheme } from "@fluentui/react-components";
40+
import { DarkTheme, LightTheme } from "./themes/babylonTheme";
2641

2742
// eslint-disable-next-line @typescript-eslint/naming-convention
2843
const useStyles = makeStyles({
@@ -40,6 +55,15 @@ const useStyles = makeStyles({
4055
to: { opacity: 1 },
4156
},
4257
},
58+
extensionErrorTitleDiv: {
59+
display: "flex",
60+
flexDirection: "row",
61+
alignItems: "center",
62+
gap: tokens.spacingHorizontalS,
63+
},
64+
extensionErrorIcon: {
65+
color: tokens.colorPaletteRedForeground1,
66+
},
4367
});
4468

4569
export type ModularToolOptions = {
@@ -78,6 +102,7 @@ export function MakeModularTool(options: ModularToolOptions): IDisposable {
78102
const { isDarkMode } = useTernaryDarkMode();
79103
const [requiredExtensions, setRequiredExtensions] = useState<string[]>();
80104
const [requiredExtensionsDeferred, setRequiredExtensionsDeferred] = useState<Deferred<boolean>>();
105+
const [extensionInstallError, setExtensionInstallError] = useState<InstallFailedInfo>();
81106

82107
const [rootComponent, setRootComponent] = useState<ComponentType>();
83108

@@ -116,7 +141,7 @@ export function MakeModularTool(options: ModularToolOptions): IDisposable {
116141
await serviceContainer.addServicesAsync(...serviceDefinitions);
117142

118143
// Create the extension manager, passing along the registry for runtime changes to the registered services.
119-
const extensionManager = await ExtensionManager.CreateAsync(serviceContainer, extensionFeeds);
144+
const extensionManager = await ExtensionManager.CreateAsync(serviceContainer, extensionFeeds, setExtensionInstallError);
120145

121146
// Check query params for required extensions. This lets users share links with sets of extensions.
122147
const queryParams = new URLSearchParams(window.location.search);
@@ -182,6 +207,10 @@ export function MakeModularTool(options: ModularToolOptions): IDisposable {
182207
requiredExtensionsDeferred?.resolve(false);
183208
}, [setRequiredExtensions, requiredExtensionsDeferred]);
184209

210+
const onAcknowledgedExtensionInstallError = useCallback(() => {
211+
setExtensionInstallError(undefined);
212+
}, [setExtensionInstallError]);
213+
185214
// Show a spinner until a main view has been set.
186215
// eslint-disable-next-line @typescript-eslint/naming-convention
187216
const Content: ComponentType = rootComponent ?? (() => <Spinner className={classes.spinner} />);
@@ -209,6 +238,33 @@ export function MakeModularTool(options: ModularToolOptions): IDisposable {
209238
</DialogBody>
210239
</DialogSurface>
211240
</Dialog>
241+
<Dialog open={!!extensionInstallError} modalType="alert">
242+
<DialogSurface>
243+
<DialogBody>
244+
<DialogTitle>
245+
<div className={classes.extensionErrorTitleDiv}>
246+
Extension Install Error
247+
<ErrorCircleRegular className={classes.extensionErrorIcon} />
248+
</div>
249+
</DialogTitle>
250+
<DialogContent>
251+
<List>
252+
<ListItem>
253+
<Body1>{`Extension "${extensionInstallError?.extension.name}" failed to install and was removed.`}</Body1>
254+
</ListItem>
255+
<ListItem>
256+
<Body1>{`${extensionInstallError?.error}`}</Body1>
257+
</ListItem>
258+
</List>
259+
</DialogContent>
260+
<DialogActions>
261+
<Button appearance="primary" onClick={onAcknowledgedExtensionInstallError}>
262+
Close
263+
</Button>
264+
</DialogActions>
265+
</DialogBody>
266+
</DialogSurface>
267+
</Dialog>
212268
<Suspense fallback={<Spinner className={classes.spinner} />}>
213269
<Content />
214270
</Suspense>

packages/dev/inspector-v2/src/services/extensionsListService.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,21 @@ const ExtensionDetails: FunctionComponent<{ extension: IExtension }> = memo((pro
103103
return stateChangedHandlerRegistration.dispose;
104104
}, [props.extension]);
105105

106-
const install = useCallback(async () => await props.extension.installAsync(), [props.extension]);
107-
const uninstall = useCallback(async () => await props.extension.uninstallAsync(), [props.extension]);
106+
const install = useCallback(async () => {
107+
try {
108+
await props.extension.installAsync();
109+
} catch {
110+
// Ignore errors. Other parts of the infrastructure handle them and communicate them to the user.
111+
}
112+
}, [props.extension]);
113+
114+
const uninstall = useCallback(async () => {
115+
try {
116+
await props.extension.uninstallAsync();
117+
} catch {
118+
// Ignore errors. Other parts of the infrastructure handle them and communicate them to the user.
119+
}
120+
}, [props.extension]);
108121

109122
return (
110123
<>

packages/dev/inspector-v2/src/themes/babylonTheme.ts

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,31 @@ import type { BrandVariants, Theme } from "@fluentui/react-components";
44

55
import { createDarkTheme, createLightTheme } from "@fluentui/react-components";
66

7-
const babylonBrand: BrandVariants = {
8-
10: "#000000",
9-
20: "#1E0F11",
10-
30: "#34181B",
11-
40: "#4B1F24",
12-
50: "#63272D",
13-
60: "#7C2F36",
14-
70: "#96373E",
15-
80: "#AF4046",
16-
90: "#C44F51",
17-
100: "#D5625F",
18-
110: "#E4766E",
19-
120: "#F18A7F",
20-
130: "#FBA092",
21-
140: "#FFB8AA",
22-
150: "#FFD1C6",
23-
160: "#FFE8E2",
7+
// Generated from https://react.fluentui.dev/?path=/docs/theme-theme-designer--docs
8+
// Key color: #3A94FC
9+
const babylonRamp: BrandVariants = {
10+
10: "#020305",
11+
20: "#121721",
12+
30: "#1A263A",
13+
40: "#1F314F",
14+
50: "#243E64",
15+
60: "#294B7B",
16+
70: "#2D5892",
17+
80: "#3166AA",
18+
90: "#3473C3",
19+
100: "#3782DC",
20+
110: "#3990F6",
21+
120: "#5A9EFD",
22+
130: "#7BACFE",
23+
140: "#96BAFF",
24+
150: "#AFC9FF",
25+
160: "#C6D8FF",
2426
};
2527

2628
export const LightTheme: Theme = {
27-
...createLightTheme(babylonBrand),
29+
...createLightTheme(babylonRamp),
2830
};
2931

3032
export const DarkTheme: Theme = {
31-
...createDarkTheme(babylonBrand),
32-
colorBrandForeground1: babylonBrand[110],
33-
colorBrandForegroundLink: babylonBrand[110],
34-
colorBrandForegroundLinkPressed: babylonBrand[110],
35-
colorBrandForegroundLinkSelected: babylonBrand[110],
36-
colorCompoundBrandBackgroundHover: babylonBrand[100],
37-
colorCompoundBrandForeground1Pressed: babylonBrand[100],
38-
colorCompoundBrandStrokePressed: babylonBrand[100],
39-
colorNeutralForeground2BrandPressed: babylonBrand[100],
33+
...createDarkTheme(babylonRamp),
4034
};

0 commit comments

Comments
 (0)