1import chalk from 'chalk';
2
3import * as Log from '../../log';
4import { openInEditorAsync } from '../../utils/editor';
5import { AbortCommandError } from '../../utils/errors';
6import { getAllSpinners, ora } from '../../utils/ora';
7import { getProgressBar, setProgressBar } from '../../utils/progress';
8import { addInteractionListener, pauseInteractions } from '../../utils/prompts';
9import { WebSupportProjectPrerequisite } from '../doctor/web/WebSupportProjectPrerequisite';
10import { DevServerManager } from '../server/DevServerManager';
11import { KeyPressHandler } from './KeyPressHandler';
12import { BLT, printHelp, printUsage, StartOptions } from './commandsTable';
13import { DevServerManagerActions } from './interactiveActions';
14
15const debug = require('debug')('expo:start:interface:startInterface') as typeof console.log;
16
17const CTRL_C = '\u0003';
18const CTRL_D = '\u0004';
19const CTRL_L = '\u000C';
20
21const PLATFORM_SETTINGS: Record<
22  string,
23  { name: string; key: 'android' | 'ios'; launchTarget: 'emulator' | 'simulator' }
24> = {
25  android: {
26    name: 'Android',
27    key: 'android',
28    launchTarget: 'emulator',
29  },
30  ios: {
31    name: 'iOS',
32    key: 'ios',
33    launchTarget: 'simulator',
34  },
35};
36
37export async function startInterfaceAsync(
38  devServerManager: DevServerManager,
39  options: Pick<StartOptions, 'platforms'>
40) {
41  const actions = new DevServerManagerActions(devServerManager);
42
43  const isWebSocketsEnabled = devServerManager.getDefaultDevServer()?.isTargetingNative();
44
45  const usageOptions = {
46    isWebSocketsEnabled,
47    devClient: devServerManager.options.devClient,
48    ...options,
49  };
50
51  actions.printDevServerInfo(usageOptions);
52
53  const onPressAsync = async (key: string) => {
54    // Auxillary commands all escape.
55    switch (key) {
56      case CTRL_C:
57      case CTRL_D: {
58        // Prevent terminal UI from accepting commands while the process is closing.
59        // Without this, fast typers will close the server then start typing their
60        // next command and have a bunch of unrelated things pop up.
61        pauseInteractions();
62
63        const spinners = getAllSpinners();
64        spinners.forEach((spinner) => {
65          spinner.fail();
66        });
67
68        const currentProgress = getProgressBar();
69        if (currentProgress) {
70          currentProgress.terminate();
71          setProgressBar(null);
72        }
73        const spinner = ora({ text: 'Stopping server', color: 'white' }).start();
74        try {
75          await devServerManager.stopAsync();
76          spinner.stopAndPersist({ text: 'Stopped server', symbol: `\u203A` });
77          // @ts-ignore: Argument of type '"SIGINT"' is not assignable to parameter of type '"disconnect"'.
78          process.emit('SIGINT');
79
80          // TODO: Is this the right place to do this?
81          process.exit();
82        } catch (error) {
83          spinner.fail('Failed to stop server');
84          throw error;
85        }
86        break;
87      }
88      case CTRL_L:
89        return Log.clear();
90      case '?':
91        return printUsage(usageOptions, { verbose: true });
92    }
93
94    // Optionally enabled
95
96    if (isWebSocketsEnabled) {
97      switch (key) {
98        case 'm':
99          return actions.toggleDevMenu();
100        case 'M':
101          return actions.openMoreToolsAsync();
102      }
103    }
104
105    const { platforms = ['ios', 'android', 'web'] } = options;
106
107    if (['i', 'a'].includes(key.toLowerCase())) {
108      const platform = key.toLowerCase() === 'i' ? 'ios' : 'android';
109
110      const shouldPrompt = ['I', 'A'].includes(key);
111      if (shouldPrompt) {
112        Log.clear();
113      }
114
115      const server = devServerManager.getDefaultDevServer();
116      const settings = PLATFORM_SETTINGS[platform];
117
118      Log.log(`${BLT} Opening on ${settings.name}...`);
119
120      if (server.isTargetingNative() && !platforms.includes(settings.key)) {
121        Log.warn(
122          chalk`${settings.name} is disabled, enable it by adding {bold ${settings.key}} to the platforms array in your app.json or app.config.js`
123        );
124      } else {
125        try {
126          await server.openPlatformAsync(settings.launchTarget, { shouldPrompt });
127          printHelp();
128        } catch (error: any) {
129          if (!(error instanceof AbortCommandError)) {
130            Log.exception(error);
131          }
132        }
133      }
134      // Break out early.
135      return;
136    }
137
138    switch (key) {
139      case 'w': {
140        try {
141          await devServerManager.ensureProjectPrerequisiteAsync(WebSupportProjectPrerequisite);
142          if (!platforms.includes('web')) {
143            platforms.push('web');
144            options.platforms?.push('web');
145          }
146        } catch (e: any) {
147          Log.warn(e.message);
148          break;
149        }
150
151        const isDisabled = !platforms.includes('web');
152        if (isDisabled) {
153          debug('Web is disabled');
154          // Use warnings from the web support setup.
155          break;
156        }
157
158        // Ensure the Webpack dev server is running first
159        if (!devServerManager.getWebDevServer()) {
160          debug('Starting up webpack dev server');
161          await devServerManager.ensureWebDevServerRunningAsync();
162          // When this is the first time webpack is started, reprint the connection info.
163          actions.printDevServerInfo(usageOptions);
164        }
165
166        Log.log(`${BLT} Open in the web browser...`);
167        try {
168          await devServerManager.getWebDevServer()?.openPlatformAsync('desktop');
169          printHelp();
170        } catch (error: any) {
171          if (!(error instanceof AbortCommandError)) {
172            Log.exception(error);
173          }
174        }
175        break;
176      }
177      case 'c':
178        Log.clear();
179        return actions.printDevServerInfo(usageOptions);
180      case 'j':
181        return actions.openJsInspectorAsync();
182      case 'r':
183        return actions.reloadApp();
184      case 'o':
185        Log.log(`${BLT} Opening the editor...`);
186        return openInEditorAsync(devServerManager.projectRoot);
187    }
188  };
189
190  const keyPressHandler = new KeyPressHandler(onPressAsync);
191
192  const listener = keyPressHandler.createInteractionListener();
193
194  addInteractionListener(listener);
195
196  // Start observing...
197  keyPressHandler.startInterceptingKeyStrokes();
198}
199