From bc82be345e0a44915f76c60fe365347325b464f8 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 20:29:12 -0600 Subject: [PATCH 1/3] chore: bump deps --- package.json | 4 +- pnpm-lock.yaml | 235 ++++++++++++++++++++++++------------------------- 2 files changed, 119 insertions(+), 120 deletions(-) diff --git a/package.json b/package.json index 06248c608..e10af70a6 100644 --- a/package.json +++ b/package.json @@ -185,7 +185,7 @@ "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", - "@vitejs/plugin-react": "^6.0.4", + "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.10", "concurrently": "^9.2.4", "electron": "^41.10.3", @@ -218,7 +218,7 @@ "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", - "vite": "^8.1.5", + "vite": "^8.2.0", "vitest": "^4.1.10", "vitest-axe": "1.0.0-pre.5", "zustand": "^5.0.14" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54b47b78a..4053c985d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,8 +181,8 @@ importers: specifier: ^8.65.0 version: 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) '@vitejs/plugin-react': - specifier: ^6.0.4 - version: 6.0.4(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + specifier: ^6.0.5 + version: 6.0.5(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.10 version: 4.1.10(vitest@4.1.10) @@ -280,11 +280,11 @@ importers: specifier: ^8.65.0 version: 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) vite: - specifier: ^8.1.5 - version: 8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + specifier: ^8.2.0 + version: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) vitest-axe: specifier: 1.0.0-pre.5 version: 1.0.0-pre.5(vitest@4.1.10) @@ -481,14 +481,14 @@ packages: engines: {node: '>=14.14'} hasBin: true - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} @@ -807,8 +807,8 @@ packages: resolution: {integrity: sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} '@peculiar/asn1-schema@2.8.0': resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} @@ -875,97 +875,96 @@ packages: engines: {node: ^v12.20.0 || ^14.13.0 || >=16.0.0} hasBin: true - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1433,8 +1432,8 @@ packages: resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-react@6.0.4': - resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} + '@vitejs/plugin-react@6.0.5': + resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 @@ -1666,8 +1665,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.7: - resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} + baseline-browser-mapping@2.11.8: + resolution: {integrity: sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -2106,8 +2105,8 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.24.4: - resolution: {integrity: sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -2407,8 +2406,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.3: - resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} @@ -3983,8 +3982,8 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4501,13 +4500,13 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5033,18 +5032,18 @@ snapshots: - supports-color optional: true - '@emnapi/core@1.11.1': + '@emnapi/core@2.0.0-alpha.3': dependencies: - '@emnapi/wasi-threads': 1.2.2 + '@emnapi/wasi-threads': 2.0.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.1': + '@emnapi/runtime@2.0.0-alpha.3': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.2': + '@emnapi/wasi-threads@2.0.1': dependencies: tslib: 2.8.1 optional: true @@ -5266,10 +5265,10 @@ snapshots: commander: 12.1.0 crypto-js: 4.2.0 - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 '@tybys/wasm-util': 0.10.3 optional: true @@ -5301,7 +5300,7 @@ snapshots: dependencies: semver: 7.8.5 - '@oxc-project/types@0.139.0': {} + '@oxc-project/types@0.142.0': {} '@peculiar/asn1-schema@2.8.0': dependencies: @@ -5367,53 +5366,53 @@ snapshots: '@reteps/dockerfmt-linux-arm64': 0.5.4 '@reteps/dockerfmt-linux-x64': 0.5.4 - '@rolldown/binding-android-arm64@1.1.5': + '@rolldown/binding-android-arm64@1.2.1': optional: true - '@rolldown/binding-darwin-arm64@1.1.5': + '@rolldown/binding-darwin-arm64@1.2.1': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rolldown/binding-darwin-x64@1.2.1': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rolldown/binding-freebsd-x64@1.2.1': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rolldown/binding-linux-arm64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rolldown/binding-linux-arm64-musl@1.2.1': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rolldown/binding-linux-ppc64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rolldown/binding-linux-s390x-gnu@1.2.1': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rolldown/binding-linux-x64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rolldown/binding-linux-x64-musl@1.2.1': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': + '@rolldown/binding-openharmony-arm64@1.2.1': optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': + '@rolldown/binding-wasm32-wasi@1.2.1': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': + '@rolldown/binding-win32-arm64-msvc@1.2.1': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rolldown/binding-win32-x64-msvc@1.2.1': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -5565,7 +5564,7 @@ snapshots: '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.24.4 + enhanced-resolve: 5.24.5 jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 @@ -5890,10 +5889,10 @@ snapshots: '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: @@ -5907,7 +5906,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -5918,13 +5917,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.7': dependencies: @@ -6173,7 +6172,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.7: {} + baseline-browser-mapping@2.11.8: {} bidi-js@1.0.3: dependencies: @@ -6208,7 +6207,7 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.7 + baseline-browser-mapping: 2.11.8 caniuse-lite: 1.0.30001806 electron-to-chromium: 1.5.398 node-releases: 2.0.51 @@ -6654,7 +6653,7 @@ snapshots: emoji-regex@9.2.2: {} - enhanced-resolve@5.24.4: + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -7094,10 +7093,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.3 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.4.3: {} + flatted@3.4.4: {} for-each@0.3.5: dependencies: @@ -8758,26 +8757,26 @@ snapshots: sprintf-js: 1.1.3 optional: true - rolldown@1.1.5: + rolldown@1.2.1: dependencies: - '@oxc-project/types': 0.139.0 + '@oxc-project/types': 0.142.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 run-parallel@1.2.0: dependencies: @@ -9376,12 +9375,12 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): + vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.25 - rolldown: 1.1.5 + rolldown: 1.2.1 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.5 @@ -9396,12 +9395,12 @@ snapshots: axe-core: 4.12.1 chalk: 5.6.2 lodash-es: 4.18.1 - vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - vitest@4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -9418,7 +9417,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 From f87f079207185d216c0af1310b7fe2b470edc31e Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 20:48:56 -0600 Subject: [PATCH 2/3] fix: harden error handling, IPC, i18n, and audit follow-ups Address the codebase quality audit: catch fire-and-forget LXMF ingest and stack stop failures, validate/rate-limit expensive IPC, localize LetsMesh guards and fix IT rooms false friend, add missing Remote/repeater tests, dedupe query-limit helpers, and correct docs drift. Includes ConnectionDriver connect/disconnect serialization and dead bindReticulumSession removal. --- README.md | 4 +- docs/contributing.md | 8 +- docs/development-environment.md | 2 +- docs/diagnostics.md | 2 +- docs/index.md | 2 +- docs/reticulum-sidecar-ipc.md | 3 +- docs/troubleshooting.md | 21 +- scripts/check-i18n-quality.mjs | 8 +- src/main/chat-export.test.ts | 54 +++- src/main/chatExportFormat.ts | 48 ++++ src/main/index.ipc-security.test.ts | 80 +++++- src/main/index.ts | 139 +++++++-- src/main/ipc/reticulum-db-handlers.ts | 5 +- src/main/ipc/reticulum-handlers.ts | 75 ++++- src/main/ipc/rrc-db-handlers.ts | 3 +- src/main/ipc/tak-handlers.ts | 30 +- src/main/ipcRateLimit.test.ts | 46 +++ src/main/ipcRateLimit.ts | 40 +++ src/main/noble-ble-manager.ts | 1 + .../components/ContactGroupsModal.test.tsx | 9 +- src/renderer/components/DiagnosticsPanel.tsx | 3 +- .../MeshcoreContactSettingsSection.test.tsx | 9 +- .../MeshcoreTelemetryPrivacySection.test.tsx | 8 +- src/renderer/components/ModulePanel.test.tsx | 9 +- src/renderer/components/RepeatersPanel.tsx | 3 +- .../components/ReticulumStackPanel.tsx | 7 +- .../components/SecurityPanel.test.tsx | 9 +- .../remote/RemoteTransferSection.test.tsx | 97 +++++++ .../hooks/useLongSessionMaintenance.ts | 5 +- src/renderer/lib/drivers/ConnectionDriver.ts | 199 +++++++------ .../lib/letsMeshConnectionGuards.test.ts | 21 +- src/renderer/lib/letsMeshConnectionGuards.ts | 9 +- src/renderer/lib/logAnalyzerI18n.test.ts | 63 +++++ .../lib/meshcoreRepeaterRpcCommon.test.ts | 265 ++++++++++++++++++ .../lib/meshcoreWebBluetoothConnection.ts | 1 + src/renderer/lib/messageRetention.ts | 47 ++-- .../lib/rncpTransferUiHelpers.test.ts | 115 ++++++++ src/renderer/lib/sessions/reticulumSession.ts | 19 -- .../lib/sourceContractTestHelpers.test.ts | 6 + src/renderer/lib/sourceContractTestHelpers.ts | 10 + src/renderer/lib/testRenderHelpers.tsx | 9 + src/renderer/locales/cs/translation.json | 6 +- src/renderer/locales/de/translation.json | 6 +- src/renderer/locales/en/translation.json | 4 + src/renderer/locales/es/translation.json | 6 +- src/renderer/locales/fr/translation.json | 6 +- src/renderer/locales/id/translation.json | 6 +- src/renderer/locales/it/translation.json | 8 +- src/renderer/locales/ja/translation.json | 6 +- src/renderer/locales/ko/translation.json | 6 +- src/renderer/locales/nl/translation.json | 6 +- src/renderer/locales/pl/translation.json | 6 +- src/renderer/locales/pt-BR/translation.json | 6 +- src/renderer/locales/ru/translation.json | 6 +- src/renderer/locales/tr/translation.json | 6 +- src/renderer/locales/uk/translation.json | 6 +- src/renderer/locales/zh/translation.json | 6 +- .../useMeshcoreRuntime.reconnect.test.ts | 3 +- ...htasticRuntime.reconnect-hardening.test.ts | 3 +- src/renderer/runtime/useMeshtasticRuntime.ts | 6 +- ...time.inbound-lxmf-catchup.contract.test.ts | 47 ++++ ...ticulumRuntime.reconnect-hardening.test.ts | 7 +- .../runtime/useReticulumRuntime.rrc.test.ts | 8 +- src/renderer/runtime/useReticulumRuntime.ts | 46 ++- src/shared/clampQueryLimit.test.ts | 25 ++ src/shared/clampQueryLimit.ts | 12 + vitest.config.ts | 1 + 67 files changed, 1464 insertions(+), 284 deletions(-) create mode 100644 src/main/ipcRateLimit.test.ts create mode 100644 src/main/ipcRateLimit.ts create mode 100644 src/renderer/components/remote/RemoteTransferSection.test.tsx create mode 100644 src/renderer/lib/logAnalyzerI18n.test.ts create mode 100644 src/renderer/lib/meshcoreRepeaterRpcCommon.test.ts create mode 100644 src/renderer/lib/rncpTransferUiHelpers.test.ts create mode 100644 src/renderer/lib/testRenderHelpers.tsx create mode 100644 src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts create mode 100644 src/shared/clampQueryLimit.test.ts create mode 100644 src/shared/clampQueryLimit.ts diff --git a/README.md b/README.md index 971f7f344..57eeccbaf 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ Mesh-Client supports **three mesh stacks** in one desktop app. Use the header ** - **Tri-protocol switcher**: Meshtastic, MeshCore, and Reticulum run simultaneously; per-protocol unread badges (green / cyan / amber); passive toast notifications when an inactive protocol receives traffic - **Localization**: 16 languages via static JSON bundles; fully offline — see [Localization & Languages](docs/localization.md) -- **Accessibility**: modal focus trap, screen reader labels, reduce-motion toggle — see [Accessibility Checklist](docs/accessibility-checklist.md) +- **Accessibility**: modal focus trap, screen reader labels, reduce-motion and **Use 24-hour time** toggles in App → Appearance — see [Accessibility Checklist](docs/accessibility-checklist.md) - **Log panel**: live stream, **Analyze** heuristics, export/delete; Reticulum sidecar lines tagged `[ReticulumSidecar]` - **SQLite persistence**: protocol-scoped history and settings; DB export/import/clear in the App tab; **Export for GitHub** (zip: debug snapshot + logs) and **Export for Developer** (includes full SQLite — share privately only) - **Updates & tray**: footer update status; system tray unread badge when the window is backgrounded @@ -202,7 +202,7 @@ These sections apply to the two LoRa companion-radio stacks. Reticulum uses the **Node Management** - Node list with SNR, battery, GPS, **last heard** (any live RF packet—position, telemetry, traceroute, text—not only chat); **signal bars** appear only for direct (0-hop) RF neighbors; multi-hop and MQTT-only paths omit bars; SNR in traces and neighbor views uses **color-coded quality** (good / marginal / poor) -- **Cross-Protocol Signal Analyzer**: foreign LoRa traffic detection (MeshCore, Reticulum RNS, other Meshtastic, unknown) on the **Meshtastic** Diagnostics tab and in node detail when RF rows are present; not shown on MeshCore/Reticulum tabs +- **Cross-Protocol Signal Analyzer**: foreign LoRa traffic detection (MeshCore, Reticulum RNS, other Meshtastic, unknown) on the **Meshtastic** and **MeshCore** Diagnostics tabs and in node detail when RF rows are present; not shown on the Reticulum Diagnostics tab - Distance filter, favorite/pin nodes, device role icons - Node Detail Modal: DM, trace route with per-hop display, delete node, neighbor info, **Map Report** (Meshtastic), PaxCounter, Detection Sensor, **channel utilization** (Meshtastic), **export/share contact** (MeshCore), **node notes** (free-text, SQLite-persisted), **watch / notify** (OS desktop notification on online/offline transition) - **Node Health Score**: composite 0–100 badge on each node row (signal 40 pts, recency 30 pts, load 20 pts, battery 10 pts); color-coded green / yellow / red with tooltip breakdown diff --git a/docs/contributing.md b/docs/contributing.md index b2e29fd75..065b4748d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -14,7 +14,13 @@ contributor guide lives in the repository at ## Quality Checks -Run these before opening a PR: +Prefer **`pnpm run check:pr`** before opening a PR (lint + typecheck + `typecheck:strict-shared` + full `test:run`, plus path-aware sidecar checks when relevant): + +```bash +pnpm run check:pr +``` + +Or run pieces individually: ```bash pnpm run test:run diff --git a/docs/development-environment.md b/docs/development-environment.md index c620bbfe7..8a8dd0d97 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -134,7 +134,7 @@ curl -s http://127.0.0.1:19437/api/v1/status # → {"status":"ok",...} ``` -In Electron dev: open the **Reticulum** protocol pill (amber) → **Connection** → **Start stack**. Then use **Radio** to generate/import identity and manage interfaces (add, edit, delete). Dev builds resolve the binary from `reticulum-sidecar/target/debug/mesh-client-reticulum`. +In Electron dev: open the **Reticulum** protocol pill (amber) → **Connection** → **Start stack**. Then use **Network** for identity (generate/import) and **Connection** for interfaces (add, edit, delete). Dev builds resolve the binary from `reticulum-sidecar/target/debug/mesh-client-reticulum`. #### Keep Rust and the sidecar current diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 8704fa3c4..17411b8aa 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -9,7 +9,7 @@ This document is the authoritative reference for every diagnostic output in Mesh - **NodeListPanel**: inline anomaly badges, redundancy `+N` echo count, MQTT-only node dimming, Node Health Score badge, JSON export - **MapPanel**: channel utilization halos, routing anomaly aura circles - **RF Histograms panel**: SNR, RSSI, and hop-count bar charts across all nodes -- **Peer Graph panel**: SVG force-directed graph of directly connected nodes (hops 0–1) +- **Peer Graph panel**: SVG force-directed graph of directly connected nodes (hops 0–1) — **Meshtastic and MeshCore only**; Reticulum uses the **Topology** tab instead All three protocols share one **Diagnostics** sidebar tab; sections differ by `ProtocolCapabilities` (see **Multi-protocol tab scoping** below). diff --git a/docs/index.md b/docs/index.md index 431e9a2e7..8fda680f2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,7 +74,7 @@ Also useful: - Noble BLE coexistence when a Reticulum BLE RNode is connected — [Troubleshooting](troubleshooting.md#reticulum-ble-rnode-blocks-meshtasticmeshcore-noble-ble) - Sidecar build / start failures — [Troubleshooting](troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) - **Support** - - [Diagnostics](diagnostics.md) — LoRa routing/RF (Meshtastic & MeshCore), foreign LoRa overhear (Meshtastic tab), Reticulum interface audit; protocol-scoped row filtering + - [Diagnostics](diagnostics.md) — LoRa routing/RF (Meshtastic & MeshCore), foreign LoRa overhear (Meshtastic & MeshCore tabs), Reticulum interface audit; protocol-scoped row filtering - [Key backup and cryptography](key-backup-and-crypto.md) (per-node full key pair backup; MT → MC migration) - [Troubleshooting](troubleshooting.md) - Export for GitHub / stuck Chat — [Troubleshooting](troubleshooting.md#reporting-bugs-export-for-github-app-tab) diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 1ad77a901..52f85c25a 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -236,13 +236,14 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri ## Electron bridge -Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sandboxed renderer cannot reach localhost directly). +Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sandboxed renderer cannot reach localhost directly). Lifecycle / proxy / Remote / factory-reset handlers live in `src/main/ipc/reticulum-handlers.ts`. Reticulum destination / Remote address / inbound-policy DB handlers are in `src/main/ipc/reticulum-db-handlers.ts`; RRC room history uses `src/main/ipc/rrc-db-handlers.ts`. | IPC channel | Role | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | `reticulum:start` / `stop` / `getStatus` | Sidecar lifecycle | | `reticulum:syncInterfaceIssueScope` | Drop TCP/TX latch entries for disabled/removed interfaces; sticky enabled-name filter for later log lines | | `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above | +| `reticulum:factoryReset` | Factory reset (generic `proxyPost` blocks `/api/v1/system/factory-reset`; UI must use this channel) | | `reticulum:validateConfig` | One-shot `validate-config --json` against `userData/reticulum/config` (read-only; safe while stack runs) | | `reticulum:readDefaultConfigFile` | Read first existing system rnsd config path | | `reticulum:showConfigImportDialog` | Native file picker for config import | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9908e1a99..507747651 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -445,6 +445,17 @@ flatpak run org.coloradomesh.MeshClient **Fix**: Click **Forget this device** on the reconnect card and pair fresh using the Bluetooth picker. +### Dual-radio Noble BLE startup serialization (macOS/Windows) + +When both Meshtastic and MeshCore have **different** saved BLE peripherals, startup auto-connect is serialized so two Noble connects do not race: + +- Coordinator: `meshcoreDualNobleBleInit.ts`; wired from **`App.tsx` `useLayoutEffect`** (not `useEffect` — child ConnectionPanel auto-connect effects must see primary/secondary roles first). +- **Primary** is chosen from `mesh-client:protocol` localStorage (`meshcore` / `meshtastic`; Reticulum or missing → Meshtastic). +- **Secondary** waits on `awaitNobleBlePrimaryAutoConnectSettled()` (GATT + handshake ready or first attempt settled) — not full device configure. +- All Noble IPC connects go through `withNobleBleConnectMutex()`. + +See also wake recovery under [Sleep, wake, and long-running sessions](#sleep-wake-and-long-running-sessions) (Meshtastic-first stagger). For Reticulum BLE RNode vs Noble, see [Reticulum BLE RNode blocks Meshtastic/MeshCore Noble BLE](#reticulum-ble-rnode-blocks-meshtasticmeshcore-noble-ble). + ## USB serial ### Serial port not detected @@ -568,6 +579,8 @@ After sleep or hibernate, mesh-client uses the same resume path as macOS: reconn **Linux Web Bluetooth:** Manual reconnect from the connection banner still requires a user gesture (Connect / picker). Linux does not use Noble IPC; see **Linux-specific** under [BLE known issues](#ble-known-issues) above for pairing and adapter reset steps. +**Reticulum (all platforms):** On suspend, `onPowerSuspend` clears in-memory rnsh sessions and rncp transfers. On resume, `onPowerResume` restarts the sidecar via `connect()` unless the user disconnected. + ### Long-running sessions (multi-day uptime) If mesh-client stays open for **days** on a busy mesh (especially **MeshCore BLE-only** with hundreds of repeaters): @@ -700,6 +713,10 @@ If a module section stays on **Waiting for … settings from the device** with A - Confirm **Channel PSKs** on the Connection tab match the channel (16- or 32-byte base64 per line; `ChannelName=base64` for MQTT-only names). - Enable **Enable TLS (mqtts / wss)** when the broker requires TLS on a non-standard port. +### Meshtastic SDK routing failures mark chat rows failed + +When the Meshtastic SDK logs a routing / queue failure, mesh-client intercepts matched `console.error` / `console.warn` lines via `meshtasticSdkRoutingErrorConsoleHook.ts`, logs them at `console.debug`, and applies `applyMeshtasticOutboundRoutingErrorFromLog` (or `FromRejection`) so the outbound Chat row shows **Failed**. Unmatched queue rejections may still appear as `[meshtasticSdkRoutingErrorLog]`. + ## MeshCore ### MeshCore contact age prune and favorites @@ -1028,7 +1045,7 @@ Unrecognized codes pass through unchanged. ### Reticulum `proxyGet` fetch failed / many `[ReticulumIPC] start` lines -**Symptoms**: Device log or devtools shows `Error occurred in handler for 'reticulum:proxyGet': TypeError: fetch failed`, often in bursts of three or more at once. The app log may also show dozens of `[ReticulumIPC] start` entries within a few seconds while Nomad/Radio/Peers panels stay empty or stale. +**Symptoms**: Device log or devtools shows `Error occurred in handler for 'reticulum:proxyGet': TypeError: fetch failed`, often in bursts of three or more at once. The app log may also show dozens of `[ReticulumIPC] start` entries within a few seconds while Nomad/Network/Peers panels stay empty or stale. **Cause**: Overlapping sidecar start attempts restart the process before its HTTP server is ready (start/reconnect storm). Panels keep calling `proxyGet` against a dead or stale localhost port during the churn. @@ -1305,6 +1322,8 @@ See [reticulum.md — RNode over Wi-Fi](reticulum.md#rnode-over-wi-fi). **Request enable / 422**: `sendRncpRequestEnable` must POST LXMF with a `text` field (not `content`) — wrong key → HTTP **422**. After you send request-enable, the peer's `mesh-client:rncp-receive-dest:v1:` reply is applied only if a pending mark exists (`rncpReceiveDestSharePending`, TTL); shares without a prior request-enable from this session are ignored. +**Sleep / wake**: Suspend clears in-memory rnsh sessions and rncp transfers; reopen Remote (or wait for resume reconnect) after wake. See [Sleep, wake, and long-running sessions](#sleep-wake-and-long-running-sessions). + ### Reticulum Remote inbound rncp blocked (Ask mode / policy) **Symptoms**: Incoming file offers never arrive, or an offer is auto-declined; a peer reports their send was rejected. diff --git a/scripts/check-i18n-quality.mjs b/scripts/check-i18n-quality.mjs index 4475bc78f..4217f436c 100644 --- a/scripts/check-i18n-quality.mjs +++ b/scripts/check-i18n-quality.mjs @@ -107,7 +107,13 @@ export const ROOMS_PANEL_FALSE_FRIENDS = { hint: 'use "룸" for MeshCore Room, not hotel/meeting "객실/회의실"', }, ], - it: [{ re: /\b[Cc]amera/i, hint: 'use "sala" for MeshCore Room, not hotel bedroom "camera"' }], + it: [ + { re: /\b[Cc]amera/i, hint: 'use "sala" for MeshCore Room, not hotel bedroom "camera"' }, + { + re: /\b[Cc]amere\b/i, + hint: 'use "sale" for MeshCore Rooms tab, not hotel bedrooms "camere"', + }, + ], ru: [ { re: /номер/i, diff --git a/src/main/chat-export.test.ts b/src/main/chat-export.test.ts index fc7f086be..eeb426f6b 100644 --- a/src/main/chat-export.test.ts +++ b/src/main/chat-export.test.ts @@ -1,7 +1,13 @@ // @vitest-environment node import { describe, expect, it } from 'vitest'; -import { formatChatExportLine } from './chatExportFormat'; +import { + assertChatExportMessageSizes, + CHAT_EXPORT_MAX_PAYLOAD_CHARS, + CHAT_EXPORT_MAX_SENDER_NAME_CHARS, + formatChatExportLine, + formatChatExportLinesWithTotalCap, +} from './chatExportFormat'; describe('formatChatExportLine', () => { it('labels broadcast to as channel traffic, not DM', () => { @@ -27,3 +33,49 @@ describe('formatChatExportLine', () => { expect(line).toContain('(DM)'); }); }); + +describe('assertChatExportMessageSizes', () => { + it('allows normal-sized fields', () => { + expect(() => { + assertChatExportMessageSizes([ + { sender_name: 'Alice', payload: 'hello' }, + { sender_name: 'Bob', payload: 'world' }, + ]); + }).not.toThrow(); + }); + + it('rejects oversized payload', () => { + expect(() => { + assertChatExportMessageSizes([ + { sender_name: 'Alice', payload: 'x'.repeat(CHAT_EXPORT_MAX_PAYLOAD_CHARS + 1) }, + ]); + }).toThrow(/message\[0\] payload exceeds max length/); + }); + + it('rejects oversized sender_name', () => { + expect(() => { + assertChatExportMessageSizes([ + { + sender_name: 'n'.repeat(CHAT_EXPORT_MAX_SENDER_NAME_CHARS + 1), + payload: 'ok', + }, + ]); + }).toThrow(/message\[0\] sender_name exceeds max length/); + }); +}); + +describe('formatChatExportLinesWithTotalCap', () => { + it('returns joined lines for a small export', () => { + const text = formatChatExportLinesWithTotalCap([ + { + timestamp: 1_700_000_000_000, + sender_name: 'Alice', + channel: 0, + payload: 'hi', + }, + ]); + expect(text).toContain('Alice'); + expect(text).toContain('hi'); + expect(text.endsWith('\n')).toBe(true); + }); +}); diff --git a/src/main/chatExportFormat.ts b/src/main/chatExportFormat.ts index da1fbec18..aaab8408f 100644 --- a/src/main/chatExportFormat.ts +++ b/src/main/chatExportFormat.ts @@ -1,5 +1,12 @@ import { isMeshtasticBroadcastNodeNum } from '../shared/nodeNameUtils'; +/** Max UTF-16 code units for a single message payload in chat:export. */ +export const CHAT_EXPORT_MAX_PAYLOAD_CHARS = 64 * 1024; +/** Max UTF-16 code units for sender_name in chat:export. */ +export const CHAT_EXPORT_MAX_SENDER_NAME_CHARS = 512; +/** Soft cap on total serialized export bytes (UTF-8). */ +export const CHAT_EXPORT_MAX_TOTAL_BYTES = 16 * 1024 * 1024; + export interface ChatExportLineInput { timestamp?: unknown; sender_name?: unknown; @@ -8,6 +15,31 @@ export interface ChatExportLineInput { payload?: unknown; } +/** + * Reject oversized payload / sender_name fields before formatting. + * Throws a clear `chat:export: …` error when any message exceeds limits. + */ +export function assertChatExportMessageSizes(messages: unknown[]): void { + for (let i = 0; i < messages.length; i++) { + const item = messages[i]; + if (typeof item !== 'object' || item === null) continue; + const row = item as ChatExportLineInput; + if (typeof row.payload === 'string' && row.payload.length > CHAT_EXPORT_MAX_PAYLOAD_CHARS) { + throw new Error( + `chat:export: message[${i}] payload exceeds max length (${CHAT_EXPORT_MAX_PAYLOAD_CHARS})`, + ); + } + if ( + typeof row.sender_name === 'string' && + row.sender_name.length > CHAT_EXPORT_MAX_SENDER_NAME_CHARS + ) { + throw new Error( + `chat:export: message[${i}] sender_name exceeds max length (${CHAT_EXPORT_MAX_SENDER_NAME_CHARS})`, + ); + } + } +} + /** Format one chat export line; broadcast `to` is channel traffic, not a DM. */ export function formatChatExportLine(item: ChatExportLineInput): string | null { if (typeof item !== 'object' || item === null) return null; @@ -26,3 +58,19 @@ export function formatChatExportLines(messages: unknown[]): string[] { return line != null ? [line] : []; }); } + +/** + * Format export lines and enforce total serialized size. + * Call after {@link assertChatExportMessageSizes}. + */ +export function formatChatExportLinesWithTotalCap(messages: unknown[]): string { + const lines = formatChatExportLines(messages); + const text = lines.join('\n') + '\n'; + const byteLength = Buffer.byteLength(text, 'utf8'); + if (byteLength > CHAT_EXPORT_MAX_TOTAL_BYTES) { + throw new Error( + `chat:export: serialized output exceeds max size (${CHAT_EXPORT_MAX_TOTAL_BYTES} bytes)`, + ); + } + return text; +} diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index 3c2a5c1a5..354db3cc7 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -454,6 +454,22 @@ describe('privileged IPC sender validation (source contract)', () => { 'db:saveNodePath', 'db:getNodes', 'db:getMessageChannels', + 'db:getNodeNote', + 'db:getMeshcoreMessages', + 'db:searchMessages', + 'db:searchMeshcoreMessages', + 'db:getMeshcoreContacts', + 'db:getMeshcoreMessageChannels', + 'db:getMeshcoreContactCount', + 'db:getMeshcoreContactById', + 'db:getContactGroups', + 'db:getContactGroupMembers', + 'db:getPositionHistory', + 'db:getMeshcoreHopHistory', + 'db:getAllMeshcoreHopHistory', + 'db:getMeshcoreTraceHistory', + 'db:getAllMeshcorePathHistory', + 'db:getMeshcorePathHistory', 'log:getPath', 'log:getRecentLines', 'mqtt:getCachedNodes', @@ -504,6 +520,34 @@ describe('privileged IPC sender validation (source contract)', () => { expect(INDEX_SOURCE).toContain('CHAT_EXPORT_MAX_MESSAGES'); }); + it('chat:export validates per-message field sizes and total bytes', () => { + const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('chat:export'"); + expect(handlerIdx).toBeGreaterThan(-1); + const body = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 900); + expect(body).toContain('assertChatExportMessageSizes(messages)'); + expect(body).toContain('formatChatExportLinesWithTotalCap(messages)'); + expect(body).toContain('exportIpcRateLimit.checkOrThrow()'); + }); + + it('expensive export/crypto IPC channels use createIpcRateLimiter', () => { + expect(INDEX_SOURCE).toContain('createIpcRateLimiter'); + expect(INDEX_SOURCE).toContain('exportIpcRateLimit'); + expect(INDEX_SOURCE).toContain('storageCryptoIpcRateLimit'); + for (const channel of [ + 'db:export', + 'db:import', + 'log:export', + 'support:exportBundle', + 'storage:encrypt', + 'storage:decrypt', + ] as const) { + const handlerIdx = INDEX_SOURCE.indexOf(`ipcMain.handle('${channel}'`); + expect(handlerIdx).toBeGreaterThan(-1); + const body = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 500); + expect(body).toMatch(/RateLimit\.checkOrThrow\(\)/); + } + }); + it('support:exportBundle validates mode and snapshot size', () => { const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('support:exportBundle'"); expect(handlerIdx).toBeGreaterThan(-1); @@ -602,6 +646,34 @@ describe('db mutator IPC sender validation (source contract, H3)', () => { expect(handlerBody).toContain('validateIpcSender(event)'); }); + const dbReadChannels = [ + 'db:getNodeNote', + 'db:getMeshcoreMessages', + 'db:searchMessages', + 'db:searchMeshcoreMessages', + 'db:getMeshcoreContacts', + 'db:getMeshcoreMessageChannels', + 'db:getMeshcoreContactCount', + 'db:getMeshcoreContactById', + 'db:getContactGroups', + 'db:getContactGroupMembers', + 'db:getPositionHistory', + 'db:getMeshcoreHopHistory', + 'db:getAllMeshcoreHopHistory', + 'db:getMeshcoreTraceHistory', + 'db:getAllMeshcorePathHistory', + 'db:getMeshcorePathHistory', + 'db:getNodes', + 'db:getMessageChannels', + ] as const; + + it.each(dbReadChannels)('%s calls assertIpcSender', (channel) => { + const handlerIdx = INDEX_SOURCE.indexOf(`ipcMain.handle('${channel}'`); + expect(handlerIdx).toBeGreaterThan(-1); + const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 400); + expect(handlerBody).toContain(`assertIpcSender(event, '${channel}')`); + }); + it.each([ ['app:setLoginItem', "assertIpcSender(event, 'app:setLoginItem')"], ['app:getLoginItem', "assertIpcSender(event, 'app:getLoginItem')"], @@ -613,16 +685,14 @@ describe('db mutator IPC sender validation (source contract, H3)', () => { expect(body).toContain(expectedCheck); }); - it('regression: no db:* mutator (non-get/search) is missing a sender check', () => { - // Any db:* handler whose name is not a read-only getter/search must call - // assertIpcSender or validateIpcSender within the first 400 chars of its body. + it('regression: no db:* handler is missing a sender check', () => { + // Any db:* handler must call assertIpcSender or validateIpcSender within the + // first 400 chars of its body (reads and mutators alike). const re = /ipcMain\.handle\(\s*\n?\s*'(db:[a-zA-Z]+)'/g; - const readOnlyPrefixes = ['db:get', 'db:search']; const missing: string[] = []; let m: RegExpExecArray | null; while ((m = re.exec(INDEX_SOURCE))) { const channel = m[1]; - if (readOnlyPrefixes.some((p) => channel.startsWith(p))) continue; const body = INDEX_SOURCE.slice(m.index, m.index + 400); const hasCheck = body.includes('assertIpcSender(event') || body.includes('validateIpcSender(event)'); diff --git a/src/main/index.ts b/src/main/index.ts index 632d891ea..93ef22f6b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,6 +28,7 @@ import zlib from 'zlib'; import type { MQTTSettings } from '../renderer/lib/types'; import { APP_ABOUT_TAGLINE } from '../shared/appTagline'; +import { clampQueryLimit } from '../shared/clampQueryLimit'; import { formatHostForSocket, parseConnectHostPort } from '../shared/connectHost'; import { NODES_LAST_HEARD_SEC_SQL, normalizeLastHeardToUnixSec } from '../shared/lastHeardUnits'; import { findLxmUrlInArgv, isForwardableMeshClientOpenUrl } from '../shared/meshClientDeepLink'; @@ -46,6 +47,7 @@ import { effectiveMessageTimestampMs } from '../shared/messageTimestampSkew'; import { sanitizeUnicodeReactionScalar } from '../shared/reactionEmoji'; import type { ReticulumSidecarStatus } from '../shared/reticulum-types'; import type { TAKServerStatus, TAKSettings } from '../shared/tak-types'; +import { MS_PER_MINUTE } from '../shared/timeConstants'; import { bleCoexistenceCoordinator, type BlePeripheralOwner, @@ -53,7 +55,10 @@ import { type BleScanOwner, } from './ble-coexistence-coordinator'; import { ensureCameraAccess, isAllowedCameraPrivacySettingsUrl } from './cameraAccess'; -import { formatChatExportLines } from './chatExportFormat'; +import { + assertChatExportMessageSizes, + formatChatExportLinesWithTotalCap, +} from './chatExportFormat'; import { addContactToGroup, closeDatabase, @@ -105,6 +110,7 @@ import { registerReticulumIpcHandlers, wireReticulumSidecarBridge } from './ipc/ import { registerReticulumIdentityIpcHandlers } from './ipc/reticulum-identity-handlers'; import { registerRrcDbIpcHandlers } from './ipc/rrc-db-handlers'; import { registerTakIpcHandlers } from './ipc/tak-handlers'; +import { createIpcRateLimiter } from './ipcRateLimit'; import { clearLogFile, exportLogTo, @@ -2685,8 +2691,16 @@ ipcMain.handle('bleCoexistence:acquireScan', async (event, owner: unknown) => { if (typeof owner !== 'string' || !BLE_SCAN_OWNERS.has(owner as BleScanOwner)) { throw new Error('bleCoexistence:acquireScan: owner must be noble, reticulum, or webbt'); } - await bleCoexistenceCoordinator.acquireScan(owner as BleScanOwner); - return bleCoexistenceCoordinator.getState(); + try { + await bleCoexistenceCoordinator.acquireScan(owner as BleScanOwner); + return bleCoexistenceCoordinator.getState(); + } catch (err) { + console.error( + '[main] bleCoexistence:acquireScan failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } }); ipcMain.handle('bleCoexistence:releaseScan', (event, owner: unknown) => { assertIpcSender(event, 'bleCoexistence:releaseScan'); @@ -2698,13 +2712,29 @@ ipcMain.handle('bleCoexistence:releaseScan', (event, owner: unknown) => { }); ipcMain.handle('bleCoexistence:pauseNobleScan', async (event) => { assertIpcSender(event, 'bleCoexistence:pauseNobleScan'); - await bleCoexistenceCoordinator.pauseNobleScan(); - return bleCoexistenceCoordinator.getState(); + try { + await bleCoexistenceCoordinator.pauseNobleScan(); + return bleCoexistenceCoordinator.getState(); + } catch (err) { + console.error( + '[main] bleCoexistence:pauseNobleScan failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } }); ipcMain.handle('bleCoexistence:suspendNobleForReticulumBleConnect', async (event) => { assertIpcSender(event, 'bleCoexistence:suspendNobleForReticulumBleConnect'); - await bleCoexistenceCoordinator.suspendNobleForReticulumBleConnect(); - return bleCoexistenceCoordinator.getState(); + try { + await bleCoexistenceCoordinator.suspendNobleForReticulumBleConnect(); + return bleCoexistenceCoordinator.getState(); + } catch (err) { + console.error( + '[main] bleCoexistence:suspendNobleForReticulumBleConnect failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } }); ipcMain.handle('noble-ble-start-scan', async (event, sessionId: unknown) => { @@ -2739,7 +2769,14 @@ ipcMain.handle('noble-ble-stop-scan', async (event, sessionId: unknown) => { if (sessionId !== 'meshtastic' && sessionId !== 'meshcore') { throw new Error('noble-ble-stop-scan: sessionId must be meshtastic or meshcore'); } - await nobleBleManager.stopScanning(sessionId); + try { + await nobleBleManager.stopScanning(sessionId); + } catch (err) { + console.error( + `[main] noble-ble-stop-scan failed: session=${sessionId} message=${sanitizeLogMessage(err instanceof Error ? err.message : String(err))}`, + ); + throw err; + } }); ipcMain.handle('noble-ble-connect', async (event, sessionId: unknown, peripheralId: unknown) => { assertIpcSender(event, 'noble-ble-connect'); @@ -2768,7 +2805,14 @@ ipcMain.handle('noble-ble-disconnect', async (event, sessionId: unknown) => { if (sessionId !== 'meshtastic' && sessionId !== 'meshcore') { throw new Error('noble-ble-disconnect: sessionId must be meshtastic or meshcore'); } - await nobleBleManager.disconnect(sessionId); + try { + await nobleBleManager.disconnect(sessionId); + } catch (err) { + console.error( + `[main] noble-ble-disconnect failed: session=${sessionId} message=${sanitizeLogMessage(err instanceof Error ? err.message : String(err))}`, + ); + throw err; + } }); ipcMain.handle('noble-ble-is-connected', (event, sessionId: unknown) => { assertIpcSender(event, 'noble-ble-is-connected'); @@ -3356,6 +3400,19 @@ ipcMain.handle('notify:message', (event, title: unknown, body: unknown) => { }); // ─── IPC: Safe storage (OS-keychain-backed encryption) ───────────── +/** Export / import dialogs — max 5 / 60s. */ +const exportIpcRateLimit = createIpcRateLimiter({ + max: 5, + windowMs: MS_PER_MINUTE, + label: 'export', +}); +/** storage:encrypt / storage:decrypt — max 30 / 60s. */ +const storageCryptoIpcRateLimit = createIpcRateLimiter({ + max: 30, + windowMs: MS_PER_MINUTE, + label: 'storage:crypto', +}); + ipcMain.handle('storage:isAvailable', (event) => { assertIpcSender(event, 'storage:isAvailable'); try { @@ -3371,6 +3428,7 @@ ipcMain.handle('storage:isAvailable', (event) => { ipcMain.handle('storage:encrypt', (event, plaintext: unknown) => { assertIpcSender(event, 'storage:encrypt'); + storageCryptoIpcRateLimit.checkOrThrow(); if (typeof plaintext !== 'string' || plaintext.length > 4096) throw new Error('storage:encrypt: invalid input'); try { @@ -3387,6 +3445,7 @@ ipcMain.handle('storage:encrypt', (event, plaintext: unknown) => { ipcMain.handle('storage:decrypt', (event, ciphertext: unknown) => { assertIpcSender(event, 'storage:decrypt'); + storageCryptoIpcRateLimit.checkOrThrow(); if (typeof ciphertext !== 'string' || ciphertext.length > 8192) throw new Error('storage:decrypt: invalid input'); try { @@ -3741,7 +3800,7 @@ ipcMain.handle('db:saveMessage', (event, message) => { ipcMain.handle('db:getMessages', (event, channel?: number, limit = 200) => { if (!validateIpcSender(event)) throw new Error('db:getMessages: unauthorized sender'); try { - const safeLimit = Math.min(Math.max(1, Number(limit) || 1000), 10000); + const safeLimit = clampQueryLimit(limit, { default: 1000, max: 10000 }); const db = getDbForIpc('db:getMessages'); if (!db) return []; const columns = `id, sender_id, sender_name, payload, channel, timestamp, @@ -3890,8 +3949,9 @@ ipcMain.handle('db:setNodeFavorited', (event, nodeId: number, favorited: boolean } }); -ipcMain.handle('db:getNodeNote', (_event, nodeId: number) => { +ipcMain.handle('db:getNodeNote', (event, nodeId: number) => { try { + assertIpcSender(event, 'db:getNodeNote'); const id = safeNonNegativeInt(nodeId); const db = getDbForIpc('db:getNodeNote'); if (!db) return null; @@ -4414,6 +4474,7 @@ ipcMain.handle( // ─── IPC: Export database ─────────────────────────────────────────── ipcMain.handle('db:export', async (event) => { if (!validateIpcSender(event)) throw new Error('db:export: unauthorized sender'); + exportIpcRateLimit.checkOrThrow(); try { if (!getDbForIpc('db:export')) return null; if (!mainWindow) return null; @@ -4435,6 +4496,7 @@ ipcMain.handle('db:export', async (event) => { // ─── IPC: Import / merge database ─────────────────────────────────── ipcMain.handle('db:import', async (event) => { if (!validateIpcSender(event)) throw new Error('db:import: unauthorized sender'); + exportIpcRateLimit.checkOrThrow(); try { if (!getDbForIpc('db:import')) return null; if (!mainWindow) return null; @@ -4528,6 +4590,7 @@ ipcMain.handle('log:export', async (event) => { if (!validateIpcSender(event)) { throw new Error('IPC sender validation failed'); } + exportIpcRateLimit.checkOrThrow(); try { if (!mainWindow) return null; const result = await dialog.showSaveDialog(mainWindow, { @@ -4558,6 +4621,7 @@ ipcMain.handle('support:exportBundle', async (event, mode: unknown, debugSnapsho if (!validateIpcSender(event)) { throw new Error('IPC sender validation failed'); } + exportIpcRateLimit.checkOrThrow(); if (!isSupportBundleMode(mode)) { throw new Error('support:exportBundle: invalid mode'); } @@ -4592,10 +4656,12 @@ ipcMain.handle('support:exportBundle', async (event, mode: unknown, debugSnapsho ipcMain.handle('chat:export', async (event, messages: unknown) => { if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); + exportIpcRateLimit.checkOrThrow(); if (!Array.isArray(messages)) throw new Error('messages must be an array'); if (messages.length > CHAT_EXPORT_MAX_MESSAGES) { throw new Error(`chat:export: too many messages (max ${CHAT_EXPORT_MAX_MESSAGES})`); } + assertChatExportMessageSizes(messages); if (!mainWindow) return { success: false }; try { const result = await dialog.showSaveDialog(mainWindow, { @@ -4604,8 +4670,8 @@ ipcMain.handle('chat:export', async (event, messages: unknown) => { filters: [{ name: 'Text file', extensions: ['txt'] }], }); if (result.canceled || !result.filePath) return { success: false }; - const lines = formatChatExportLines(messages as unknown[]); - await fs.promises.writeFile(result.filePath, lines.join('\n') + '\n', 'utf8'); + const text = formatChatExportLinesWithTotalCap(messages); + await fs.promises.writeFile(result.filePath, text, 'utf8'); return { success: true, path: result.filePath }; } catch (err) { console.error( @@ -4961,9 +5027,10 @@ function rowToOutboxEntry(row: Record) { } // ─── IPC: MeshCore database operations ────────────────────────────── -ipcMain.handle('db:getMeshcoreMessages', (_event, channelIdx?: number, limit = 200) => { +ipcMain.handle('db:getMeshcoreMessages', (event, channelIdx?: number, limit = 200) => { try { - const safeLimit = Math.min(Math.max(1, Number(limit) || 200), 10000); + assertIpcSender(event, 'db:getMeshcoreMessages'); + const safeLimit = clampQueryLimit(limit, { default: 200, max: 10000 }); const db = getDbForIpc('db:getMeshcoreMessages'); if (!db) return []; // Order by row id (insert order at this client), not `timestamp`: @@ -4996,8 +5063,9 @@ ipcMain.handle('db:getMeshcoreMessages', (_event, channelIdx?: number, limit = 2 } }); -ipcMain.handle('db:searchMessages', (_event, query: string, limit?: number) => { +ipcMain.handle('db:searchMessages', (event, query: string, limit?: number) => { try { + assertIpcSender(event, 'db:searchMessages'); if (!getDbForIpc('db:searchMessages')) return []; if (typeof query !== 'string' || query.length > 500) return []; return searchMessages(query, Math.min(limit ?? 50, 200)); @@ -5006,8 +5074,9 @@ ipcMain.handle('db:searchMessages', (_event, query: string, limit?: number) => { } }); -ipcMain.handle('db:searchMeshcoreMessages', (_event, query: string, limit?: number) => { +ipcMain.handle('db:searchMeshcoreMessages', (event, query: string, limit?: number) => { try { + assertIpcSender(event, 'db:searchMeshcoreMessages'); if (!getDbForIpc('db:searchMeshcoreMessages')) return []; if (typeof query !== 'string' || query.length > 500) return []; return searchMeshcoreMessages(query, Math.min(limit ?? 50, 200)); @@ -5016,8 +5085,9 @@ ipcMain.handle('db:searchMeshcoreMessages', (_event, query: string, limit?: numb } }); -ipcMain.handle('db:getMeshcoreContacts', () => { +ipcMain.handle('db:getMeshcoreContacts', (event) => { try { + assertIpcSender(event, 'db:getMeshcoreContacts'); const db = getDbForIpc('db:getMeshcoreContacts'); if (!db) return []; return db.prepareOnce('SELECT * FROM meshcore_contacts').all(); @@ -5376,8 +5446,9 @@ ipcMain.handle('db:clearMeshcoreMessages', (event) => { } }); -ipcMain.handle('db:getMeshcoreMessageChannels', () => { +ipcMain.handle('db:getMeshcoreMessageChannels', (event) => { try { + assertIpcSender(event, 'db:getMeshcoreMessageChannels'); const db = getDbForIpc('db:getMeshcoreMessageChannels'); if (!db) return []; return db @@ -5449,8 +5520,9 @@ ipcMain.handle('db:markAllMeshcoreContactsOffRadio', (event) => { }); // Returns count of contacts currently marked as on_radio = 1. -ipcMain.handle('db:getMeshcoreContactCount', () => { +ipcMain.handle('db:getMeshcoreContactCount', (event) => { try { + assertIpcSender(event, 'db:getMeshcoreContactCount'); const db = getDbForIpc('db:getMeshcoreContactCount'); if (!db) return 0; const result = db @@ -5512,8 +5584,9 @@ ipcMain.handle('db:offloadAllMeshcoreContacts', (event) => { }); // Get a single contact by node_id (returns on_radio status). -ipcMain.handle('db:getMeshcoreContactById', (_event, nodeId: number) => { +ipcMain.handle('db:getMeshcoreContactById', (event, nodeId: number) => { try { + assertIpcSender(event, 'db:getMeshcoreContactById'); const db = getDbForIpc('db:getMeshcoreContactById'); if (!db) return null; const id = safeNonNegativeInt(nodeId); @@ -5527,8 +5600,9 @@ ipcMain.handle('db:getMeshcoreContactById', (_event, nodeId: number) => { // ─── IPC: Contact groups ────────────────────────────────────────────────────── -ipcMain.handle('db:getContactGroups', (_event, selfNodeId: number) => { +ipcMain.handle('db:getContactGroups', (event, selfNodeId: number) => { try { + assertIpcSender(event, 'db:getContactGroups'); if (!getDbForIpc('db:getContactGroups')) return []; return getContactGroups(safeNonNegativeInt(selfNodeId)); } catch (err) { @@ -5594,8 +5668,9 @@ ipcMain.handle('db:removeContactFromGroup', (event, groupId: number, contactNode } }); -ipcMain.handle('db:getContactGroupMembers', (_event, groupId: number) => { +ipcMain.handle('db:getContactGroupMembers', (event, groupId: number) => { try { + assertIpcSender(event, 'db:getContactGroupMembers'); if (!getDbForIpc('db:getContactGroupMembers')) return []; return getContactGroupMembers(safeNonNegativeInt(groupId)); } catch (err) { @@ -5743,8 +5818,9 @@ ipcMain.handle( }, ); -ipcMain.handle('db:getPositionHistory', (_event, sinceMs: number) => { +ipcMain.handle('db:getPositionHistory', (event, sinceMs: number) => { try { + assertIpcSender(event, 'db:getPositionHistory'); const db = getDbForIpc('db:getPositionHistory'); if (!db) return []; const since = typeof sinceMs === 'number' && isFinite(sinceMs) ? sinceMs : 0; @@ -5793,8 +5869,9 @@ ipcMain.handle( }, ); -ipcMain.handle('db:getMeshcoreHopHistory', (_event, nodeId: number) => { +ipcMain.handle('db:getMeshcoreHopHistory', (event, nodeId: number) => { try { + assertIpcSender(event, 'db:getMeshcoreHopHistory'); if (!getDbForIpc('db:getMeshcoreHopHistory')) return []; return getMeshcoreHopHistory(nodeId); } catch (err) { @@ -5802,8 +5879,9 @@ ipcMain.handle('db:getMeshcoreHopHistory', (_event, nodeId: number) => { } }); -ipcMain.handle('db:getAllMeshcoreHopHistory', () => { +ipcMain.handle('db:getAllMeshcoreHopHistory', (event) => { try { + assertIpcSender(event, 'db:getAllMeshcoreHopHistory'); if (!getDbForIpc('db:getAllMeshcoreHopHistory')) return []; return getAllMeshcoreHopHistoryRows(); } catch (err) { @@ -5834,8 +5912,9 @@ ipcMain.handle( }, ); -ipcMain.handle('db:getMeshcoreTraceHistory', (_event, nodeId: number) => { +ipcMain.handle('db:getMeshcoreTraceHistory', (event, nodeId: number) => { try { + assertIpcSender(event, 'db:getMeshcoreTraceHistory'); if (!getDbForIpc('db:getMeshcoreTraceHistory')) return []; return getMeshcoreTraceHistory(nodeId); } catch (err) { @@ -5900,8 +5979,9 @@ ipcMain.handle( }, ); -ipcMain.handle('db:getAllMeshcorePathHistory', () => { +ipcMain.handle('db:getAllMeshcorePathHistory', (event) => { try { + assertIpcSender(event, 'db:getAllMeshcorePathHistory'); if (!getDbForIpc('db:getAllMeshcorePathHistory')) return []; return getAllMeshcorePathHistory(); } catch (err) { @@ -5909,8 +5989,9 @@ ipcMain.handle('db:getAllMeshcorePathHistory', () => { } }); -ipcMain.handle('db:getMeshcorePathHistory', (_event, nodeId: number) => { +ipcMain.handle('db:getMeshcorePathHistory', (event, nodeId: number) => { try { + assertIpcSender(event, 'db:getMeshcorePathHistory'); if (!getDbForIpc('db:getMeshcorePathHistory')) return []; return getMeshcorePathHistory(nodeId); } catch (err) { diff --git a/src/main/ipc/reticulum-db-handlers.ts b/src/main/ipc/reticulum-db-handlers.ts index cb2d6e476..2a1a7515f 100644 --- a/src/main/ipc/reticulum-db-handlers.ts +++ b/src/main/ipc/reticulum-db-handlers.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { IpcMain } from 'electron'; +import { clampQueryLimit } from '../../shared/clampQueryLimit'; import { isMeshProtocol } from '../../shared/meshProtocol'; import type { RemoteAddressBookRow, @@ -109,7 +110,7 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): try { assertIpcSender(event, 'db:getReticulumMessages'); if (typeof identityId !== 'string' || identityId.length > 128) return []; - const safeLimit = Math.min(Math.max(1, Number(limit) || 500), 10000); + const safeLimit = clampQueryLimit(limit, { default: 500, max: 10000 }); const db = getDbForIpc('db:getReticulumMessages'); if (!db) return []; const rows = db @@ -287,7 +288,7 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): assertIpcSender(event, 'db:searchReticulumMessages'); if (typeof identityId !== 'string' || identityId.length > 128) return []; if (typeof query !== 'string' || query.length > 256) return []; - const safeLimit = Math.min(Math.max(1, Number(limit) || 200), 5000); + const safeLimit = clampQueryLimit(limit, { default: 200, max: 5000 }); const db = getDbForIpc('db:searchReticulumMessages'); if (!db) return []; const ftsQuery = buildFtsMatchQuery(query); diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index 0bc667654..5adb18cc5 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -6,6 +6,8 @@ import type { ReticulumSidecarStatus, } from '../../shared/reticulum-types'; import { canonicalizeReticulumDestinationHash } from '../../shared/reticulumDestinationHash'; +import { MS_PER_MINUTE } from '../../shared/timeConstants'; +import { createIpcRateLimiter } from '../ipcRateLimit'; import { sanitizeLogMessage } from '../log-service'; import { isAllowedNomadContentSourcePath, @@ -29,6 +31,13 @@ import type { ReticulumSidecarManager } from '../reticulum-sidecar-manager'; import { parseEnabledInterfaceNames } from '../reticulumInterfaceIssueScope'; import { assertIpcSender } from '../validate-ipc-sender'; +/** Shared rolling window for all reticulum proxy verbs (Get/Post/Put/Delete). */ +const reticulumProxyIpcRateLimit = createIpcRateLimiter({ + max: 120, + windowMs: MS_PER_MINUTE, + label: 'reticulum:proxy', +}); + export interface ReticulumIpcDeps { idleStatus: ReticulumSidecarStatus; ensureManager: () => ReticulumSidecarManager; @@ -132,8 +141,16 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:stop', async (event) => { assertIpcSender(event, 'reticulum:stop'); - console.debug('[ReticulumIPC] stop'); - await getManager()?.stop(); + try { + console.debug('[ReticulumIPC] stop'); + await getManager()?.stop(); + } catch (err) { + console.error( + '[ReticulumIPC] stop failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } }); ipcMain.handle('reticulum:getStatus', (event) => { @@ -151,6 +168,7 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:proxyGet', async (event, apiPath: unknown) => { assertIpcSender(event, 'reticulum:proxyGet'); + reticulumProxyIpcRateLimit.checkOrThrow(); const pathArg = assertProxyApiPath(apiPath); try { const m = ensureManager(); @@ -163,6 +181,7 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:proxyPost', async (event, apiPath: unknown, body: unknown) => { assertIpcSender(event, 'reticulum:proxyPost'); + reticulumProxyIpcRateLimit.checkOrThrow(); const pathArg = assertProxyApiPath(apiPath); if (isRncpPickerGatedApiPath(pathArg)) { throw new Error( @@ -192,6 +211,7 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:proxyPut', async (event, apiPath: unknown, body: unknown) => { assertIpcSender(event, 'reticulum:proxyPut'); + reticulumProxyIpcRateLimit.checkOrThrow(); const pathArg = assertProxyApiPath(apiPath); if (isNomadContentSourceApiPath(pathArg)) { throw new Error( @@ -209,6 +229,7 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:proxyDelete', async (event, apiPath: unknown) => { assertIpcSender(event, 'reticulum:proxyDelete'); + reticulumProxyIpcRateLimit.checkOrThrow(); const pathArg = assertProxyApiPath(apiPath); try { const m = ensureManager(); @@ -226,17 +247,41 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:showConfigImportDialog', async (event) => { assertIpcSender(event, 'reticulum:showConfigImportDialog'); - return showReticulumConfigImportDialog(); + try { + return await showReticulumConfigImportDialog(); + } catch (err) { + console.error( + '[ReticulumIPC] showConfigImportDialog failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } }); ipcMain.handle('reticulum:showIdentityImportDialog', async (event) => { assertIpcSender(event, 'reticulum:showIdentityImportDialog'); - return showReticulumIdentityImportDialog(); + try { + return await showReticulumIdentityImportDialog(); + } catch (err) { + console.error( + '[ReticulumIPC] showIdentityImportDialog failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } }); ipcMain.handle('reticulum:showNomadContentSourceDialog', async (event) => { assertIpcSender(event, 'reticulum:showNomadContentSourceDialog'); - return showNomadContentSourceDialog(); + try { + return await showNomadContentSourceDialog(); + } catch (err) { + console.error( + '[ReticulumIPC] showNomadContentSourceDialog failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } }); /** @@ -284,12 +329,28 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:showRncpOpenFileDialog', async (event) => { assertIpcSender(event, 'reticulum:showRncpOpenFileDialog'); - return showRncpOpenFileDialog(); + try { + return await showRncpOpenFileDialog(); + } catch (err) { + console.error( + '[ReticulumIPC] showRncpOpenFileDialog failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } }); ipcMain.handle('reticulum:showRncpSaveDirectoryDialog', async (event) => { assertIpcSender(event, 'reticulum:showRncpSaveDirectoryDialog'); - return showRncpSaveDirectoryDialog(); + try { + return await showRncpSaveDirectoryDialog(); + } catch (err) { + console.error( + '[ReticulumIPC] showRncpSaveDirectoryDialog failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } }); ipcMain.handle('reticulum:revealInFolder', (event, pathArg: unknown) => { diff --git a/src/main/ipc/rrc-db-handlers.ts b/src/main/ipc/rrc-db-handlers.ts index 6057cc266..2105f92fc 100644 --- a/src/main/ipc/rrc-db-handlers.ts +++ b/src/main/ipc/rrc-db-handlers.ts @@ -1,5 +1,6 @@ import type { IpcMain } from 'electron'; +import { clampQueryLimit } from '../../shared/clampQueryLimit'; import { canonicalizeReticulumDestinationHash } from '../../shared/reticulumDestinationHash'; import type { RrcChatMessageKind } from '../../shared/rrc-types'; import { finishDbIpcHandler, getDbForIpc } from '../db-ipc-lifecycle'; @@ -87,7 +88,7 @@ export function registerRrcDbIpcHandlers({ ipcMain }: RrcDbIpcDeps): void { const hub = canonicalizeHubHash(hubHash); const roomKey = normalizeRoom(room); if (!hub || !roomKey) return []; - const safeLimit = Math.min(Math.max(1, Number(limit) || 500), 10_000); + const safeLimit = clampQueryLimit(limit, { default: 500, max: 10_000 }); const db = getDbForIpc('db:listRrcMessages'); if (!db) return []; const rows = db diff --git a/src/main/ipc/tak-handlers.ts b/src/main/ipc/tak-handlers.ts index 2b4b493c9..8271fadaa 100644 --- a/src/main/ipc/tak-handlers.ts +++ b/src/main/ipc/tak-handlers.ts @@ -82,17 +82,25 @@ export function registerTakIpcHandlers(deps: TakIpcDeps): void { ipcMain.handle('tak:pushNodeUpdate', async (event, node: unknown) => { assertIpcSender(event, 'tak:pushNodeUpdate'); - if (!node || typeof node !== 'object') - throw new Error('tak:pushNodeUpdate: node must be object'); - const n = node as Record; - const nodeId = Number(n.node_id); - if (!Number.isFinite(nodeId) || nodeId <= 0) - throw new Error('tak:pushNodeUpdate: invalid node_id'); - const m = await ensureTakServerManager(); - if (!m.getStatus().running) { - console.debug('[IPC] tak:pushNodeUpdate: TAK server not running, skipping'); - return; + try { + if (!node || typeof node !== 'object') + throw new Error('tak:pushNodeUpdate: node must be object'); + const n = node as Record; + const nodeId = Number(n.node_id); + if (!Number.isFinite(nodeId) || nodeId <= 0) + throw new Error('tak:pushNodeUpdate: invalid node_id'); + const m = await ensureTakServerManager(); + if (!m.getStatus().running) { + console.debug('[IPC] tak:pushNodeUpdate: TAK server not running, skipping'); + return; + } + m.onNodeUpdate(n as Parameters[0]); + } catch (err) { + console.error( + '[IPC] tak:pushNodeUpdate failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; } - m.onNodeUpdate(n as Parameters[0]); }); } diff --git a/src/main/ipcRateLimit.test.ts b/src/main/ipcRateLimit.test.ts new file mode 100644 index 000000000..e7f951bad --- /dev/null +++ b/src/main/ipcRateLimit.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; + +import { createIpcRateLimiter } from './ipcRateLimit'; + +describe('createIpcRateLimiter', () => { + const limiter = createIpcRateLimiter({ max: 3, windowMs: 1_000, label: 'test:channel' }); + + afterEach(() => { + limiter.resetForTests(); + }); + + it('allows up to max calls within the window', () => { + const now = 1_000_000; + expect(() => { + limiter.checkOrThrow(now); + }).not.toThrow(); + expect(() => { + limiter.checkOrThrow(now + 1); + }).not.toThrow(); + expect(() => { + limiter.checkOrThrow(now + 2); + }).not.toThrow(); + }); + + it('throws when over the rolling-window max', () => { + const now = 2_000_000; + limiter.checkOrThrow(now); + limiter.checkOrThrow(now + 1); + limiter.checkOrThrow(now + 2); + expect(() => { + limiter.checkOrThrow(now + 3); + }).toThrow('test:channel: rate limit exceeded'); + }); + + it('allows new calls after the window slides', () => { + const now = 3_000_000; + limiter.checkOrThrow(now); + limiter.checkOrThrow(now + 1); + limiter.checkOrThrow(now + 2); + // Strictly past the window so the oldest timestamp falls below cutoff. + expect(() => { + limiter.checkOrThrow(now + 1_001); + }).not.toThrow(); + }); +}); diff --git a/src/main/ipcRateLimit.ts b/src/main/ipcRateLimit.ts new file mode 100644 index 000000000..1caa5517e --- /dev/null +++ b/src/main/ipcRateLimit.ts @@ -0,0 +1,40 @@ +/** + * Small rolling-window IPC rate limiter (export/crypto/proxy DoS guard). + * Similar to fetchLinkPreview / reticulum-attachment-image token windows. + */ + +export interface IpcRateLimiterOpts { + max: number; + windowMs: number; + label: string; +} + +export interface IpcRateLimiter { + /** Throws `${label}: rate limit exceeded` when over the rolling-window max. */ + checkOrThrow(now?: number): void; + /** Test helper — clears the rolling window. */ + resetForTests(): void; +} + +export function createIpcRateLimiter(opts: IpcRateLimiterOpts): IpcRateLimiter { + const { max, windowMs, label } = opts; + const timestamps: number[] = []; + + return { + checkOrThrow(now = Date.now()): void { + const cutoff = now - windowMs; + while (timestamps.length > 0) { + const oldest = timestamps[0]; + if (oldest === undefined || oldest >= cutoff) break; + timestamps.shift(); + } + if (timestamps.length >= max) { + throw new Error(`${label}: rate limit exceeded`); + } + timestamps.push(now); + }, + resetForTests(): void { + timestamps.length = 0; + }, + }; +} diff --git a/src/main/noble-ble-manager.ts b/src/main/noble-ble-manager.ts index fa4645123..9692bcd75 100644 --- a/src/main/noble-ble-manager.ts +++ b/src/main/noble-ble-manager.ts @@ -1209,6 +1209,7 @@ export class NobleBleManager extends EventEmitter { rejectGatt = reject; }); // Avoid unhandledRejection when no duplicate connect is awaiting coalesce. + // catch-no-log-ok coalesce tail — duplicate connects await the same promise; lone setup uses this void promise.catch(() => {}); session.meshcoreGattInflight = { promise, diff --git a/src/renderer/components/ContactGroupsModal.test.tsx b/src/renderer/components/ContactGroupsModal.test.tsx index a038227df..d45ebc918 100644 --- a/src/renderer/components/ContactGroupsModal.test.tsx +++ b/src/renderer/components/ContactGroupsModal.test.tsx @@ -1,17 +1,12 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { ReactElement } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ContactGroup } from '@/shared/electron-api.types'; +import { renderWithToast } from '../lib/testRenderHelpers'; import type { MeshNode } from '../lib/types'; import ContactGroupsModal from './ContactGroupsModal'; -import { ToastProvider } from './Toast'; - -function renderWithToast(ui: ReactElement) { - return render({ui}); -} function makeNode(partial: Partial & Pick): MeshNode { return { diff --git a/src/renderer/components/DiagnosticsPanel.tsx b/src/renderer/components/DiagnosticsPanel.tsx index 3d6e5e168..56887ad42 100644 --- a/src/renderer/components/DiagnosticsPanel.tsx +++ b/src/renderer/components/DiagnosticsPanel.tsx @@ -26,6 +26,7 @@ import { import { useTimeFormatStore } from '@/renderer/stores/timeFormatStore'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; import { formatMeshtasticNodeId, meshtasticNodeIdMatchesHexQuery } from '@/shared/nodeNameUtils'; +import { MS_PER_DAY } from '@/shared/timeConstants'; import { diagnosticRowsToRoutingMap, @@ -944,7 +945,7 @@ export default function DiagnosticsPanel({ const samples = cuHistory.get(myNodeNum) ?? []; if (samples.length < 2) return null; const now = Date.now(); - const cutoff = now - 24 * 60 * 60 * 1000; + const cutoff = now - MS_PER_DAY; const chartData = samples .filter((s) => s.t >= cutoff) .map((s) => ({ diff --git a/src/renderer/components/MeshcoreContactSettingsSection.test.tsx b/src/renderer/components/MeshcoreContactSettingsSection.test.tsx index 24ccd396f..d6641533d 100644 --- a/src/renderer/components/MeshcoreContactSettingsSection.test.tsx +++ b/src/renderer/components/MeshcoreContactSettingsSection.test.tsx @@ -1,16 +1,11 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { ReactElement } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; import { enrichMeshCoreSelfInfo } from '../lib/meshcoreTelemetryPrivacy'; +import { renderWithToast } from '../lib/testRenderHelpers'; import MeshcoreContactSettingsSection from './MeshcoreContactSettingsSection'; -import { ToastProvider } from './Toast'; - -function renderWithToast(ui: ReactElement) { - return render({ui}); -} function minimalSelfInfo(manualAddContacts: boolean) { return enrichMeshCoreSelfInfo({ diff --git a/src/renderer/components/MeshcoreTelemetryPrivacySection.test.tsx b/src/renderer/components/MeshcoreTelemetryPrivacySection.test.tsx index c57183f06..78c5c1a37 100644 --- a/src/renderer/components/MeshcoreTelemetryPrivacySection.test.tsx +++ b/src/renderer/components/MeshcoreTelemetryPrivacySection.test.tsx @@ -1,16 +1,10 @@ -import { render } from '@testing-library/react'; -import type { ReactElement } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { enrichMeshCoreSelfInfo } from '../lib/meshcoreTelemetryPrivacy'; +import { renderWithToast } from '../lib/testRenderHelpers'; import MeshcoreTelemetryPrivacySection from './MeshcoreTelemetryPrivacySection'; -import { ToastProvider } from './Toast'; - -function renderWithToast(ui: ReactElement) { - return render({ui}); -} function minimalSelfInfo() { return enrichMeshCoreSelfInfo({ diff --git a/src/renderer/components/ModulePanel.test.tsx b/src/renderer/components/ModulePanel.test.tsx index c913c21fa..c660373e6 100644 --- a/src/renderer/components/ModulePanel.test.tsx +++ b/src/renderer/components/ModulePanel.test.tsx @@ -1,14 +1,9 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { ReactElement } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderWithToast } from '../lib/testRenderHelpers'; import ModulePanel from './ModulePanel'; -import { ToastProvider } from './Toast'; - -function renderWithToast(ui: ReactElement) { - return render({ui}); -} const baseProps = { moduleConfigs: { diff --git a/src/renderer/components/RepeatersPanel.tsx b/src/renderer/components/RepeatersPanel.tsx index a2c10da55..336091382 100644 --- a/src/renderer/components/RepeatersPanel.tsx +++ b/src/renderer/components/RepeatersPanel.tsx @@ -43,6 +43,7 @@ import { } from '../lib/nodeStatus'; import type { PathRecord } from '../lib/pathHistoryTypes'; import { useRadioProvider } from '../lib/radio/providerFactory'; +import { MS_PER_DAY } from '../lib/timeConstants'; import type { MeshNode } from '../lib/types'; import { useCoordFormatStore } from '../stores/coordFormatStore'; import { usePathHistoryStore } from '../stores/pathHistoryStore'; @@ -87,7 +88,7 @@ interface Props { onToggleFavorite?: (nodeId: number, favorited: boolean) => void; } -const SIGNAL_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const SIGNAL_MAX_AGE_MS = MS_PER_DAY; const REPEATER_ROW_ESTIMATE_PX = 48; const REPEATER_ROW_EXPANDED_EXTRA_PX = 160; const REPEATER_VIRTUALIZE_THRESHOLD = 100; diff --git a/src/renderer/components/ReticulumStackPanel.tsx b/src/renderer/components/ReticulumStackPanel.tsx index b77b94d71..71eb72c02 100644 --- a/src/renderer/components/ReticulumStackPanel.tsx +++ b/src/renderer/components/ReticulumStackPanel.tsx @@ -308,7 +308,12 @@ export function ReticulumStackPanel({ void (async () => { await onStopStack(); await refreshSidecarStatus(); - })(); + })().catch((e: unknown) => { + console.warn( + '[ReticulumStackPanel] stop stack failed ' + + (e instanceof Error ? e.message : String(e)), + ); + }); }} className="w-full rounded-lg bg-red-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-500 disabled:opacity-40" > diff --git a/src/renderer/components/SecurityPanel.test.tsx b/src/renderer/components/SecurityPanel.test.tsx index 40ee24b5c..a34269b97 100644 --- a/src/renderer/components/SecurityPanel.test.tsx +++ b/src/renderer/components/SecurityPanel.test.tsx @@ -1,20 +1,15 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { ReactElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { meshtasticDmKeyBackupStorageKey } from '../lib/meshtasticDmKeyBackupStorage'; +import { renderWithToast } from '../lib/testRenderHelpers'; import SecurityPanel from './SecurityPanel'; -import { ToastProvider } from './Toast'; vi.mock('../lib/writeClipboardText', () => ({ writeClipboardText: vi.fn().mockResolvedValue(undefined), })); -function renderWithToast(ui: ReactElement) { - return render({ui}); -} - function makeSecurityConfig() { return { publicKey: new Uint8Array(32).fill(0x01), diff --git a/src/renderer/components/remote/RemoteTransferSection.test.tsx b/src/renderer/components/remote/RemoteTransferSection.test.tsx new file mode 100644 index 000000000..858e046c3 --- /dev/null +++ b/src/renderer/components/remote/RemoteTransferSection.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RemoteTransferSection } from '@/renderer/components/remote/RemoteTransferSection'; +import { DEFAULT_REMOTE_SETTINGS } from '@/renderer/lib/remoteSettingsStorage'; +import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore'; +import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; + +const addToast = vi.fn(); + +vi.mock('@/renderer/components/Toast', () => ({ + useToast: () => ({ addToast }), +})); + +describe('RemoteTransferSection', () => { + beforeEach(() => { + addToast.mockReset(); + useRncpTransferStore.getState().clearAll(); + useReticulumRemoteAddressStore.setState({ + addresses: new Map(), + hydrated: true, + hydrate: () => Promise.resolve(), + }); + vi.mocked(window.electronAPI.reticulum.remote.getIdentity).mockReset(); + vi.mocked(window.electronAPI.reticulum.remote.getIdentity).mockResolvedValue({ + identity_hash: 'a'.repeat(32), + rncp_receive_hash: 'b'.repeat(32), + }); + vi.mocked(window.electronAPI.reticulum.rncp.accept).mockReset(); + vi.mocked(window.electronAPI.reticulum.rncp.accept).mockResolvedValue({ ok: true }); + vi.mocked(window.electronAPI.reticulum.rncp.reject).mockReset(); + vi.mocked(window.electronAPI.reticulum.rncp.reject).mockResolvedValue({ ok: true }); + }); + + it('smoke-renders transfer controls and loads identity when sidecar is running', async () => { + render(); + + expect(screen.getByRole('button', { name: 'Switch to send mode' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Switch to fetch mode' })).toBeInTheDocument(); + expect(screen.getByText('My identity:')).toBeInTheDocument(); + + await waitFor(() => { + expect(window.electronAPI.reticulum.remote.getIdentity).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.getByText('a'.repeat(32))).toBeInTheDocument(); + }); + }); + + it('shows pending offers and accepts one via rncp.accept', async () => { + useRncpTransferStore.getState().applyOffer({ + transfer_id: 'offer-1', + file_name: 'notes.txt', + bytes: 42, + identity_hash: 'c'.repeat(32), + }); + const user = userEvent.setup(); + render(); + + expect(screen.getByText('Pending inbound files')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Accept notes.txt' })); + + expect(window.electronAPI.reticulum.rncp.accept).toHaveBeenCalledWith({ + transfer_id: 'offer-1', + }); + expect(useRncpTransferStore.getState().pendingOffers.size).toBe(0); + }); + + it('rejects a pending offer and removes it even if IPC fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(window.electronAPI.reticulum.rncp.reject).mockRejectedValue(new Error('down')); + useRncpTransferStore.getState().applyOffer({ + transfer_id: 'offer-2', + file_name: 'photo.png', + bytes: 100, + identity_hash: 'd'.repeat(32), + }); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Reject photo.png' })); + expect(window.electronAPI.reticulum.rncp.reject).toHaveBeenCalledWith({ + transfer_id: 'offer-2', + }); + expect(useRncpTransferStore.getState().pendingOffers.size).toBe(0); + }); + + it('does not fetch identity when the sidecar is stopped', async () => { + render(); + expect(screen.getByText('My identity:')).toBeInTheDocument(); + await waitFor(() => { + expect(window.electronAPI.reticulum.remote.getIdentity).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/renderer/hooks/useLongSessionMaintenance.ts b/src/renderer/hooks/useLongSessionMaintenance.ts index 9c3c457ef..614b3deee 100644 --- a/src/renderer/hooks/useLongSessionMaintenance.ts +++ b/src/renderer/hooks/useLongSessionMaintenance.ts @@ -2,10 +2,11 @@ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useToast } from '../components/Toast'; +import { MS_PER_DAY, MS_PER_HOUR, MS_PER_SECOND } from '../lib/timeConstants'; /** Main-process uptime before showing a one-time restart suggestion toast. */ -const RESTART_NUDGE_UPTIME_SEC = 4 * 24 * 60 * 60; -const RESTART_NUDGE_CHECK_INTERVAL_MS = 60 * 60 * 1000; +const RESTART_NUDGE_UPTIME_SEC = (4 * MS_PER_DAY) / MS_PER_SECOND; +const RESTART_NUDGE_CHECK_INTERVAL_MS = MS_PER_HOUR; const RESTART_NUDGE_SHOWN_KEY = 'mesh-client:longSessionRestartNudgeShown'; diff --git a/src/renderer/lib/drivers/ConnectionDriver.ts b/src/renderer/lib/drivers/ConnectionDriver.ts index f5761758f..5818ed104 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.ts @@ -65,17 +65,38 @@ function transportTypeToConnectionType(type: TransportType): ConnectionType | nu * protocol events into PacketRouter, and resolves identity signatures so that * reconnecting a previously-seen device reuses its existing store slices. * - * Watchdog and reconnect-with-backoff are intentionally not yet implemented; - * they layer on top of the slot registry's `lastDataAt`. - * - * MQTT status mirroring into `connectionStore` is updated from legacy runtime - * MQTT IPC handlers via `mirrorMqttStatusToConnection` until MQTT moves fully into - * drivers (see AGENTS.md). + * Watchdog and reconnect-with-backoff remain in protocol runtimes + * (`useMeshtasticRuntime` / `useMeshcoreRuntime`); this driver owns connect/ + * disconnect serialization and slot registry only. MQTT status mirroring into + * `connectionStore` is updated from runtime MQTT IPC handlers via + * `mirrorMqttStatusToConnection` until MQTT moves fully into drivers (see AGENTS.md). */ export class ConnectionDriver { private slots = new Map(); /** transport-key → identityId; persists identity across reconnects of the same physical device. */ private transportKeyMap = new Map(); + /** Serializes connect/disconnect so teardown cannot interleave with GATT setup. */ + private lifecycleGate: Promise = Promise.resolve(); + + private async withLifecycle(fn: () => Promise): Promise { + const prev = this.lifecycleGate; + let release!: (value: unknown) => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + this.lifecycleGate = prev.then( + () => gate, + () => gate, + ); + await prev.catch(() => { + // catch-no-log-ok prior lifecycle failure must not block the next connect/disconnect + }); + try { + return await fn(); + } finally { + release(undefined); + } + } /** Resolve identity from transport and/or device-intrinsic signature keys. */ lookupIdentityId(...keys: string[]): IdentityId | null { @@ -164,106 +185,110 @@ export class ConnectionDriver { } } - let handle: unknown; - try { - handle = await protocol.createDevice(params); - } catch (err) { - if (createdProvisional) removeIdentityFromStore(identityId); - throw err; - } - - let info: DiscoveryInfo | undefined; - if (protocol.discoverSelf) { + return this.withLifecycle(async () => { + let handle: unknown; try { - info = await protocol.discoverSelf(handle); + handle = await protocol.createDevice(params); } catch (err) { - await protocol.destroyDevice(handle).catch((e: unknown) => { - console.warn('[ConnectionDriver] destroy after discoverSelf failure ' + String(e)); - }); if (createdProvisional) removeIdentityFromStore(identityId); throw err; } - } - if (info) { - const resolvedKey = protocol.identitySignature(params, info); - if (resolvedKey !== provisionalKey) { - const matched = findIdentityBySignature(resolvedKey); - if (matched && matched.id !== identityId) { + let info: DiscoveryInfo | undefined; + if (protocol.discoverSelf) { + try { + info = await protocol.discoverSelf(handle); + } catch (err) { + await protocol.destroyDevice(handle).catch((e: unknown) => { + console.warn('[ConnectionDriver] destroy after discoverSelf failure ' + String(e)); + }); if (createdProvisional) removeIdentityFromStore(identityId); - identityId = matched.id; + throw err; } } - updateIdentity(identityId, { - signature: resolvedKey, - publicKey: info.publicKey, - selfNodeNum: info.myNodeNum, - }); - this.registerTransportKeys(identityId, provisionalKey, resolvedKey); - } else { - this.registerTransportKeys(identityId, provisionalKey); - } - const transportId = randomPrefixedId('t'); - const resolvedIdentityId = identityId; - const teardown = protocol.subscribe(handle, (event: DomainEvent) => { - const slot = this.slots.get(transportId); - if (slot) slot.lastDataAt = Date.now(); - try { - packetRouter.dispatch(event, resolvedIdentityId); - } catch (err) { - console.error( - '[ConnectionDriver] packetRouter.dispatch failed:', - err instanceof Error ? err.message : String(err), - ); + if (info) { + const resolvedKey = protocol.identitySignature(params, info); + if (resolvedKey !== provisionalKey) { + const matched = findIdentityBySignature(resolvedKey); + if (matched && matched.id !== identityId) { + if (createdProvisional) removeIdentityFromStore(identityId); + identityId = matched.id; + } + } + updateIdentity(identityId, { + signature: resolvedKey, + publicKey: info.publicKey, + selfNodeNum: info.myNodeNum, + }); + this.registerTransportKeys(identityId, provisionalKey, resolvedKey); + } else { + this.registerTransportKeys(identityId, provisionalKey); } - }); - const transportRef: TransportRef = { - transportId, - type: params.type, - status: 'connected', - params, - lastDataReceivedAt: Date.now(), - }; - addTransport(identityId, transportRef); + const transportId = randomPrefixedId('t'); + const resolvedIdentityId = identityId; + const teardown = protocol.subscribe(handle, (event: DomainEvent) => { + const slot = this.slots.get(transportId); + if (slot) slot.lastDataAt = Date.now(); + try { + packetRouter.dispatch(event, resolvedIdentityId); + } catch (err) { + console.error( + '[ConnectionDriver] packetRouter.dispatch failed:', + err instanceof Error ? err.message : String(err), + ); + } + }); - this.slots.set(transportId, { - transportId, - identityId, - protocol, - handle, - type: params.type, - params, - teardown, - lastDataAt: Date.now(), - }); + const transportRef: TransportRef = { + transportId, + type: params.type, + status: 'connected', + params, + lastDataReceivedAt: Date.now(), + }; + addTransport(identityId, transportRef); - setConnection(identityId, { - status: 'connecting', - connectionType: transportTypeToConnectionType(params.type), - }); - setActiveIdentity(identityId); - mergeOfflineStoreIntoIdentity(protocol.type as MeshProtocol, identityId); + this.slots.set(transportId, { + transportId, + identityId, + protocol, + handle, + type: params.type, + params, + teardown, + lastDataAt: Date.now(), + }); - return identityId; + setConnection(identityId, { + status: 'connecting', + connectionType: transportTypeToConnectionType(params.type), + }); + setActiveIdentity(identityId); + mergeOfflineStoreIntoIdentity(protocol.type as MeshProtocol, identityId); + + return identityId; + }); } async disconnect(identityId: IdentityId): Promise { - const slotsToRemove = [...this.slots.values()].filter((s) => s.identityId === identityId); - for (const slot of slotsToRemove) { - try { - slot.teardown(); - } catch (e) { - console.warn('[ConnectionDriver] teardown error ' + errLikeToLogString(e)); + await this.withLifecycle(async () => { + const slotsToRemove = [...this.slots.values()].filter((s) => s.identityId === identityId); + for (const slot of slotsToRemove) { + try { + slot.teardown(); + } catch (e) { + console.warn('[ConnectionDriver] teardown error ' + errLikeToLogString(e)); + } + await slot.protocol.destroyDevice(slot.handle).catch((e: unknown) => { + console.warn('[ConnectionDriver] destroy error ' + errLikeToLogString(e)); + }); + this.slots.delete(slot.transportId); + removeTransport(identityId, slot.transportId); } - await slot.protocol.destroyDevice(slot.handle).catch((e: unknown) => { - console.warn('[ConnectionDriver] destroy error ' + errLikeToLogString(e)); - }); - this.slots.delete(slot.transportId); - removeTransport(identityId, slot.transportId); - } - setConnection(identityId, { status: 'disconnected' }); + setConnection(identityId, { status: 'disconnected' }); + }); } /** "Forget this device": disconnects + clears every per-identity store slice. */ diff --git a/src/renderer/lib/letsMeshConnectionGuards.test.ts b/src/renderer/lib/letsMeshConnectionGuards.test.ts index e96a682ae..48416162a 100644 --- a/src/renderer/lib/letsMeshConnectionGuards.test.ts +++ b/src/renderer/lib/letsMeshConnectionGuards.test.ts @@ -1,4 +1,23 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./i18n', () => ({ + default: { + t: (key: string, opts?: { port?: number }) => { + switch (key) { + case 'connectionPanel.letsMeshRequiresWebSocket': + return 'LetsMesh requires WebSocket transport.'; + case 'connectionPanel.letsMeshRequiresPort': + return `LetsMesh requires port ${opts?.port ?? ''}.`; + case 'connectionPanel.letsMeshKnownBrokersOnly': + return 'LetsMesh / MeshMapper preset only supports known device-signing brokers. Use Custom for other brokers.'; + case 'connectionPanel.letsMeshUsernameV1Hex': + return 'Username must be v1_ followed by 64 hex characters (public key).'; + default: + return key; + } + }, + }, +})); import { letsMeshPresetConfigurationDeviation, diff --git a/src/renderer/lib/letsMeshConnectionGuards.ts b/src/renderer/lib/letsMeshConnectionGuards.ts index 1e883cd6d..e871980c1 100644 --- a/src/renderer/lib/letsMeshConnectionGuards.ts +++ b/src/renderer/lib/letsMeshConnectionGuards.ts @@ -1,3 +1,4 @@ +import i18n from './i18n'; import { isLetsMeshSettings } from './letsMeshJwt'; import type { MQTTSettings } from './types'; @@ -6,13 +7,13 @@ const DEVICE_SIGNING_BROKER_PORT = 443; /** Hard validation before connecting with the LetsMesh preset (public US/EU brokers only). */ export function validateLetsMeshPresetConnect(settings: MQTTSettings): string | null { if (!(settings.useWebSocket ?? false)) { - return 'LetsMesh requires WebSocket transport.'; + return i18n.t('connectionPanel.letsMeshRequiresWebSocket'); } if (settings.port !== DEVICE_SIGNING_BROKER_PORT) { - return `LetsMesh requires port ${DEVICE_SIGNING_BROKER_PORT}.`; + return i18n.t('connectionPanel.letsMeshRequiresPort', { port: DEVICE_SIGNING_BROKER_PORT }); } if (!isLetsMeshSettings(settings.server)) { - return 'LetsMesh / MeshMapper preset only supports known device-signing brokers. Use Custom for other brokers.'; + return i18n.t('connectionPanel.letsMeshKnownBrokersOnly'); } return null; } @@ -25,7 +26,7 @@ export function validateLetsMeshManualCredentials(settings: MQTTSettings): strin if (!settings.password?.trim()) return null; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Persisted legacy settings may omit username at runtime. if (!V1_USERNAME_HEX.test(settings.username?.trim() ?? '')) { - return 'Username must be v1_ followed by 64 hex characters (public key).'; + return i18n.t('connectionPanel.letsMeshUsernameV1Hex'); } return null; } diff --git a/src/renderer/lib/logAnalyzerI18n.test.ts b/src/renderer/lib/logAnalyzerI18n.test.ts new file mode 100644 index 000000000..331f56065 --- /dev/null +++ b/src/renderer/lib/logAnalyzerI18n.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import en from '@/renderer/locales/en/translation.json'; + +import { + LOG_ANALYZER_CATEGORY_LABEL_KEYS, + LOG_ANALYZER_CATEGORY_RECOMMENDATION_KEYS, + LOG_ANALYZER_GROUP_RECOMMENDATION_KEYS, + resolveLogAnalyzerRecommendationKey, +} from './logAnalyzerI18n'; + +function getByFlatKeyPath(root: unknown, flatKey: string): unknown { + let cur: unknown = root; + for (const part of flatKey.split('.')) { + if (cur == null || typeof cur !== 'object' || !(part in cur)) { + return undefined; + } + cur = (cur as Record)[part]; + } + return cur; +} + +describe('logAnalyzerI18n key maps', () => { + it('every LOG_ANALYZER_CATEGORY_LABEL_KEYS value exists in en/translation.json', () => { + for (const [category, key] of Object.entries(LOG_ANALYZER_CATEGORY_LABEL_KEYS)) { + const value = getByFlatKeyPath(en, key); + expect(value, `missing label for ${category}: ${key}`).toEqual(expect.any(String)); + expect(String(value).length, `empty label for ${category}: ${key}`).toBeGreaterThan(0); + } + }); + + it('every LOG_ANALYZER_CATEGORY_RECOMMENDATION_KEYS value exists in en/translation.json', () => { + for (const [category, key] of Object.entries(LOG_ANALYZER_CATEGORY_RECOMMENDATION_KEYS)) { + const value = getByFlatKeyPath(en, key); + expect(value, `missing recommendation for ${category}: ${key}`).toEqual(expect.any(String)); + expect(String(value).length, `empty recommendation for ${category}: ${key}`).toBeGreaterThan( + 0, + ); + } + }); + + it('every LOG_ANALYZER_GROUP_RECOMMENDATION_KEYS value exists in en/translation.json', () => { + for (const [group, key] of Object.entries(LOG_ANALYZER_GROUP_RECOMMENDATION_KEYS)) { + const value = getByFlatKeyPath(en, key); + expect(value, `missing group recommendation for ${group}: ${key}`).toEqual( + expect.any(String), + ); + expect( + String(value).length, + `empty group recommendation for ${group}: ${key}`, + ).toBeGreaterThan(0); + } + }); + + it('resolveLogAnalyzerRecommendationKey prefers group map then falls back', () => { + expect(resolveLogAnalyzerRecommendationKey('__test_merged')).toBe( + 'logAnalyzer.recommendationGroups.__test_merged.recommendation', + ); + expect(resolveLogAnalyzerRecommendationKey('unknown-group')).toBe( + 'logAnalyzer.categories.internal-error.recommendation', + ); + }); +}); diff --git a/src/renderer/lib/meshcoreRepeaterRpcCommon.test.ts b/src/renderer/lib/meshcoreRepeaterRpcCommon.test.ts new file mode 100644 index 000000000..ea2e2de8c --- /dev/null +++ b/src/renderer/lib/meshcoreRepeaterRpcCommon.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildSendBinaryReqFrame, + buildSendLoginFrame, + buildSendStatusReqFrame, + buildSendTelemetryReqFrame, + MESHCORE_PUBKEY_PREFIX_LEN, + meshcoreLoginErrorIsAuthFailure, + normalizePubKeyPrefix, + parseRepeaterStatsFromStatusData, + prefixToHex, + pubKeyPrefixesEqual, + requireContactPubKeyPrefix, + unknownToError, +} from './meshcoreRepeaterRpcCommon'; +import { makePubKey } from './meshcoreTestHelpers'; +import { + MC_CMD_SEND_BINARY_REQ, + MC_CMD_SEND_LOGIN, + MC_CMD_SEND_STATUS_REQ, + MC_CMD_SEND_TELEMETRY_REQ, +} from './meshcoreWireCodes'; + +function buildStatusData(stats: { + batt_milli_volts: number; + curr_tx_queue_len: number; + noise_floor: number; + last_rssi: number; + n_packets_recv: number; + n_packets_sent: number; + total_air_time_secs: number; + total_up_time_secs: number; + n_sent_flood: number; + n_sent_direct: number; + n_recv_flood: number; + n_recv_direct: number; + err_events: number; + last_snr: number; + n_direct_dups: number; + n_flood_dups: number; +}): Uint8Array { + const buf = new ArrayBuffer(48); + const view = new DataView(buf); + let o = 0; + view.setUint16(o, stats.batt_milli_volts, true); + o += 2; + view.setUint16(o, stats.curr_tx_queue_len, true); + o += 2; + view.setInt16(o, stats.noise_floor, true); + o += 2; + view.setInt16(o, stats.last_rssi, true); + o += 2; + view.setUint32(o, stats.n_packets_recv, true); + o += 4; + view.setUint32(o, stats.n_packets_sent, true); + o += 4; + view.setUint32(o, stats.total_air_time_secs, true); + o += 4; + view.setUint32(o, stats.total_up_time_secs, true); + o += 4; + view.setUint32(o, stats.n_sent_flood, true); + o += 4; + view.setUint32(o, stats.n_sent_direct, true); + o += 4; + view.setUint32(o, stats.n_recv_flood, true); + o += 4; + view.setUint32(o, stats.n_recv_direct, true); + o += 4; + view.setUint16(o, stats.err_events, true); + o += 2; + view.setInt16(o, stats.last_snr, true); + o += 2; + view.setUint16(o, stats.n_direct_dups, true); + o += 2; + view.setUint16(o, stats.n_flood_dups, true); + return new Uint8Array(buf); +} + +describe('requireContactPubKeyPrefix', () => { + it('returns the first 6 bytes of a long enough key', () => { + const key = makePubKey(0xab); + const prefix = requireContactPubKeyPrefix(key); + expect(prefix).toEqual(key.subarray(0, MESHCORE_PUBKEY_PREFIX_LEN)); + expect(prefix.length).toBe(6); + }); + + it('throws when the key is shorter than 6 bytes', () => { + expect(() => requireContactPubKeyPrefix(new Uint8Array([1, 2, 3]))).toThrow( + /public key too short for prefix match/, + ); + }); +}); + +describe('normalizePubKeyPrefix', () => { + it('accepts a 6-byte Uint8Array', () => { + const prefix = new Uint8Array([1, 2, 3, 4, 5, 6]); + expect(normalizePubKeyPrefix(prefix)).toEqual(prefix); + }); + + it('accepts a DataView of length 6', () => { + const buf = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2]); + const view = new DataView(buf.buffer, 1, 6); + expect(normalizePubKeyPrefix(view)).toEqual(new Uint8Array([8, 7, 6, 5, 4, 3])); + }); + + it('accepts a 6-element number array', () => { + expect(normalizePubKeyPrefix([10, 20, 30, 40, 50, 60])).toEqual( + new Uint8Array([10, 20, 30, 40, 50, 60]), + ); + }); + + it('returns null for wrong lengths and non-array-likes', () => { + expect(normalizePubKeyPrefix(new Uint8Array([1, 2, 3]))).toBeNull(); + expect(normalizePubKeyPrefix([1, 2, 3, 4, 5])).toBeNull(); + expect(normalizePubKeyPrefix(null)).toBeNull(); + expect(normalizePubKeyPrefix('deadbeef')).toBeNull(); + }); +}); + +describe('pubKeyPrefixesEqual / prefixToHex', () => { + it('compares equal 6-byte prefixes', () => { + const a = new Uint8Array([1, 2, 3, 4, 5, 6]); + const b = new Uint8Array([1, 2, 3, 4, 5, 6]); + expect(pubKeyPrefixesEqual(a, b)).toBe(true); + }); + + it('returns false for mismatched bytes or wrong lengths', () => { + expect( + pubKeyPrefixesEqual(new Uint8Array([1, 2, 3, 4, 5, 6]), new Uint8Array([1, 2, 3, 4, 5, 7])), + ).toBe(false); + expect(pubKeyPrefixesEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3, 4, 5, 6]))).toBe( + false, + ); + }); + + it('formats prefix bytes as lowercase hex', () => { + expect(prefixToHex(new Uint8Array([0x0a, 0xbc, 0x00, 0xff, 0x10, 0x01]))).toBe('0abc00ff1001'); + }); +}); + +describe('unknownToError', () => { + it('passes through Error instances', () => { + const err = new Error('boom'); + expect(unknownToError(err, 'fallback')).toBe(err); + }); + + it('wraps strings and uses fallback for nullish/other', () => { + expect(unknownToError('nope', 'fallback').message).toBe('nope'); + expect(unknownToError(null, 'fallback').message).toBe('fallback'); + expect(unknownToError(undefined, 'fallback').message).toBe('fallback'); + expect(unknownToError({ x: 1 }, 'fallback').message).toBe('fallback'); + }); +}); + +describe('frame builders', () => { + const key = makePubKey(0x42); + + it('buildSendLoginFrame encodes cmd + 32-byte key + password', () => { + const frame = buildSendLoginFrame(key, 'secret'); + expect(frame[0]).toBe(MC_CMD_SEND_LOGIN); + expect(frame.subarray(1, 33)).toEqual(key); + expect(new TextDecoder().decode(frame.subarray(33))).toBe('secret'); + }); + + it('buildSendLoginFrame allows empty password', () => { + const frame = buildSendLoginFrame(key, ''); + expect(frame.length).toBe(33); + expect(frame[0]).toBe(MC_CMD_SEND_LOGIN); + }); + + it('buildSendStatusReqFrame encodes cmd + key', () => { + const frame = buildSendStatusReqFrame(key); + expect(frame).toEqual(new Uint8Array([MC_CMD_SEND_STATUS_REQ, ...key])); + }); + + it('buildSendTelemetryReqFrame leaves 3 reserved bytes before the key', () => { + const frame = buildSendTelemetryReqFrame(key); + expect(frame[0]).toBe(MC_CMD_SEND_TELEMETRY_REQ); + expect(frame.subarray(1, 4)).toEqual(new Uint8Array([0, 0, 0])); + expect(frame.subarray(4)).toEqual(key); + }); + + it('buildSendBinaryReqFrame appends request payload after the key', () => { + const payload = new Uint8Array([0x06, 1, 2, 3]); + const frame = buildSendBinaryReqFrame(key, payload); + expect(frame[0]).toBe(MC_CMD_SEND_BINARY_REQ); + expect(frame.subarray(1, 33)).toEqual(key); + expect(frame.subarray(33)).toEqual(payload); + }); + + it('rejects non-32-byte public keys', () => { + const short = new Uint8Array(16); + expect(() => buildSendLoginFrame(short, 'x')).toThrow(/32-byte public key/); + expect(() => buildSendStatusReqFrame(short)).toThrow(/32-byte public key/); + expect(() => buildSendTelemetryReqFrame(short)).toThrow(/32-byte public key/); + expect(() => buildSendBinaryReqFrame(short, new Uint8Array([1]))).toThrow(/32-byte public key/); + }); +}); + +describe('parseRepeaterStatsFromStatusData', () => { + it('parses little-endian status payload fields', () => { + const expected = { + batt_milli_volts: 3700, + curr_tx_queue_len: 12, + noise_floor: -95, + last_rssi: -72, + n_packets_recv: 1001, + n_packets_sent: 2002, + total_air_time_secs: 3003, + total_up_time_secs: 4004, + n_sent_flood: 5, + n_sent_direct: 6, + n_recv_flood: 7, + n_recv_direct: 8, + err_events: 9, + last_snr: -11, + n_direct_dups: 10, + n_flood_dups: 11, + }; + expect(parseRepeaterStatsFromStatusData(buildStatusData(expected))).toEqual(expected); + }); + + it('reads correctly from a non-zero byteOffset view', () => { + const inner = buildStatusData({ + batt_milli_volts: 3300, + curr_tx_queue_len: 1, + noise_floor: -100, + last_rssi: -80, + n_packets_recv: 1, + n_packets_sent: 2, + total_air_time_secs: 3, + total_up_time_secs: 4, + n_sent_flood: 5, + n_sent_direct: 6, + n_recv_flood: 7, + n_recv_direct: 8, + err_events: 9, + last_snr: 3, + n_direct_dups: 10, + n_flood_dups: 11, + }); + const padded = new Uint8Array(4 + inner.length); + padded.set(inner, 4); + const sliced = padded.subarray(4); + expect(parseRepeaterStatsFromStatusData(sliced).batt_milli_volts).toBe(3300); + expect(parseRepeaterStatsFromStatusData(sliced).n_flood_dups).toBe(11); + }); +}); + +describe('meshcoreLoginErrorIsAuthFailure', () => { + it('detects rejected / wrong password / acl denied messages', () => { + expect(meshcoreLoginErrorIsAuthFailure(new Error('Login rejected'))).toBe(true); + expect(meshcoreLoginErrorIsAuthFailure('Wrong password')).toBe(true); + expect(meshcoreLoginErrorIsAuthFailure(new Error('ACL denied by repeater'))).toBe(true); + }); + + it('returns false for unrelated errors', () => { + expect(meshcoreLoginErrorIsAuthFailure(new Error('timeout waiting for LoginSuccess'))).toBe( + false, + ); + expect(meshcoreLoginErrorIsAuthFailure(null)).toBe(false); + expect(meshcoreLoginErrorIsAuthFailure(42)).toBe(false); + }); +}); diff --git a/src/renderer/lib/meshcoreWebBluetoothConnection.ts b/src/renderer/lib/meshcoreWebBluetoothConnection.ts index 8d8cebf4f..fa77c2456 100644 --- a/src/renderer/lib/meshcoreWebBluetoothConnection.ts +++ b/src/renderer/lib/meshcoreWebBluetoothConnection.ts @@ -41,6 +41,7 @@ export class MeshcoreWebBluetoothConnection extends Connection { async close(): Promise { this._serializedToDevice = null; if (this._fromDeviceReader) { + // catch-no-log-ok cancel during close — reader may already be cancelled await this._fromDeviceReader.cancel().catch(() => {}); this._fromDeviceReader = null; } diff --git a/src/renderer/lib/messageRetention.ts b/src/renderer/lib/messageRetention.ts index 03eefb55b..cd62ec595 100644 --- a/src/renderer/lib/messageRetention.ts +++ b/src/renderer/lib/messageRetention.ts @@ -69,41 +69,26 @@ function parseCount(v: string | undefined, fallback: number): number { return Math.max(MESSAGE_RETENTION_MIN_COUNT, Math.min(MESSAGE_RETENTION_MAX_COUNT, n)); } +const MESSAGE_RETENTION_PROTOCOLS = ['meshtastic', 'meshcore', 'reticulum', 'rrc'] as const; + export function parseMessageRetention( raw: Record | null | undefined, ): MessageRetentionSettings { const r = raw ?? {}; - return { - meshtasticEnabled: parseBool( - r[MESSAGE_RETENTION_KEYS.meshtasticEnabled], - DEFAULT_MESSAGE_RETENTION.meshtasticEnabled, - ), - meshtasticCount: parseCount( - r[MESSAGE_RETENTION_KEYS.meshtasticCount], - DEFAULT_MESSAGE_RETENTION.meshtasticCount, - ), - meshcoreEnabled: parseBool( - r[MESSAGE_RETENTION_KEYS.meshcoreEnabled], - DEFAULT_MESSAGE_RETENTION.meshcoreEnabled, - ), - meshcoreCount: parseCount( - r[MESSAGE_RETENTION_KEYS.meshcoreCount], - DEFAULT_MESSAGE_RETENTION.meshcoreCount, - ), - reticulumEnabled: parseBool( - r[MESSAGE_RETENTION_KEYS.reticulumEnabled], - DEFAULT_MESSAGE_RETENTION.reticulumEnabled, - ), - reticulumCount: parseCount( - r[MESSAGE_RETENTION_KEYS.reticulumCount], - DEFAULT_MESSAGE_RETENTION.reticulumCount, - ), - rrcEnabled: parseBool( - r[MESSAGE_RETENTION_KEYS.rrcEnabled], - DEFAULT_MESSAGE_RETENTION.rrcEnabled, - ), - rrcCount: parseCount(r[MESSAGE_RETENTION_KEYS.rrcCount], DEFAULT_MESSAGE_RETENTION.rrcCount), - }; + const settings = { ...DEFAULT_MESSAGE_RETENTION }; + for (const protocol of MESSAGE_RETENTION_PROTOCOLS) { + const enabledKey = `${protocol}Enabled` as const; + const countKey = `${protocol}Count` as const; + settings[enabledKey] = parseBool( + r[MESSAGE_RETENTION_KEYS[enabledKey]], + DEFAULT_MESSAGE_RETENTION[enabledKey], + ); + settings[countKey] = parseCount( + r[MESSAGE_RETENTION_KEYS[countKey]], + DEFAULT_MESSAGE_RETENTION[countKey], + ); + } + return settings; } /** diff --git a/src/renderer/lib/rncpTransferUiHelpers.test.ts b/src/renderer/lib/rncpTransferUiHelpers.test.ts new file mode 100644 index 000000000..4fed2cf71 --- /dev/null +++ b/src/renderer/lib/rncpTransferUiHelpers.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import type { TFunction } from 'i18next'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + acceptRncpOffer, + rejectRncpOffer, + toastRncpRequestEnableResult, +} from './rncpTransferUiHelpers'; + +const t = ((key: string, opts?: Record) => { + if (opts && typeof opts === 'object') { + const parts = Object.entries(opts) + .filter(([k]) => k !== 'defaultValue') + .map(([k, v]) => `${k}=${String(v)}`); + return parts.length > 0 ? `${key}|${parts.join(',')}` : key; + } + return key; +}) as TFunction; + +describe('toastRncpRequestEnableResult', () => { + it('toasts success', () => { + const addToast = vi.fn(); + toastRncpRequestEnableResult({ ok: true }, addToast, t); + expect(addToast).toHaveBeenCalledWith('reticulumRemote.transfer.requestEnableSent', 'success'); + }); + + it('toasts rate_limited as info', () => { + const addToast = vi.fn(); + toastRncpRequestEnableResult({ ok: false, error: 'rate_limited' }, addToast, t); + expect(addToast).toHaveBeenCalledWith( + 'reticulumRemote.transfer.requestEnableRateLimited', + 'info', + ); + }); + + it('toasts other failures with detail', () => { + const addToast = vi.fn(); + toastRncpRequestEnableResult({ ok: false, error: 'send_failed', detail: 'boom' }, addToast, t); + expect(addToast).toHaveBeenCalledWith( + 'reticulumRemote.transfer.requestEnableFailed|error=boom', + 'error', + ); + }); + + it('falls back to common.error when detail is missing', () => { + const addToast = vi.fn(); + toastRncpRequestEnableResult({ ok: false, error: 'invalid_peer' }, addToast, t); + expect(addToast).toHaveBeenCalledWith( + 'reticulumRemote.transfer.requestEnableFailed|error=common.error', + 'error', + ); + }); +}); + +describe('acceptRncpOffer', () => { + beforeEach(() => { + vi.mocked(window.electronAPI.reticulum.rncp.accept).mockReset(); + vi.mocked(window.electronAPI.reticulum.rncp.accept).mockResolvedValue({ ok: true }); + }); + + it('accepts via IPC and removes the offer', async () => { + const removeOffer = vi.fn(); + const addToast = vi.fn(); + await acceptRncpOffer('t1', { + removeOffer, + addToast, + t, + logTag: 'test', + }); + expect(window.electronAPI.reticulum.rncp.accept).toHaveBeenCalledWith({ transfer_id: 't1' }); + expect(removeOffer).toHaveBeenCalledWith('t1'); + expect(addToast).not.toHaveBeenCalled(); + }); + + it('toasts on accept failure and keeps the offer', async () => { + vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.mocked(window.electronAPI.reticulum.rncp.accept).mockRejectedValue(new Error('nope')); + const removeOffer = vi.fn(); + const addToast = vi.fn(); + await acceptRncpOffer('t2', { + removeOffer, + addToast, + t, + logTag: 'test', + }); + expect(removeOffer).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith( + expect.stringContaining('reticulumRemote.transfer.acceptFailed'), + 'error', + ); + }); +}); + +describe('rejectRncpOffer', () => { + beforeEach(() => { + vi.mocked(window.electronAPI.reticulum.rncp.reject).mockReset(); + vi.mocked(window.electronAPI.reticulum.rncp.reject).mockResolvedValue({ ok: true }); + }); + + it('rejects via IPC and always removes the offer', async () => { + const removeOffer = vi.fn(); + await rejectRncpOffer('t3', { removeOffer, logTag: 'test' }); + expect(window.electronAPI.reticulum.rncp.reject).toHaveBeenCalledWith({ transfer_id: 't3' }); + expect(removeOffer).toHaveBeenCalledWith('t3'); + }); + + it('still removes the offer when reject IPC fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(window.electronAPI.reticulum.rncp.reject).mockRejectedValue(new Error('down')); + const removeOffer = vi.fn(); + await rejectRncpOffer('t4', { removeOffer, logTag: 'test' }); + expect(removeOffer).toHaveBeenCalledWith('t4'); + }); +}); diff --git a/src/renderer/lib/sessions/reticulumSession.ts b/src/renderer/lib/sessions/reticulumSession.ts index 8ea23bb12..cfb270fad 100644 --- a/src/renderer/lib/sessions/reticulumSession.ts +++ b/src/renderer/lib/sessions/reticulumSession.ts @@ -53,22 +53,3 @@ export function getReticulumSendMessage( export function resolveReticulumOutboundVia(_destinationHash: string): MessageTransport { return activeSession?.resolveOutboundVia?.(_destinationHash) ?? 'network'; } - -/** @deprecated Use registerReticulumSession from useReticulumRuntime mount */ -export function bindReticulumSession(runtime: { - sendMessage: ReticulumSendMessageFn; - selfNodeId: string | number | null; - getFullNodeLabel: (nodeId: number) => string; - connect: () => Promise; - connectAutomatic: () => Promise; - disconnect: () => Promise; - finalizeDriverDisconnect?: () => Promise; - sendReaction?: (glyph: string, replyId: number, channel: number) => Promise; - handleSidecarEvent?: ReticulumSessionApi['handleSidecarEvent']; - resolveOutboundVia?: (destinationHash: string) => MessageTransport; -}): void { - registerReticulumSession({ - ...runtime, - finalizeDriverDisconnect: runtime.finalizeDriverDisconnect ?? runtime.disconnect, - }); -} diff --git a/src/renderer/lib/sourceContractTestHelpers.test.ts b/src/renderer/lib/sourceContractTestHelpers.test.ts index 3729c0cc0..a571f2bd6 100644 --- a/src/renderer/lib/sourceContractTestHelpers.test.ts +++ b/src/renderer/lib/sourceContractTestHelpers.test.ts @@ -4,6 +4,7 @@ import { extractBalancedBlock, extractIfBlockBody, extractUseCallbackBody, + loadRuntimeSource, } from './sourceContractTestHelpers'; describe('sourceContractTestHelpers', () => { @@ -43,4 +44,9 @@ describe('sourceContractTestHelpers', () => { it('extractUseCallbackBody returns empty string when marker is missing', () => { expect(extractUseCallbackBody('const x = 1;', 'missing')).toBe(''); }); + + it('loadRuntimeSource reads a runtime module', () => { + const source = loadRuntimeSource('useReticulumRuntime.ts'); + expect(source).toContain('useReticulumRuntime'); + }); }); diff --git a/src/renderer/lib/sourceContractTestHelpers.ts b/src/renderer/lib/sourceContractTestHelpers.ts index c9ea6c13a..a8daf508f 100644 --- a/src/renderer/lib/sourceContractTestHelpers.ts +++ b/src/renderer/lib/sourceContractTestHelpers.ts @@ -1,5 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + import { expect } from 'vitest'; +const RUNTIME_DIR = join(import.meta.dirname, '../runtime'); + +/** Load a `src/renderer/runtime/*` source file for contract tests. */ +export function loadRuntimeSource(filename: string): string { + return readFileSync(join(RUNTIME_DIR, filename), 'utf-8'); +} + /** Returns the inner text of a `{ ... }` block starting at `openBraceIndex`. */ export function extractBalancedBlock(source: string, openBraceIndex: number): string { let depth = 0; diff --git a/src/renderer/lib/testRenderHelpers.tsx b/src/renderer/lib/testRenderHelpers.tsx new file mode 100644 index 000000000..a50f53045 --- /dev/null +++ b/src/renderer/lib/testRenderHelpers.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +import { ToastProvider } from '../components/Toast'; + +/** Render UI wrapped in ToastProvider (panel tests that fire toasts). */ +export function renderWithToast(ui: ReactElement) { + return render({ui}); +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 04c404029..8ba4e035c 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -1264,7 +1264,11 @@ "disableShare": "Vypnout sdílení instance a restartovat", "disableShareAria": "Vypněte možnost Sdílet instanci a restartujte zásobník", "disableFailed": "Sdílení instance se nezdařilo vypnout." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh vyžaduje WebSocket transport.", + "letsMeshRequiresPort": "LetsMesh vyžaduje port {{port}}.", + "letsMeshKnownBrokersOnly": "Přednastavení LetsMesh /MeshMapper podporuje pouze známé makléře podepisující zařízení. Použít vlastní pro ostatní makléře.", + "letsMeshUsernameV1Hex": "Uživatelské jméno musí být v1_ následované 64 hexadecimálními znaky (veřejný klíč)." }, "contactGroupsModal": { "failedLoadMembers": "Nepodařilo se načíst členy: {{message}}", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 79e07f873..62e7fbf77 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "Share-Instanz deaktivieren und neu starten", "disableShareAria": "Deaktivieren Sie die Share-Instanz und starten Sie den Stack neu", "disableFailed": "Fehler beim Deaktivieren der Share-Instanz." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh erfordert WebSocket-Transport.", + "letsMeshRequiresPort": "LetsMesh erfordert Port {{port}}.", + "letsMeshKnownBrokersOnly": "Die LetsMesh /MeshMapper-Voreinstellung unterstützt nur bekannte Gerätesignatur-Broker. Verwenden Sie Custom für andere Broker.", + "letsMeshUsernameV1Hex": "Der Benutzername muss v1_ gefolgt von 64 Hexadezimalzeichen (öffentlicher Schlüssel) sein." }, "contactGroupsModal": { "failedLoadMembers": "Mitglieder konnten nicht geladen werden: {{message}}", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index cd147c559..2ca8805f5 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -728,6 +728,10 @@ "connectionPanel": { "maxReconnectAttempts": "Max MQTT reconnect attempts", "letsMeshRegion": "LetsMesh region", + "letsMeshRequiresWebSocket": "LetsMesh requires WebSocket transport.", + "letsMeshRequiresPort": "LetsMesh requires port {{port}}.", + "letsMeshKnownBrokersOnly": "LetsMesh / MeshMapper preset only supports known device-signing brokers. Use Custom for other brokers.", + "letsMeshUsernameV1Hex": "Username must be v1_ followed by 64 hex characters (public key).", "ripplePresetConfirm": "The Ripple preset uses shared community credentials and disables TLS certificate verification. Only use this on a trusted network. Continue?", "coloradoPresetConfirm": "Colorado Mesh MQTT is for Colorado-area mesh users. The preset publishes under meshcore/DEN (Denver IATA). If you are not in Colorado, use LetsMesh or MeshMapper instead. Continue?", "coloradoServerNote": "Colorado Mesh is for Colorado-area mesh users. Topic prefix uses the Denver IATA (meshcore/DEN).", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 651bc2ac0..1b332390b 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "Desactivar la instancia Compartir y reiniciar", "disableShareAria": "Desactivar la instancia Compartir y reiniciar la pila", "disableFailed": "No se ha podido desactivar la instancia Compartir." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh requiere transporte WebSocket.", + "letsMeshRequiresPort": "LetsMesh requiere el puerto {{port}}.", + "letsMeshKnownBrokersOnly": "El ajuste preestablecido de LetsMesh/MeshMapper solo admite brókers de firma de dispositivos conocidos. Utilice Personalizado para otros brókers.", + "letsMeshUsernameV1Hex": "El nombre de usuario debe ser v1_ seguido de 64 caracteres hexadecimales (clave pública)." }, "contactGroupsModal": { "failedLoadMembers": "No se han podido cargar los miembros: {{message}}", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 6eacfa378..6184c05d7 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "Désactiver l'instance de partage et redémarrer", "disableShareAria": "Désactiver l'instance de partage et redémarrer la pile", "disableFailed": "Impossible de désactiver l'instance de partage." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh nécessite le transport WebSocket.", + "letsMeshRequiresPort": "LetsMesh nécessite le port {{port}}.", + "letsMeshKnownBrokersOnly": "Le préréglage LetsMesh/ MeshMapper ne prend en charge que les courtiers de signature d'appareils connus. Utilisez Personnalisé pour les autres courtiers.", + "letsMeshUsernameV1Hex": "Le nom d'utilisateur doit être v1_ suivi de 64 caractères hexadécimaux (clé publique)." }, "contactGroupsModal": { "failedLoadMembers": "Échec du chargement des membres : {{message}}", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 84accaacf..e8e8c1f20 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "Nonaktifkan Bagikan sesi dan mulai ulang", "disableShareAria": "Nonaktifkan Bagikan instance dan mulai ulang tumpukan", "disableFailed": "Gagal menonaktifkan Bagikan instance." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh membutuhkan transportasi WebSocket.", + "letsMeshRequiresPort": "LetsMesh membutuhkan port {{port}}.", + "letsMeshKnownBrokersOnly": "Preset LetsMesh/ MeshMapper hanya mendukung broker penandatanganan perangkat yang dikenal. Gunakan Custom untuk broker lain.", + "letsMeshUsernameV1Hex": "Nama pengguna harus v1_disusul oleh 64 karakter hex (kunci publik)." }, "contactGroupsModal": { "failedLoadMembers": "Gagal memuat anggota: {{message}}", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index dea8bf381..6844b6517 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "Disattiva istanza di condivisione e riavvia", "disableShareAria": "Disattiva l'istanza di condivisione e riavvia lo stack", "disableFailed": "Impossibile disattivare l'istanza di condivisione." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh richiede il trasporto WebSocket.", + "letsMeshRequiresPort": "LetsMesh richiede la porta {{port}}.", + "letsMeshKnownBrokersOnly": "Il preset LetsMesh / MeshMapper supporta solo broker noti per la firma del dispositivo. Usa Custom per altri broker.", + "letsMeshUsernameV1Hex": "Il nome utente deve essere v1_ seguito da 64 caratteri esadecimali (chiave pubblica)." }, "contactGroupsModal": { "failedLoadMembers": "Impossibile caricare i membri: {{message}}", @@ -4420,7 +4424,7 @@ "repeaters": "Ripetitori", "rf": "RF", "graph": "Graph", - "rooms": "Camere", + "rooms": "Sale", "admin": "Amministrazione", "peers": "Compagni", "nomadnetwork": "Nomad Network", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 0946419a4..5819704c9 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "インスタンスの共有をオフにして再起動する", "disableShareAria": "共有インスタンスをオフにして、スタックを再起動します", "disableFailed": "共有インスタンスをオフにできませんでした。" - } + }, + "letsMeshRequiresWebSocket": "LetsMeshにはWebSocketトランスポートが必要です。", + "letsMeshRequiresPort": "LetsMeshにはポート{{port}}が必要です。", + "letsMeshKnownBrokersOnly": "LetsMesh/MeshMapperプリセットは、既知のデバイス署名ブローカーのみをサポートします。他のブローカーにはカスタムを使用します。", + "letsMeshUsernameV1Hex": "ユーザー名はv 1_の後に64文字の16進数文字(公開鍵)でなければなりません。" }, "contactGroupsModal": { "failedLoadMembers": "メンバーのロードに失敗しました: {{message}}", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 91b4ad3c1..0d3c459c9 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "인스턴스 공유 끄고 다시 시작", "disableShareAria": "인스턴스 공유 끄고 스택 다시 시작", "disableFailed": "회차 공유 기능을 끄지 못했습니다." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh는 WebSocket 전송이 필요합니다.", + "letsMeshRequiresPort": "LetsMesh에는 {{port}} 포트가 필요합니다.", + "letsMeshKnownBrokersOnly": "LetsMesh/MeshMapper 사전 설정은 알려진 장치 서명 브로커만 지원합니다. 다른 브로커에 대해 사용자 정의를 사용하십시오.", + "letsMeshUsernameV1Hex": "사용자 이름은 v1_뒤에 64 16진수 문자 (공개 키) 이어야 합니다." }, "contactGroupsModal": { "failedLoadMembers": "멤버를 로드하지 못했습니다: {{message}}", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index ed07d4753..5a33d79c2 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "Deel instantie uitschakelen en opnieuw opstarten", "disableShareAria": "Schakel de instantie Delen uit en start de stapel opnieuw", "disableFailed": "Kan de instantie Delen niet uitschakelen." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh vereist WebSocket transport.", + "letsMeshRequiresPort": "LetsMesh vereist poort {{port}}.", + "letsMeshKnownBrokersOnly": "LetsMesh / MeshMapper preset ondersteunt alleen bekende apparaatondertekenende makelaars. Gebruik Aangepast voor andere makelaars.", + "letsMeshUsernameV1Hex": "Gebruikersnaam moet v1_zijn, gevolgd door 64 hexadecimale tekens (openbare sleutel)." }, "contactGroupsModal": { "failedLoadMembers": "Kan leden niet laden: {{message}}", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 0a328e293..8d2127e65 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -1266,7 +1266,11 @@ "disableShare": "Wyłącz udostępnianie instancji i uruchom ponownie", "disableShareAria": "Wyłącz opcję Udostępnij instancję i uruchom ponownie stos", "disableFailed": "Nie udało się wyłączyć instancji udostępniania." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh wymaga transportu WebSocket.", + "letsMeshRequiresPort": "LetsMesh wymaga portu {{port}}.", + "letsMeshKnownBrokersOnly": "Ustawienie wstępne LetsMesh /MeshMapper obsługuje tylko znane brokery podpisujące urządzenia. Użyj Custom dla innych brokerów.", + "letsMeshUsernameV1Hex": "Nazwa użytkownika musi składać się z v1_oraz 64 znaków szesnastkowych (klucz publiczny)." }, "contactGroupsModal": { "failedLoadMembers": "Nie udało się załadować członków: {{message}}", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 9b113fb28..f95677622 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "Desativar a instância Compartilhar e reiniciar", "disableShareAria": "Desativar a instância Compartilhar e reiniciar a pilha", "disableFailed": "Falha ao desativar a instância Compartilhar." - } + }, + "letsMeshRequiresWebSocket": "O LetsMesh requer transporte WebSocket.", + "letsMeshRequiresPort": "O LetsMesh requer a porta {{port}}.", + "letsMeshKnownBrokersOnly": "A predefinição LetsMesh /MeshMapper suporta apenas corretores de assinatura de dispositivos conhecidos. Use o Custom para outras corretoras.", + "letsMeshUsernameV1Hex": "O nome de usuário deve ser v1_ seguido por 64 caracteres hexadecimais (chave pública)." }, "contactGroupsModal": { "failedLoadMembers": "Falha ao carregar membros: {{message}}", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 3f3b9ffe3..cc236b989 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -1264,7 +1264,11 @@ "disableShare": "Отключите «Поделиться экземпляром» и перезапустите", "disableShareAria": "Отключите «Поделиться экземпляром» и перезапустите стек", "disableFailed": "Не удалось отключить экземпляр Share." - } + }, + "letsMeshRequiresWebSocket": "Для LetsMesh требуется транспорт WebSocket.", + "letsMeshRequiresPort": "LetsMesh требует порт {{port}}.", + "letsMeshKnownBrokersOnly": "Предустановка LetsMesh /MeshMapper поддерживает только известные брокеры подписи устройств. Используйте Custom для других брокеров.", + "letsMeshUsernameV1Hex": "Имя пользователя должно быть v1_, за которым должны следовать 64 шестнадцатеричных символа (открытый ключ)." }, "contactGroupsModal": { "failedLoadMembers": "Не удалось загрузить участников: {{message}}.", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index f3bb70a9f..09f2bf7f0 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "Paylaşım örneğini kapatın ve yeniden başlatın", "disableShareAria": "Paylaşım örneğini kapat ve yığını yeniden başlat", "disableFailed": "Paylaşım örneği kapatılamadı." - } + }, + "letsMeshRequiresWebSocket": "LetsMesh, WebSocket taşımacılığı gerektirir.", + "letsMeshRequiresPort": "LetsMesh, {{port}} numaralı bağlantı noktasını gerektirir.", + "letsMeshKnownBrokersOnly": "LetsMesh /MeshMapper ön ayarı yalnızca bilinen cihaz imzalama aracılarını destekler. Diğer brokerler için Özel'i kullanın.", + "letsMeshUsernameV1Hex": "Kullanıcı adı v1_ ve ardından 64 onaltılık karakter (genel anahtar) olmalıdır." }, "contactGroupsModal": { "failedLoadMembers": "Üyeler yüklenemedi: {{message}}", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 4885e5274..6eddad0fb 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -1264,7 +1264,11 @@ "disableShare": "Вимкнути спільний екземпляр і перезапустити", "disableShareAria": "Вимкніть функцію Поділитися екземпляром і перезапустіть стек", "disableFailed": "Не вдалося вимкнути екземпляр спільного доступу." - } + }, + "letsMeshRequiresWebSocket": "Для LetsMesh потрібен транспорт WebSocket.", + "letsMeshRequiresPort": "Для LetsMesh потрібен порт {{port}}.", + "letsMeshKnownBrokersOnly": "Попереднє налаштування LetsMesh /MeshMapper підтримує лише відомих брокерів, які підписують пристрої. Використовуйте Custom для інших брокерів.", + "letsMeshUsernameV1Hex": "Ім'я користувача має бути v1_ з 64 шістнадцятковими символами (відкритий ключ)." }, "contactGroupsModal": { "failedLoadMembers": "Не вдалося завантажити учасників: {{message}}", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 18353716e..4d17209a2 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -1262,7 +1262,11 @@ "disableShare": "关闭共享实例并重新启动", "disableShareAria": "关闭共享实例并重新启动堆栈", "disableFailed": "无法关闭共享实例。" - } + }, + "letsMeshRequiresWebSocket": "LetsMesh需要WebSocket传输。", + "letsMeshRequiresPort": "LetsMesh需要端口{{port}}。", + "letsMeshKnownBrokersOnly": "LetsMesh/MeshMapper预设仅支持已知的设备签名代理。对其他经纪商使用自定义。", + "letsMeshUsernameV1Hex": "用户名必须为v1_后跟64个十六进制字符(公钥)。" }, "contactGroupsModal": { "failedLoadMembers": "无法加载会员: {{message}}", diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index d45999afe..ed2c97699 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -7,9 +7,10 @@ import { describe, expect, it } from 'vitest'; import { assertPowerResumeSkipsOnExplicitDisconnect, extractUseCallbackBody, + loadRuntimeSource, } from '../lib/sourceContractTestHelpers'; -const RUNTIME_SOURCE = readFileSync(join(__dirname, '../runtime/useMeshcoreRuntime.ts'), 'utf-8'); +const RUNTIME_SOURCE = loadRuntimeSource('useMeshcoreRuntime.ts'); const CONN_EVENTS_SOURCE = readFileSync( join(__dirname, '../hooks/meshcore/meshcoreConnSideEffects.ts'), 'utf-8', diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index 4667cd3d4..4ba5a3b72 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -16,10 +16,11 @@ import { assertPowerResumeSkipsOnExplicitDisconnect, extractIfBlockBody, extractUseCallbackBody, + loadRuntimeSource, } from '../lib/sourceContractTestHelpers'; +const SOURCE = loadRuntimeSource('useMeshtasticRuntime.ts'); const TEST_DIR = import.meta.dirname ?? __dirname; -const SOURCE = readFileSync(join(TEST_DIR, 'useMeshtasticRuntime.ts'), 'utf-8'); describe('useMeshtasticRuntime reconnect hardening (regression)', () => { it('uses suspend-aware delayUnlessSuspended for reconnect backoff', () => { diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 038b59a03..c5a24d457 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -3154,7 +3154,11 @@ export function useMeshtasticRuntime() { useEffect(() => { if (configureTargetNodeNum == null) return; if (state.status !== 'configured') return; - void refreshRemoteConfigSnapshot(configureTargetNodeNum, 'radio'); + void refreshRemoteConfigSnapshot(configureTargetNodeNum, 'radio').catch((e: unknown) => { + console.warn( + '[useMeshtasticRuntime] refreshRemoteConfigSnapshot effect failed ' + errLikeToLogString(e), + ); + }); }, [configureTargetNodeNum, state.status, refreshRemoteConfigSnapshot]); const runRemoteAdminOp = useCallback( diff --git a/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts new file mode 100644 index 000000000..a13cb52c0 --- /dev/null +++ b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts @@ -0,0 +1,47 @@ +/** + * Source contract: useReticulumRuntime wires catchUpRecentInboundLxmf on connect, + * restart, WS lag/reconnect, and periodic tick — without a full runtime integration mock. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { extractUseCallbackBody } from '../lib/sourceContractTestHelpers'; + +const TEST_DIR = import.meta.dirname ?? __dirname; +const SOURCE = readFileSync(join(TEST_DIR, 'useReticulumRuntime.ts'), 'utf-8'); + +describe('useReticulumRuntime inbound LXMF catch-up wiring (source contract)', () => { + it('imports catchUpRecentInboundLxmf and wraps it in a useCallback', () => { + expect(SOURCE).toMatch( + /import \{ catchUpRecentInboundLxmf as runInboundLxmfCatchUp \} from '@\/renderer\/lib\/reticulum\/catchUpRecentInboundLxmf'/, + ); + expect(SOURCE).toMatch( + /const catchUpRecentInboundLxmf = useCallback\(\s*async \(opts\?: \{ sinceTs\?: number; reason\?: string \}\) => \{/, + ); + expect(SOURCE).toContain('await runInboundLxmfCatchUp({'); + }); + + it('catches up after connect and restartStack', () => { + const connectBody = extractUseCallbackBody(SOURCE, 'connect'); + expect(connectBody).toContain("await catchUpRecentInboundLxmf({ reason: 'connect' })"); + + const restartBody = extractUseCallbackBody(SOURCE, 'restartStack'); + expect(restartBody).toContain("await catchUpRecentInboundLxmf({ reason: 'restartStack' })"); + }); + + it('catches up on WS events_lagged and ws_reconnect', () => { + expect(SOURCE).toMatch( + /evt\.type === 'events_lagged'[\s\S]*?catchUpRecentInboundLxmf\(\{ reason: 'events_lagged' \}\)/, + ); + expect(SOURCE).toMatch( + /evt\.type === 'ws_connected'[\s\S]*?reconnect === true[\s\S]*?catchUpRecentInboundLxmf\(\{ reason: 'ws_reconnect' \}\)/, + ); + }); + + it('schedules periodic catch-up while the stack is active', () => { + expect(SOURCE).toMatch(/void catchUpRecentInboundLxmf\(\{ sinceTs, reason: 'periodic' \}\)/); + expect(SOURCE).toMatch(/RETICULUM_INBOUND_LXMF_CATCHUP_MS/); + }); +}); diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index 95cb875c1..7fa384238 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -2,18 +2,15 @@ /** * Source contract tests for useReticulumRuntime sidecar reconnect hardening. */ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - import { describe, expect, it } from 'vitest'; import { assertPowerResumeSkipsOnExplicitDisconnect, extractUseCallbackBody, + loadRuntimeSource, } from '../lib/sourceContractTestHelpers'; -const TEST_DIR = import.meta.dirname ?? __dirname; -const SOURCE = readFileSync(join(TEST_DIR, 'useReticulumRuntime.ts'), 'utf-8'); +const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); describe('useReticulumRuntime reconnect hardening (regression)', () => { it('ignores sidecar stop status while connect is in flight', () => { diff --git a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts index d0a9ec7e7..f9258cb26 100644 --- a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts @@ -2,13 +2,11 @@ /** * Source contract tests for RRC multi-hub WebSocket event routing. */ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - import { describe, expect, it } from 'vitest'; -const TEST_DIR = import.meta.dirname ?? __dirname; -const SOURCE = readFileSync(join(TEST_DIR, 'useReticulumRuntime.ts'), 'utf-8'); +import { loadRuntimeSource } from '../lib/sourceContractTestHelpers'; + +const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); describe('useReticulumRuntime RRC event routing (regression)', () => { it('honors will_reconnect=false by clearing the hub session', () => { diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index dfa0d4eb5..4f1b6e6af 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -604,7 +604,9 @@ export function useReticulumRuntime(): ProtocolRuntime { pushAppToast(i18n.t('reticulumRemote.transfer.receiveDestShareFailed'), 'error'); } } - })(); + })().catch((e: unknown) => { + console.warn('[useReticulumRuntime] ingestLxmfPayload failed ' + errLikeToLogString(e)); + }); }, [identityId, selfLxmfHash], ); @@ -702,13 +704,21 @@ export function useReticulumRuntime(): ProtocolRuntime { console.warn( `[useReticulumRuntime] sidecar WS lagged skipped=${skipped ?? '?'} — catching up inbound LXMF`, ); - void catchUpRecentInboundLxmf({ reason: 'events_lagged' }); + void catchUpRecentInboundLxmf({ reason: 'events_lagged' }).catch((e: unknown) => { + console.warn( + '[useReticulumRuntime] catch-up after events_lagged failed ' + errLikeToLogString(e), + ); + }); } if (evt.type === 'ws_connected' && evt.payload && typeof evt.payload === 'object') { const reconnect = (evt.payload as { reconnect?: boolean }).reconnect === true; if (reconnect) { console.debug('[useReticulumRuntime] sidecar WS reconnected — catching up inbound LXMF'); - void catchUpRecentInboundLxmf({ reason: 'ws_reconnect' }); + void catchUpRecentInboundLxmf({ reason: 'ws_reconnect' }).catch((e: unknown) => { + console.warn( + '[useReticulumRuntime] catch-up after ws_reconnect failed ' + errLikeToLogString(e), + ); + }); } } if (evt.type === 'lxmf_outbound_status' && evt.payload && typeof evt.payload === 'object') { @@ -1163,7 +1173,9 @@ export function useReticulumRuntime(): ProtocolRuntime { identity_hash: p.identity_hash, }); // Toast so offers are visible even when the user is not on Remote/Chat DM. - console.debug(`[useReticulumRuntime] rncp.offer ${p.file_name}`); + console.debug( + `[useReticulumRuntime] rncp.offer ${sanitizeLogMessage(p.file_name.slice(0, 200))}`, + ); try { window.dispatchEvent( new CustomEvent('mesh-client:rncp-offer', { @@ -1305,7 +1317,12 @@ export function useReticulumRuntime(): ProtocolRuntime { if (connectInFlightRef.current) { const pending = connectInFlightDoneRef.current; if (pending) { - await pending.catch(() => {}); + await pending.catch((e: unknown) => { + console.debug( + '[useReticulumRuntime] coalesced connect waited on failed in-flight attempt ' + + errLikeToLogString(e), + ); + }); return; } throw new Error('Reticulum connect already in progress'); @@ -1403,7 +1420,11 @@ export function useReticulumRuntime(): ProtocolRuntime { } unsubEventRef.current?.(); unsubEventRef.current = null; - await window.electronAPI.reticulum.stop(); + try { + await window.electronAPI.reticulum.stop(); + } catch (e) { + console.warn('[useReticulumRuntime] disconnect stop failed ' + errLikeToLogString(e)); + } localInterfacesRef.current = []; setSelfLxmfHash(null); rawPacketAppenderRef.current?.clearPending(); @@ -1418,7 +1439,12 @@ export function useReticulumRuntime(): ProtocolRuntime { if (connectInFlightRef.current) { const pending = connectInFlightDoneRef.current; if (pending) { - await pending.catch(() => {}); + await pending.catch((e: unknown) => { + console.debug( + '[useReticulumRuntime] restart waited on failed in-flight connect ' + + errLikeToLogString(e), + ); + }); } if (connectInFlightRef.current) { throw new Error('Reticulum stack operation already in progress'); @@ -1528,7 +1554,11 @@ export function useReticulumRuntime(): ProtocolRuntime { : RETICULUM_INBOUND_LXMF_CATCHUP_MS; timeoutId = setTimeout(() => { const sinceTs = getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs ?? undefined; - void catchUpRecentInboundLxmf({ sinceTs, reason: 'periodic' }); + void catchUpRecentInboundLxmf({ sinceTs, reason: 'periodic' }).catch((e: unknown) => { + console.warn( + '[useReticulumRuntime] periodic inbound LXMF catch-up failed ' + errLikeToLogString(e), + ); + }); scheduleNext(); }, ms); }; diff --git a/src/shared/clampQueryLimit.test.ts b/src/shared/clampQueryLimit.test.ts new file mode 100644 index 000000000..ba8f8030f --- /dev/null +++ b/src/shared/clampQueryLimit.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { clampQueryLimit } from './clampQueryLimit'; + +describe('clampQueryLimit', () => { + it('returns a finite in-range value unchanged', () => { + expect(clampQueryLimit(50, { default: 200, max: 10_000 })).toBe(50); + expect(clampQueryLimit('50', { default: 200, max: 10_000 })).toBe(50); + }); + + it('uses default when value is missing or non-numeric', () => { + expect(clampQueryLimit(undefined, { default: 200, max: 10_000 })).toBe(200); + expect(clampQueryLimit(null, { default: 500, max: 10_000 })).toBe(500); + expect(clampQueryLimit('', { default: 500, max: 10_000 })).toBe(500); + expect(clampQueryLimit('abc', { default: 500, max: 10_000 })).toBe(500); + expect(clampQueryLimit(0, { default: 1000, max: 10_000 })).toBe(1000); + expect(clampQueryLimit(NaN, { default: 1000, max: 10_000 })).toBe(1000); + }); + + it('clamps below min (default 1) and above max', () => { + expect(clampQueryLimit(-5, { default: 200, max: 10_000 })).toBe(1); + expect(clampQueryLimit(99_999, { default: 200, max: 10_000 })).toBe(10_000); + expect(clampQueryLimit(-5, { default: 200, min: 10, max: 100 })).toBe(10); + }); +}); diff --git a/src/shared/clampQueryLimit.ts b/src/shared/clampQueryLimit.ts new file mode 100644 index 000000000..02309600a --- /dev/null +++ b/src/shared/clampQueryLimit.ts @@ -0,0 +1,12 @@ +/** + * Clamp an IPC/query `limit` to `[min, max]`, falling back when the value is + * missing or non-numeric (`Number(value) || default` — same as the prior + * copy-pasted handlers). + */ +export function clampQueryLimit( + value: unknown, + opts: { default: number; min?: number; max: number }, +): number { + const min = opts.min ?? 1; + return Math.min(Math.max(min, Number(value) || opts.default), opts.max); +} diff --git a/vitest.config.ts b/vitest.config.ts index a5ad4e14c..bc085fd12 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -138,6 +138,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/applyRncpReceiveDestShareFromChatHistory.test.ts', 'src/renderer/lib/pushRncpListenerPolicy.test.ts', 'src/renderer/lib/rncpListenerApply.test.ts', + 'src/renderer/lib/rncpTransferUiHelpers.test.ts', 'src/renderer/lib/rfReconnectHelper.test.ts', 'src/renderer/lib/reticulum/useReticulumSidecarApi.test.ts', 'src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.test.ts', From a59e778f3f91ec25331276b31f2473abfa1622a4 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 21:07:55 -0600 Subject: [PATCH 3/3] fix: enforce chat export size incrementally and share runtime source loader Cap chat export UTF-8 bytes per line before appending, and use loadRuntimeSource in the inbound LXMF catch-up contract test. --- src/main/chat-export.test.ts | 18 +++++++++++++ src/main/chatExportFormat.ts | 25 ++++++++++++------- ...time.inbound-lxmf-catchup.contract.test.ts | 8 ++---- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/main/chat-export.test.ts b/src/main/chat-export.test.ts index eeb426f6b..6a4feae0c 100644 --- a/src/main/chat-export.test.ts +++ b/src/main/chat-export.test.ts @@ -5,6 +5,7 @@ import { assertChatExportMessageSizes, CHAT_EXPORT_MAX_PAYLOAD_CHARS, CHAT_EXPORT_MAX_SENDER_NAME_CHARS, + CHAT_EXPORT_MAX_TOTAL_BYTES, formatChatExportLine, formatChatExportLinesWithTotalCap, } from './chatExportFormat'; @@ -78,4 +79,21 @@ describe('formatChatExportLinesWithTotalCap', () => { expect(text).toContain('hi'); expect(text.endsWith('\n')).toBe(true); }); + + it('throws before completing when serialized UTF-8 would exceed the total cap', () => { + // One oversized line (under per-field caps) that alone exceeds the total byte budget. + const payload = 'x'.repeat(CHAT_EXPORT_MAX_TOTAL_BYTES); + expect(() => { + formatChatExportLinesWithTotalCap([ + { + timestamp: 1_700_000_000_000, + sender_name: 'Alice', + channel: 0, + payload, + }, + ]); + }).toThrow( + `chat:export: serialized output exceeds max size (${CHAT_EXPORT_MAX_TOTAL_BYTES} bytes)`, + ); + }); }); diff --git a/src/main/chatExportFormat.ts b/src/main/chatExportFormat.ts index aaab8408f..a66c0e24b 100644 --- a/src/main/chatExportFormat.ts +++ b/src/main/chatExportFormat.ts @@ -60,17 +60,24 @@ export function formatChatExportLines(messages: unknown[]): string[] { } /** - * Format export lines and enforce total serialized size. + * Format export lines and enforce total serialized size incrementally. * Call after {@link assertChatExportMessageSizes}. */ export function formatChatExportLinesWithTotalCap(messages: unknown[]): string { - const lines = formatChatExportLines(messages); - const text = lines.join('\n') + '\n'; - const byteLength = Buffer.byteLength(text, 'utf8'); - if (byteLength > CHAT_EXPORT_MAX_TOTAL_BYTES) { - throw new Error( - `chat:export: serialized output exceeds max size (${CHAT_EXPORT_MAX_TOTAL_BYTES} bytes)`, - ); + const lines: string[] = []; + let byteLength = 0; + for (const m of messages) { + const line = formatChatExportLine(m as ChatExportLineInput); + if (line == null) continue; + // Each line is followed by a newline in the final `join('\n') + '\n'` text. + const nextBytes = Buffer.byteLength(line, 'utf8') + 1; + if (byteLength + nextBytes > CHAT_EXPORT_MAX_TOTAL_BYTES) { + throw new Error( + `chat:export: serialized output exceeds max size (${CHAT_EXPORT_MAX_TOTAL_BYTES} bytes)`, + ); + } + lines.push(line); + byteLength += nextBytes; } - return text; + return lines.join('\n') + '\n'; } diff --git a/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts index a13cb52c0..6f84bf4e7 100644 --- a/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts @@ -2,15 +2,11 @@ * Source contract: useReticulumRuntime wires catchUpRecentInboundLxmf on connect, * restart, WS lag/reconnect, and periodic tick — without a full runtime integration mock. */ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - import { describe, expect, it } from 'vitest'; -import { extractUseCallbackBody } from '../lib/sourceContractTestHelpers'; +import { extractUseCallbackBody, loadRuntimeSource } from '../lib/sourceContractTestHelpers'; -const TEST_DIR = import.meta.dirname ?? __dirname; -const SOURCE = readFileSync(join(TEST_DIR, 'useReticulumRuntime.ts'), 'utf-8'); +const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); describe('useReticulumRuntime inbound LXMF catch-up wiring (source contract)', () => { it('imports catchUpRecentInboundLxmf and wraps it in a useCallback', () => {