1import chalk from 'chalk';
2
3import { KeyPressHandler } from './KeyPressHandler';
4import { BLT, printHelp, printUsage, StartOptions } from './commandsTable';
5import { DevServerManagerActions } from './interactiveActions';
6import * as Log from '../../log';
7import { openInEditorAsync } from '../../utils/editor';
8import { AbortCommandError } from '../../utils/errors';
9import { getAllSpinners, ora } from '../../utils/ora';
10import { getProgressBar, setProgressBar } from '../../utils/progress';
11import { addInteractionListener, pauseInteractions } from '../../utils/prompts';
12import { WebSupportProjectPrerequisite } from '../doctor/web/WebSupportProjectPrerequisite';
13import { DevServerManager } from '../server/DevServerManager';
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, 'devClient' | '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 's': {
140        Log.clear();
141        if (await devServerManager.toggleRuntimeMode()) {
142          usageOptions.devClient = devServerManager.options.devClient;
143          return actions.printDevServerInfo(usageOptions);
144        }
145        break;
146      }
147      case 'w': {
148        try {
149          await devServerManager.ensureProjectPrerequisiteAsync(WebSupportProjectPrerequisite);
150          if (!platforms.includes('web')) {
151            platforms.push('web');
152            options.platforms?.push('web');
153          }
154        } catch (e: any) {
155          Log.warn(e.message);
156          break;
157        }
158
159        const isDisabled = !platforms.includes('web');
160        if (isDisabled) {
161          debug('Web is disabled');
162          // Use warnings from the web support setup.
163          break;
164        }
165
166        // Ensure the Webpack dev server is running first
167        if (!devServerManager.getWebDevServer()) {
168          debug('Starting up webpack dev server');
169          await devServerManager.ensureWebDevServerRunningAsync();
170          // When this is the first time webpack is started, reprint the connection info.
171          actions.printDevServerInfo(usageOptions);
172        }
173
174        Log.log(`${BLT} Open in the web browser...`);
175        try {
176          await devServerManager.getWebDevServer()?.openPlatformAsync('desktop');
177          printHelp();
178        } catch (error: any) {
179          if (!(error instanceof AbortCommandError)) {
180            Log.exception(error);
181          }
182        }
183        break;
184      }
185      case 'c':
186        Log.clear();
187        return actions.printDevServerInfo(usageOptions);
188      case 'j':
189        return actions.openJsInspectorAsync();
190      case 'r':
191        return actions.reloadApp();
192      case 'o':
193        Log.log(`${BLT} Opening the editor...`);
194        return openInEditorAsync(devServerManager.projectRoot);
195    }
196  };
197
198  const keyPressHandler = new KeyPressHandler(onPressAsync);
199
200  const listener = keyPressHandler.createInteractionListener();
201
202  addInteractionListener(listener);
203
204  // Start observing...
205  keyPressHandler.startInterceptingKeyStrokes();
206}
207