import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { StorageService } from '@theia/core/lib/browser/storage-service'; import { Command, CommandContribution, CommandRegistry, CommandService, } from '@theia/core/lib/common/command'; import type { Disposable } from '@theia/core/lib/common/disposable'; import { Emitter } from '@theia/core/lib/common/event'; import { ILogger } from '@theia/core/lib/common/logger'; import { MessageService } from '@theia/core/lib/common/message-service'; import { nls } from '@theia/core/lib/common/nls'; import { deepClone } from '@theia/core/lib/common/objects'; import { Deferred } from '@theia/core/lib/common/promise-util'; import type { Mutable } from '@theia/core/lib/common/types'; import { inject, injectable, optional } from '@theia/core/shared/inversify'; import { OutputChannel, OutputChannelManager, } from '@theia/output/lib/browser/output-channel'; import { BoardIdentifier, BoardUserField, BoardWithPackage, BoardsConfig, BoardsConfigChangeEvent, BoardsPackage, BoardsService, DetectedPorts, Port, PortIdentifier, boardIdentifierEquals, emptyBoardsConfig, isBoardIdentifier, isBoardIdentifierChangeEvent, isPortIdentifier, isPortIdentifierChangeEvent, portIdentifierEquals, sanitizeFqbn, serializePlatformIdentifier, } from '../../common/protocol'; import { BoardList, BoardListHistory, EditBoardsConfigActionParams, SelectBoardsConfigActionParams, createBoardList, isBoardListHistory, } from '../../common/protocol/board-list'; import type { Defined } from '../../common/types'; import type { StartupTask, StartupTaskProvider, } from '../../electron-common/startup-task'; import { NotificationCenter } from '../notification-center'; const boardListHistoryStorageKey = 'arduino-ide:boardListHistory'; const selectedPortStorageKey = 'arduino-ide:selectedPort'; const selectedBoardStorageKey = 'arduino-ide:selectedBoard'; type UpdateBoardsConfigReason = /** * Restore previous state at IDE startup. */ | 'restore' /** * The board and the optional port were changed from the dialog. */ | 'dialog' /** * The board and the port were updated from the board select toolbar. */ | 'toolbar' /** * The board and port configuration was inherited from another window. */ | 'inherit'; interface RefreshBoardListParams { detectedPorts?: DetectedPorts; boardsConfig?: BoardsConfig; boardListHistory?: BoardListHistory; } export type UpdateBoardsConfigParams = | BoardIdentifier /** * `'unset-board'` is special case when a non installed board is selected (no FQBN), the platform is installed, * but there is no way to determine the FQBN from the previous partial data, and IDE2 unsets the board. */ | 'unset-board' | PortIdentifier | (Readonly> & Readonly<{ reason?: UpdateBoardsConfigReason }>); type HistoryDidNotChange = undefined; type HistoryDidDelete = Readonly<{ [portKey: string]: undefined }>; type HistoryDidUpdate = Readonly<{ [portKey: string]: BoardIdentifier }>; type BoardListHistoryUpdateResult = | HistoryDidNotChange | HistoryDidDelete | HistoryDidUpdate; type BoardToSelect = BoardIdentifier | undefined | 'ignore-board'; type PortToSelect = PortIdentifier | undefined | 'ignore-port'; function sanitizeBoardToSelectFQBN(board: BoardToSelect): BoardToSelect { if (isBoardIdentifier(board)) { return sanitizeBoardIdentifierFQBN(board); } return board; } function sanitizeBoardIdentifierFQBN(board: BoardIdentifier): BoardIdentifier { if (board.fqbn) { const copy: Mutable = deepClone(board); copy.fqbn = sanitizeFqbn(board.fqbn); return copy; } return board; } interface UpdateBoardListHistoryParams { readonly portToSelect: PortToSelect; readonly boardToSelect: BoardToSelect; } interface UpdateBoardsDataParams { readonly boardToSelect: BoardToSelect; readonly reason?: UpdateBoardsConfigReason; } export interface SelectBoardsConfigAction { (params: SelectBoardsConfigActionParams): void; } export interface EditBoardsConfigAction { (params?: EditBoardsConfigActionParams): void; } export interface BoardListUIActions { /** * Sets the frontend's port and board configuration according to the params. */ readonly select: SelectBoardsConfigAction; /** * Opens up the boards config dialog with the port and (optional) board to select in the dialog. * Calling this function does not immediately change the frontend's port and board config, but * preselects items in the dialog. */ readonly edit: EditBoardsConfigAction; } export type BoardListUI = BoardList & BoardListUIActions; export type BoardsConfigChangeEventUI = BoardsConfigChangeEvent & Readonly<{ reason?: UpdateBoardsConfigReason }>; @injectable() export class BoardListDumper implements Disposable { @inject(OutputChannelManager) private readonly outputChannelManager: OutputChannelManager; private outputChannel: OutputChannel | undefined; dump(boardList: BoardList): void { if (!this.outputChannel) { this.outputChannel = this.outputChannelManager.getChannel( 'Developer (Arduino)' ); } this.outputChannel.show({ preserveFocus: true }); this.outputChannel.append(boardList.toString() + '\n'); } dispose(): void { this.outputChannel?.dispose(); } } @injectable() export class BoardsServiceProvider implements FrontendApplicationContribution, StartupTaskProvider, CommandContribution { @inject(ILogger) private readonly logger: ILogger; @inject(MessageService) private readonly messageService: MessageService; @inject(BoardsService) private readonly boardsService: BoardsService; @inject(CommandService) private readonly commandService: CommandService; @inject(StorageService) private readonly storageService: StorageService; @inject(NotificationCenter) private readonly notificationCenter: NotificationCenter; @inject(FrontendApplicationStateService) private readonly appStateService: FrontendApplicationStateService; @optional() @inject(BoardListDumper) private readonly boardListDumper?: BoardListDumper; private _boardsConfig = emptyBoardsConfig(); private _detectedPorts: DetectedPorts = {}; private _boardList = this.createBoardListUI(createBoardList({})); private _boardListHistory: Mutable = {}; private _ready = new Deferred(); private readonly boardsConfigDidChangeEmitter = new Emitter(); readonly onBoardsConfigDidChange = this.boardsConfigDidChangeEmitter.event; private readonly boardListDidChangeEmitter = new Emitter(); /** * Emits an event on board config (port or board) change, and when the discovery (`board list --watch`) detected any changes. */ readonly onBoardListDidChange = this.boardListDidChangeEmitter.event; onStart(): void { this.notificationCenter.onDetectedPortsDidChange(({ detectedPorts }) => this.refreshBoardList({ detectedPorts }) ); this.notificationCenter.onPlatformDidInstall((event) => this.maybeUpdateSelectedBoard(event) ); this.appStateService .reachedState('ready') .then(async () => { const [detectedPorts, storedState] = await Promise.all([ this.boardsService.getDetectedPorts(), this.restoreState(), ]); const { selectedBoard, selectedPort, boardListHistory } = storedState; const options: RefreshBoardListParams = { boardListHistory, detectedPorts, }; // If either the port or the board is set, restore it. Otherwise, do not restore nothing. // It might override the inherited boards config from the other window on File > New Sketch if (selectedBoard || selectedPort) { options.boardsConfig = { selectedBoard, selectedPort }; } this.refreshBoardList(options); this._ready.resolve(); }) .finally(() => this._ready.resolve()); } onStop(): void { this.boardListDumper?.dispose(); } registerCommands(registry: CommandRegistry): void { registry.registerCommand(USE_INHERITED_CONFIG, { execute: ( boardsConfig: BoardsConfig, boardListHistory: BoardListHistory ) => { if (boardListHistory) { this._boardListHistory = boardListHistory; } this.update({ boardsConfig }, 'inherit'); }, }); if (this.boardListDumper) { registry.registerCommand(DUMP_BOARD_LIST, { execute: () => this.boardListDumper?.dump(this._boardList), }); } registry.registerCommand(CLEAR_BOARD_LIST_HISTORY, { execute: () => { this.refreshBoardList({ boardListHistory: {} }); this.setData(boardListHistoryStorageKey, undefined); }, }); registry.registerCommand(CLEAR_BOARDS_CONFIG, { execute: () => { this.refreshBoardList({ boardsConfig: emptyBoardsConfig() }); Promise.all([ this.setData(selectedPortStorageKey, undefined), this.setData(selectedBoardStorageKey, undefined), ]); }, }); } tasks(): StartupTask[] { return [ { command: USE_INHERITED_CONFIG.id, args: [this._boardsConfig, this._boardListHistory], }, ]; } private refreshBoardList(params?: RefreshBoardListParams): void { if (params?.detectedPorts) { this._detectedPorts = params.detectedPorts; } if (params?.boardsConfig) { this._boardsConfig = params.boardsConfig; } if (params?.boardListHistory) { this._boardListHistory = params.boardListHistory; } const boardList = createBoardList( this._detectedPorts, this._boardsConfig, this._boardListHistory ); this._boardList = this.createBoardListUI(boardList); this.boardListDidChangeEmitter.fire(this._boardList); } private createBoardListUI(boardList: BoardList): BoardListUI { return Object.assign(boardList, { select: this.onBoardsConfigSelect.bind(this), edit: this.onBoardsConfigEdit.bind(this), }); } private onBoardsConfigSelect(params: SelectBoardsConfigActionParams): void { this.updateConfig({ ...params, reason: 'toolbar' }); } private async onBoardsConfigEdit( params?: EditBoardsConfigActionParams ): Promise { const boardsConfig = await this.commandService.executeCommand< BoardsConfig | undefined >('arduino-open-boards-dialog', params); if (boardsConfig) { this.update({ boardsConfig }, 'dialog'); } } private update( params: RefreshBoardListParams, reason?: UpdateBoardsConfigReason ): void { const { boardsConfig } = params; if (!boardsConfig) { return; } const { selectedBoard, selectedPort } = boardsConfig; if (selectedBoard && selectedPort) { this.updateConfig({ selectedBoard, selectedPort, reason, }); } else if (selectedBoard) { this.updateConfig(selectedBoard); } else if (selectedPort) { this.updateConfig(selectedPort); } } updateConfig(params: UpdateBoardsConfigParams): boolean { const previousSelectedBoard = this._boardsConfig.selectedBoard; const previousSelectedPort = this._boardsConfig.selectedPort; const boardToSelect = this.getBoardToSelect(params); const portToSelect = this.getPortToSelect(params); const reason = this.getUpdateReason(params); const boardDidChange = boardToSelect !== 'ignore-board' && !boardIdentifierEquals(boardToSelect, previousSelectedBoard); const portDidChange = portToSelect !== 'ignore-port' && !portIdentifierEquals(portToSelect, previousSelectedPort); const boardDidChangeEvent = boardDidChange ? // The change event must always contain any custom board options. Hence the board to select is not sanitized. { selectedBoard: boardToSelect, previousSelectedBoard } : undefined; const portDidChangeEvent = portDidChange ? { selectedPort: portToSelect, previousSelectedPort } : undefined; let event: BoardsConfigChangeEvent | undefined = boardDidChangeEvent; if (portDidChangeEvent) { if (event) { event = { ...event, ...portDidChangeEvent, }; } else { event = portDidChangeEvent; } } if (!event) { return false; } // unlike for the board change event, every persistent state must not contain custom board config options in the FQBN const sanitizedBoardToSelect = sanitizeBoardToSelectFQBN(boardToSelect); this.maybeUpdateBoardListHistory({ portToSelect, boardToSelect: sanitizedBoardToSelect, }); this.maybeUpdateBoardsData({ boardToSelect: sanitizedBoardToSelect, reason, }); if (isBoardIdentifierChangeEvent(event)) { this._boardsConfig.selectedBoard = event.selectedBoard ? sanitizeBoardIdentifierFQBN(event.selectedBoard) : event.selectedBoard; } if (isPortIdentifierChangeEvent(event)) { this._boardsConfig.selectedPort = event.selectedPort; } if (reason) { event = Object.assign(event, { reason }); } this.boardsConfigDidChangeEmitter.fire(event); this.refreshBoardList(); this.saveState(); return true; } private getBoardToSelect(params: UpdateBoardsConfigParams): BoardToSelect { if (isPortIdentifier(params)) { return 'ignore-board'; } if (params === 'unset-board') { return undefined; } return isBoardIdentifier(params) ? params : params.selectedBoard; } private getPortToSelect( params: UpdateBoardsConfigParams ): Exclude { if (isBoardIdentifier(params) || params === 'unset-board') { return 'ignore-port'; } return isPortIdentifier(params) ? params : params.selectedPort; } private getUpdateReason( params: UpdateBoardsConfigParams ): UpdateBoardsConfigReason | undefined { if ( isBoardIdentifier(params) || isPortIdentifier(params) || params === 'unset-board' ) { return undefined; } return params.reason; } get ready(): Promise { return this._ready.promise; } get boardsConfig(): BoardsConfig { return this._boardsConfig; } get boardList(): BoardListUI { return this._boardList; } get detectedPorts(): DetectedPorts { return this._detectedPorts; } async searchBoards({ query, }: { query?: string; cores?: string[]; }): Promise { const boards = await this.boardsService.searchBoards({ query }); return boards; } async selectedBoardUserFields(): Promise { if (!this._boardsConfig.selectedBoard) { return []; } const fqbn = this._boardsConfig.selectedBoard.fqbn; if (!fqbn) { return []; } // Protocol must be set to `default` when uploading without a port selected: // https://arduino.github.io/arduino-cli/dev/platform-specification/#sketch-upload-configuration const protocol = this._boardsConfig.selectedPort?.protocol || 'default'; return await this.boardsService.getBoardUserFields({ fqbn, protocol }); } private async maybeUpdateSelectedBoard(platformDidInstallEvent: { item: BoardsPackage; }): Promise { const { selectedBoard } = this._boardsConfig; if ( selectedBoard && !selectedBoard.fqbn && BoardWithPackage.is(selectedBoard) ) { const selectedBoardPlatformId = serializePlatformIdentifier( selectedBoard.packageId ); if (selectedBoardPlatformId === platformDidInstallEvent.item.id) { const installedSelectedBoard = platformDidInstallEvent.item.boards.find( (board) => board.name === selectedBoard.name ); // if the board can be found by its name after the install event select it. otherwise unselect it // historical hint: https://github.com/arduino/arduino-ide/blob/144df893d0dafec64a26565cf912a98f32572da9/arduino-ide-extension/src/browser/boards/boards-service-provider.ts#L289-L320 this.updateConfig( installedSelectedBoard ? installedSelectedBoard : 'unset-board' ); if (!installedSelectedBoard) { const yes = nls.localize('vscode/extensionsUtils/yes', 'Yes'); const answer = await this.messageService.warn( nls.localize( 'arduino/board/couldNotFindPreviouslySelected', "Could not find previously selected board '{0}' in installed platform '{1}'. Please manually reselect the board you want to use. Do you want to reselect it now?", selectedBoard.name, platformDidInstallEvent.item.name ), nls.localize('arduino/board/reselectLater', 'Reselect later'), yes ); if (answer === yes) { this.onBoardsConfigEdit({ query: selectedBoard.name, portToSelect: this._boardsConfig.selectedPort, }); } } } } } private maybeUpdateBoardListHistory( params: UpdateBoardListHistoryParams ): BoardListHistoryUpdateResult { const { portToSelect, boardToSelect } = params; const selectedPort = isPortIdentifier(portToSelect) ? portToSelect : portToSelect === 'ignore-port' ? this._boardsConfig.selectedPort : undefined; const selectedBoard = isBoardIdentifier(boardToSelect) ? boardToSelect : boardToSelect === 'ignore-board' ? this._boardsConfig.selectedBoard : undefined; if (selectedBoard && selectedPort) { const match = this.boardList.items.find( (item) => portIdentifierEquals(item.port, selectedPort) && item.board && boardIdentifierEquals(item.board, selectedBoard) ); const portKey = Port.keyOf(selectedPort); if (match) { // When board `B` is detected on port `P` and saving `B` on `P`, remove the entry instead! delete this._boardListHistory[portKey]; } else { this._boardListHistory[portKey] = selectedBoard; } if (match) { return { [portKey]: undefined }; } return { [portKey]: selectedBoard }; } return undefined; } private maybeUpdateBoardsData(params: UpdateBoardsDataParams): void { const { boardToSelect, reason } = params; if ( boardToSelect && boardToSelect !== 'ignore-board' && boardToSelect.fqbn && (reason === 'toolbar' || reason === 'inherit') ) { const [, , , ...rest] = boardToSelect.fqbn.split(':'); if (rest.length) { // https://github.com/arduino/arduino-ide/pull/2113 // TODO: save update data store if reason is toolbar and the FQBN has options } } } private async saveState(): Promise { const { selectedBoard, selectedPort } = this.boardsConfig; await Promise.all([ this.setData( selectedBoardStorageKey, selectedBoard ? JSON.stringify(selectedBoard) : undefined ), this.setData( selectedPortStorageKey, selectedPort ? JSON.stringify(selectedPort) : undefined ), this.setData( boardListHistoryStorageKey, JSON.stringify(this._boardListHistory) ), ]); } private async restoreState(): Promise< Readonly & { boardListHistory: BoardListHistory | undefined } > { const [maybeSelectedBoard, maybeSelectedPort, maybeBoardHistory] = await Promise.all([ this.getData(selectedBoardStorageKey), this.getData(selectedPortStorageKey), this.getData(boardListHistoryStorageKey), ]); const selectedBoard = this.tryParse(maybeSelectedBoard, isBoardIdentifier); const selectedPort = this.tryParse(maybeSelectedPort, isPortIdentifier); const boardListHistory = this.tryParse( maybeBoardHistory, isBoardListHistory ); return { selectedBoard, selectedPort, boardListHistory }; } private tryParse( raw: string | undefined, typeGuard: (object: unknown) => object is T ): T | undefined { if (!raw) { return undefined; } try { const object = JSON.parse(raw); if (typeGuard(object)) { return object; } } catch { this.logger.error(`Failed to parse raw: '${raw}'`); } return undefined; } private setData(key: string, value: T): Promise { return this.storageService.setData(key, value); } private getData(key: string): Promise { return this.storageService.getData(key); } } /** * It should be neither visible nor called from outside. * * This service creates a startup task with the current board config and * passes the task to the electron-main process so that the new window * can inherit the boards config state of this service. * * Note that the state is always set, but new windows might ignore it. * For example, the new window already has a valid boards config persisted to the local storage. */ const USE_INHERITED_CONFIG: Command = { id: 'arduino-use-inherited-boards-config', }; const DUMP_BOARD_LIST: Command = { id: 'arduino-dump-board-list', label: nls.localize('arduino/developer/dumpBoardList', 'Dump the Board List'), category: 'Developer (Arduino)', }; const CLEAR_BOARD_LIST_HISTORY: Command = { id: 'arduino-clear-board-list-history', label: nls.localize( 'arduino/developer/clearBoardList', 'Clear the Board List History' ), category: 'Developer (Arduino)', }; const CLEAR_BOARDS_CONFIG: Command = { id: 'arduino-clear-boards-config', label: nls.localize( 'arduino/developer/clearBoardsConfig', 'Clear the Board and Port Selection' ), category: 'Developer (Arduino)', };