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
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
50 changes: 50 additions & 0 deletions scripts/check-i18n-quality.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2067,4 +2067,54 @@ 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');
});

it('flags Spanish pathMediumPreference leaving English Path', () => {
const issues = localeStringQualityIssues({
locale: 'es',
flatKey: 'networkPanel.reticulumStackSettings.pathMediumPreference',
enVal: 'Preferred path medium',
val: 'Medio Path preferido',
});
expectIssue(issues, 'English "Path"');
});
});
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
Loading