Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/i18n/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@
"ApplyUpdates": "A new version has been downloaded. Restart the application to apply the updates."
},
"ConfiguratorView": {
"FirmwareOutputFolderDialogTitle": "Select firmware output folder",
"FirmwareOutputFolderDialogMessage": "A folder for this build is created in the selected location",
"FirmwareOutputFolderError": "Failed to save the firmware files into the selected folder.",
"DownloadLUAScript": "Download LUA script",
"FirmwareVersion": "Firmware version",
"Target": "Target",
Expand Down Expand Up @@ -302,7 +305,17 @@
"ThemeSelector": "Theme",
"ThemeSystem": "System (follow OS preference)",
"ThemeLight": "Light",
"ThemeDark": "Dark"
"ThemeDark": "Dark",
"FirmwareOutput": "Firmware Output Folder",
"FirmwareOutputTemporaryFolder": "Temporary folder",
"FirmwareOutputAskEveryTime": "Ask me where to save after every build (default)",
"FirmwareOutputFixedFolder": "Always save into the same folder",
"FirmwareOutputFolder": "Output folder",
"FirmwareOutputFolderNotSet": "No folder selected yet",
"FirmwareOutputChooseFolder": "Choose folder",
"FirmwareOutputDescription": "Choose where firmware binaries are kept after a build. A folder named after the firmware is created in the selected location. Temporary folders are cleaned up by the operating system, so pick one of the other options if you want to keep your builds.",
"FirmwareOutputFolderDialogTitle": "Select firmware output folder",
"FirmwareOutputFolderDialogMessage": "A folder for every build is created in the selected location"
},
"SupportView": {
"Support": "Support",
Expand Down
38 changes: 38 additions & 0 deletions src/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export enum IpcRequest {
UpdateBuildStatus = 'UPDATE_BUILD_STATUS',
ChooseFolder = 'CHOOSE_FOLDER',
SaveFile = 'SAVE_FILE',
SaveBuildOutput = 'SAVE_BUILD_OUTPUT',
DownloadFile = 'DOWNLOAD_FILE',
}

Expand All @@ -15,6 +16,12 @@ export interface UpdateBuildStatusRequestBody {
buildInProgress: boolean;
}

export interface ChooseFolderRequestBody {
title?: string;
message?: string;
defaultPath?: string;
}

export interface ChooseFolderResponseBody {
success: boolean;
directoryPath: string;
Expand All @@ -30,6 +37,37 @@ export interface SaveFileResponseBody {
path: string;
}

export interface SaveBuildOutputRequestBody {
/**
* Firmware binary produced by the build. Every file that sits next to it is
* considered a build artefact and is copied along with it.
*/
firmwareBinPath: string;
/**
* When set, artefacts are copied into this directory without asking the
* user. When empty, a folder selection dialog is shown.
*/
destinationDirectory?: string;
/**
* Name of the folder that is created for this build. Falls back to the
* firmware filename when empty.
*/
folderName?: string;
title?: string;
message?: string;
}

export interface SaveBuildOutputResponseBody {
success: boolean;
/** true when the user dismissed the folder selection dialog */
canceled: boolean;
/** folder the artefacts have been copied into */
directoryPath: string;
/** firmware binary inside directoryPath */
firmwareBinPath: string;
message?: string;
}

export interface DownloadFileRequestBody {
url: string;
}
115 changes: 111 additions & 4 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,20 @@ import 'reflect-metadata';
import path from 'path';
import { app, BrowserWindow, dialog, ipcMain, shell, session } from 'electron';
import { mkdirp } from 'mkdirp';
import sanitize from 'sanitize-filename';
import winston from 'winston';
import fs from 'fs';
import { URL } from 'url';
import MenuBuilder from './menu';
import ApiServer from '../api';
import {
ChooseFolderRequestBody,
ChooseFolderResponseBody,
DownloadFileRequestBody,
IpcRequest,
OpenFileLocationRequestBody,
SaveBuildOutputRequestBody,
SaveBuildOutputResponseBody,
SaveFileRequestBody,
SaveFileResponseBody,
UpdateBuildStatusRequestBody,
Expand Down Expand Up @@ -511,11 +515,16 @@ ipcMain.on(

ipcMain.handle(
IpcRequest.ChooseFolder,
async (): Promise<ChooseFolderResponseBody> => {
async (
_,
args?: [ChooseFolderRequestBody?],
): Promise<ChooseFolderResponseBody> => {
const arg = args?.[0];
const result = await dialog.showOpenDialog({
title: 'Select firmware source folder',
message: 'Folder must contain platformio.ini file',
properties: ['openDirectory'],
title: arg?.title ?? 'Select firmware source folder',
message: arg?.message ?? 'Folder must contain platformio.ini file',
defaultPath: arg?.defaultPath,
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return {
Expand Down Expand Up @@ -571,6 +580,104 @@ ipcMain.handle(
},
);

/**
* Name of the folder that is created for a single build. The device target is
* used when the renderer knows it, because build artefacts are not always
* named after the target, ie. a WiFi build produces a plain "firmware.bin.gz".
* Otherwise the firmware filename is used, ie. "MyTX-3.5.6.bin.gz" ->
* "MyTX-3.5.6".
*/
const buildOutputFolderName = (
firmwareBinPath: string,
requestedName?: string,
): string => {
const sanitized = sanitize(requestedName ?? '', { replacement: '_' }).trim();
if (sanitized.length > 0) {
return sanitized;
}
return path
.basename(firmwareBinPath)
.replace(/\.gz$/i, '')
.replace(/\.(elrs|bin|hex)$/i, '');
};

ipcMain.handle(
IpcRequest.SaveBuildOutput,
async (
_,
args: [SaveBuildOutputRequestBody],
): Promise<SaveBuildOutputResponseBody> => {
const arg = args[0];
const failure = (message: string, canceled = false) => ({
success: false,
canceled,
directoryPath: '',
firmwareBinPath: '',
message,
});
logger.log('received a request to save build output', {
firmwareBinPath: arg.firmwareBinPath,
destinationDirectory: arg.destinationDirectory,
});
try {
if (!fs.existsSync(arg.firmwareBinPath)) {
return failure(`Firmware binary not found: ${arg.firmwareBinPath}`);
}

let parentDirectory = arg.destinationDirectory ?? '';
if (parentDirectory.length === 0) {
const result = await dialog.showOpenDialog({
title: arg.title ?? 'Select firmware output folder',
message:
arg.message
?? 'A folder for this build is created in the selected location',
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return failure('', true);
}
parentDirectory = result.filePaths[0];
}

const outputDirectory = path.join(
parentDirectory,
buildOutputFolderName(arg.firmwareBinPath, arg.folderName),
);
await mkdirp(outputDirectory);

const sourceDirectory = path.dirname(arg.firmwareBinPath);
const artefacts = (
await fs.promises.readdir(sourceDirectory, { withFileTypes: true })
).filter((item) => item.isFile());
await Promise.all(
artefacts.map((item) =>
fs.promises.copyFile(
path.join(sourceDirectory, item.name),
path.join(outputDirectory, item.name),
),
),
);

logger.log('build output saved', {
outputDirectory,
artefacts: artefacts.map((item) => item.name),
});
return {
success: true,
canceled: false,
directoryPath: outputDirectory,
firmwareBinPath: path.join(
outputDirectory,
path.basename(arg.firmwareBinPath),
),
};
} catch (err) {
logger.error('failed to save build output', (err as Error)?.stack);
return failure(`Failed to save firmware files: ${err}`);
}
},
);

ipcMain.on(IpcRequest.DownloadFile, (event, arg: DownloadFileRequestBody) => {
logger.log('received a request to download file', {
url: arg.url,
Expand Down
1 change: 1 addition & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type Channels =
| IpcRequest.DownloadFile
| IpcRequest.OpenFileLocation
| IpcRequest.OpenLogsFolder
| IpcRequest.SaveBuildOutput
| IpcRequest.SaveFile
| IpcRequest.UpdateBuildStatus;

Expand Down
22 changes: 22 additions & 0 deletions src/ui/context/AppStateProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { FunctionComponent, useMemo, useState } from 'react';
import AppState from '../models/AppState';
import AppStatus from '../models/enum/AppStatus';
import FirmwareOutputMode from '../models/enum/FirmwareOutputMode';
import ThemeMode from '../models/enum/ThemeMode';
import ApplicationStorage from '../storage';

Expand All @@ -12,6 +13,8 @@ export const AppStateContext = React.createContext<{
appStatus: AppStatus.Interactive,
isExpertModeEnabled: false,
themeMode: ThemeMode.System,
firmwareOutputMode: FirmwareOutputMode.AskEveryTime,
firmwareOutputFolder: '',
},
setAppState: () => {},
});
Expand All @@ -33,6 +36,19 @@ const parseThemeMode = (value: string | null): ThemeMode => {
}
};

const parseFirmwareOutputMode = (value: string | null): FirmwareOutputMode => {
switch (value) {
case FirmwareOutputMode.AskEveryTime:
return FirmwareOutputMode.AskEveryTime;
case FirmwareOutputMode.FixedFolder:
return FirmwareOutputMode.FixedFolder;
case FirmwareOutputMode.TemporaryFolder:
return FirmwareOutputMode.TemporaryFolder;
default:
return FirmwareOutputMode.AskEveryTime;
}
};

const AppStateProvider: FunctionComponent<AppStateProviderContextProps> = ({
children,
}) => {
Expand All @@ -42,12 +58,18 @@ const AppStateProvider: FunctionComponent<AppStateProviderContextProps> = ({
appStatus: AppStatus.Interactive,
isExpertModeEnabled: storage.getExpertModeEnabled() ?? false,
themeMode: parseThemeMode(storage.getThemeMode()),
firmwareOutputMode: parseFirmwareOutputMode(
storage.getFirmwareOutputMode(),
),
firmwareOutputFolder: storage.getFirmwareOutputFolder() ?? '',
};
});
const preSetAppState = (state: AppState) => {
const storage = new ApplicationStorage();
storage.setExpertModeEnabled(state.isExpertModeEnabled);
storage.setThemeMode(state.themeMode);
storage.setFirmwareOutputMode(state.firmwareOutputMode);
storage.setFirmwareOutputFolder(state.firmwareOutputFolder);
setAppState(state);
};
const value = useMemo(
Expand Down
3 changes: 3 additions & 0 deletions src/ui/models/AppState.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import AppStatus from './enum/AppStatus';
import FirmwareOutputMode from './enum/FirmwareOutputMode';
import ThemeMode from './enum/ThemeMode';

export default interface AppState {
appStatus: AppStatus;
isExpertModeEnabled: boolean;
themeMode: ThemeMode;
firmwareOutputMode: FirmwareOutputMode;
firmwareOutputFolder: string;
}
10 changes: 10 additions & 0 deletions src/ui/models/enum/FirmwareOutputMode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
enum FirmwareOutputMode {
/** firmware stays in the temporary folder it was built in */
TemporaryFolder = 'temporary_folder',
/** ask for a destination folder after every build (default) */
AskEveryTime = 'ask_every_time',
/** always copy the firmware into a folder configured by the user */
FixedFolder = 'fixed_folder',
}

export default FirmwareOutputMode;
26 changes: 26 additions & 0 deletions src/ui/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ export interface IApplicationStorage {

getThemeMode(): string | null;

setFirmwareOutputMode(value: string): void;

getFirmwareOutputMode(): string | null;

setFirmwareOutputFolder(value: string): void;

getFirmwareOutputFolder(): string | null;

getBindingPhraseHistory(): Promise<string[]>;

addBindingPhraseToHistory(phrase: string): Promise<void>;
Expand All @@ -72,6 +80,8 @@ const FIRMWARE_SOURCE_KEY = 'firmware_source';
const UI_SHOW_FIRMWARE_PRE_RELEASES = 'ui_show_pre_releases';
const EXPERT_MODE = 'expert_mode';
const THEME_MODE_KEY = 'theme_mode';
const FIRMWARE_OUTPUT_MODE_KEY = 'firmware_output_mode';
const FIRMWARE_OUTPUT_FOLDER_KEY = 'firmware_output_folder';
const BINDING_PHRASE_HISTORY_KEY = 'binding_phrase_history';
const BINDING_PHRASE_HISTORY_LIMIT = 15;

Expand Down Expand Up @@ -276,6 +286,22 @@ export default class ApplicationStorage implements IApplicationStorage {
return localStorage.getItem(THEME_MODE_KEY);
}

setFirmwareOutputMode(value: string): void {
localStorage.setItem(FIRMWARE_OUTPUT_MODE_KEY, value);
}

getFirmwareOutputMode(): string | null {
return localStorage.getItem(FIRMWARE_OUTPUT_MODE_KEY);
}

setFirmwareOutputFolder(value: string): void {
localStorage.setItem(FIRMWARE_OUTPUT_FOLDER_KEY, value);
}

getFirmwareOutputFolder(): string | null {
return localStorage.getItem(FIRMWARE_OUTPUT_FOLDER_KEY);
}

async getBindingPhraseHistory(): Promise<string[]> {
const value = localStorage.getItem(BINDING_PHRASE_HISTORY_KEY);
if (value === null) {
Expand Down
Loading