Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 82 additions & 0 deletions scripts/check-i18n-quality.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3755,6 +3755,87 @@ function checkReticulumMapIssues(ctx) {
return issues;
}

/**
* Post-v5.25.0 false friends: backbone anatomy, Nomad “left”, hub→backbone picker,
* zh recipes tabAll, ja spaced I2P.
* @param {LocaleQualityCtx} ctx
* @returns {string[]}
*/
function checkPreReleaseLocaleAccuracyIssues(ctx) {
const { locale, flatKey, val, enVal } = ctx;
const issues = [];
if (locale === 'en') return issues;

const BACKBONE_ANATOMY = [
{ re: /colonne vertébrale/i, hint: 'use network backbone, not spine/anatomy' },
{ re: /spina dorsale/i, hint: 'use network backbone (dorsale), not spine anatomy' },
{ re: /tulang punggung/i, hint: 'use backbone (network), not spine anatomy' },
{ re: /\bkręgosłup/i, hint: 'use magistrala/sieć szkieletowa, not kręgosłup' },
];

if (
flatKey === 'connectionPanel.reticulumInterfaces.defaultHubRegion.primary_global' ||
flatKey === 'connectionPanel.reticulumInterfaces.addDefaultHubs' ||
flatKey === 'connectionPanel.reticulumInterfaces.defaultHubsPickerTitle'
) {
for (const { re, hint } of BACKBONE_ANATOMY) {
if (re.test(val)) {
issues.push(`${flatKey} false friend: ${hint}`);
}
}
}

if (
flatKey === 'connectionPanel.reticulumInterfaces.defaultHubsPickerTitle' &&
/network hub|netzwerk-hub|concentrator|集线器|ネットワークハブ|hub jaringan|ağ hub/i.test(val)
) {
issues.push(
'defaultHubsPickerTitle must use backbone wording aligned with addDefaultHubs, not network hubs',
);
}

if (
(flatKey === 'nomadNetwork.pageLoadingCountdown' ||
flatKey === 'nomadNetwork.pageLoadingRetryCountdown' ||
flatKey === 'nomadNetwork.pageLoadingTimeLeft') &&
enVal.includes('left')
) {
if (
/\bgauche\b|\bizquierda\b|\bsinistra\b|\besquerda\b|(?:^|[^\p{L}])左(?:$|[^\p{L}])|\bліворуч\b/iu.test(
val,
)
) {
issues.push(
`${flatKey}: translate "left" as time remaining, not direction (gauche/izquierda/左/…)`,
);
}
}

if (flatKey === 'nodeListPanel.tabAll' && locale === 'zh' && /食谱/.test(val)) {
issues.push('nodeListPanel.tabAll must mean all nodes, not recipes (食谱)');
}

if (
(flatKey === 'connectionPanel.reticulumInterfaces.purpose.i2p' ||
flatKey === 'connectionPanel.reticulumInterfaces.backboneEnableGuidanceBody' ||
flatKey === 'networkPanel.reticulumStackSettings.pathMediumPreferenceHint') &&
locale === 'ja' &&
/I\s+2\s+P/.test(val)
) {
issues.push(`${flatKey}: keep I2P as contiguous token (not "I 2 P")`);
}

if (
flatKey === 'networkPanel.reticulumStackSettings.pathMediumPreference' &&
locale === 'es' &&
/\bPath\b/.test(val)
) {
issues.push('pathMediumPreference must not leave English "Path" in Spanish');
}

return issues;
}

/** RRC chat rooms — not hotel rooms; slash commands must stay wire tokens. */
export const RRC_PREFIX = 'rrc.';

Expand Down Expand Up @@ -3929,6 +4010,7 @@ const LOCALE_STRING_QUALITY_CHECKS = [
checkReticulumDefaultHubKeyIssues,
checkRepeatersCliIssues,
checkReticulumMapIssues,
checkPreReleaseLocaleAccuracyIssues,
checkHopAwayVerbFalseFriendIssues,
checkAirTimeFalseFriendIssues,
checkWireTokenLiteralPreservedIssues,
Expand Down
40 changes: 40 additions & 0 deletions scripts/check-i18n-quality.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2067,4 +2067,44 @@ describe('sniffer tab and MQTT channel PSK i18n quality', () => {
});
expectIssue(issues, 'rrc false friend');
});

it('flags Nomad countdown "left" translated as direction', () => {
const issues = localeStringQualityIssues({
locale: 'es',
flatKey: 'nomadNetwork.pageLoadingCountdown',
enVal: 'Loading page… {{time}} left',
val: 'Cargando página... {{time}} izquierda',
});
expectIssue(issues, 'time remaining');
});

it('flags zh nodeListPanel.tabAll recipes false friend', () => {
const issues = localeStringQualityIssues({
locale: 'zh',
flatKey: 'nodeListPanel.tabAll',
enVal: 'All',
val: '所有食谱',
});
expectIssue(issues, 'recipes');
});

it('flags anatomy backbone on primary_global', () => {
const issues = localeStringQualityIssues({
locale: 'fr',
flatKey: 'connectionPanel.reticulumInterfaces.defaultHubRegion.primary_global',
enVal: 'Primary & Global Backbone',
val: 'Colonne vertébrale principale et mondiale',
});
expectIssue(issues, 'spine/anatomy');
});

it('flags ja spaced I2P token', () => {
const issues = localeStringQualityIssues({
locale: 'ja',
flatKey: 'connectionPanel.reticulumInterfaces.purpose.i2p',
enVal: 'I2P backbone via a router on this machine.',
val: 'I 2 P バックボーン',
});
expectIssue(issues, 'I2P');
});
});
22 changes: 22 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ import { useReticulumIdentityStore } from './stores/reticulumIdentityStore';
import { useReticulumPeerStore } from './stores/reticulumPeerStore';
import { useRncpTransferStore } from './stores/rncpTransferStore';
import { useRrcSessionStore } from './stores/rrcSessionStore';
import { useTimeFormatStore } from './stores/timeFormatStore';

// Tabs capability filtering lives in appTabMappings.ts (computeTabMappings).

Expand Down Expand Up @@ -498,6 +499,27 @@ function AppContent() {
window.removeEventListener('mesh-client:rncp-offer', onOffer);
};
}, [addToast, t]);

// Reconcile 24h clock from SQLite early — AppPanel is lazy and Chat reads the store first.
useEffect(() => {
let cancelled = false;
void window.electronAPI.appSettings
.getAll()
.then((raw) => {
if (cancelled) return;
const use24 = raw?.use24HourTime;
if (use24 === 'true' || use24 === 'false') {
useTimeFormatStore.getState().hydrateFromSqlite(use24 === 'true');
}
})
.catch((err: unknown) => {
console.warn('[App] use24HourTime hydrate failed ' + errLikeToLogString(err));
});
return () => {
cancelled = true;
};
}, []);

const runtimes = useAllRuntimes();
const meshtasticRuntime = runtimes.meshtastic as unknown as MeshtasticRuntime;
const meshcoreRuntime = runtimes.meshcore as unknown as MeshcoreRuntime;
Expand Down
50 changes: 48 additions & 2 deletions src/renderer/lib/connectedMeshcoreBleMac.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';

import {
getConnectedMeshcoreBleMac,
readMeshcoreWebBluetoothDeviceId,
resetConnectedMeshcoreBleMacForTests,
resolveConnectedMeshcoreBleIdentity,
resolveConnectedMeshcoreBleMacForSuppression,
setConnectedMeshcoreBleMac,
} from './connectedMeshcoreBleMac';
import { shouldSuppressMeshtasticNodeHear } from './meshcoreBleMacMeshtasticNodeId';

describe('resolveConnectedMeshcoreBleIdentity', () => {
it('prefers explicit blePeripheralId over Web Bluetooth and last-id fallbacks', () => {
Expand All @@ -16,7 +21,7 @@ describe('resolveConnectedMeshcoreBleIdentity', () => {
).toBe('aa:bb:cc:dd:ee:ff');
});

it('uses Web Bluetooth device id when peripheral id is missing (Linux)', () => {
it('uses Web Bluetooth device id when peripheral id is missing (Linux reconnect identity)', () => {
expect(
resolveConnectedMeshcoreBleIdentity({
blePeripheralId: undefined,
Expand Down Expand Up @@ -47,6 +52,47 @@ describe('resolveConnectedMeshcoreBleIdentity', () => {
});
});

describe('resolveConnectedMeshcoreBleMacForSuppression', () => {
it('skips opaque Web Bluetooth UUIDs and prefers a parseable MAC fallback', () => {
expect(
resolveConnectedMeshcoreBleMacForSuppression({
blePeripheralId: undefined,
webBluetoothDeviceId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
fallbackLastBlePeripheralId: 'cc:2e:e3:da:2e:2f',
}),
).toBe('cc:2e:e3:da:2e:2f');
});

it('returns null when only opaque Linux Web Bluetooth ids are available', () => {
expect(
resolveConnectedMeshcoreBleMacForSuppression({
blePeripheralId: undefined,
webBluetoothDeviceId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
fallbackLastBlePeripheralId: 'stored-opaque-uuid',
}),
).toBeNull();
});
});

describe('setConnectedMeshcoreBleMac', () => {
afterEach(() => {
resetConnectedMeshcoreBleMacForTests();
});

it('stores parseable Noble MACs', () => {
setConnectedMeshcoreBleMac('cc:2e:e3:da:2e:2f');
expect(getConnectedMeshcoreBleMac()).toBe('cc:2e:e3:da:2e:2f');
expect(shouldSuppressMeshtasticNodeHear(0xe3da2e2f, getConnectedMeshcoreBleMac())).toBe(true);
});

it('clears instead of storing opaque Web Bluetooth UUIDs', () => {
setConnectedMeshcoreBleMac('cc:2e:e3:da:2e:2f');
setConnectedMeshcoreBleMac('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
expect(getConnectedMeshcoreBleMac()).toBeNull();
expect(shouldSuppressMeshtasticNodeHear(0xe3da2e2f, getConnectedMeshcoreBleMac())).toBe(false);
});
});

describe('readMeshcoreWebBluetoothDeviceId', () => {
it('reads getWebBluetoothDeviceId from duck-typed connections', () => {
expect(
Expand Down
43 changes: 40 additions & 3 deletions src/renderer/lib/connectedMeshcoreBleMac.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
/** BLE MAC / peripheral id of the live MeshCore RF link (null when not BLE-connected). */
import { meshcoreBleMacToMeshtasticNodeId } from './meshcoreBleMacMeshtasticNodeId';

/** BLE MAC of the live MeshCore RF link (null when not BLE-connected or id is not a MAC). */
let connectedMeshcoreBleMac: string | null = null;

/** Called from MeshCore runtime when a BLE session connects or disconnects. */
/**
* Called from MeshCore runtime when a BLE session connects or disconnects.
* Only stores ids that parse as a 12-hex BLE MAC (Noble peripheral id). Linux
* Web Bluetooth device ids are opaque UUIDs — storing them would never match a
* Meshtastic nodeNum, so we clear instead of pretending they are MACs.
*/
export function setConnectedMeshcoreBleMac(mac: string | null): void {
const trimmed = mac?.trim() ?? '';
connectedMeshcoreBleMac = trimmed.length > 0 ? trimmed : null;
if (trimmed.length === 0) {
connectedMeshcoreBleMac = null;
return;
}
if (meshcoreBleMacToMeshtasticNodeId(trimmed) == null) {
connectedMeshcoreBleMac = null;
return;
}
connectedMeshcoreBleMac = trimmed;
}

export function getConnectedMeshcoreBleMac(): string | null {
Expand All @@ -14,6 +29,7 @@ export function getConnectedMeshcoreBleMac(): string | null {
/**
* Prefer an explicit Noble peripheral id, then a live Web Bluetooth device id,
* then a remembered last-BLE id (Linux chooser may omit blePeripheralId on connect).
* Used for reconnect identity — may return opaque Web BT UUIDs on Linux.
*/
export function resolveConnectedMeshcoreBleIdentity(opts: {
blePeripheralId?: string | null;
Expand All @@ -31,6 +47,27 @@ export function resolveConnectedMeshcoreBleIdentity(opts: {
return null;
}

/**
* First candidate that parses as a BLE MAC for Meshtastic ghost suppression.
* Skips opaque Linux Web Bluetooth device ids.
*/
export function resolveConnectedMeshcoreBleMacForSuppression(opts: {
blePeripheralId?: string | null;
webBluetoothDeviceId?: string | null;
fallbackLastBlePeripheralId?: string | null;
}): string | null {
for (const candidate of [
opts.blePeripheralId,
opts.webBluetoothDeviceId,
opts.fallbackLastBlePeripheralId,
]) {
const trimmed = candidate?.trim() ?? '';
if (trimmed.length === 0) continue;
if (meshcoreBleMacToMeshtasticNodeId(trimmed) != null) return trimmed;
}
return null;
}

/** Duck-typed read of MeshcoreWebBluetoothConnection.getWebBluetoothDeviceId(). */
export function readMeshcoreWebBluetoothDeviceId(conn: unknown): string | null {
if (!conn || typeof conn !== 'object') return null;
Expand Down
46 changes: 46 additions & 0 deletions src/renderer/lib/drivers/PacketRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,4 +407,50 @@ describe('PacketRouter', () => {
packetRouter.dispatch({ type: 'nonexistent' } as unknown as DomainEvent, ID);
}).not.toThrow();
});

it('skips Meshtastic node_info store writes for MeshCore BLE MAC ghost nodes', async () => {
const { setConnectedMeshcoreBleMac, resetConnectedMeshcoreBleMacForTests } =
await import('../connectedMeshcoreBleMac');
addIdentity({
id: ID_MT,
protocol: meshtasticProtocol,
signature: 'sig-mt-ghost',
transports: [],
createdAt: 1,
lastSeenAt: 1,
});
const ghostId = 0xe3da2e2f;
const priorHeard = 1_700_000_000_000;
useNodeStore.setState({
nodes: {
[ID_MT]: {
[ghostId]: {
nodeId: ghostId,
longName: 'Blue',
shortName: 'BLUE',
lastHeardAt: priorHeard,
},
},
},
});
setConnectedMeshcoreBleMac('cc:2e:e3:da:2e:2f');
const listener = vi.fn();
const detach = packetRouter.addListener(listener);
packetRouter.dispatch(
{
type: 'node_info',
payload: {
nodeId: ghostId,
longName: 'Blue',
shortName: 'BLUE',
lastHeardAt: Date.now(),
},
},
ID_MT,
);
expect(useNodeStore.getState().nodes[ID_MT][ghostId].lastHeardAt).toBe(priorHeard);
expect(listener).not.toHaveBeenCalled();
detach();
resetConnectedMeshcoreBleMacForTests();
});
});
Loading