Skip to content

Commit e28b0b0

Browse files
committed
Add the ability to launch custom emulators
Fixes #1553.
1 parent 137ddbb commit e28b0b0

8 files changed

Lines changed: 129 additions & 38 deletions

File tree

‎package.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,21 @@
10591059
},
10601060
"scope": "resource"
10611061
},
1062+
"dart.flutterCustomEmulators": {
1063+
"type": "array",
1064+
"default": [],
1065+
"description": "Custom emulators to show in the emulator list for easier launching. If IDs match existing emulators returned by Flutter, the custom emulators will override them.",
1066+
"items": {
1067+
"type": "object",
1068+
"properties": {
1069+
"id": {"type": "string"},
1070+
"name": {"type": "string"},
1071+
"executable": {"type": "string"},
1072+
"args": {"type": "array", "items": {"type": "string"}}
1073+
}
1074+
},
1075+
"scope": "window"
1076+
},
10621077
"dart.debugSdkLibraries": {
10631078
"type": "boolean",
10641079
"default": false,

‎src/extension/config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class Config {
6565
get extensionLogFile(): undefined | string { return createFolderForFile(resolvePaths(this.getConfig<null | string>("extensionLogFile", null))); }
6666
get flutterAdbConnectOnChromeOs(): boolean { return this.getConfig<boolean>("flutterAdbConnectOnChromeOs", false); }
6767
get flutterAndroidX(): boolean { return this.getConfig<boolean>("flutterAndroidX", false); }
68+
get flutterCustomEmulators(): Array<{ id: string, name: string, executable: string, args?: string[] }> { return this.getConfig<Array<{ id: string, name: string, executable: string, args?: string[] }>>("flutterCustomEmulators", []); }
6869
get flutterCreateAndroidLanguage(): "java" | "kotlin" { return this.getConfig<"java" | "kotlin">("flutterCreateAndroidLanguage", "kotlin"); }
6970
get flutterCreateIOSLanguage(): "objc" | "swift" { return this.getConfig<"objc" | "swift">("flutterCreateIOSLanguage", "swift"); }
7071
get flutterCreateOffline(): boolean { return this.getConfig<boolean>("flutterCreateOffline", false); }

‎src/extension/extension.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ export async function activate(context: vs.ExtensionContext, isRestart: boolean
378378
// Fire up Flutter daemon if required.
379379
if (workspaceContext.hasAnyFlutterMobileProjects && sdks.flutter) {
380380
flutterDaemon = new FlutterDaemon(logger, path.join(sdks.flutter, flutterPath), sdks.flutter);
381-
deviceManager = new FlutterDeviceManager(logger, flutterDaemon, config.flutterSelectDeviceWhenConnected);
381+
deviceManager = new FlutterDeviceManager(logger, flutterDaemon, config, config.flutterSelectDeviceWhenConnected);
382382

383383
context.subscriptions.push(deviceManager);
384384
context.subscriptions.push(flutterDaemon);

‎src/extension/flutter/flutter_daemon.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export class FlutterDaemon extends StdIOService<UnknownNotification> implements
155155
return this.sendRequest("device.enable");
156156
}
157157

158-
public getEmulators(): Thenable<f.Emulator[]> {
158+
public getEmulators(): Thenable<f.FlutterEmulator[]> {
159159
return this.sendRequest("emulator.getEmulators");
160160
}
161161

‎src/shared/flutter/daemon_interfaces.ts‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,11 @@ export interface Device {
1313
type: "device";
1414
}
1515

16-
export interface Emulator {
16+
export interface FlutterEmulator {
1717
id: string;
1818
name: string;
19-
category: Category | undefined | null;
20-
platformType: PlatformType | undefined | null;
21-
type: "emulator";
22-
}
23-
24-
export interface EmulatorCreator {
25-
platformType: PlatformType | null | undefined;
26-
type: "emulator-creator";
27-
id?: undefined;
19+
category?: Category | undefined | null;
20+
platformType?: PlatformType | undefined | null;
2821
}
2922

3023
export interface DaemonConnected {

‎src/shared/interfaces.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export interface IFlutterDaemon extends IAmDisposable {
5757
capabilities: DaemonCapabilities;
5858

5959
deviceEnable(): Thenable<UnknownResponse>;
60-
getEmulators(): Thenable<f.Emulator[]>;
60+
getEmulators(): Thenable<f.FlutterEmulator[]>;
6161
launchEmulator(emulatorId: string): Thenable<void>;
6262
createEmulator(name?: string): Thenable<{ success: boolean, emulatorName: string, error: string }>;
6363
getSupportedPlatforms(projectRoot: string): Thenable<f.SupportedPlatformsResponse>;
@@ -70,6 +70,31 @@ export interface IFlutterDaemon extends IAmDisposable {
7070
registerForDaemonShowMessage(subscriber: (notification: f.ShowMessage) => void): IAmDisposable;
7171
}
7272

73+
export type Emulator = (f.FlutterEmulator & { type: "emulator" }) | CustomEmulator;
74+
75+
export interface CustomEmulator {
76+
id: string;
77+
type: "custom-emulator";
78+
category?: undefined;
79+
platformType?: undefined;
80+
name: string;
81+
executable: string;
82+
args?: string[];
83+
}
84+
85+
export interface EmulatorCreator {
86+
id?: undefined;
87+
type: "emulator-creator";
88+
platformType: "android";
89+
}
90+
91+
export interface CustomEmulatorDefinition {
92+
id: string;
93+
name: string;
94+
executable: string;
95+
args?: string[];
96+
}
97+
7398
export interface Location {
7499
startLine: number;
75100
startColumn: number;

‎src/shared/vscode/device_manager.ts‎

Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import * as vs from "vscode";
22
import { flatMap } from "../../shared/utils";
33
import { findProjectFolders, fsPath } from "../../shared/utils/fs";
44
import { getDartWorkspaceFolders } from "../../shared/vscode/utils";
5+
import { LogCategory } from "../enums";
56
import * as f from "../flutter/daemon_interfaces";
6-
import { IFlutterDaemon, Logger } from "../interfaces";
7+
import { CustomEmulator, CustomEmulatorDefinition, Emulator, EmulatorCreator, IFlutterDaemon, Logger } from "../interfaces";
8+
import { logProcess } from "../logging";
9+
import { safeSpawn } from "../processes";
710
import { unique } from "../utils/array";
811
import { isRunningLocally } from "./utils";
912

@@ -14,7 +17,7 @@ export class FlutterDeviceManager implements vs.Disposable {
1417
public currentDevice?: f.Device;
1518
private readonly knownEmulatorNames: { [key: string]: string } = {};
1619

17-
constructor(private readonly logger: Logger, private daemon: IFlutterDaemon, private readonly autoSelectNewlyConnectedDevices: boolean) {
20+
constructor(private readonly logger: Logger, private daemon: IFlutterDaemon, private readonly config: { flutterCustomEmulators: CustomEmulatorDefinition[] }, private readonly autoSelectNewlyConnectedDevices: boolean) {
1821
this.statusBarItem = vs.window.createStatusBarItem(vs.StatusBarAlignment.Right, 1);
1922
this.statusBarItem.tooltip = "Flutter";
2023
this.statusBarItem.command = "flutter.selectDevice";
@@ -36,7 +39,7 @@ export class FlutterDeviceManager implements vs.Disposable {
3639
this.subscriptions.forEach((s) => s.dispose());
3740
}
3841

39-
public isSupported(types: f.PlatformType[] | undefined, device: { platformType: f.PlatformType | null | undefined } | undefined) {
42+
public isSupported(types: f.PlatformType[] | undefined, device: { platformType?: f.PlatformType | null | undefined } | undefined) {
4043
// If we don't get any types to filter, assume everything is valid.
4144
return device && (!types || !types.length || !device.platformType || types.indexOf(device.platformType) !== -1);
4245
}
@@ -147,13 +150,23 @@ export class FlutterDeviceManager implements vs.Disposable {
147150
this.currentDevice = undefined;
148151
this.statusBarItem.text = `Creating ${emulatorTypeLabel}...`;
149152
await this.createEmulator();
153+
this.updateStatusBar();
150154
break;
151155
case "emulator":
152156
// Clear the current device so we can wait for the new one
153157
// to connect.
154158
this.currentDevice = undefined;
155159
this.statusBarItem.text = `Launching ${emulatorTypeLabel}...`;
156160
await this.launchEmulator(selection.device);
161+
this.updateStatusBar();
162+
break;
163+
case "custom-emulator":
164+
// Clear the current device so we can wait for the new one
165+
// to connect.
166+
this.currentDevice = undefined;
167+
this.statusBarItem.text = `Launching ${emulatorTypeLabel}...`;
168+
await this.launchCustomEmulator(selection.device);
169+
this.updateStatusBar();
157170
break;
158171
case "device":
159172
this.currentDevice = selection.device;
@@ -230,21 +243,36 @@ export class FlutterDeviceManager implements vs.Disposable {
230243
}
231244
}
232245

233-
private async getEmulators(): Promise<f.Emulator[]> {
246+
private async getEmulators(): Promise<Emulator[]> {
234247
try {
235248
const emus = await this.daemon.getEmulators();
236249

250+
const allEmulatorsByID: { [key: string]: Emulator } = {};
251+
for (const e of emus) {
252+
allEmulatorsByID[e.id] = {
253+
category: e.category,
254+
id: e.id,
255+
name: e.name || e.id,
256+
platformType: e.platformType,
257+
type: "emulator",
258+
};
259+
}
260+
261+
// Add/overwrite any custom emulators.
262+
for (const e of this.config.flutterCustomEmulators) {
263+
allEmulatorsByID[e.id] = {
264+
...e,
265+
type: "custom-emulator",
266+
};
267+
}
268+
269+
const allEmulators = Object.values(allEmulatorsByID);
270+
237271
// Whenever we see emulators, record all their names.
238-
for (const e of emus.filter((e) => e.id))
272+
for (const e of allEmulators)
239273
this.knownEmulatorNames[e.id] = e.name;
240274

241-
return emus.map((e) => ({
242-
category: e.category,
243-
id: e.id,
244-
name: e.name || e.id,
245-
platformType: e.platformType,
246-
type: "emulator",
247-
}));
275+
return allEmulators;
248276
} catch (e) {
249277
this.logger.error({ message: e });
250278
return [];
@@ -282,6 +310,8 @@ export class FlutterDeviceManager implements vs.Disposable {
282310
return this.createEmulator();
283311
} else if (selectedEmulator && selectedEmulator.device && selectedEmulator.device.type === "emulator") {
284312
return this.launchEmulator(selectedEmulator.device);
313+
} else if (selectedEmulator && selectedEmulator.device && selectedEmulator.device.type === "custom-emulator") {
314+
return this.launchCustomEmulator(selectedEmulator.device);
285315
} else {
286316
return !!(this.currentDevice);
287317
}
@@ -328,25 +358,22 @@ export class FlutterDeviceManager implements vs.Disposable {
328358
.map((e) => ({
329359
alwaysShow: false,
330360
description: showAsEmulators ? `${e.category || "mobile"} ${this.emulatorLabel(e.platformType)}` : e.platformType || undefined,
331-
device: {
332-
...e,
333-
type: "emulator",
334-
},
361+
device: e,
335362
label: showAsEmulators ? `Start ${e.name}` : e.name,
336363
}));
337364

338365
// Add an option to create a new emulator if the daemon supports it.
339366
if (this.daemon.capabilities.canCreateEmulators && this.isSupported(supportedTypes, { platformType: "android" })) {
340367
emulators.push({
341368
alwaysShow: true,
342-
device: { type: "emulator-creator", platformType: "android" },
369+
device: { type: "emulator-creator", platformType: "android", name: "Create Android emulator" } as EmulatorCreator,
343370
label: "Create Android emulator",
344371
});
345372
}
346373
return emulators;
347374
}
348375

349-
private async launchEmulator(emulator: { id: string, name: string }): Promise<boolean> {
376+
private async launchEmulator(emulator: f.FlutterEmulator): Promise<boolean> {
350377
try {
351378
await vs.window.withProgress({
352379
location: vs.ProgressLocation.Notification,
@@ -370,6 +397,37 @@ export class FlutterDeviceManager implements vs.Disposable {
370397
await new Promise((resolve) => setTimeout(resolve, 1000));
371398
return true;
372399
}
400+
401+
private async launchCustomEmulator(emulator: CustomEmulator): Promise<boolean> {
402+
try {
403+
await vs.window.withProgress({
404+
location: vs.ProgressLocation.Notification,
405+
}, async (progress) => {
406+
progress.report({ message: `Launching ${emulator.name}...` });
407+
const binPath = emulator.executable;
408+
const args = emulator.args || [];
409+
410+
const customEmulatorProc = safeSpawn(undefined, emulator.executable, args, {});
411+
this.logger.info(`(PROC ${customEmulatorProc.pid}) Spawned ${binPath} ${args.join(" ")}`, LogCategory.CommandProcesses);
412+
logProcess(this.logger, LogCategory.CommandProcesses, customEmulatorProc);
413+
414+
progress.report({ message: `Waiting for ${emulator.name} to connect...` });
415+
// Wait up to 60 seconds for emulator to launch.
416+
for (let i = 0; i < 120; i++) {
417+
await new Promise((resolve) => setTimeout(resolve, 500));
418+
if (this.currentDevice)
419+
return;
420+
}
421+
throw new Error("Emulator didn't connect within 60 seconds");
422+
});
423+
} catch (e) {
424+
vs.window.showErrorMessage(`Failed to launch ${emulator.name}: ${e}`);
425+
return false;
426+
}
427+
// Wait an additional second to try and void some possible races.
428+
await new Promise((resolve) => setTimeout(resolve, 1000));
429+
return true;
430+
}
373431
}
374432

375-
type PickableDevice = vs.QuickPickItem & { device: f.Device | f.Emulator | f.EmulatorCreator };
433+
type PickableDevice = vs.QuickPickItem & { device: f.Device | Emulator | EmulatorCreator };

‎src/test/flutter/device_manager.test.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ describe("device_manager", () => {
1313

1414
beforeEach(() => {
1515
daemon = new FakeFlutterDaemon();
16-
dm = new FlutterDeviceManager(logger, daemon, true);
16+
// TODO: Tests for custom emulators.
17+
dm = new FlutterDeviceManager(logger, daemon, { flutterCustomEmulators: [] }, true);
1718
});
1819

1920
afterEach(() => {
@@ -145,7 +146,7 @@ class FakeFlutterDaemon extends FakeStdIOService implements IFlutterDaemon {
145146
public deviceEnable(): Thenable<UnknownResponse> {
146147
throw new Error("Method not implemented.");
147148
}
148-
public async getEmulators(): Promise<f.Emulator[]> {
149+
public async getEmulators(): Promise<f.FlutterEmulator[]> {
149150
return [androidEmulator];
150151
}
151152
public launchEmulator(emulatorId: string): Thenable<void> {
@@ -224,12 +225,11 @@ const emulatedAndroidMobile: f.Device = {
224225
type: "device",
225226
};
226227

227-
const androidEmulator: f.Emulator = {
228+
const androidEmulator: f.FlutterEmulator = {
228229
category: "mobile",
229230
id: "my_emulator_id",
230231
name: "My Cool Emulator!",
231232
platformType: "android",
232-
type: "emulator",
233233
};
234234

235235
const physicaliOSMobile: f.Device = {
@@ -256,10 +256,9 @@ const emulatediOSMobile: f.Device = {
256256
type: "device",
257257
};
258258

259-
const iOSEmulator: f.Emulator = {
259+
const iOSEmulator: f.FlutterEmulator = {
260260
category: "mobile",
261261
id: "my_emulator_id",
262262
name: "My Cool iOS Emulator!",
263263
platformType: "ios",
264-
type: "emulator",
265264
};

0 commit comments

Comments
 (0)