Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
*.gif binary
*.fcp binary
*.webm binary
*.mp3 binary
5 changes: 3 additions & 2 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ name: Build and Test
on:
push:
branches: [ main, dev ]
tags:
- 'v*'
# Don't need to run on tags anymore, we always push artifacts to a draft release now.
# tags:
# - 'v*'
# pull_request:
# branches: [ main, dev ]

Expand Down
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## Unreleased

## [5.8.0] - 2025-10-13

### Added

- Added the ability to play sounds when "pass" or "fail" is received from the serial connection.

### Fixed

- Improved Bluetooth disconnection logic by de-registering peripheral event listeners when the connection is lost.

## [5.7.1] - 2025-10-11

### Fixed
Expand Down Expand Up @@ -945,7 +955,8 @@ Fixed bug where pressing Ctrl-Shift-C to copy text from a terminal would enable
- Added auto-scroll to TX pane, closes #89.
- Added special delete behaviour for backspace button when in "send on enter" mode, closes #90.

[unreleased]: https://github.com/gbmhunter/NinjaTerm/compare/v5.7.1...HEAD
[unreleased]: https://github.com/gbmhunter/NinjaTerm/compare/v5.8.0...HEAD
[5.8.0]: https://github.com/gbmhunter/NinjaTerm/compare/v5.7.1...v5.8.0
[5.7.1]: https://github.com/gbmhunter/NinjaTerm/compare/v5.7.0...v5.7.1
[5.7.0]: https://github.com/gbmhunter/NinjaTerm/compare/v5.6.0...v5.7.0
[5.6.0]: https://github.com/gbmhunter/NinjaTerm/compare/v5.5.0...v5.6.0
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ npm run dev
npm run build
```

## To Build A Single Executable

```bash
npm run dist
```

This will be generated in the `dist` directory.

## Testing

Both unit tests and end-to-end tests can be run with:
Expand Down Expand Up @@ -99,7 +107,7 @@ Arduino sketches in `arduino-serial` allow you to program different applications
1. If you have updated the app data structure, save a copy of the default app data created by the app to `local-storage-data/`. You can do this by running the app, clearing app data in `Settings > General Settings`, loading up the Chrome dev. tools, and copying the key `appData` from local storage.
1. Create pull request on GitHub merging your branch into `main`.
1. Once the build on `main` has been successfully run, merge your branch into `main` via the merge request.
1. Tag the branch on main with the version number, e.g. `v4.1.0`.
1. Tag the branch on main with the version number, e.g. `v4.1.0`. Wait for the GitHub build and publish action spawned from the merge into main to complete (so that the artifacts from the build are used in the release in the next step).
1. Find the draft release on GitHub and publish it. Enter the CHANGELOG contents into the release body text.

The app is built by GitHub Actions on every commit. If the build is successful and there is not already a non-draft release for this version number, the build artifacts will be uploaded to the release (files in existing draft releases are overwritten).
Expand Down Expand Up @@ -174,9 +182,6 @@ The files with `default` in the name are the default data for that app version.
* Prettier ESLint: Provides formatting of .tsx files.
* Playwright: Provides useful add-ons for running and debugging the Playwright E2E tests.


npm install @abandonware/noble --save --target=37.2.4 --runtime=electron --dist-url=https://electronjs.org/headers

[github-actions-status]: https://github.com/gbmhunter/NinjaTerm/actions/workflows/build-and-test.yml/badge.svg?branch=main
[github-actions-url]: https://github.com/gbmhunter/NinjaTerm/actions
[github-tag-image]: https://img.shields.io/github/tag/gbmhunter/NinjaTerm.svg?label=version
Expand Down
3 changes: 3 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export default defineConfig({
},
renderer: {
root: 'src/renderer',
// publicDir is where assets are served from that are accessible to the renderer process
// One use is for sounds,
publicDir: resolve(__dirname, 'public'),
optimizeDeps: {
include: [
'@emotion/react',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ninjaterm",
"productName": "NinjaTerm",
"version": "5.7.1",
"version": "5.8.0",
"description": "A modern, powerful serial terminal for developers and engineers.",
"author": {
"name": "Geoffrey Hunter",
Expand Down
8 changes: 8 additions & 0 deletions public/assets/sounds/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Sound Files

Place your sound files in this directory:

- **pass.mp3** - Played when "pass" is detected in received data
- **fail.mp3** - Played when "fail" is detected in received data

These files are used by the Sounds settings feature in NinjaTerm.
Binary file added public/assets/sounds/fail.mp3
Binary file not shown.
Binary file added public/assets/sounds/pass.mp3
Binary file not shown.
2 changes: 2 additions & 0 deletions src/main/Logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export function initLogging() {
mainLogger.initialize();
// {scope} is in the form "(main)" or "(renderer)". Doesn't need square brackets as already has brackets.
mainLogger.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] {scope} [{level}] {text}';
mainLogger.transports.file.level = 'info';
mainLogger.transports.console.level = 'info';

// Setting the IPC level in the main process here results in main log messages being forwarded to the renderer process and shown in the devtools console (useful for debugging without having to dig up the log file).
// Set to false to disable.
Expand Down
15 changes: 11 additions & 4 deletions src/main/MainBluetoothService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ enum ConnectionState {
* Provide a Bluetooth service running in the Electron main process for the renderer process to use.
*
* Uses the noble library under the hood to communicate with Bluetooth devices.
*
* This should probably be refactored to use a finite state machine, as the connection/disconnection logic is a bit messy, especially with all the event handler callbacks.
*/
export class MainBluetoothService {

Expand Down Expand Up @@ -283,6 +285,7 @@ export class MainBluetoothService {
const peripheral = this.discoveredDevices.find(p => p.id === deviceId);
if (!peripheral) {
log.error(`Device not found in discovered peripherals. deviceId=${deviceId}`);
this.connectionState = ConnectionState.DISCONNECTED;
return { error: 'Device not found in discovered peripherals.' };
}

Expand Down Expand Up @@ -318,6 +321,7 @@ export class MainBluetoothService {
this.connectionAttemptTimeout = null;
}
this.connectionState = ConnectionState.DISCONNECTED;
peripheral.removeAllListeners();
// Emit a IPC connection attempt complete message, indicating failure
this.mainWindow!.webContents.send('bluetooth:connection-attempt-complete', error, null);
return;
Expand Down Expand Up @@ -362,7 +366,10 @@ export class MainBluetoothService {
deviceId: this.peripheral!.id,
services: this.convertServicesToSerializable(services)
};
this.mainWindow!.webContents.send('bluetooth:connection-attempt-complete', error, bluetoothConnectionAttemptSuccess);
this.mainWindow!.webContents.send(
'bluetooth:connection-attempt-complete',
error,
bluetoothConnectionAttemptSuccess);
}

async disconnectFromDevice(deviceId: string): Promise<{ success: boolean; error?: string }> {
Expand All @@ -382,7 +389,7 @@ export class MainBluetoothService {
}
});
});

peripheral.removeAllListeners();
return { success: true };
} catch (error) {
log.error(`Failed to disconnect from Bluetooth device ${deviceId}:`, error);
Expand All @@ -407,14 +414,14 @@ export class MainBluetoothService {
// but also does not trigger an error (fails silently). This event gets triggered, so in this case we need to set connectionState to DISCONNECTED.
if (this.connectionState === ConnectionState.CONNECTING) {
log.info('Got disconnect event for peripheral, but we are still connecting to it. Setting connectionState to DISCONNECTED.');
this.connectionState = ConnectionState.DISCONNECTED;
// Emit a IPC connection attempt complete message, indicating failure
this.mainWindow!.webContents.send('bluetooth:connection-attempt-complete', 'Device disconnected while still connecting and scanning for services and characteristics.', null);
}

const deviceId = peripheral.id;
log.info(`Bluetooth device disconnected: ${deviceId}`);
this.connectionState = ConnectionState.DISCONNECTED;
peripheral.removeAllListeners();
this.peripheral = null;
this.discoveredServices = [];
this.txCharacteristic = null;
Expand Down Expand Up @@ -465,7 +472,7 @@ export class MainBluetoothService {
}
});
txCharacteristic.on('data', (data: Buffer) => {
log.info(`Received data from ${peripheral.id} on write characteristic. data: ${data.toString('hex')}`);
log.debug(`Received data from ${peripheral.id} on write characteristic. data: ${data.toString('hex')}`);
// Send data to renderer
this.mainWindow?.webContents.send('bluetooth:data-received', peripheral.id, data);
});
Expand Down
15 changes: 14 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, dialog, Menu, shell } from 'electron';
import { app, BrowserWindow, ipcMain, dialog, Menu, shell, session } from 'electron';
import pkg from 'electron-updater';
const { autoUpdater } = pkg;
import * as path from 'path';
Expand Down Expand Up @@ -165,6 +165,19 @@ app.whenReady().then(async () => {

log.info('Main process started.');

// Uncomment this if we want to retry applying CSP correctly
// Apply Content Security Policy (CSP)
// session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
// callback({
// responseHeaders: {
// ...details.responseHeaders,
// 'Content-Security-Policy': [
// "default-src 'self'; script-src 'self' 'unsafe-inline' https://accounts.google.com https://*.gstatic.com; style-src 'self' 'unsafe-inline' https://accounts.google.com https://*.gstatic.com; img-src 'self' data: https://*.gstatic.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://accounts.google.com https://www.googleapis.com; frame-src https://accounts.google.com;"
// ],
// },
// });
// });

createWindow();

// Initialize serial handlers
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>
85 changes: 80 additions & 5 deletions src/renderer/src/model/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { AppDataManager } from './AppDataManager/AppDataManager';
import PerformanceMonitor from './Performance/PerformanceMonitor';
import PerformanceTester, { PerformanceTestSuiteResult } from './Performance/PerformanceTester';
import { ConnController } from './ConnController/ConnController';
import { SoundPlayer } from './Util/SoundPlayer';

declare global {
interface String {
Expand Down Expand Up @@ -130,6 +131,16 @@ export class App {
*/
connController: ConnController;

/**
* Sound player for playing audio feedback based on received data.
*/
soundPlayer: SoundPlayer;

/**
* Buffer for detecting pass/fail strings across data chunks.
*/
private soundDetectionBuffer: string = '';
private readonly SOUND_DETECTION_BUFFER_MAX_LENGTH = 100;

constructor(testing = false) {
initLogging();
Expand All @@ -155,6 +166,8 @@ export class App {

this.connController = new ConnController(this);

this.soundPlayer = new SoundPlayer();

this.terminals = new Terminals(this);

this.numBytesReceived = 0;
Expand Down Expand Up @@ -208,6 +221,7 @@ export class App {
this.connController.cleanup();
this.stopRateCalculation();
this.stopCpuMonitoring();
this.soundPlayer.cleanup();

// Clean up auto-updater listeners
if ((window as any).electronAPI?.updater) {
Expand Down Expand Up @@ -555,12 +569,9 @@ export class App {
}

/**
* In normal operation this is called from the readUntilClose() function above.
* This is called from whatever connection type is currently being used. All data should be funnelled through this function no matter what the connection type is.
*
* Unit tests call this instead of mocking out the serial port read() function
* as setting up the deferred promise was too tricky.
*
* @param rxData
* @param rxData The received data.
*/
parseRxData(rxData: Uint8Array) {
// Start performance monitoring for data processing
Expand All @@ -578,6 +589,11 @@ export class App {

this.logging.handleRxData(rxData);

// Check for pass/fail strings and play sounds if enabled
if (this.settings.soundsSettings.playSoundsOnPassFail) {
this.detectAndPlaySounds(rxData);
}

// End performance monitoring and record metrics
const totalProcessingTime = this.performanceMonitor.endTiming('dataProcessing');
this.performanceMonitor.recordDataProcessing(rxData.length, totalProcessingTime);
Expand All @@ -587,6 +603,65 @@ export class App {
this.recordRxDataPoint(rxData.length);
}

/**
* Detects "pass" and "fail" strings in received data and plays appropriate sounds.
* Uses a buffer to handle detection across data chunks.
* Finds all occurrences of both patterns and plays them in order.
*
* @param rxData The received data as a Uint8Array
*/
private detectAndPlaySounds(rxData: Uint8Array) {
// Convert received data to string (lowercase for case-insensitive matching)
const dataStr = new TextDecoder().decode(rxData).toLowerCase();

// Add new data to buffer
this.soundDetectionBuffer += dataStr;

// Find all occurrences of both patterns
const foundPatterns: Array<{index: number, type: 'pass' | 'fail', length: number}> = [];

// Find all "pass" occurrences
let searchIndex = 0;
while ((searchIndex = this.soundDetectionBuffer.indexOf('pass', searchIndex)) !== -1) {
foundPatterns.push({index: searchIndex, type: 'pass', length: 4});
searchIndex += 4; // Move past this occurrence
}

// Find all "fail" occurrences
searchIndex = 0;
while ((searchIndex = this.soundDetectionBuffer.indexOf('fail', searchIndex)) !== -1) {
foundPatterns.push({index: searchIndex, type: 'fail', length: 4});
searchIndex += 4; // Move past this occurrence
}

// Sort by index to play sounds in order they appear
foundPatterns.sort((a, b) => a.index - b.index);

// Play sounds in the order they appear
for (const pattern of foundPatterns) {
if (pattern.type === 'pass') {
this.soundPlayer.playDing();
} else {
this.soundPlayer.playBuzzer();
}
}

// Clear buffer only up to the end of the last found pattern
// This preserves any partial patterns at the end of the buffer
if (foundPatterns.length > 0) {
const lastPattern = foundPatterns[foundPatterns.length - 1];
const endOfLastPattern = lastPattern.index + lastPattern.length;
// Keep everything after the last pattern to preserve partial matches
this.soundDetectionBuffer = this.soundDetectionBuffer.slice(endOfLastPattern);
}

// Keep buffer length manageable
if (this.soundDetectionBuffer.length > this.SOUND_DETECTION_BUFFER_MAX_LENGTH) {
// Keep only the last portion of the buffer to catch strings split across chunks
this.soundDetectionBuffer = this.soundDetectionBuffer.slice(-this.SOUND_DETECTION_BUFFER_MAX_LENGTH);
}
}


/**
* Run performance tests to measure baseline performance and identify bottlenecks
Expand Down
Loading