Compare commits

...

170 Commits

Author SHA1 Message Date
f60daddaea temp 2025-10-01 15:47:43 -05:00
fbdcaef6e8 Add debian packaging support. 2025-10-01 15:47:06 -05:00
liamcottle
002360399c add docs 2025-08-01 23:35:32 +12:00
liamcottle
c9f4ef64c1 update peewee to v3.18.1 2025-07-28 22:34:39 +12:00
liamcottle
ffe2cb884d update aiohttp to v3.12.14 2025-07-28 22:11:24 +12:00
liamcottle
d6847d262a add python version to about screen 2025-07-28 21:38:43 +12:00
liamcottle
65df111b87 rework async utils to always use main event loop in threadsafe manner 2025-07-28 19:01:15 +12:00
liamcottle
747236ae8b add try catch for fallback file download parsing, so client can show as unsupported 2025-07-28 17:23:03 +12:00
liamcottle
4e55006084 fix bug where downloading files from cicada forums was not working 2025-07-28 17:21:52 +12:00
liamcottle
dcaffe2594 2.2.1 2025-07-27 21:52:08 +12:00
liamcottle
094f6cb5ec added custom confirm dialog as js confirm in electron on windows causes all text fields to be disabled 2025-07-27 21:45:27 +12:00
liamcottle
0c0f059ec4 2.2.0 2025-07-27 20:18:38 +12:00
liamcottle
9031c1a3d7 add dropdown menu to nomadnetwork favourites list to rename and remove 2025-07-27 20:17:08 +12:00
liamcottle
64adad27f8 limit nomadnetwork announces list to 500 recent nodes 2025-07-25 23:22:56 +12:00
liamcottle
4734e62468 implement favourites system for nomadnetwork nodes 2025-07-25 23:02:05 +12:00
liamcottle
37cc6aa158 add button to identify self to nomad network node 2025-07-25 21:56:02 +12:00
liamcottle
f3bf0abd84 2.1.0 2025-07-18 21:45:35 +12:00
liamcottle
90445467e1 remove todos 2025-07-18 20:34:26 +12:00
liamcottle
51bdd35f01 fix downloading files from nomadnet by handling buffered reader responses 2025-07-18 20:29:10 +12:00
liamcottle
817d5b5e59 don't use await in websocket handler as it blocks all other requests 2025-07-18 19:47:47 +12:00
liamcottle
a094a741a8 don't use await in websocket handler as it blocks all other requests 2025-07-18 19:37:59 +12:00
liamcottle
24acbaf223 update axios to v1.10.0 2025-07-18 19:16:57 +12:00
Liam Cottle
0bb171a81b Merge pull request #82 from Amlor/fix-airtime-limit-description
Fix Airtime Limit fields placeholders
2025-07-18 18:24:37 +12:00
Liam Cottle
b5a54dd120 Merge pull request #89 from kujeger/wayland_flag
add `ozone-platform-hint=auto` to known flags
2025-07-18 16:42:14 +12:00
liamcottle
86cfddce52 update micron-parser to v1.0.2 2025-07-18 16:21:56 +12:00
liamcottle
97071c7edb update lxmf to v0.8.0 2025-07-18 16:19:28 +12:00
liamcottle
a58f73357a update rns to v1.0.0 2025-07-18 16:16:36 +12:00
liamcottle
6b3639dcd2 2.0.0 2025-07-13 19:28:29 +12:00
liamcottle
47a84fc110 update rns to v0.9.6 2025-07-13 18:20:39 +12:00
Nikolai Vincent Vaags
588780d632 add ozone-platform-hint=auto to known flags
This allows electron to run natively under wayland
2025-07-09 11:22:31 +02:00
Viacheslav Komarov
5b783399f8 Fix long window placeholder to say minutes 2025-06-07 11:48:54 +04:00
liamcottle
df533fb1bf update lxmf to v0.7.1 2025-05-24 17:00:16 +12:00
liamcottle
e757a2f022 update lxmf to v0.7.0 2025-05-16 02:46:35 +12:00
liamcottle
ce56c205c6 1.22.0 2025-05-11 21:42:06 +12:00
liamcottle
66b619c398 single line 2025-05-11 21:04:26 +12:00
liamcottle
458a387517 update rns to v0.9.5 2025-05-11 21:02:35 +12:00
Liam Cottle
e97352713d Merge pull request #81 from stephen304/ignore-known-flatpak-flags
filter out known flags that should not be passed to python. fixes #61
2025-05-11 20:59:13 +12:00
Amlor
07a41215be Fix Airtime Limit fields placeholders 2025-04-20 13:31:21 +04:00
Stephen Smith
e9a9e9f831 filter out known flags that should not be passed to python. fixes #61 2025-04-18 11:52:32 -04:00
liamcottle
b8d388fa56 1.21.0 2025-03-15 12:19:10 +13:00
liamcottle
d7080c8ca1 migrate to data attributes for micron parser links 2025-03-15 12:18:30 +13:00
liamcottle
7c20529d62 migrate to using micron-parser from npm 2025-03-15 11:17:34 +13:00
liamcottle
c6eeab97e6 update rns to v0.9.3 2025-03-15 10:52:02 +13:00
liamcottle
10c85cdba0 update lxmf to v0.6.3 2025-03-15 10:47:04 +13:00
liamcottle
9ea98eb0f0 toggle page source in place rather than opening in a new tab 2025-02-09 16:59:08 +13:00
liamcottle
2662f96c8b add button to view source of a node page 2025-02-09 16:19:44 +13:00
liamcottle
59deac6d07 allow passing --headless to compiled electron binary to avoid launching gui 2025-02-08 22:38:47 +13:00
liamcottle
9d60707515 1.20.0 2025-02-08 13:16:02 +13:00
liamcottle
6f321741d7 update rnode flasher 2025-02-08 13:14:48 +13:00
liamcottle
eaf1b75c54 update lxmf to v0.6.2 2025-02-08 13:11:38 +13:00
liamcottle
c59ed015ce update websockets to v14.2 2025-02-08 13:10:24 +13:00
liamcottle
d13b395a2c simplify config for webocket client interface 2025-02-08 12:37:13 +13:00
liamcottle
59c185354b remove todos 2025-02-08 11:57:47 +13:00
liamcottle
9e7d0cdfeb add ping pong to make sure websocket connection doesn't go stale 2025-02-08 11:53:10 +13:00
liamcottle
e6ff5097c0 update logging 2025-02-07 20:10:13 +13:00
liamcottle
ee08a5619c time.sleep uses seconds not millis 2025-02-07 19:49:20 +13:00
liamcottle
c0bb0763a1 fix tx rx stats for web socket server and don't tx and rx when offline or detached 2025-02-07 19:39:24 +13:00
liamcottle
b6e41b3027 remove packet logs 2025-02-07 17:46:45 +13:00
liamcottle
030a1e64a9 always show clients count for interfaces that provide a count 2025-02-07 17:31:25 +13:00
liamcottle
5802671e0d allow setting target protocol type to ws or wss 2025-02-07 17:28:22 +13:00
liamcottle
03d7b669ae show connected clients count for websocket server interface 2025-02-07 17:17:46 +13:00
liamcottle
a81c6787c7 refactor websocket interfaces to use threading and implement detach 2025-02-07 17:00:37 +13:00
liamcottle
a500b58d05 fix missing object values 2025-02-07 14:51:02 +13:00
liamcottle
94179f9779 add todos for detaching 2025-02-07 14:51:02 +13:00
liamcottle
93b6104aef initial implementation of a WebsocketClientInterface and a WebsocketServerInterface for RNS 2025-02-07 14:51:02 +13:00
liamcottle
10bef61a90 use short interface name to find interface stats 2025-02-07 12:54:49 +13:00
liamcottle
0f31c9f8c0 show network name in interfaces list if ifac is enabled 2025-02-03 13:57:41 +13:00
liamcottle
2c518d1b31 fix for calling async functions in sync callbacks from different threads 2025-02-03 13:13:11 +13:00
Liam Cottle
176aed98ff Merge pull request #60 from RFnexus/interfaces-update
Add additional interfaces and interface options
2025-02-03 12:49:44 +13:00
liamcottle
e1ae122297 remove spaces so config format is the same as normal file saving 2025-02-03 01:25:27 +13:00
liamcottle
f6b1c65faa use built in rns config parser for parsing interface config files 2025-02-03 01:23:26 +13:00
liamcottle
4f497620c8 dont export json dict 2025-02-03 01:20:53 +13:00
liamcottle
4d816ae87c fix validation 2025-02-03 01:07:18 +13:00
liamcottle
df8e98366b remove existing sub interfaces when saving an rnode multi interface 2025-02-03 00:13:00 +13:00
liamcottle
54b1d56107 make for loop more readable 2025-02-02 23:58:58 +13:00
liamcottle
ba118f7a9c allow vport 0 2025-02-02 23:56:55 +13:00
liamcottle
e48c26042c always show interface mode setting even if transport is disabled 2025-02-02 23:19:49 +13:00
liamcottle
d95878c659 allow removing custom select settings 2025-02-02 23:16:53 +13:00
liamcottle
734eaeed1b refactor updating of interface settings to allow removing values when saving an existing interface 2025-02-02 23:06:06 +13:00
liamcottle
33e4888737 prevent crash caused by interface settings being set to none 2025-02-02 20:13:52 +13:00
liamcottle
408a62dffe slight adjustments 2025-02-02 20:01:27 +13:00
liamcottle
43a5a907c0 check if null 2025-02-02 18:31:18 +13:00
liamcottle
620c147dbd if interface enable is a boolean, check it as a string 2025-02-02 18:30:43 +13:00
liamcottle
4555de5836 add button to reload comports 2025-02-02 18:21:04 +13:00
liamcottle
842dbeb0b4 make naming consistent and remove unused functions 2025-02-02 18:17:23 +13:00
liamcottle
9d2f3eebc8 refactor to reusable form sub label component 2025-02-02 18:13:45 +13:00
liamcottle
b21e3fc026 add link to docs for interface modes 2025-02-02 18:06:08 +13:00
liamcottle
abd70ae606 refactor to reusable form label component 2025-02-02 18:00:41 +13:00
liamcottle
1e2d4387e7 move ifac subtitle inside of collapsible section 2025-02-02 17:34:10 +13:00
liamcottle
d4b5b99045 add e.g to ui for example values 2025-02-02 17:26:10 +13:00
liamcottle
ce52532522 ui adjustments for rnode interface 2025-02-02 17:21:01 +13:00
liamcottle
6c43c2cc4f revert so interfaces page can scroll 2025-02-02 17:02:43 +13:00
liamcottle
c5e4776dc1 tidy ui for on air rnode bitrate and link budget 2025-02-02 17:00:37 +13:00
liamcottle
dabd6c4a37 ui adjustments 2025-02-02 16:35:57 +13:00
liamcottle
dacd2ea3f2 remove unused component 2025-02-02 16:13:54 +13:00
liamcottle
9741cdcd60 adjust rnode subinterfaces ui 2025-02-02 16:12:23 +13:00
liamcottle
f87a360d5c move optional tcp server interface and udp interface settings to own section 2025-02-02 15:49:29 +13:00
liamcottle
9b62f60e18 simplify ui for ip2 interface peers 2025-02-02 15:35:22 +13:00
liamcottle
019ba93d80 move optional rnode interface settings to own section 2025-02-02 15:22:08 +13:00
liamcottle
01562aff75 move optional rnode interface settings to own section 2025-02-02 02:33:05 +13:00
liamcottle
e2b844f2c2 move shared interface settings to own common interface settings section 2025-02-02 02:24:12 +13:00
liamcottle
c555d8f15b move optional tcp client interface settings to own section 2025-02-02 02:18:57 +13:00
liamcottle
0dc3dc955f move optional auto interface settings to own section 2025-02-02 02:07:18 +13:00
liamcottle
812ff6b887 fix styles 2025-02-02 01:54:44 +13:00
liamcottle
3a13442bb9 collapse ifac grid on small screens 2025-02-02 01:43:32 +13:00
liamcottle
d7375081f3 move ifac settings to its own card section 2025-02-02 01:38:00 +13:00
liamcottle
68ebe4a1c9 remove comment 2025-02-02 01:01:10 +13:00
liamcottle
8b2520f3fa refactor interface section dropdown to a custom expanding section header component 2025-02-02 00:58:10 +13:00
liamcottle
5e068b7341 initial formatting adjustments 2025-02-02 00:28:54 +13:00
liamcottle
d796722772 fix layout for save interface button 2025-02-01 23:47:40 +13:00
rfnx
adad97e917 Add additional interfaces to AddInterfacePage 2025-02-01 01:09:06 -05:00
liamcottle
59eba2ff64 adding a new line in message composer should add it where the cursor is 2025-01-28 17:54:31 +13:00
liamcottle
1bad77553c use router url params for navigating to lxmf conversation 2025-01-22 00:10:27 +13:00
liamcottle
b215c4ac31 update router url when a new nomadnetwork page is loaded 2025-01-22 00:03:38 +13:00
liamcottle
6af4e53de4 add ability to double click a nomadnetwork node in network visualiser to open the browser 2025-01-21 23:58:43 +13:00
liamcottle
558e4c8b3d use isActive instead of isExactActive to allow url props to still show link as active 2025-01-21 23:32:48 +13:00
liamcottle
7d1681fbf1 auto update router url when navigating through conversations 2025-01-21 23:28:00 +13:00
liamcottle
580c907138 add ability to double click an lxmf.delivery node in network visualiser to open the conversation 2025-01-21 23:19:55 +13:00
liamcottle
4ae83ca980 fix formatting 2025-01-20 21:14:53 +13:00
liamcottle
29c062d701 stop updating message state if message gets cancelled 2025-01-20 16:14:08 +13:00
liamcottle
d4b204029a 1.19.0 2025-01-20 13:50:02 +13:00
liamcottle
6f325d24e7 fix issues with calling async function from different threads that may or may not have an event loop 2025-01-20 13:20:03 +13:00
liamcottle
b5f9403c52 add cancelled icon and set background to red 2025-01-20 12:58:33 +13:00
liamcottle
cf059fab63 add button to cancel messages being sent 2025-01-20 12:50:50 +13:00
liamcottle
a3565ef063 add new lxmf message states 2025-01-20 12:45:01 +13:00
liamcottle
541dd8d4f1 update lxmf to v0.6.0 2025-01-20 12:10:07 +13:00
liamcottle
6a1243f482 update rns to v0.9.1 2025-01-20 12:09:34 +13:00
liamcottle
9b36120faa update lang 2025-01-06 19:05:22 +13:00
liamcottle
ff38d4c239 if user provided an address with an "lxmf@" prefix, lets remove that to get the raw destination hash 2025-01-06 18:00:57 +13:00
liamcottle
c5955295d7 add button to open an lxmf address 2025-01-06 17:59:09 +13:00
liamcottle
5d022888b7 add button to open a nomadnet url without having to click a random node first 2025-01-06 17:47:55 +13:00
liamcottle
6b4bf0e31a ignore lxmf messages if they are telemetry requests from sideband 2025-01-05 23:22:20 +13:00
liamcottle
48e56e5285 move transport mode setting to the top 2025-01-02 17:16:58 +13:00
liamcottle
4b6978f7cc add setting to enable and disable transport mode 2025-01-02 17:13:37 +13:00
liamcottle
d3e8c2de9a 1.18.0 2025-01-02 02:20:03 +13:00
liamcottle
282f08edb1 tidy html 2025-01-02 02:09:09 +13:00
liamcottle
629e8d47fb ui improvements for interfaces page 2025-01-02 02:03:25 +13:00
liamcottle
3f73beff2e show port 2025-01-02 01:43:24 +13:00
liamcottle
c55a02ffdc get rid of confusing coding rate prefix 2025-01-02 01:41:09 +13:00
liamcottle
c26d27d01c ui improvements 2025-01-02 01:39:30 +13:00
liamcottle
6d233b759e show info about interfaces being imported 2025-01-02 01:18:40 +13:00
liamcottle
1306593efc export interfaces as a .txt for ease of editing and avoiding issues with weird interface names 2025-01-02 01:08:28 +13:00
liamcottle
8a85a730ab dark mode fixes 2025-01-02 01:05:27 +13:00
liamcottle
e490782d41 dismiss modal when clicking outside of it 2025-01-02 00:48:55 +13:00
liamcottle
7e63c1e752 increase max height 2025-01-02 00:43:14 +13:00
liamcottle
64562c2dc8 ui improvements 2025-01-02 00:41:36 +13:00
liamcottle
b0e7e1d425 adjust ui and tell user what files can be imported 2025-01-02 00:31:53 +13:00
liamcottle
ddf144688e add enable and disable button to interface dropdown menu 2025-01-02 00:21:28 +13:00
liamcottle
ed8ac77ecc add dropdown menu to interfaces 2025-01-02 00:18:32 +13:00
liamcottle
b19ee171eb add button to export single interface 2025-01-02 00:05:51 +13:00
liamcottle
fabb6d5ca3 refactor importing interfaces to use interface parser and allow importing all key value pairs 2025-01-01 23:22:21 +13:00
liamcottle
0b6b390388 refactor interface parser to its own class 2025-01-01 22:04:58 +13:00
liamcottle
82c67bb71c refactor downloading file 2025-01-01 20:55:56 +13:00
liamcottle
372e61ed7c refactor importing interfaces preview 2025-01-01 20:55:10 +13:00
liamcottle
9815decc99 refactor exporting interfaces 2025-01-01 20:30:56 +13:00
liamcottle
65dfd6c540 send json body instead of multipart 2025-01-01 20:26:03 +13:00
liamcottle
de049aead5 rename route 2025-01-01 20:04:03 +13:00
Liam Cottle
99b225e484 Merge pull request #35 from Sudo-Ivan/interface-import-export
Interface Import/Export
2025-01-01 17:35:15 +13:00
liamcottle
de1df07a46 ensure modal can scroll vertically if too high for screen size 2025-01-01 17:23:34 +13:00
liamcottle
e0585d8bcf ui adjustments 2025-01-01 17:19:05 +13:00
liamcottle
12c3310943 move checkbox to right side and allow clicking full container to toggle selection 2025-01-01 16:56:59 +13:00
liamcottle
9ff82c2623 show interface type under interface name 2025-01-01 16:37:48 +13:00
liamcottle
d767c5c002 refactor and ui adjustments 2025-01-01 16:34:25 +13:00
liamcottle
b12aa387bd refactor importing interfaces to its own modal component 2025-01-01 16:07:04 +13:00
liamcottle
80db27da07 tighten spacing 2025-01-01 15:44:26 +13:00
liamcottle
f802eab630 fix vertical alignment 2025-01-01 15:43:01 +13:00
liamcottle
a0d3f88b03 show import button first 2025-01-01 15:37:59 +13:00
liamcottle
3b47d2a521 migrate address 2024-12-31 16:08:09 +13:00
Sudo-Ivan
a49deea8cd accept all file types for import 2024-12-30 20:04:44 -06:00
Sudo-Ivan
b6f8df01f8 align comment 2024-12-30 19:42:30 -06:00
45 changed files with 3806 additions and 1032 deletions

View File

@@ -118,7 +118,7 @@ jobs:
replacesArtifacts: true replacesArtifacts: true
omitDraftDuringUpdate: true omitDraftDuringUpdate: true
omitNameDuringUpdate: true omitNameDuringUpdate: true
artifacts: "dist/*-linux.AppImage" artifacts: "dist/*-linux.AppImage,dist/*-linux.deb"
build_docker: build_docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -149,9 +149,9 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
ghcr.io/liamcottle/reticulum-meshchat:latest ghcr.io/${{ github.repository }}/reticulum-meshchat:latest
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }} ghcr.io/${{ github.repository }}/reticulum-meshchat:${{ github.ref_name }}
labels: | labels: |
org.opencontainers.image.title=Reticulum MeshChat org.opencontainers.image.title=Reticulum MeshChat
org.opencontainers.image.description=Docker image for Reticulum MeshChat org.opencontainers.image.description=Docker image for Reticulum MeshChat
org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/ org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/

View File

@@ -9,7 +9,7 @@
<a href="https://twitter.com/liamcottle"><img src="https://img.shields.io/badge/Twitter-@liamcottle-%231DA1F2?style=flat&logo=twitter" alt="twitter"/></a> <a href="https://twitter.com/liamcottle"><img src="https://img.shields.io/badge/Twitter-@liamcottle-%231DA1F2?style=flat&logo=twitter" alt="twitter"/></a>
<br/> <br/>
<a href="https://ko-fi.com/liamcottle"><img src="https://img.shields.io/badge/Donate%20a%20Coffee-liamcottle-yellow?style=flat&logo=buy-me-a-coffee" alt="donate on ko-fi"/></a> <a href="https://ko-fi.com/liamcottle"><img src="https://img.shields.io/badge/Donate%20a%20Coffee-liamcottle-yellow?style=flat&logo=buy-me-a-coffee" alt="donate on ko-fi"/></a>
<a href="./donate.md"><img src="https://img.shields.io/badge/Donate%20Bitcoin-3FPBfiEwioWHFix3kZqe5bdU9F5o8mG8dh-%23FF9900?style=flat&logo=bitcoin" alt="donate bitcoin"/></a> <a href="./donate.md"><img src="https://img.shields.io/badge/Donate%20Bitcoin-bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q-%23FF9900?style=flat&logo=bitcoin" alt="donate bitcoin"/></a>
</p> </p>
## What is Reticulum MeshChat? ## What is Reticulum MeshChat?
@@ -283,20 +283,6 @@ I build the vite app everytime without hot reload, since MeshChat expects everyt
## TODO ## TODO
- [ ] button to forget announces - [ ] button to forget announces
- [ ] support for managing Reticulum interfaces via the web ui
- [x] AutoInterface
- [x] RNodeInterface
- [x] TCPClientInterface
- [x] TCPServerInterface
- [x] UDPInterface
- [ ] I2PInterface
- [ ] SerialInterface
- [ ] PipeInterface
- [ ] KISSInterface
- [ ] AX25KISSInterface
- [ ] Other Options
- [ ] network_name
- [ ] passphrase
# Notes # Notes

View File

@@ -95,6 +95,21 @@ class CustomDestinationDisplayName(BaseModel):
table_name = "custom_destination_display_names" table_name = "custom_destination_display_names"
class FavouriteDestination(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash
display_name = CharField() # custom display name for the destination hash
aspect = CharField() # e.g: nomadnetwork.node
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
# define table name
class Meta:
table_name = "favourite_destinations"
class LxmfMessage(BaseModel): class LxmfMessage(BaseModel):
id = BigAutoField() id = BigAutoField()

View File

@@ -4,7 +4,7 @@ Thank you for considering donating, this helps support my work on this project
## How can I donate? ## How can I donate?
- Bitcoin: 3FPBfiEwioWHFix3kZqe5bdU9F5o8mG8dh - Bitcoin: bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q
- Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D - Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D
- Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle) - Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle)
- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle) - Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle)

View File

@@ -22,6 +22,27 @@ ipcMain.handle('alert', async(event, message) => {
}); });
}); });
// add support for showing a confirm window via ipc
ipcMain.handle('confirm', async(event, message) => {
// show confirm dialog
const result = await dialog.showMessageBox(mainWindow, {
type: "question",
title: "Confirm",
message: message,
cancelId: 0, // esc key should press cancel button
defaultId: 1, // enter key should press ok button
buttons: [
"Cancel", // 0
"OK", // 1
],
});
// check if user clicked OK
return result.response === 1;
});
// add support for showing a prompt window via ipc // add support for showing a prompt window via ipc
ipcMain.handle('prompt', async(event, message) => { ipcMain.handle('prompt', async(event, message) => {
return await electronPrompt({ return await electronPrompt({
@@ -48,6 +69,9 @@ ipcMain.handle('showPathInFolder', (event, path) => {
function log(message) { function log(message) {
// log to stdout of this process
console.log(message);
// make sure main window exists // make sure main window exists
if(!mainWindow){ if(!mainWindow){
return; return;
@@ -58,9 +82,6 @@ function log(message) {
return; return;
} }
// log to electron console
console.log(message);
// log to web console // log to web console
mainWindow.webContents.send('log', message); mainWindow.webContents.send('log', message);
@@ -98,50 +119,64 @@ function getDefaultReticulumConfigDir() {
app.whenReady().then(async () => { app.whenReady().then(async () => {
// create browser window // get arguments passed to application, and remove the provided application path
mainWindow = new BrowserWindow({ const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
width: 1500, const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
height: 800, const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
webPreferences: {
// used to inject logging over ipc
preload: path.join(__dirname, 'preload.js'),
},
});
// open external links in default web browser instead of electron if(!shouldLaunchHeadless){
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
var shouldShowInNewElectronWindow = false; // create browser window
mainWindow = new BrowserWindow({
width: 1500,
height: 800,
webPreferences: {
// used to inject logging over ipc
preload: path.join(__dirname, 'preload.js'),
},
});
// we want to open call.html in a new electron window // open external links in default web browser instead of electron
// but all other target="_blank" links should open in the system web browser mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
if(url.startsWith("http://localhost") && url.includes("/call.html")){
shouldShowInNewElectronWindow = true;
}
// we want to open blob urls in a new electron window var shouldShowInNewElectronWindow = false;
else if(url.startsWith("blob:")) {
shouldShowInNewElectronWindow = true;
}
// open in new electron window // we want to open call.html in a new electron window
if(shouldShowInNewElectronWindow){ // but all other target="_blank" links should open in the system web browser
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
if(url.startsWith("http://localhost") && url.includes("/call.html")){
shouldShowInNewElectronWindow = true;
}
// we want to open blob urls in a new electron window
else if(url.startsWith("blob:")) {
shouldShowInNewElectronWindow = true;
}
// open in new electron window
if(shouldShowInNewElectronWindow){
return {
action: "allow",
};
}
// fallback to opening any other url in external browser
shell.openExternal(url);
return { return {
action: "allow", action: "deny",
}; };
});
// navigate to loading page
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
// ask mac users for microphone access for audio calls to work
if(process.platform === "darwin"){
await systemPreferences.askForMediaAccess('microphone');
} }
// fallback to opening any other url in external browser }
shell.openExternal(url);
return {
action: "deny",
};
});
// navigate to loading page
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
// find path to python/cxfreeze reticulum meshchat executable // find path to python/cxfreeze reticulum meshchat executable
const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat"; const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat";
@@ -152,16 +187,8 @@ app.whenReady().then(async () => {
exe = path.join(__dirname, '..', `build/exe/${exeName}`); exe = path.join(__dirname, '..', `build/exe/${exeName}`);
} }
// ask mac users for microphone access for audio calls to work
if(process.platform === "darwin"){
await systemPreferences.askForMediaAccess('microphone');
}
try { try {
// get arguments passed to application, and remove the provided application path
const userProvidedArguments = process.argv.slice(1);
// arguments we always want to pass in // arguments we always want to pass in
const requiredArguments = [ const requiredArguments = [
'--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron '--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron

View File

@@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('electron', {
return await ipcRenderer.invoke('alert', message); return await ipcRenderer.invoke('alert', message);
}, },
// show a confirm dialog in electron browser window, this fixes a bug where confirm breaks input fields on windows
confirm: async function(message) {
return await ipcRenderer.invoke('confirm', message);
},
// add support for using "prompt" in electron browser window // add support for using "prompt" in electron browser window
prompt: async function(message) { prompt: async function(message) {
return await ipcRenderer.invoke('prompt', message); return await ipcRenderer.invoke('prompt', message);

View File

File diff suppressed because it is too large Load Diff

126
package-lock.json generated
View File

@@ -1,22 +1,23 @@
{ {
"name": "reticulum-meshchat", "name": "reticulum-meshchat",
"version": "1.17.0", "version": "2.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "reticulum-meshchat", "name": "reticulum-meshchat",
"version": "1.17.0", "version": "2.2.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.7.9", "axios": "^1.10.0",
"click-outside-vue3": "^4.0.1", "click-outside-vue3": "^4.0.1",
"compressorjs": "^1.2.1", "compressorjs": "^1.2.1",
"electron-prompt": "^1.7.0", "electron-prompt": "^1.7.0",
"micron-parser": "^1.0.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
@@ -52,7 +53,6 @@
"version": "7.24.8", "version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -61,7 +61,6 @@
"version": "7.24.7", "version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -70,7 +69,6 @@
"version": "7.25.3", "version": "7.25.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz",
"integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/types": "^7.25.2" "@babel/types": "^7.25.2"
}, },
@@ -85,7 +83,6 @@
"version": "7.25.2", "version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz",
"integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.24.8", "@babel/helper-string-parser": "^7.24.8",
"@babel/helper-validator-identifier": "^7.24.7", "@babel/helper-validator-identifier": "^7.24.7",
@@ -1353,8 +1350,7 @@
"node_modules/@types/hammerjs": { "node_modules/@types/hammerjs": {
"version": "2.0.45", "version": "2.0.45",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.45.tgz", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.45.tgz",
"integrity": "sha512-qkcUlZmX6c4J8q45taBKTL3p+LbITgyx7qhlPYOdOHZB7B31K0mXbP5YA7i7SgDeEGuI9MnumiKPEMrxg8j3KQ==", "integrity": "sha512-qkcUlZmX6c4J8q45taBKTL3p+LbITgyx7qhlPYOdOHZB7B31K0mXbP5YA7i7SgDeEGuI9MnumiKPEMrxg8j3KQ=="
"peer": true
}, },
"node_modules/@types/http-cache-semantics": { "node_modules/@types/http-cache-semantics": {
"version": "4.0.4", "version": "4.0.4",
@@ -1405,6 +1401,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"node_modules/@types/verror": { "node_modules/@types/verror": {
"version": "1.10.10", "version": "1.10.10",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.10.tgz", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.10.tgz",
@@ -1438,7 +1440,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.38.tgz",
"integrity": "sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==", "integrity": "sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.24.7", "@babel/parser": "^7.24.7",
"@vue/shared": "3.4.38", "@vue/shared": "3.4.38",
@@ -1451,7 +1452,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.38.tgz",
"integrity": "sha512-Osc/c7ABsHXTsETLgykcOwIxFktHfGSUDkb05V61rocEfsFDcjDLH/IHJSNJP+/Sv9KeN2Lx1V6McZzlSb9EhQ==", "integrity": "sha512-Osc/c7ABsHXTsETLgykcOwIxFktHfGSUDkb05V61rocEfsFDcjDLH/IHJSNJP+/Sv9KeN2Lx1V6McZzlSb9EhQ==",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.4.38", "@vue/compiler-core": "3.4.38",
"@vue/shared": "3.4.38" "@vue/shared": "3.4.38"
@@ -1461,7 +1461,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.38.tgz",
"integrity": "sha512-s5QfZ+9PzPh3T5H4hsQDJtI8x7zdJaew/dCGgqZ2630XdzaZ3AD8xGZfBqpT8oaD/p2eedd+pL8tD5vvt5ZYJQ==", "integrity": "sha512-s5QfZ+9PzPh3T5H4hsQDJtI8x7zdJaew/dCGgqZ2630XdzaZ3AD8xGZfBqpT8oaD/p2eedd+pL8tD5vvt5ZYJQ==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.24.7", "@babel/parser": "^7.24.7",
"@vue/compiler-core": "3.4.38", "@vue/compiler-core": "3.4.38",
@@ -1478,7 +1477,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.38.tgz",
"integrity": "sha512-YXznKFQ8dxYpAz9zLuVvfcXhc31FSPFDcqr0kyujbOwNhlmaNvL2QfIy+RZeJgSn5Fk54CWoEUeW+NVBAogGaw==", "integrity": "sha512-YXznKFQ8dxYpAz9zLuVvfcXhc31FSPFDcqr0kyujbOwNhlmaNvL2QfIy+RZeJgSn5Fk54CWoEUeW+NVBAogGaw==",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.4.38", "@vue/compiler-dom": "3.4.38",
"@vue/shared": "3.4.38" "@vue/shared": "3.4.38"
@@ -1493,7 +1491,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.38.tgz",
"integrity": "sha512-4vl4wMMVniLsSYYeldAKzbk72+D3hUnkw9z8lDeJacTxAkXeDAP1uE9xr2+aKIN0ipOL8EG2GPouVTH6yF7Gnw==", "integrity": "sha512-4vl4wMMVniLsSYYeldAKzbk72+D3hUnkw9z8lDeJacTxAkXeDAP1uE9xr2+aKIN0ipOL8EG2GPouVTH6yF7Gnw==",
"peer": true,
"dependencies": { "dependencies": {
"@vue/shared": "3.4.38" "@vue/shared": "3.4.38"
} }
@@ -1502,7 +1499,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.38.tgz",
"integrity": "sha512-21z3wA99EABtuf+O3IhdxP0iHgkBs1vuoCAsCKLVJPEjpVqvblwBnTj42vzHRlWDCyxu9ptDm7sI2ZMcWrQqlA==", "integrity": "sha512-21z3wA99EABtuf+O3IhdxP0iHgkBs1vuoCAsCKLVJPEjpVqvblwBnTj42vzHRlWDCyxu9ptDm7sI2ZMcWrQqlA==",
"peer": true,
"dependencies": { "dependencies": {
"@vue/reactivity": "3.4.38", "@vue/reactivity": "3.4.38",
"@vue/shared": "3.4.38" "@vue/shared": "3.4.38"
@@ -1512,7 +1508,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.38.tgz",
"integrity": "sha512-afZzmUreU7vKwKsV17H1NDThEEmdYI+GCAK/KY1U957Ig2NATPVjCROv61R19fjZNzMmiU03n79OMnXyJVN0UA==", "integrity": "sha512-afZzmUreU7vKwKsV17H1NDThEEmdYI+GCAK/KY1U957Ig2NATPVjCROv61R19fjZNzMmiU03n79OMnXyJVN0UA==",
"peer": true,
"dependencies": { "dependencies": {
"@vue/reactivity": "3.4.38", "@vue/reactivity": "3.4.38",
"@vue/runtime-core": "3.4.38", "@vue/runtime-core": "3.4.38",
@@ -1524,7 +1519,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.38.tgz",
"integrity": "sha512-NggOTr82FbPEkkUvBm4fTGcwUY8UuTsnWC/L2YZBmvaQ4C4Jl/Ao4HHTB+l7WnFCt5M/dN3l0XLuyjzswGYVCA==", "integrity": "sha512-NggOTr82FbPEkkUvBm4fTGcwUY8UuTsnWC/L2YZBmvaQ4C4Jl/Ao4HHTB+l7WnFCt5M/dN3l0XLuyjzswGYVCA==",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.4.38", "@vue/compiler-ssr": "3.4.38",
"@vue/shared": "3.4.38" "@vue/shared": "3.4.38"
@@ -1536,8 +1530,7 @@
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.38.tgz",
"integrity": "sha512-q0xCiLkuWWQLzVrecPb0RMsNWyxICOjPrcrwxTUEHb1fsnvni4dcuyG7RT/Ie7VPTvnjzIaWzRMUBsrqNj/hhw==", "integrity": "sha512-q0xCiLkuWWQLzVrecPb0RMsNWyxICOjPrcrwxTUEHb1fsnvni4dcuyG7RT/Ie7VPTvnjzIaWzRMUBsrqNj/hhw=="
"peer": true
}, },
"node_modules/@vuetify/loader-shared": { "node_modules/@vuetify/loader-shared": {
"version": "2.0.3", "version": "2.0.3",
@@ -1583,6 +1576,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -1742,7 +1736,6 @@
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^2.1.0", "archiver-utils": "^2.1.0",
"async": "^3.2.4", "async": "^3.2.4",
@@ -1761,7 +1754,6 @@
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.4", "glob": "^7.1.4",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -1783,7 +1775,6 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -1798,15 +1789,13 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/archiver-utils/node_modules/string_decoder": { "node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -1908,9 +1897,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.9", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@@ -1958,7 +1947,6 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
"inherits": "^2.0.4", "inherits": "^2.0.4",
@@ -2029,6 +2017,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001688", "caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73", "electron-to-chromium": "^1.5.73",
@@ -2409,7 +2398,6 @@
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz",
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"buffer-crc32": "^0.2.13", "buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2", "crc32-stream": "^4.0.2",
@@ -2510,7 +2498,6 @@
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"crc32": "bin/crc32.njs" "crc32": "bin/crc32.njs"
}, },
@@ -2523,7 +2510,6 @@
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz",
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"crc-32": "^1.2.0", "crc-32": "^1.2.0",
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
@@ -2559,8 +2545,7 @@
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
"peer": true
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.6", "version": "4.3.6",
@@ -2712,6 +2697,7 @@
"resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz",
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"builder-util": "24.13.1", "builder-util": "24.13.1",
@@ -2785,6 +2771,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dompurify": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz",
@@ -2869,7 +2863,6 @@
"resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz",
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"archiver": "^5.3.1", "archiver": "^5.3.1",
@@ -2882,7 +2875,6 @@
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -2897,7 +2889,6 @@
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -2910,7 +2901,6 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -3028,7 +3018,6 @@
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"peer": true,
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
}, },
@@ -3143,8 +3132,7 @@
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
"peer": true
}, },
"node_modules/extract-zip": { "node_modules/extract-zip": {
"version": "2.0.1", "version": "2.0.1",
@@ -3314,8 +3302,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
@@ -3842,8 +3829,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/isbinaryfile": { "node_modules/isbinaryfile": {
"version": "5.0.2", "version": "5.0.2",
@@ -4002,7 +3988,6 @@
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"readable-stream": "^2.0.5" "readable-stream": "^2.0.5"
}, },
@@ -4015,7 +4000,6 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -4030,15 +4014,13 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/lazystream/node_modules/string_decoder": { "node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -4069,36 +4051,31 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/lodash.difference": { "node_modules/lodash.difference": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/lodash.union": { "node_modules/lodash.union": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/long": { "node_modules/long": {
"version": "5.2.3", "version": "5.2.3",
@@ -4130,7 +4107,6 @@
"version": "0.30.11", "version": "0.30.11",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
"integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
@@ -4168,6 +4144,14 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/micron-parser": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.2.tgz",
"integrity": "sha512-lYrEolylOUXeSISYrPRW/ZZAH1dpZRyTJ0VzQIA4cWJy0yNCXUUs+ujuAwV2OYlAPH8tCE1Z22+zg04Ilp/JWg==",
"dependencies": {
"dompurify": "*"
}
},
"node_modules/mime": { "node_modules/mime": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
@@ -4528,6 +4512,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -4650,8 +4635,7 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/progress": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
@@ -4783,7 +4767,6 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
"string_decoder": "^1.1.1", "string_decoder": "^1.1.1",
@@ -4798,7 +4781,6 @@
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"minimatch": "^5.1.0" "minimatch": "^5.1.0"
} }
@@ -4970,8 +4952,7 @@
"type": "consulting", "type": "consulting",
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ]
"peer": true
}, },
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
@@ -5154,7 +5135,6 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
@@ -5318,6 +5298,7 @@
"version": "3.4.17", "version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -5372,7 +5353,6 @@
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@@ -5470,7 +5450,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"peer": true,
"engines": { "engines": {
"node": ">=4" "node": ">=4"
} }
@@ -5518,6 +5497,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"devOptional": true, "devOptional": true,
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -5630,6 +5610,7 @@
"version": "7.1.9", "version": "7.1.9",
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.9.tgz", "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.9.tgz",
"integrity": "sha512-COQsxlVrmcRIbZMMTYwD+C2bxYCFDNQ2EHESklPiInbD/Pk3JZ6qNL84Bp9wWjYjAzXfSlsNaFtRk+hO9yBPWA==", "integrity": "sha512-COQsxlVrmcRIbZMMTYwD+C2bxYCFDNQ2EHESklPiInbD/Pk3JZ6qNL84Bp9wWjYjAzXfSlsNaFtRk+hO9yBPWA==",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/visjs" "url": "https://opencollective.com/visjs"
@@ -5677,6 +5658,7 @@
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz",
"integrity": "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g==", "integrity": "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g==",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "0.24.0", "esbuild": "0.24.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
@@ -5747,6 +5729,7 @@
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-2.0.4.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-2.0.4.tgz",
"integrity": "sha512-A4cliYUoP/u4AWSRVRvAPKgpgR987Pss7LpFa7s1GvOe8WjgDq92Rt3eVXrvgxGCWvZsPKziVqfHHdCMqeDhfw==", "integrity": "sha512-A4cliYUoP/u4AWSRVRvAPKgpgR987Pss7LpFa7s1GvOe8WjgDq92Rt3eVXrvgxGCWvZsPKziVqfHHdCMqeDhfw==",
"peer": true,
"dependencies": { "dependencies": {
"@vuetify/loader-shared": "^2.0.3", "@vuetify/loader-shared": "^2.0.3",
"debug": "^4.3.3", "debug": "^4.3.3",
@@ -5800,6 +5783,7 @@
"version": "3.7.6", "version": "3.7.6",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.6.tgz", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.6.tgz",
"integrity": "sha512-lol0Va5HtMIqZfjccSD5DLv5v31R/asJXzc6s7ULy51PHr1DjXxWylZejhq0kVpMGW64MiV1FmA/p8eYQfOWfQ==", "integrity": "sha512-lol0Va5HtMIqZfjccSD5DLv5v31R/asJXzc6s7ULy51PHr1DjXxWylZejhq0kVpMGW64MiV1FmA/p8eYQfOWfQ==",
"peer": true,
"engines": { "engines": {
"node": "^12.20 || >=14.13" "node": "^12.20 || >=14.13"
}, },
@@ -5956,7 +5940,6 @@
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^3.0.4", "archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2", "compress-commons": "^4.1.2",
@@ -5971,7 +5954,6 @@
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz",
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.2.3", "glob": "^7.2.3",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "reticulum-meshchat", "name": "reticulum-meshchat",
"version": "1.17.0", "version": "2.2.1",
"description": "", "description": "",
"main": "electron/main.js", "main": "electron/main.js",
"scripts": { "scripts": {
@@ -70,7 +70,10 @@
}, },
"linux": { "linux": {
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}", "artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
"target": "AppImage", "target": [
"AppImage",
"deb"
],
"extraFiles": [ "extraFiles": [
{ {
"from": "build/exe", "from": "build/exe",
@@ -98,10 +101,11 @@
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.7.9", "axios": "^1.10.0",
"click-outside-vue3": "^4.0.1", "click-outside-vue3": "^4.0.1",
"compressorjs": "^1.2.1", "compressorjs": "^1.2.1",
"electron-prompt": "^1.7.0", "electron-prompt": "^1.7.0",
"micron-parser": "^1.0.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",

View File

@@ -1,6 +1,6 @@
aiohttp>=3.9.5 aiohttp>=3.12.14
cx_freeze>=7.0.0 cx_freeze>=7.0.0
lxmf>=0.5.8 lxmf>=0.8.0
peewee>=3.17.3 peewee>=3.18.1
rns>=0.8.8 rns>=1.0.0
websockets>=12.0 websockets>=14.2

View File

@@ -0,0 +1,25 @@
import asyncio
from typing import Coroutine
class AsyncUtils:
# remember main loop
main_loop: asyncio.AbstractEventLoop | None = None
@staticmethod
def set_main_loop(loop: asyncio.AbstractEventLoop):
AsyncUtils.main_loop = loop
# this method allows running the provided async coroutine from within a sync function
# it will run the async function on the main event loop if possible, otherwise it logs a warning
@staticmethod
def run_async(coroutine: Coroutine):
# run provided coroutine on main event loop, ensuring thread safety
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
return
# main event loop not running...
print("WARNING: Main event loop not available. Could not schedule task.")

View File

@@ -0,0 +1,31 @@
import RNS.vendor.configobj
class InterfaceConfigParser:
@staticmethod
def parse(text):
# get lines from provided text
lines = text.splitlines()
# ensure [interfaces] section exists
if "[interfaces]" not in lines:
lines.insert(0, "[interfaces]")
# parse lines as rns config object
config = RNS.vendor.configobj.ConfigObj(lines)
# get interfaces from config
config_interfaces = config.get("interfaces")
# process interfaces
interfaces = []
for interface_name in config_interfaces:
# ensure interface has a name
interface_config = config_interfaces[interface_name]
interface_config["name"] = interface_name
interfaces.append(interface_config)
return interfaces

View File

@@ -0,0 +1,14 @@
class InterfaceEditor:
@staticmethod
def update_value(interface_details: dict, data: dict, key: str):
# update value if provided and not empty
value = data.get(key)
if value is not None and value != "":
interface_details[key] = value
return
# otherwise remove existing value
if key in interface_details:
del interface_details[key]

View File

@@ -0,0 +1,134 @@
import threading
import time
import RNS
from RNS.Interfaces.Interface import Interface
from websockets.sync.client import connect
from websockets.sync.connection import Connection
class WebsocketClientInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RECONNECT_DELAY_SECONDS = 5
def __str__(self):
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
def __init__(self, owner, configuration, websocket: Connection = None):
super().__init__()
self.owner = owner
self.parent_interface = None
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
# parse config
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf.get("name")
self.target_url = ifconf.get("target_url", None)
# ensure target url is provided
if self.target_url is None:
raise SystemError(f"target_url is required for interface '{self.name}'")
# connect to websocket server if an existing connection was not provided
self.websocket = websocket
if self.websocket is None:
thread = threading.Thread(target=self.connect)
thread.daemon = True
thread.start()
# called when a full packet has been received over the websocket
def process_incoming(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
# update received bytes counter
self.rxb += len(data)
# update received bytes counter for parent interface
if self.parent_interface is not None:
self.parent_interface.rxb += len(data)
# send received data to transport instance
self.owner.inbound(data, self)
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
def process_outgoing(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
# send to websocket server
try:
self.websocket.send(data)
except Exception as e:
RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR)
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
return
# update sent bytes counter
self.txb += len(data)
# update received bytes counter for parent interface
if self.parent_interface is not None:
self.parent_interface.txb += len(data)
# connect to the configured websocket server
def connect(self):
# do nothing if interface is detached
if self.detached:
return
# connect to websocket server
try:
RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
self.websocket = connect(f"{self.target_url}", max_size=None, compression=None)
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
self.read_loop()
except Exception as e:
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
# auto reconnect after delay
RNS.log(f"Websocket disconnected for {str(self)}...", RNS.LOG_DEBUG)
time.sleep(self.RECONNECT_DELAY_SECONDS)
self.connect()
def read_loop(self):
self.online = True
try:
for message in self.websocket:
self.process_incoming(message)
except Exception as e:
RNS.log(f"{self} read loop error: {e}", RNS.LOG_ERROR)
self.online = False
def detach(self):
# mark as offline
self.online = False
# close websocket
if self.websocket is not None:
self.websocket.close()
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketClientInterface

View File

@@ -0,0 +1,156 @@
import threading
import time
import RNS
from RNS.Interfaces.Interface import Interface
from websockets.sync.server import Server
from websockets.sync.server import serve
from websockets.sync.server import ServerConnection
from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface
class WebsocketServerInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RESTART_DELAY_SECONDS = 5
def __str__(self):
return f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
def __init__(self, owner, configuration):
super().__init__()
self.owner = owner
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.server: Server | None = None
self.spawned_interfaces: [WebsocketClientInterface] = []
# parse config
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf.get("name")
self.listen_ip = ifconf.get("listen_ip", None)
self.listen_port = ifconf.get("listen_port", None)
# ensure listen ip is provided
if self.listen_ip is None:
raise SystemError(f"listen_ip is required for interface '{self.name}'")
# ensure listen port is provided
if self.listen_port is None:
raise SystemError(f"listen_port is required for interface '{self.name}'")
# convert listen port to int
self.listen_port = int(self.listen_port)
# run websocket server
thread = threading.Thread(target=self.serve)
thread.daemon = True
thread.start()
@property
def clients(self):
return len(self.spawned_interfaces)
# todo docs
def received_announce(self, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
# todo docs
def sent_announce(self, from_spawned=False):
if from_spawned:
self.oa_freq_deque.append(time.time())
# do nothing as the spawned child interface will take care of rx/tx
def process_incoming(self, data):
pass
# do nothing as the spawned child interface will take care of rx/tx
def process_outgoing(self, data):
pass
def serve(self):
# handle new websocket client connections
def on_websocket_client_connected(websocket: ServerConnection):
# create new child interface
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
spawned_interface = WebsocketClientInterface(self.owner, {
"name": f"Client on {self.name}",
"target_host": websocket.remote_address[0],
"target_port": str(websocket.remote_address[1]),
}, websocket=websocket)
# configure child interface
spawned_interface.IN = self.IN
spawned_interface.OUT = self.OUT
spawned_interface.HW_MTU = self.HW_MTU
spawned_interface.bitrate = self.bitrate
spawned_interface.mode = self.mode
spawned_interface.parent_interface = self
spawned_interface.online = True
# todo implement?
spawned_interface.announce_rate_target = None
spawned_interface.announce_rate_grace = None
spawned_interface.announce_rate_penalty = None
# todo ifac?
# todo announce rates?
# activate child interface
RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE)
RNS.Transport.interfaces.append(spawned_interface)
# associate child interface with this interface
while spawned_interface in self.spawned_interfaces:
self.spawned_interfaces.remove(spawned_interface)
self.spawned_interfaces.append(spawned_interface)
# run read loop
spawned_interface.read_loop()
# client must have disconnected as the read loop finished, so forget the spawned interface
self.spawned_interfaces.remove(spawned_interface)
# run websocket server
try:
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server:
self.online = True
self.server = server
server.serve_forever()
except Exception as e:
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
# websocket server is no longer running, let's restart it
self.online = False
RNS.log(f"Websocket server stopped for {str(self)}...", RNS.LOG_DEBUG)
time.sleep(self.RESTART_DELAY_SECONDS)
self.serve()
def detach(self):
# mark as offline
self.online = False
# stop websocket server
if self.server is not None:
self.server.shutdown()
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketServerInterface

View File

@@ -0,0 +1,3 @@
# https://github.com/markqvist/Sideband/blob/e515889e210037f881c201e0d627a7b09a48eb69/sbapp/sideband/sense.py#L11
class SidebandCommands:
TELEMETRY_REQUEST = 0x01

View File

@@ -448,7 +448,7 @@ export default {
// ask to stop syncing if already syncing // ask to stop syncing if already syncing
if(this.isSyncingPropagationNode){ if(this.isSyncingPropagationNode){
if(confirm("Are you sure you want to stop syncing?")){ if(await DialogUtils.confirm("Are you sure you want to stop syncing?")){
await this.stopSyncingPropagationNode(); await this.stopSyncingPropagationNode();
} }
return; return;
@@ -529,7 +529,7 @@ export default {
async hangupAllCalls() { async hangupAllCalls() {
// confirm user wants to hang up calls // confirm user wants to hang up calls
if(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){ if(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
return; return;
} }

View File

@@ -14,7 +14,7 @@
leave-active-class="transition ease-in duration-75" leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100" leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"> leave-to-class="transform opacity-0 scale-95">
<div v-if="isShowingMenu" @click.stop="hideMenu" class="overflow-hidden absolute right-0 z-10 mr-4 w-56 rounded-md bg-white shadow-md border border-gray-200 focus:outline-none" :class="[ dropdownClass ]"> <div v-if="isShowingMenu" @click.stop="hideMenu" class="overflow-hidden absolute right-0 z-10 mr-4 w-56 rounded-md bg-white shadow-md border border-gray-200 focus:outline-none dark:border-zinc-700" :class="[ dropdownClass ]">
<slot name="items"/> <slot name="items"/>
</div> </div>
</Transition> </Transition>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="cursor-pointer flex p-3 space-x-2 text-sm text-gray-500 hover:bg-gray-100"> <div class="cursor-pointer flex p-3 space-x-2 text-sm bg-white text-gray-500 hover:bg-gray-100 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700">
<slot/> <slot/>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<button type="button" class="p-2 rounded-full text-gray-700 bg-gray-100 hover:bg-gray-200"> <button type="button" class="text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full">
<slot/> <slot/>
</button> </button>
</template> </template>

View File

@@ -1,11 +1,11 @@
<template> <template>
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive, isExactActive }" custom> <RouterLink :to="to" v-slot="{ href, route, navigate, isActive }" custom>
<a <a
:href="href" :href="href"
@click="handleNavigate($event, navigate)" @click="handleNavigate($event, navigate)"
type="button" type="button"
:class="[ :class="[
isExactActive isActive
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300' ? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-zinc-700' : 'hover:bg-gray-100 dark:hover:bg-zinc-700'
]" ]"

View File

@@ -12,7 +12,7 @@
<div class="mr-auto"> <div class="mr-auto">
<div>Versions</div> <div>Versions</div>
<div class="text-sm text-gray-700 dark:text-zinc-400"> <div class="text-sm text-gray-700 dark:text-zinc-400">
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} • Python v{{ appInfo.python_version }}
</div> </div>
</div> </div>
<div class="hidden sm:block mx-2 my-auto"> <div class="hidden sm:block mx-2 my-auto">

View File

@@ -259,6 +259,7 @@
<script> <script>
import protobuf from "protobufjs"; import protobuf from "protobufjs";
import DialogUtils from "../../js/DialogUtils";
export default { export default {
name: 'CallPage', name: 'CallPage',
data() { data() {
@@ -488,7 +489,7 @@ export default {
async hangupCall(callHash) { async hangupCall(callHash) {
// confirm user wants to hang up call // confirm user wants to hang up call
if(!confirm("Are you sure you want to hang up this call?")){ if(!await DialogUtils.confirm("Are you sure you want to hang up this call?")){
return; return;
} }
@@ -681,7 +682,7 @@ export default {
async deleteCall(callHash) { async deleteCall(callHash) {
// confirm user wants to delete call // confirm user wants to delete call
if(!confirm("Are you sure you want to delete this call?")){ if(!await DialogUtils.confirm("Are you sure you want to delete this call?")){
return; return;
} }
@@ -701,7 +702,7 @@ export default {
async clearCallHistory() { async clearCallHistory() {
// confirm user wants to clear call history // confirm user wants to clear call history
if(!confirm("Are you sure you want to clear your call history?")){ if(!await DialogUtils.confirm("Are you sure you want to clear your call history?")){
return; return;
} }

View File

@@ -0,0 +1,10 @@
<template>
<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">
<slot/>
</label>
</template>
<script>
export default {
name: 'FormLabel',
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<div class="text-xs text-gray-600 dark:text-zinc-300">
<slot/>
</div>
</template>
<script>
export default {
name: 'FormSubLabel',
}
</script>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
<template>
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900 overflow-hidden">
<div @click="isExpanded = !isExpanded" class="flex p-2 justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800">
<div class="my-auto mr-auto">
<div class="font-bold dark:text-white">
<slot name="title"/>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<slot name="subtitle"/>
</div>
</div>
<div class="my-auto ml-2">
<div class="w-5 h-5 text-gray-600 dark:text-gray-300 transform transition-transform duration-200" :class="{ 'rotate-90': isExpanded }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="size-5">
<rect width="256" height="256" fill="none"/>
<path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z" fill="currentColor"/>
</svg>
</div>
</div>
</div>
<div v-if="isExpanded" class="divide-y divide-gray-200 dark:text-white">
<slot name="content"/>
</div>
</div>
</template>
<script>
export default {
name: 'ExpandingSection',
data() {
return {
isExpanded: false,
};
},
}
</script>

View File

@@ -0,0 +1,240 @@
<template>
<div v-if="isShowing" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center">
<div class="flex w-full h-full p-4 overflow-y-auto">
<div v-click-outside="dismiss" class="my-auto mx-auto w-full bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl">
<!-- title -->
<div class="p-4 border-b dark:border-zinc-700">
<h3 class="text-lg font-semibold dark:text-white">Import Interfaces</h3>
</div>
<!-- content -->
<div class="divide-y dark:divide-zinc-700">
<!-- file input -->
<div class="p-2">
<div>
<input ref="import-interfaces-file-input" type="file" @change="onFileSelected" accept="*" class="w-full text-sm text-gray-500 dark:text-zinc-400">
</div>
<div v-if="!selectedFile" class="mt-2 text-sm text-gray-700 dark:text-zinc-200">
<ul class="list-disc list-inside">
<li>You can import interfaces from a ~/.reticulum/config file.</li>
<li>You can import interfaces from an exported interfaces file.</li>
</ul>
</div>
</div>
<!-- select interfaces -->
<div v-if="importableInterfaces.length > 0" class="divide-y dark:divide-zinc-700">
<div class="flex p-2">
<div class="my-auto mr-auto text-sm font-medium text-gray-700 dark:text-zinc-200">Select Interfaces to Import</div>
<div class="my-auto space-x-2">
<button @click="selectAllInterfaces" class="text-sm text-blue-500 hover:underline">Select All</button>
<button @click="deselectAllInterfaces" class="text-sm text-blue-500 hover:underline">Deselect All</button>
</div>
</div>
<div class="bg-gray-200 p-2 space-y-2 max-h-80 overflow-y-auto dark:bg-zinc-800">
<div @click="toggleSelectedInterface(iface.name)" v-for="iface in importableInterfaces" :key="iface.name" class="bg-white cursor-pointer flex items-center p-2 border rounded shadow dark:bg-zinc-900 dark:border-zinc-700">
<div class="mr-auto text-sm">
<div class="font-semibold text-gray-700 dark:text-zinc-100">{{ iface.name }}</div>
<div class="text-sm text-gray-500 dark:text-zinc-100">
<!-- auto interface -->
<div v-if="iface.type === 'AutoInterface'">
<div>{{ iface.type }}</div>
<div>Ethernet and WiFi</div>
</div>
<!-- tcp client interface -->
<div v-else-if="iface.type === 'TCPClientInterface'">
<div>{{ iface.type }}</div>
<div>{{ iface.target_host }}:{{ iface.target_port }}</div>
</div>
<!-- tcp server interface -->
<div v-else-if="iface.type === 'TCPServerInterface'">
<div>{{ iface.type }}</div>
<div>{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
</div>
<!-- udp interface -->
<div v-else-if="iface.type === 'UDPInterface'">
<div>{{ iface.type }}</div>
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</div>
</div>
<!-- rnode interface details -->
<div v-else-if="iface.type === 'RNodeInterface'">
<div>{{ iface.type }}</div>
<div>Port: {{ iface.port }}</div>
<div>Frequency: {{ formatFrequency(iface.frequency) }}</div>
<div>Bandwidth: {{ formatFrequency(iface.bandwidth) }}</div>
<div>Spreading Factor: {{ iface.spreadingfactor }}</div>
<div>Coding Rate: {{ iface.codingrate }}</div>
<div>Transmit Power: {{ iface.txpower }}dBm</div>
</div>
<!-- other interface types -->
<div v-else>{{ iface.type }}</div>
</div>
</div>
<input @click.stop type="checkbox" v-model="selectedInterfaces" :value="iface.name" class="mx-2 h-4 w-4 text-blue-600 rounded border-gray-300 dark:border-zinc-600">
</div>
</div>
</div>
</div>
<!-- actions -->
<div class="p-4 border-t dark:border-zinc-700 flex justify-end space-x-2">
<button @click="dismiss" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-zinc-800 dark:text-zinc-200 dark:border-zinc-600 dark:hover:bg-zinc-700">
Cancel
</button>
<button @click="importSelectedInterfaces" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600">
Import Selected
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import Utils from "../../js/Utils";
export default {
name: "ImportInterfacesModal",
emits: [
"dismissed",
],
data() {
return {
isShowing: false,
selectedFile: null,
importableInterfaces: [],
selectedInterfaces: [],
};
},
methods: {
show() {
this.isShowing = true;
this.selectedFile = null;
this.importableInterfaces = [];
this.selectedInterfaces = [];
},
dismiss() {
this.isShowing = false;
this.$emit("dismissed");
},
clearSelectedFile() {
this.selectedFile = null;
this.$refs["import-interfaces-file-input"].value = null;
},
async onFileSelected(event) {
// get selected file
const file = event.target.files[0];
if(!file){
return;
}
// update ui
this.selectedFile = file;
this.importableInterfaces = [];
this.selectedInterfaces = [];
try {
// fetch preview of interfaces to import
const response = await window.axios.post('/api/v1/reticulum/interfaces/import-preview', {
config: await file.text(),
});
// ensure there are some interfaces available to import
if(!response.data.interfaces || response.data.interfaces.length === 0){
this.clearSelectedFile();
DialogUtils.alert("No interfaces were found in the selected configuration file");
return;
}
// update ui
this.importableInterfaces = response.data.interfaces;
// auto select all interfaces
this.selectAllInterfaces();
} catch(e) {
this.clearSelectedFile();
DialogUtils.alert("Failed to parse configuration file");
console.error(e);
}
},
isInterfaceSelected(name) {
return this.selectedInterfaces.includes(name);
},
selectInterface(name) {
if(!this.isInterfaceSelected(name)){
this.selectedInterfaces.push(name);
}
},
deselectInterface(name) {
this.selectedInterfaces = this.selectedInterfaces.filter((selectedInterfaceName) => {
return selectedInterfaceName !== name;
});
},
toggleSelectedInterface(name) {
if(this.isInterfaceSelected(name)){
this.deselectInterface(name);
} else {
this.selectInterface(name);
}
},
selectAllInterfaces() {
this.selectedInterfaces = this.importableInterfaces.map(i => i.name);
},
deselectAllInterfaces() {
this.selectedInterfaces = [];
},
async importSelectedInterfaces() {
// ensure user selected a file to import from
if(!this.selectedFile){
DialogUtils.alert("Please select a configuration file");
return;
}
// ensure user selected some interfaces
if(this.selectedInterfaces.length === 0){
DialogUtils.alert("Please select at least one interface to import");
return;
}
try {
// import interfaces
await window.axios.post('/api/v1/reticulum/interfaces/import', {
config: await this.selectedFile.text(),
selected_interface_names: this.selectedInterfaces,
});
// dismiss modal
this.dismiss();
// tell user interfaces were imported
DialogUtils.alert("Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.");
} catch(e) {
const message = e.response?.data?.message || "Failed to import interfaces";
DialogUtils.alert(message);
console.error(e);
}
},
formatFrequency(hz) {
return Utils.formatFrequency(hz);
},
},
}
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="border rounded bg-white shadow overflow-hidden dark:bg-zinc-800 dark:border-zinc-700"> <div class="border rounded bg-white shadow dark:bg-zinc-800 dark:border-zinc-700">
<!-- IFAC info --> <!-- IFAC info -->
<div v-if="iface._stats?.ifac_signature != null" class="bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-b dark:bg-zinc-800 dark:border-zinc-700"> <div v-if="iface._stats?.ifac_signature != null" class="bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-b dark:bg-zinc-800 dark:border-zinc-700">
@@ -10,7 +10,7 @@
</svg> </svg>
</div> </div>
<span class="ml-1 my-auto"> <span class="ml-1 my-auto">
<span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> with sig <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer">&lt;{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}&gt;</span> <span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> <span v-if="iface._stats?.ifac_netname != null">• Network Name: <span class="text-purple-500">{{ iface._stats.ifac_netname }}</span></span> • Signature <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer">&lt;{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}&gt;</span>
</span> </span>
</div> </div>
</div> </div>
@@ -36,6 +36,14 @@
<path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path> <path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path>
</svg> </svg>
<svg v-else-if="iface.type === 'RNodeMultiInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"></path></svg>
<svg v-else-if="iface.type === 'I2PInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M72,92A12,12,0,1,1,60,80,12,12,0,0,1,72,92Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,128,80Zm68,24a12,12,0,1,0-12-12A12,12,0,0,0,196,104ZM60,152a12,12,0,1,0,12,12A12,12,0,0,0,60,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,152Z"></path></svg>
<svg v-else-if="iface.type === 'KISSInterface' || iface.type === 'AX25KISSInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M104,168a8,8,0,0,1-8,8H64a8,8,0,0,1,0-16H96A8,8,0,0,1,104,168Zm-8-40H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16Zm0-32H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16ZM232,80V192a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V72a8,8,0,0,1,5.7-7.66l160-48a8,8,0,0,1,4.6,15.33L86.51,64H216A16,16,0,0,1,232,80ZM216,192V80H40V192H216Zm-16-56a40,40,0,1,1-40-40A40,40,0,0,1,200,136Zm-16,0a24,24,0,1,0-24,24A24,24,0,0,0,184,136Z"></path></svg>
<svg v-else-if="iface.type === 'PipeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,128a8,8,0,0,1-3,6.25l-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32A8,8,0,0,1,128,128Zm48,24H136a8,8,0,0,0,0,16h40a8,8,0,0,0,0-16Zm56-96V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56ZM216,200V56H40V200H216Z"></path></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"> <svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path> <path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
</svg> </svg>
@@ -48,34 +56,22 @@
<div class="text-sm flex space-x-1 dark:text-zinc-100"> <div class="text-sm flex space-x-1 dark:text-zinc-100">
<!-- auto interface --> <!-- auto interface -->
<span v-if="iface.type === 'AutoInterface'"> <div v-if="iface.type === 'AutoInterface'">
{{ iface.type }} • Ethernet and WiFi {{ iface.type }} • Ethernet and WiFi
</span> </div>
<!-- tcp client interface --> <!-- tcp client interface -->
<span v-else-if="iface.type === 'TCPClientInterface'"> <div v-else-if="iface.type === 'TCPClientInterface'">
{{ iface.type }} • {{ iface.target_host }}:{{ iface.target_port }} {{ iface.type }} • {{ iface.target_host }}:{{ iface.target_port }}
</span> </div>
<!-- tcp server interface --> <!-- tcp server interface -->
<span v-else-if="iface.type === 'TCPServerInterface'"> <div v-else-if="iface.type === 'TCPServerInterface'">
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }} {{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }}
</span> </div>
<!-- udp interface --> <!-- other interface types -->
<span v-else-if="iface.type === 'UDPInterface'"> <div v-else>{{ iface.type }}</div>
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }} • {{ iface.forward_ip }}:{{ iface.forward_port }}
</span>
<!-- rnode interface details -->
<span v-else-if="iface.type === 'RNodeInterface'">
{{ iface.type }} • {{ iface.port }} • freq={{ iface.frequency }} • bw={{ iface.bandwidth }} • power={{ iface.txpower }}dBm • sf={{ iface.spreadingfactor }} • cr={{ iface.codingrate }}
</span>
<!-- unknown interface types -->
<span v-else>
{{ iface.type ?? 'Unknown Interface Type' }}
</span>
</div> </div>
</div> </div>
@@ -96,7 +92,7 @@
</span> </span>
</button> </button>
<button v-else @click="enableInterface" type="button" class="cursor-pointer"> <button v-else @click="enableInterface" type="button" class="cursor-pointer">
<span class="flex text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 rounded-full"> <span class="flex text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
</svg> </svg>
@@ -104,31 +100,91 @@
</button> </button>
</div> </div>
<!-- edit interface button -->
<div class="my-auto mr-1">
<button @click="editInterface" type="button" class="cursor-pointer">
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 rounded-full ">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
</span>
</button>
</div>
<!-- delete interface button -->
<div class="my-auto mr-2"> <div class="my-auto mr-2">
<button @click="deleteInterface" type="button" class="cursor-pointer"> <DropDownMenu>
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 rounded-full"> <template v-slot:button>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> <IconButton>
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
</svg> <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</span> </svg>
</button> </IconButton>
</template>
<template v-slot:items>
<!-- enable/disable interface button -->
<div class="border-b dark:border-zinc-700">
<!-- enable interface button -->
<DropDownMenuItem v-if="isInterfaceEnabled(iface)" @click="disableInterface">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
<span>Disable Interface</span>
</DropDownMenuItem>
<DropDownMenuItem v-else @click="enableInterface">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
<span>Enable Interface</span>
</DropDownMenuItem>
</div>
<!-- edit interface button -->
<DropDownMenuItem @click="editInterface">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path d="M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32L19.513 8.2Z" />
</svg>
<span>Edit Interface</span>
</DropDownMenuItem>
<!-- export interface button -->
<DropDownMenuItem @click="exportInterface">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v11.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 1 1 1.06-1.06l3.22 3.22V3a.75.75 0 0 1 .75-.75Zm-9 13.5a.75.75 0 0 1 .75.75v2.25a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5V16.5a.75.75 0 0 1 1.5 0v2.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V16.5a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd" />
</svg>
<span>Export Interface</span>
</DropDownMenuItem>
<!-- delete interface button -->
<div class="border-t dark:border-zinc-700">
<DropDownMenuItem @click="deleteInterface">
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
</svg>
<span class="text-red-500">Delete Interface</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
</div> </div>
</div> </div>
<div class="flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t dark:bg-zinc-800 dark:text-white dark:border-zinc-700"> <!-- extra interface details -->
<div v-if="['UDPInterface', 'RNodeInterface'].includes(iface.type)" class="p-1 text-sm border-t dark:text-zinc-100 dark:border-zinc-700">
<!-- udp interface -->
<div v-if="iface.type === 'UDPInterface'">
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</div>
</div>
<!-- rnode interface details -->
<div v-else-if="iface.type === 'RNodeInterface'">
<div>Port: {{ iface.port }}</div>
<div>Frequency: {{ formatFrequency(iface.frequency) }}</div>
<div>Bandwidth: {{ formatFrequency(iface.bandwidth) }}</div>
<div>Spreading Factor: {{ iface.spreadingfactor }}</div>
<div>Coding Rate: {{ iface.codingrate }}</div>
<div>Transmit Power: {{ iface.txpower }}dBm</div>
</div>
</div>
<div class="flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t rounded-b dark:bg-zinc-800 dark:text-white dark:border-zinc-700">
<!-- status --> <!-- status -->
<div v-if="iface._stats?.status === true" class="text-sm text-green-500">Connected</div> <div v-if="iface._stats?.status === true" class="text-sm text-green-500">Connected</div>
@@ -138,7 +194,11 @@
<div>• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div> <div>• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div>
<div>• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div> <div>• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div>
<div>• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div> <div>• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div>
<div v-if="iface._stats?.clients">• Clients: {{ iface._stats?.clients }}</div> <div v-if="iface.type === 'RNodeInterface'">• Noise Floor: {{
iface._stats?.noise_floor
}} dBm
</div>
<div v-if="iface._stats?.clients != null">• Clients: {{ iface._stats?.clients }}</div>
</div> </div>
@@ -148,9 +208,17 @@
<script> <script>
import DialogUtils from "../../js/DialogUtils"; import DialogUtils from "../../js/DialogUtils";
import Utils from "../../js/Utils"; import Utils from "../../js/Utils";
import DropDownMenuItem from "../DropDownMenuItem.vue";
import IconButton from "../IconButton.vue";
import DropDownMenu from "../DropDownMenu.vue";
export default { export default {
name: 'Interface', name: 'Interface',
components: {
DropDownMenu,
IconButton,
DropDownMenuItem,
},
props: { props: {
iface: Object, iface: Object,
}, },
@@ -175,6 +243,9 @@ export default {
editInterface() { editInterface() {
this.$emit("edit"); this.$emit("edit");
}, },
exportInterface() {
this.$emit("export");
},
deleteInterface() { deleteInterface() {
this.$emit("delete"); this.$emit("delete");
}, },
@@ -184,6 +255,9 @@ export default {
formatBytes: function(bytes) { formatBytes: function(bytes) {
return Utils.formatBytes(bytes); return Utils.formatBytes(bytes);
}, },
formatFrequency(hz) {
return Utils.formatFrequency(hz);
},
}, },
} }
</script> </script>

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950"> <div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
<div class="overflow-y-auto p-2 space-y-2"> <div class="overflow-y-auto p-2 space-y-2">
<!-- warning - keeping orange-500 for warning visibility in both modes --> <!-- warning - keeping orange-500 for warning visibility in both modes -->
<div class="flex bg-orange-500 p-2 text-sm font-semibold leading-6 text-white rounded shadow"> <div class="flex bg-orange-500 p-2 text-sm font-semibold leading-6 text-white rounded shadow">
<div class="my-auto"> <div class="my-auto">
@@ -17,7 +18,8 @@
</button> </button>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-1">
<!-- Add Interface button --> <!-- Add Interface button -->
<RouterLink :to="{ name: 'interfaces.add' }"> <RouterLink :to="{ name: 'interfaces.add' }">
<button type="button" <button type="button"
@@ -29,23 +31,26 @@
</button> </button>
</RouterLink> </RouterLink>
<!-- Export button -->
<button @click="exportInterfaces" type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<span>Export</span>
</button>
<!-- Import button --> <!-- Import button -->
<button @click="showImportDialog" type="button" <div class="my-auto">
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700"> <button @click="showImportInterfacesModal" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg> </svg>
<span>Import</span> <span>Import</span>
</button> </button>
</div>
<!-- Export button -->
<div class="my-auto">
<button @click="exportInterfaces" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<span>Export</span>
</button>
</div>
</div> </div>
<!-- enabled interfaces --> <!-- enabled interfaces -->
@@ -55,6 +60,7 @@
@enable="enableInterface(iface._name)" @enable="enableInterface(iface._name)"
@disable="disableInterface(iface._name)" @disable="disableInterface(iface._name)"
@edit="editInterface(iface._name)" @edit="editInterface(iface._name)"
@export="exportInterface(iface._name)"
@delete="deleteInterface(iface._name)"/> @delete="deleteInterface(iface._name)"/>
<!-- disabled interfaces --> <!-- disabled interfaces -->
@@ -65,69 +71,15 @@
@enable="enableInterface(iface._name)" @enable="enableInterface(iface._name)"
@disable="disableInterface(iface._name)" @disable="disableInterface(iface._name)"
@edit="editInterface(iface._name)" @edit="editInterface(iface._name)"
@export="exportInterface(iface._name)"
@delete="deleteInterface(iface._name)"/> @delete="deleteInterface(iface._name)"/>
</div> </div>
</div> </div>
<!-- Import Dialog --> <!-- Import Dialog -->
<div v-if="showingImportDialog" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center"> <ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed"/>
<div class="bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl w-full mx-4">
<div class="p-4 border-b dark:border-zinc-700">
<h3 class="text-lg font-semibold dark:text-white">Import Interfaces</h3>
</div>
<div class="p-4 space-y-4">
<!-- File Input -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-zinc-200">Select Configuration File</label>
<input type="file"
@change="onFileSelected"
accept=".conf"
class="mt-1 block w-full text-sm text-gray-500 dark:text-zinc-400
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-gray-500 file:text-white
hover:file:bg-gray-400
dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600">
</div>
<!-- Interface Selection -->
<div v-if="importableInterfaces.length > 0">
<div class="flex justify-between mb-2">
<label class="block text-sm font-medium text-gray-700 dark:text-zinc-200">Select Interfaces to Import</label>
<div class="space-x-2">
<button @click="selectAllInterfaces" class="text-sm text-blue-500">Select All</button>
<button @click="deselectAllInterfaces" class="text-sm text-blue-500">Deselect All</button>
</div>
</div>
<div class="space-y-2 max-h-60 overflow-y-auto">
<div v-for="iface in importableInterfaces" :key="iface.name"
class="flex items-center p-2 border rounded dark:border-zinc-700">
<input type="checkbox"
v-model="selectedInterfaces"
:value="iface.name"
class="h-4 w-4 text-blue-600 rounded border-gray-300 dark:border-zinc-600">
<label class="ml-2 text-sm text-gray-700 dark:text-zinc-200">
{{ iface.name }} ({{ iface.type }})
</label>
</div>
</div>
</div>
</div>
<div class="p-4 border-t dark:border-zinc-700 flex justify-end space-x-2">
<button @click="closeImportDialog"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-zinc-800 dark:text-zinc-200 dark:border-zinc-600 dark:hover:bg-zinc-700">
Cancel
</button>
<button @click="importSelectedInterfaces"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600">
Import Selected
</button>
</div>
</div>
</div>
</template> </template>
<script> <script>
@@ -135,10 +87,13 @@ import DialogUtils from "../../js/DialogUtils";
import ElectronUtils from "../../js/ElectronUtils"; import ElectronUtils from "../../js/ElectronUtils";
import Interface from "./Interface.vue"; import Interface from "./Interface.vue";
import Utils from "../../js/Utils"; import Utils from "../../js/Utils";
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
import DownloadUtils from "../../js/DownloadUtils";
export default { export default {
name: 'InterfacesPage', name: 'InterfacesPage',
components: { components: {
ImportInterfacesModal,
Interface, Interface,
}, },
data() { data() {
@@ -146,10 +101,6 @@ export default {
interfaces: {}, interfaces: {},
interfaceStats: {}, interfaceStats: {},
reloadInterval: null, reloadInterval: null,
showingImportDialog: false,
importableInterfaces: [],
selectedInterfaces: [],
importFile: null
}; };
}, },
beforeUnmount() { beforeUnmount() {
@@ -190,57 +141,13 @@ export default {
// update data // update data
const interfaces = response.data.interface_stats?.interfaces ?? []; const interfaces = response.data.interface_stats?.interfaces ?? [];
for(const iface of interfaces){ for(const iface of interfaces){
this.interfaceStats[iface.name] = iface; this.interfaceStats[iface.short_name] = iface;
} }
} catch(e) { } catch(e) {
// do nothing if failed to load interfaces // do nothing if failed to load interfaces
} }
}, },
findInterfaceStats(interfaceName) {
const interfaceDescription = this.getInterfaceDescription(interfaceName);
return this.interfaceStats[interfaceDescription];
},
getInterfaceDescription(interfaceName) {
// the interface-stats api returns interface names like the following;
//
// "AutoInterface[Default Interface]"
// "RNodeInterface[RNode LoRa Interface Fast]"
// "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]"
//
// however, the interfaces api just returns;
// "Default Interface"
// "RNode LoRa Interface Fast"
// "RNS Testnet Amsterdam"
//
// so we need to map the basic interface name to the former, so we can lookup stats for the interface
const iface = this.interfaces[interfaceName];
if(iface){
switch(iface.type){
case "TCPClientInterface": {
// yes, this is meant to be passed as TCPInterface, even though the interface type includes client...
// example: "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]";
return `TCPInterface[${interfaceName}/${iface.target_host}:${iface.target_port}]`;
}
case "TCPServerInterface": {
// example: "TCPServerInterface[TCP Server Interface/0.0.0.0:4242]";
return `TCPServerInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
}
case "UDPInterface": {
// example: "UDPInterface[UDP Interface/0.0.0.0:1234]";
return `UDPInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
}
default: {
// example: "RNodeInterface[RNode LoRa Interface Fast]",
return `${iface.type}[${interfaceName}]`;
}
}
}
return null;
},
async enableInterface(interfaceName) { async enableInterface(interfaceName) {
// enable interface // enable interface
@@ -284,7 +191,7 @@ export default {
async deleteInterface(interfaceName) { async deleteInterface(interfaceName) {
// ask user to confirm deleting conversation history // ask user to confirm deleting conversation history
if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){ if(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){
return; return;
} }
@@ -304,89 +211,43 @@ export default {
}, },
async exportInterfaces() { async exportInterfaces() {
try { try {
const response = await window.axios.get('/api/v1/reticulum/interfaces/export', {
responseType: 'blob' // fetch exported interfaces
}); const response = await window.axios.post('/api/v1/reticulum/interfaces/export');
const url = window.URL.createObjectURL(new Blob([response.data])); // download file to browser
const link = document.createElement('a'); DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
link.href = url;
link.setAttribute('download', 'reticulum_interfaces');
document.body.appendChild(link);
link.click();
link.remove();
} catch(e) { } catch(e) {
DialogUtils.alert("Failed to export interfaces"); DialogUtils.alert("Failed to export interfaces");
console.error(e); console.error(e);
} }
}, },
showImportDialog() { async exportInterface(interfaceName) {
this.showingImportDialog = true;
this.importableInterfaces = [];
this.selectedInterfaces = [];
this.importFile = null;
},
closeImportDialog() {
this.showingImportDialog = false;
},
async onFileSelected(event) {
const file = event.target.files[0];
if (!file) return;
this.importFile = file;
this.importableInterfaces = [];
this.selectedInterfaces = [];
const formData = new FormData();
formData.append('config', file);
try { try {
const response = await window.axios.post('/api/v1/reticulum/interfaces/preview', formData);
if (response.data.interfaces && response.data.interfaces.length > 0) { // fetch exported interfaces
this.importableInterfaces = response.data.interfaces; const response = await window.axios.post('/api/v1/reticulum/interfaces/export', {
this.selectedInterfaces = this.importableInterfaces.map(i => i.name); selected_interface_names: [
} else { interfaceName,
DialogUtils.alert("No valid interfaces found in configuration file"); ],
this.closeImportDialog(); });
}
// download file to browser
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
} catch(e) { } catch(e) {
DialogUtils.alert("Failed to parse configuration file"); DialogUtils.alert("Failed to export interface");
console.error(e);
this.closeImportDialog();
}
},
selectAllInterfaces() {
this.selectedInterfaces = this.importableInterfaces.map(i => i.name);
},
deselectAllInterfaces() {
this.selectedInterfaces = [];
},
async importSelectedInterfaces() {
if (!this.importFile) {
DialogUtils.alert("Please select a configuration file");
return;
}
if (this.selectedInterfaces.length === 0) {
DialogUtils.alert("Please select at least one interface to import");
return;
}
const formData = new FormData();
formData.append('config', this.importFile);
formData.append('selected_interfaces', JSON.stringify(this.selectedInterfaces));
try {
await window.axios.post('/api/v1/reticulum/interfaces/import', formData);
await this.loadInterfaces();
this.closeImportDialog();
DialogUtils.alert("Interfaces imported successfully");
} catch(e) {
const message = e.response?.data?.message || "Failed to import interfaces";
DialogUtils.alert(message);
console.error(e); console.error(e);
} }
} },
showImportInterfacesModal() {
this.$refs["import-interfaces-modal"].show();
},
onImportInterfacesModalDismissed() {
// reload interfaces as something may have been imported
this.loadInterfaces();
},
}, },
computed: { computed: {
isElectron() { isElectron() {
@@ -396,7 +257,7 @@ export default {
const results = []; const results = [];
for(const [interfaceName, iface] of Object.entries(this.interfaces)){ for(const [interfaceName, iface] of Object.entries(this.interfaces)){
iface._name = interfaceName; iface._name = interfaceName;
iface._stats = this.findInterfaceStats(interfaceName); iface._stats = this.interfaceStats[interfaceName];
results.push(iface); results.push(iface);
} }
return results; return results;

View File

@@ -73,7 +73,7 @@ export default {
async onDeleteMessageHistory() { async onDeleteMessageHistory() {
// ask user to confirm deleting conversation history // ask user to confirm deleting conversation history
if(!confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){ if(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
return; return;
} }

View File

@@ -72,15 +72,11 @@
<!-- close button --> <!-- close button -->
<div class="my-auto mr-2"> <div class="my-auto mr-2">
<div @click="close" class="cursor-pointer"> <IconButton @click="close">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<div> <path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> </svg>
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" /> </IconButton>
</svg>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -93,7 +89,7 @@
<div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-xl mt-3" :class="{ 'ml-auto pl-4 md:pl-16 items-end': chatItem.is_outbound, 'mr-auto pr-4 md:pr-16 items-start': !chatItem.is_outbound }"> <div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-xl mt-3" :class="{ 'ml-auto pl-4 md:pl-16 items-end': chatItem.is_outbound, 'mr-auto pr-4 md:pr-16 items-start': !chatItem.is_outbound }">
<!-- message content --> <!-- message content -->
<div @click="onChatItemClick(chatItem)" class="border border-gray-300 dark:border-zinc-800 rounded-xl shadow overflow-hidden" :class="[ chatItem.lxmf_message.state === 'failed' ? 'bg-red-500 text-white' : chatItem.is_outbound ? 'bg-[#3b82f6] text-white' : 'bg-[#efefef]' ]"> <div @click="onChatItemClick(chatItem)" class="border border-gray-300 dark:border-zinc-800 rounded-xl shadow overflow-hidden" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'bg-red-500 text-white' : chatItem.is_outbound ? 'bg-[#3b82f6] text-white' : 'bg-[#efefef]' ]">
<div class="w-full space-y-0.5 px-2.5 py-1"> <div class="w-full space-y-0.5 px-2.5 py-1">
@@ -167,7 +163,7 @@
</div> </div>
<!-- message state --> <!-- message state -->
<div v-if="chatItem.is_outbound" class="flex text-right" :class="[ chatItem.lxmf_message.state === 'failed' ? 'text-red-500' : 'text-gray-500' ]"> <div v-if="chatItem.is_outbound" class="flex text-right" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'text-red-500' : 'text-gray-500' ]">
<div class="flex ml-auto space-x-1"> <div class="flex ml-auto space-x-1">
<!-- state label --> <!-- state label -->
@@ -179,6 +175,7 @@
<span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'propagated'">to propagation node</span> <span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'propagated'">to propagation node</span>
<span v-if="chatItem.lxmf_message.state === 'sending'">{{ chatItem.lxmf_message.progress.toFixed(0) }}%</span> <span v-if="chatItem.lxmf_message.state === 'sending'">{{ chatItem.lxmf_message.progress.toFixed(0) }}%</span>
</span> </span>
<a v-if="chatItem.lxmf_message.state === 'outbound' || chatItem.lxmf_message.state === 'sending' || chatItem.lxmf_message.state === 'sent'" @click="cancelSendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">cancel?</a>
<a v-if="chatItem.lxmf_message.state === 'failed'" @click="retrySendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">retry?</a> <a v-if="chatItem.lxmf_message.state === 'failed'" @click="retrySendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">retry?</a>
</div> </div>
@@ -189,6 +186,13 @@
</svg> </svg>
</div> </div>
<!-- cancelled icon -->
<div v-else-if="chatItem.lxmf_message.state === 'cancelled'" class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z" clip-rule="evenodd" />
</svg>
</div>
<!-- failed icon --> <!-- failed icon -->
<div v-else-if="chatItem.lxmf_message.state === 'failed'" class="my-auto"> <div v-else-if="chatItem.lxmf_message.state === 'failed'" class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
@@ -312,6 +316,7 @@
<!-- text input --> <!-- text input -->
<textarea <textarea
ref="message-input"
id="message-input" id="message-input"
:readonly="isSendingMessage" :readonly="isSendingMessage"
v-model="newMessageText" v-model="newMessageText"
@@ -379,6 +384,13 @@
</div> </div>
<div class="font-semibold dark:text-white">No Active Chat</div> <div class="font-semibold dark:text-white">No Active Chat</div>
<div class='dark:text-zinc-300'>Select a Peer to start chatting!</div> <div class='dark:text-zinc-300'>Select a Peer to start chatting!</div>
<div class="mx-auto mt-2">
<button @click.stop="openLXMFAddress" type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
Enter an LXMF Address
</button>
</div>
</div> </div>
</template> </template>
@@ -395,10 +407,13 @@ import SendMessageButton from "./SendMessageButton.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue"; import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue"; import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
import AddImageButton from "./AddImageButton.vue"; import AddImageButton from "./AddImageButton.vue";
import IconButton from "../IconButton.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
export default { export default {
name: 'ConversationViewer', name: 'ConversationViewer',
components: { components: {
IconButton,
AddImageButton, AddImageButton,
ConversationDropDownMenu, ConversationDropDownMenu,
MaterialDesignIcon, MaterialDesignIcon,
@@ -596,6 +611,9 @@ export default {
} }
} }
}, },
openLXMFAddress() {
GlobalEmitter.emit("compose-new-message");
},
onLxmfMessageReceived(lxmfMessage) { onLxmfMessageReceived(lxmfMessage) {
// add inbound message to ui // add inbound message to ui
@@ -979,7 +997,7 @@ export default {
try { try {
// ask user to confirm deleting message // ask user to confirm deleting message
if(shouldConfirm && !confirm("Are you sure you want to delete this message? This can not be undone!")){ if(shouldConfirm && !await DialogUtils.confirm("Are you sure you want to delete this message? This can not be undone!")){
return; return;
} }
@@ -1038,7 +1056,11 @@ export default {
if(this.newMessageImage){ if(this.newMessageImage){
imageTotalSize = this.newMessageImage.size; imageTotalSize = this.newMessageImage.size;
fields["image"] = { fields["image"] = {
// Reticulum sends image type as "jpg" or "png" and not "image/jpg" or "image/png" // Reticulum sends image type as "jpg", "png", "webp" etc and not "image/jpg" or "image/png"
// From memory, Sideband would not display images if the image type has the "image/" prefix
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/docs/example_plugins/view.py#L78
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/main.py#L1900
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/ui/messages.py#L783
"image_type": this.newMessageImage.type.replace("image/", ""), "image_type": this.newMessageImage.type.replace("image/", ""),
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()), "image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
}; };
@@ -1060,7 +1082,7 @@ export default {
// ask user if they still want to send message if it may be rejected by sender // ask user if they still want to send message if it may be rejected by sender
if(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb if(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb
if(!confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){ if(!await DialogUtils.confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
return; return;
} }
} }
@@ -1106,6 +1128,38 @@ export default {
this.isSendingMessage = false; this.isSendingMessage = false;
} }
},
async cancelSendingMessage(chatItem) {
// get lxmf message hash else do nothing
const lxmfMessageHash = chatItem.lxmf_message.hash;
if(!lxmfMessageHash){
return;
}
try {
// cancel sending lxmf message
const response = await window.axios.post(`/api/v1/lxmf-messages/${lxmfMessageHash}/cancel`);
// get lxmf message from response
const lxmfMessage = response.data.lxmf_message;
if(!lxmfMessage){
return;
}
// update lxmf message in ui
this.onLxmfMessageUpdated(lxmfMessage);
} catch(e) {
// show error
const message = e.response?.data?.message ?? "failed to cancel message";
DialogUtils.alert(message);
console.log(e);
}
}, },
async retrySendingMessage(chatItem) { async retrySendingMessage(chatItem) {
@@ -1159,10 +1213,10 @@ export default {
clearFileInput: function() { clearFileInput: function() {
this.$refs["file-input"].value = null; this.$refs["file-input"].value = null;
}, },
removeImageAttachment: function() { async removeImageAttachment() {
// ask user to confirm removing image attachment // ask user to confirm removing image attachment
if(!confirm("Are you sure you want to remove this image attachment?")){ if(!await DialogUtils.confirm("Are you sure you want to remove this image attachment?")){
return; return;
} }
@@ -1194,7 +1248,7 @@ export default {
} }
// ask user to confirm recording new audio attachment, if an existing audio attachment exists // ask user to confirm recording new audio attachment, if an existing audio attachment exists
if(this.newMessageAudio && !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){ if(this.newMessageAudio && !await DialogUtils.confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
return; return;
} }
@@ -1336,10 +1390,10 @@ export default {
} }
}, },
removeAudioAttachment: function() { async removeAudioAttachment() {
// ask user to confirm removing audio attachment // ask user to confirm removing audio attachment
if(!confirm("Are you sure you want to remove this audio attachment?")){ if(!await DialogUtils.confirm("Are you sure you want to remove this audio attachment?")){
return; return;
} }
@@ -1353,7 +1407,22 @@ export default {
}); });
}, },
addNewLine: function() { addNewLine: function() {
this.newMessageText += "\n";
// get cursor position for message input
const input = this.$refs["message-input"];
const cursorPosition = input.selectionStart;
// insert a newline character after the cursor position
const text = this.newMessageText;
this.newMessageText = text.slice(0, cursorPosition) + '\n' + text.slice(cursorPosition);
// move cursor to the position after the added newline
const newCursorPosition = cursorPosition + 1;
this.$nextTick(() => {
input.selectionStart = newCursorPosition;
input.selectionEnd = newCursorPosition;
});
}, },
onEnterPressed: function() { onEnterPressed: function() {

View File

@@ -15,7 +15,7 @@
:my-lxmf-address-hash="config?.lxmf_address_hash" :my-lxmf-address-hash="config?.lxmf_address_hash"
:selected-peer="selectedPeer" :selected-peer="selectedPeer"
:conversations="conversations" :conversations="conversations"
@close="selectedPeer = null" @close="onCloseConversationViewer"
@reload-conversations="getConversations"/> @reload-conversations="getConversations"/>
</div> </div>
@@ -38,6 +38,9 @@ export default {
ConversationViewer, ConversationViewer,
MessagesSidebar, MessagesSidebar,
}, },
props: {
destinationHash: String,
},
data() { data() {
return { return {
@@ -76,6 +79,11 @@ export default {
this.getConversations(); this.getConversations();
}, 5000); }, 5000);
// compose message if a destination hash was provided on page load
if(this.destinationHash){
this.onComposeNewMessage(this.destinationHash);
}
}, },
methods: { methods: {
async onComposeNewMessage(destinationHash) { async onComposeNewMessage(destinationHash) {
@@ -88,6 +96,14 @@ export default {
} }
} }
// if user provided an address with an "lxmf@" prefix, lets remove that to get the raw destination hash
if(destinationHash.startsWith("lxmf@")){
destinationHash = destinationHash.replace("lxmf@", "");
}
// fetch updated announce as we might be composing new message before we loaded the announces list
await this.getLxmfDeliveryAnnounce(destinationHash);
// attempt to find existing peer so we can show their name // attempt to find existing peer so we can show their name
const existingPeer = this.peers[destinationHash]; const existingPeer = this.peers[destinationHash];
if(existingPeer){ if(existingPeer){
@@ -160,6 +176,28 @@ export default {
console.log(e); console.log(e);
} }
}, },
async getLxmfDeliveryAnnounce(destinationHash) {
try {
// fetch announce for destination hash
const response = await window.axios.get(`/api/v1/announces`, {
params: {
destination_hash: destinationHash,
limit: 1,
},
});
// update ui
const lxmfDeliveryAnnounces = response.data.announces;
for(const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces){
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
}
} catch(e) {
// do nothing if failed to load announce
console.log(e);
}
},
async getConversations() { async getConversations() {
try { try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`); const response = await window.axios.get(`/api/v1/lxmf/conversations`);
@@ -173,7 +211,18 @@ export default {
this.peers[announce.destination_hash] = announce; this.peers[announce.destination_hash] = announce;
}, },
onPeerClick: function(peer) { onPeerClick: function(peer) {
// update selected peer
this.selectedPeer = peer; this.selectedPeer = peer;
// update current route
this.$router.replace({
name: "messages",
params: {
destinationHash: peer.destination_hash,
},
});
}, },
onConversationClick: function(conversation) { onConversationClick: function(conversation) {
@@ -184,6 +233,17 @@ export default {
this.$refs["conversation-viewer"].markConversationAsRead(conversation); this.$refs["conversation-viewer"].markConversationAsRead(conversation);
}, },
onCloseConversationViewer: function() {
// clear selected peer
this.selectedPeer = null;
// update current route
this.$router.replace({
name: "messages",
});
},
}, },
watch: { watch: {
conversations() { conversations() {

View File

@@ -165,6 +165,60 @@ export default {
}, },
}); });
// handle double click on a node
this.network.on("doubleClick", (params) => {
// get clicked node id
const clickedNodeId = params.nodes[0];
if(!clickedNodeId){
return;
}
// find node by id
const node = this.network.body.nodes[clickedNodeId];
if(!node){
return;
}
// handle double click on an announce node
if(node.options.group === "announce"){
// get announce
const announce = node.options._announce;
if(!announce) {
return;
}
// handle double click on lxmf.delivery node
if(announce.aspect === "lxmf.delivery"){
// go to messages page for this destination hash
this.$router.push({
name: "messages",
params: {
destinationHash: announce.destination_hash,
},
});
}
// handle double click on nomadnetwork.node node
if(announce.aspect === "nomadnetwork.node"){
// go to nomadnetwork page for this destination hash
this.$router.push({
name: "nomadnetwork",
params: {
destinationHash: announce.destination_hash,
},
});
}
}
});
// update network // update network
await this.update(); await this.update();
@@ -358,6 +412,9 @@ export default {
} }
// attach announce to this node
node._announce = announce;
// add node // add node
nodes.push(node); nodes.push(node);

View File

@@ -3,23 +3,62 @@
<!-- nomadnetwork sidebar --> <!-- nomadnetwork sidebar -->
<NomadNetworkSidebar <NomadNetworkSidebar
:nodes="nodes" :nodes="nodes"
:favourites="favourites"
:selected-destination-hash="selectedNode?.destination_hash" :selected-destination-hash="selectedNode?.destination_hash"
@node-click="onNodeClick"/> @node-click="onNodeClick"
@rename-favourite="onRenameFavourite"
@remove-favourite="onRemoveFavourite"/>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950"> <div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
<!-- node --> <!-- node -->
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900"> <div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
<!-- header --> <!-- header -->
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800"> <div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
<!-- favourite button -->
<div class="my-auto mr-2">
<div v-if="isFavourite(selectedNode.destination_hash)" @click="removeFavourite(selectedNode)" class="cursor-pointer">
<div class="flex text-yellow-500 dark:text-yellow-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
<div v-else @click="addFavourite(selectedNode)" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</div>
</div>
</div>
</div>
<!-- node info --> <!-- node info -->
<div class="my-auto dark:text-gray-100"> <div class="my-auto dark:text-gray-100">
<span class="font-semibold">{{ selectedNode.display_name }}</span> <span class="font-semibold">{{ selectedNode.display_name }}</span>
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span> <span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
</div> </div>
<!-- close button --> <!-- identify button -->
<div class="my-auto ml-auto mr-2"> <div class="my-auto ml-auto mr-2">
<div @click="selectedNode = null" class="cursor-pointer"> <div @click="identify(selectedNode.destination_hash)" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 0 0 4.5 10.5a7.464 7.464 0 0 1-1.15 3.993m1.989 3.559A11.209 11.209 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.6 9.75m6.633-4.596a18.666 18.666 0 0 1-2.485 5.33" />
</svg>
</div>
</div>
</div>
</div>
<!-- close button -->
<div class="my-auto mr-2">
<div @click="onCloseNodeViewer" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full"> <div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div> <div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
@@ -33,7 +72,7 @@
<!-- browser navigation --> <!-- browser navigation -->
<div class="flex w-full border-gray-300 dark:border-zinc-800 border-b p-2"> <div class="flex w-full border-gray-300 dark:border-zinc-800 border-b p-2">
<button @click="loadNodePage(selectedNode.destination_hash, '/page/index.mu')" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer"> <button @click="loadNodePage(selectedNode.destination_hash, defaultNodePagePath)" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
</svg> </svg>
@@ -43,6 +82,11 @@
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
</svg> </svg>
</button> </button>
<button @click="toggleNodePageSource" type="button" title="Toggle Source Code" class="ml-1 my-auto text-gray-500 dark:text-gray-300 rounded p-1 cursor-pointer" :class="[ isShowingNodePageSource ? 'bg-green-500 hover:bg-green-600 text-white' : 'bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' ]">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
</button>
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' : 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-900']" class="ml-1 my-auto rounded p-1 cursor-pointer"> <button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' : 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-900']" class="ml-1 my-auto rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
@@ -69,7 +113,7 @@
</div> </div>
<div class="my-auto">Loading {{ nodePageProgress }}%</div> <div class="my-auto">Loading {{ nodePageProgress }}%</div>
</div> </div>
<pre v-else v-html="nodePageContent" class="h-full text-wrap"></pre> <pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
</div> </div>
<!-- file download bottom bar --> <!-- file download bottom bar -->
@@ -93,6 +137,13 @@
</div> </div>
<div class="font-semibold">No Active Node</div> <div class="font-semibold">No Active Node</div>
<div>Select a Node to start browsing!</div> <div>Select a Node to start browsing!</div>
<div class="mx-auto mt-2">
<button @click.stop="openUrl" type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
Open a Nomadnet URL
</button>
</div>
</div> </div>
</div> </div>
@@ -123,7 +174,7 @@ pre a:hover {
<script> <script>
import MicronParser from "../../js/MicronParser"; import MicronParser from "micron-parser";
import DialogUtils from "../../js/DialogUtils"; import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection"; import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue"; import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
@@ -134,14 +185,23 @@ export default {
components: { components: {
NomadNetworkSidebar, NomadNetworkSidebar,
}, },
props: {
destinationHash: String,
},
data() { data() {
return { return {
reloadInterval: null,
nodes: {}, nodes: {},
selectedNode: null, selectedNode: null,
selectedNodePath: null, selectedNodePath: null,
favourites: [],
isLoadingNodePage: false, isLoadingNodePage: false,
isShowingNodePageSource: false,
defaultNodePagePath: "/page/index.mu",
nodePageRequestSequence: 0, nodePageRequestSequence: 0,
nodePagePath: null, nodePagePath: null,
nodePagePathUrlInput: null, nodePagePathUrlInput: null,
@@ -150,7 +210,6 @@ export default {
nodePagePathHistory: [], nodePagePathHistory: [],
nodePageCache: {}, nodePageCache: {},
isDownloadingNodeFile: false, isDownloadingNodeFile: false,
nodeFilePath: null, nodeFilePath: null,
nodeFileProgress: 0, nodeFileProgress: 0,
@@ -161,23 +220,59 @@ export default {
}; };
}, },
beforeUnmount() { beforeUnmount() {
clearInterval(this.reloadInterval);
// stop listening for websocket messages // stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage); WebSocketConnection.off("message", this.onWebsocketMessage);
// stop listening for element clicks
window.document.removeEventListener('click', this.onElementClick);
}, },
mounted() { mounted() {
// listen for websocket messages // listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage); WebSocketConnection.on("message", this.onWebsocketMessage);
// fixme: this is called by the micron-parser.js // listen for element clicks
window.onNodePageUrlClick = (url, options = null) => { window.document.addEventListener('click', this.onElementClick);
this.onNodePageUrlClick(url, options);
};
// load nomadnetwork node if a destination hash was provided on page load
if(this.destinationHash){
(async () => {
// fetch updated announce as we are probably loading node page before we loaded the announces list
await this.getNomadnetworkNodeAnnounce(this.destinationHash);
await this.onNodePageUrlClick(`${this.destinationHash}:${this.defaultNodePagePath}`);
})();
}
this.getFavourites();
this.getNomadnetworkNodeAnnounces(); this.getNomadnetworkNodeAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.getFavourites();
}, 5000);
}, },
methods: { methods: {
onElementClick(event) {
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
const element = event.target.closest('[data-action="openNode"]');
if(!element){
return;
}
// get the destination and fields
const destination = element.getAttribute("data-destination");
const fields = element.getAttribute("data-fields");
// navigate to destination
this.onNodePageUrlClick(destination, fields);
},
async onWebsocketMessage(message) { async onWebsocketMessage(message) {
const json = JSON.parse(message.data); const json = JSON.parse(message.data);
switch(json.type){ switch(json.type){
@@ -265,6 +360,54 @@ export default {
onDestinationPathClick: function(path) { onDestinationPathClick: function(path) {
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`); DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
}, },
async getFavourites() {
try {
const response = await window.axios.get("/api/v1/favourites", {
params: {
aspect: "nomadnetwork.node",
},
});
this.favourites = response.data.favourites;
} catch(e) {
// do nothing if failed to load favourites
console.log(e);
}
},
isFavourite(destinationHash) {
return this.favourites.find((favourite) => {
return favourite.destination_hash === destinationHash;
}) != null;
},
async addFavourite(node) {
// add to favourites
try {
await window.axios.post("/api/v1/favourites/add", {
destination_hash: node.destination_hash,
display_name: node.display_name,
aspect: "nomadnetwork.node",
});
} catch(e) {
console.log(e);
}
// update favourites
this.getFavourites();
},
async removeFavourite(node) {
// remove from favourites
try {
await window.axios.delete(`/api/v1/favourites/${node.destination_hash}`);
} catch(e) {
console.log(e);
}
// update favourites
this.getFavourites();
},
async getNomadnetworkNodeAnnounces() { async getNomadnetworkNodeAnnounces() {
try { try {
@@ -272,6 +415,7 @@ export default {
const response = await window.axios.get(`/api/v1/announces`, { const response = await window.axios.get(`/api/v1/announces`, {
params: { params: {
aspect: "nomadnetwork.node", aspect: "nomadnetwork.node",
limit: 500, // limit ui to showing 500 latest announces
}, },
}); });
@@ -286,11 +430,53 @@ export default {
console.log(e); console.log(e);
} }
}, },
async getNomadnetworkNodeAnnounce(destinationHash) {
try {
// fetch announces for "nomadnetwork.node" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
destination_hash: destinationHash,
limit: 1,
},
});
// update ui
const nodeAnnounces = response.data.announces;
for(const nodeAnnounce of nodeAnnounces){
this.updateNodeFromAnnounce(nodeAnnounce);
}
} catch(e) {
// do nothing if failed to load announce
console.log(e);
}
},
updateNodeFromAnnounce: function(announce) { updateNodeFromAnnounce: function(announce) {
this.nodes[announce.destination_hash] = announce; this.nodes[announce.destination_hash] = announce;
}, },
async openUrl() {
// ask for url
const url = await DialogUtils.prompt("Enter a Nomadnet URL");
if(!url){
return;
}
// navigate to the url
await this.onNodePageUrlClick(url);
},
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) { async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
// update current route
this.$router.replace({
name: "nomadnetwork",
params: {
destinationHash: destinationHash,
},
});
// get new sequence for this page load // get new sequence for this page load
const seq = ++this.nodePageRequestSequence; const seq = ++this.nodePageRequestSequence;
@@ -324,6 +510,7 @@ export default {
// if page is cache, we can just return it now // if page is cache, we can just return it now
if(cachedNodePageContent != null){ if(cachedNodePageContent != null){
this.nodePageContent = cachedNodePageContent; this.nodePageContent = cachedNodePageContent;
this.renderPageContent(pagePath, cachedNodePageContent);
this.isLoadingNodePage = false; this.isLoadingNodePage = false;
return; return;
} }
@@ -332,31 +519,21 @@ export default {
this.downloadNomadNetPage(destinationHash, pagePath, fieldData, (pageContent) => { this.downloadNomadNetPage(destinationHash, pagePath, fieldData, (pageContent) => {
const muParser = new MicronParser();
// do nothing if callback is for a previous request // do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){ if(seq !== this.nodePageRequestSequence){
console.log("ignoring page content callback for previous page request") console.log("ignoring page content callback for previous page request")
return; return;
} }
// check if page url ends with .mu but remove page data first // update page content
// address:/page/index.mu`Data=123 this.nodePageContent = pageContent;
const [ pagePathWithoutData, pageData ] = pagePath.split("`");
// convert micron to html if page ends with .mu extension
// otherwise, we will just serve the content as is
if(pagePathWithoutData.endsWith(".mu")){
this.nodePageContent = muParser.convertMicronToHtml(pageContent);
} else {
this.nodePageContent = pageContent;
}
// update cache // update cache
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`; const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent; this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
// update page content // update page content
this.renderPageContent(pagePath, pageContent);
this.isLoadingNodePage = false; this.isLoadingNodePage = false;
// update node path // update node path
@@ -390,6 +567,35 @@ export default {
}); });
}, },
renderPageContent(path, content) {
// render page content if we aren't viewing source
if(!this.isShowingNodePageSource){
// check if page url ends with .mu but remove page data first
// address:/page/index.mu`Data=123
const [ pagePathWithoutData ] = path.split("`");
// convert micron to html if page ends with .mu extension
if(pagePathWithoutData.endsWith(".mu")){
const muParser = new MicronParser();
return muParser.convertMicronToHtml(content);
}
}
// otherwise, we will just serve the raw content, making sure to prevent injecting html
return content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
},
toggleNodePageSource() {
this.isShowingNodePageSource = !this.isShowingNodePageSource;
},
async reloadNodePage() { async reloadNodePage() {
// reload current node page without adding to history and without using cache // reload current node page without adding to history and without using cache
@@ -416,9 +622,9 @@ export default {
// remove leading ":" // remove leading ":"
var path = url.substring(1); var path = url.substring(1);
// if page path is empty we should load "/page/index.mu" // if page path is empty we should load default page path
if(path === ""){ if(path === ""){
path = "/page/index.mu"; path = this.defaultNodePagePath;
} }
return { return {
@@ -448,7 +654,7 @@ export default {
if(url.length === 32){ if(url.length === 32){
return { return {
destination_hash: url, destination_hash: url,
path: "/page/index.mu", path: this.defaultNodePagePath,
}; };
} }
@@ -520,8 +726,12 @@ export default {
if(url.startsWith("lxmf@")){ if(url.startsWith("lxmf@")){
const destinationHash = url.replace("lxmf@", ""); const destinationHash = url.replace("lxmf@", "");
if(destinationHash.length === 32){ if(destinationHash.length === 32){
await this.$router.push({ name: "messages" }); await this.$router.push({
GlobalEmitter.emit("compose-new-message", destinationHash); name: "messages",
params: {
destinationHash: destinationHash,
},
});
return; return;
} }
} }
@@ -620,8 +830,58 @@ export default {
}, },
onNodeClick: function(node) { onNodeClick: function(node) {
// update selected node
this.selectedNode = node; this.selectedNode = node;
this.loadNodePage(node.destination_hash, "/page/index.mu");
// load default node page
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
},
async onRenameFavourite(favourite) {
// ask user for new display name
const displayName = await DialogUtils.prompt("Rename this favourite");
if(displayName == null){
return;
}
try {
// rename on server
await axios.post(`/api/v1/favourites/${favourite.destination_hash}/rename`, {
display_name: displayName,
});
// reload favourites
await this.getFavourites();
} catch(e) {
console.log(e);
DialogUtils.alert("Failed to rename favourite");
}
},
async onRemoveFavourite(favourite) {
// ask user to confirm
if(!await DialogUtils.confirm("Are you sure you want to remove this favourite?")){
return;
}
this.removeFavourite(favourite);
},
onCloseNodeViewer: function() {
// clear selected node
this.selectedNode = null;
// update current route
this.$router.replace({
name: "nomadnetwork",
});
}, },
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) { getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
return `${destinationHash}:${pagePath}`; return `${destinationHash}:${pagePath}`;
@@ -647,6 +907,24 @@ export default {
} }
}, },
async identify(destinationHash) {
try {
// ask user to confirm
if(!await DialogUtils.confirm("Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent.")){
return;
}
// identify self to nomadnetwork node
await window.axios.post(`/api/v1/nomadnetwork/${destinationHash}/identify`);
// reload page
this.reloadNodePage();
} catch(e) {
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
}
},
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) { downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
try { try {
@@ -694,6 +972,9 @@ export default {
console.error(e); console.error(e);
} }
}, },
renderedNodePageContent() {
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
},
}, },
} }
</script> </script>

View File

@@ -1,6 +1,99 @@
<template> <template>
<div class="flex flex-col w-80 min-w-80"> <div class="flex flex-col w-80 min-w-80">
<div class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
<!-- tabs -->
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
<div class="-mb-px flex">
<div @click="tab = 'favourites'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'favourites' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Favourites</div>
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Announces</div>
</div>
</div>
<!-- favourites -->
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
<!-- search -->
<div v-if="favourites.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} Favourites...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
</div>
<!-- peers -->
<div class="flex h-full overflow-y-auto">
<div v-if="searchedFavourites.length > 0" class="w-full">
<div @click="onFavouriteClick(favourite)" v-for="favourite of searchedFavourites" class="flex cursor-pointer p-2 border-l-2" :class="[ favourite.destination_hash === selectedDestinationHash ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400' : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600' ]">
<div class="my-auto mr-2">
<div class="bg-gray-200 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 p-2 rounded">
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
</div>
</div>
<div>
<div class="text-gray-900 dark:text-gray-100">{{ favourite.display_name }}</div>
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatDestinationHash(favourite.destination_hash) }}</div>
</div>
<div class="ml-auto my-auto">
<DropDownMenu>
<template v-slot:button>
<IconButton class="bg-transparent dark:bg-transparent">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</svg>
</IconButton>
</template>
<template v-slot:items>
<!-- rename button -->
<DropDownMenuItem @click="onRenameFavourite(favourite)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M5.25 2.25a3 3 0 0 0-3 3v4.318a3 3 0 0 0 .879 2.121l9.58 9.581c.92.92 2.39 1.186 3.548.428a18.849 18.849 0 0 0 5.441-5.44c.758-1.16.492-2.629-.428-3.548l-9.58-9.581a3 3 0 0 0-2.122-.879H5.25ZM6.375 7.5a1.125 1.125 0 1 0 0-2.25 1.125 1.125 0 0 0 0 2.25Z" clip-rule="evenodd" />
</svg>
<span>Rename Favourite</span>
</DropDownMenuItem>
<!-- remove favourite button -->
<div>
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
</svg>
<span class="text-red-500">Remove Favourite</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
</div>
</div>
</div>
<div v-else class="mx-auto my-auto text-center leading-5">
<!-- no favourites at all -->
<div v-if="favourites.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</div>
<div class="font-semibold">No Favourites</div>
<div>Discover nodes on the Announces tab.</div>
</div>
<!-- is searching, but no results -->
<div v-if="favouritesSearchTerm !== '' && favourites.length > 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<div class="font-semibold">No Search Results</div>
<div>Your search didn't match any Favourites!</div>
</div>
</div>
</div>
</div>
<!-- announces -->
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
<!-- search --> <!-- search -->
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800"> <div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
<input <input
@@ -58,6 +151,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -65,16 +159,22 @@
import Utils from "../../js/Utils"; import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue"; import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DropDownMenu from "../DropDownMenu.vue";
import IconButton from "../IconButton.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
export default { export default {
name: 'NomadNetworkSidebar', name: 'NomadNetworkSidebar',
components: {MaterialDesignIcon}, components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
props: { props: {
nodes: Object, nodes: Object,
favourites: Array,
selectedDestinationHash: String, selectedDestinationHash: String,
}, },
data() { data() {
return { return {
tab: "favourites",
favouritesSearchTerm: "",
nodesSearchTerm: "", nodesSearchTerm: "",
}; };
}, },
@@ -82,9 +182,21 @@ export default {
onNodeClick(node) { onNodeClick(node) {
this.$emit("node-click", node); this.$emit("node-click", node);
}, },
onFavouriteClick(favourite) {
this.onNodeClick(favourite);
},
onRenameFavourite(favourite) {
this.$emit("rename-favourite", favourite);
},
onRemoveFavourite(favourite) {
this.$emit("remove-favourite", favourite);
},
formatTimeAgo: function(datetimeString) { formatTimeAgo: function(datetimeString) {
return Utils.formatTimeAgo(datetimeString); return Utils.formatTimeAgo(datetimeString);
}, },
formatDestinationHash: function(destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
}, },
computed: { computed: {
nodesCount() { nodesCount() {
@@ -107,6 +219,15 @@ export default {
return matchesDisplayName || matchesDestinationHash; return matchesDisplayName || matchesDestinationHash;
}); });
}, },
searchedFavourites() {
return this.favourites.filter((favourite) => {
const search = this.favouritesSearchTerm.toLowerCase();
const matchesDisplayName = favourite.display_name.toLowerCase().includes(search);
const matchesCustomDisplayName = favourite.custom_display_name?.toLowerCase()?.includes(search) === true;
const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
}, },
} }
</script> </script>

View File

@@ -145,7 +145,7 @@ export default {
} }
// confirm user wants to update their icon // confirm user wants to update their icon
if(!confirm("Are you sure you want to set this as your profile icon?")){ if(!await DialogUtils.confirm("Are you sure you want to set this as your profile icon?")){
return; return;
} }
@@ -160,7 +160,7 @@ export default {
async removeProfileIcon() { async removeProfileIcon() {
// confirm user wants to remove their icon // confirm user wants to remove their icon
if(!confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){ if(!await DialogUtils.confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
return; return;
} }

View File

@@ -19,6 +19,25 @@
</div> </div>
</div> </div>
<!-- transport mode -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Transport Mode</div>
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
<div class="p-2">
<div class="flex items-start">
<div class="flex items-center h-5">
<input v-model="config.is_transport_enabled" @change="onIsTransportEnabledChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
</div>
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Enable Transport Mode</label>
</div>
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, MeshChat will route traffic for other peers, respond to path requests and pass announces over your interfaces.</div>
<div class="text-sm text-gray-700 dark:text-gray-300">Changing this setting requires you to restart MeshChat.</div>
</div>
</div>
</div>
<!-- interfaces --> <!-- interfaces -->
<div class="bg-white dark:bg-zinc-800 rounded shadow"> <div class="bg-white dark:bg-zinc-800 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Interfaces</div> <div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Interfaces</div>
@@ -150,6 +169,7 @@
<script> <script>
import Utils from "../../js/Utils"; import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection"; import WebSocketConnection from "../../js/WebSocketConnection";
import DialogUtils from "../../js/DialogUtils";
export default { export default {
name: 'SettingsPage', name: 'SettingsPage',
@@ -247,6 +267,25 @@ export default {
"lxmf_preferred_propagation_node_auto_sync_interval_seconds": this.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds, "lxmf_preferred_propagation_node_auto_sync_interval_seconds": this.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds,
}); });
}, },
async onIsTransportEnabledChange() {
if(this.config.is_transport_enabled){
try {
const response = await window.axios.post("/api/v1/reticulum/enable-transport");
DialogUtils.alert(response.data.message);
} catch(e) {
DialogUtils.alert("Failed to enable transport mode!");
console.log(e);
}
} else {
try {
const response = await window.axios.post("/api/v1/reticulum/disable-transport");
DialogUtils.alert(response.data.message);
} catch(e) {
DialogUtils.alert("Failed to disable transport mode!");
console.log(e);
}
}
},
formatSecondsAgo: function(seconds) { formatSecondsAgo: function(seconds) {
return Utils.formatSecondsAgo(seconds); return Utils.formatSecondsAgo(seconds);
}, },

View File

@@ -10,6 +10,16 @@ class DialogUtils {
} }
} }
static confirm(message) {
if(window.electron){
// running inside electron, use ipc confirm
return window.electron.confirm(message);
} else {
// running inside normal browser, use browser alert
return window.confirm(message);
}
}
static async prompt(message) { static async prompt(message) {
if(window.electron){ if(window.electron){
// running inside electron, use ipc prompt // running inside electron, use ipc prompt

View File

@@ -0,0 +1,28 @@
class DownloadUtils {
static downloadFile(filename, blob) {
// create object url for blob
const objectUrl = URL.createObjectURL(blob);
// create hidden link element to download blob
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
link.style.display = "none";
document.body.append(link);
// click link to download file in browser
link.click();
// link element is no longer needed
link.remove();
// revoke object url to clear memory
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
}
}
export default DownloadUtils;

View File

@@ -2,6 +2,13 @@ import moment from "moment";
class Utils { class Utils {
static formatDestinationHash(destinationHashHex) {
const bytesPerSide = 4;
const leftSide = destinationHashHex.substring(0, bytesPerSide * 2);
const rightSide = destinationHashHex.substring(destinationHashHex.length - bytesPerSide * 2);
return `<${leftSide}...${rightSide}>`
}
static formatBytes(bytes) { static formatBytes(bytes) {
if(bytes === 0){ if(bytes === 0){
@@ -143,7 +150,7 @@ class Utils {
static isInterfaceEnabled(iface) { static isInterfaceEnabled(iface) {
const rawValue = iface.enabled ?? iface.interface_enabled; const rawValue = iface.enabled ?? iface.interface_enabled;
const value = rawValue?.toLowerCase(); const value = rawValue?.toString()?.toLowerCase();
return value === "on" || value === "yes" || value === "true"; return value === "on" || value === "yes" || value === "true";
} }

View File

@@ -3,8 +3,18 @@ import mitt from 'mitt';
class WebSocketConnection { class WebSocketConnection {
constructor() { constructor() {
this.emitter = mitt(); this.emitter = mitt();
this.reconnect(); this.reconnect();
/**
* ping websocket server every 30 seconds
* this helps to prevent the underlying tcp connection from going stale when there's no traffic for a long time
*/
setInterval(() => {
this.ping();
}, 30000);
} }
// add event listener // add event listener
@@ -47,6 +57,16 @@ class WebSocketConnection {
} }
} }
ping() {
try {
this.send(JSON.stringify({
"type": "ping",
}));
} catch(e) {
// ignore error
}
}
} }
export default new WebSocketConnection(); export default new WebSocketConnection();

View File

@@ -46,7 +46,8 @@ const router = createRouter({
}, },
{ {
name: "messages", name: "messages",
path: '/messages', path: '/messages/:destinationHash?',
props: true,
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")), component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
}, },
{ {
@@ -56,7 +57,8 @@ const router = createRouter({
}, },
{ {
name: "nomadnetwork", name: "nomadnetwork",
path: '/nomadnetwork', path: '/nomadnetwork/:destinationHash?',
props: true,
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")), component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
}, },
{ {

View File

@@ -110,7 +110,7 @@
<div class="border-t px-2 py-1 text-sm"> <div class="border-t px-2 py-1 text-sm">
<div> <div>
<span>Download Firmware</span> <span>Download Firmware</span>
<span v-if="selectedProduct && selectedModel && recommendedFirmwareFilename">: {{ recommendedFirmwareFilename }}</span> <span v-if="selectedProduct && selectedModel && recommendedFirmwareFilename">: <a target="_blank" :href="`https://github.com/markqvist/RNode_Firmware/releases/latest/download/${recommendedFirmwareFilename}`" class="text-blue-500 hover:underline">{{ recommendedFirmwareFilename }}</a></span>
</div> </div>
<div class="space-x-1"> <div class="space-x-1">
<a target="_blank" href="https://github.com/markqvist/RNode_Firmware/releases" class="text-blue-500 hover:underline">Official Firmware</a> <a target="_blank" href="https://github.com/markqvist/RNode_Firmware/releases" class="text-blue-500 hover:underline">Official Firmware</a>
@@ -312,6 +312,48 @@
</div> </div>
<div class="border bg-gray-50 rounded shadow">
<div class="border-b px-2 py-1">
Configure Display (optional)
</div>
<div class="p-3 space-y-2">
<div class="flex space-x-1">
<div class="my-auto">Rotation</div>
<button @click="setDisplayRotation(0)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
0
</button>
<button @click="setDisplayRotation(1)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
1
</button>
<button @click="setDisplayRotation(2)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
2
</button>
<button @click="setDisplayRotation(3)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
3
</button>
</div>
<div class="flex space-x-1">
<div class="my-auto">Reconditioning</div>
<button @click="startDisplayReconditioning" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
Start
</button>
<button @click="reboot" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
Stop
</button>
</div>
</div>
<div class="border-t px-2 py-1 text-sm">
<div>Setting display rotation requires firmware v1.80+</div>
</div>
</div>
</div> </div>
<!-- setup web-serial-polyfill --> <!-- setup web-serial-polyfill -->
@@ -334,6 +376,8 @@
data() { data() {
return { return {
rnode: null,
isFlashing: false, isFlashing: false,
flashingProgress: 0, flashingProgress: 0,
@@ -397,6 +441,22 @@
}, },
}, },
}, },
{
name: "Heltec T114",
id: ROM.PRODUCT_HELTEC_T114,
platform: ROM.PLATFORM_NRF52,
models: [
{
id: ROM.MODEL_C6,
name: "470-510 MHz (HT-n5262-LF)",
},
{
id: ROM.MODEL_C7,
name: "863-928 MHz (HT-n5262-HF)",
},
],
firmware_filename: "rnode_firmware_heltec_t114.zip",
},
{ {
name: "LilyGO LoRa32 v1.0", name: "LilyGO LoRa32 v1.0",
id: ROM.PRODUCT_T32_10, id: ROM.PRODUCT_T32_10,
@@ -587,6 +647,21 @@
}, },
}, },
}, },
{
id: ROM.MODEL_AC,
name: "2.4 GHz (with SX1280 chip)",
firmware_filename: "rnode_firmware_t3s3_sx1280_pa.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_t3s3_sx1280_pa.boot_app0",
"0x0": "rnode_firmware_t3s3_sx1280_pa.bootloader",
"0x10000": "rnode_firmware_t3s3_sx1280_pa.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_t3s3_sx1280_pa.partitions",
},
},
},
], ],
}, },
{ {
@@ -714,11 +789,11 @@
platform: ROM.PLATFORM_NRF52, platform: ROM.PLATFORM_NRF52,
models: [ models: [
{ {
id: ROM.MODEL_T4, id: ROM.MODEL_16,
name: "433 MHz", name: "433 MHz",
}, },
{ {
id: ROM.MODEL_T9, id: ROM.MODEL_17,
name: "868 MHz / 915 MHz / 923 MHz", name: "868 MHz / 915 MHz / 923 MHz",
}, },
], ],
@@ -858,11 +933,37 @@
return null; return null;
} }
// close any existing rnode connection
if(this.rnode){
await this.rnode.close();
this.rnode = null;
}
// ask user to select device // ask user to select device
return await navigator.serial.requestPort({ return await navigator.serial.requestPort({
filters: [], filters: [],
}); });
},
async askForRNode() {
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return false;
}
// check if device is an rnode
this.rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await this.rnode.detect();
if(!isRNode){
await this.rnode.close();
alert("Selected device is not an RNode!");
return false;
}
return this.rnode;
}, },
async enterDfuMode() { async enterDfuMode() {
@@ -1069,17 +1170,9 @@
}, },
async detect() { async detect() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1122,17 +1215,9 @@
}, },
async reboot() { async reboot() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1210,17 +1295,9 @@
}, },
async readDisplay() { async readDisplay() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1236,17 +1313,9 @@
}, },
async dumpEeprom() { async dumpEeprom() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1264,22 +1333,15 @@
}, },
async wipeEeprom() { async wipeEeprom() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return; return;
} }
// ask user to confirm // ask user to confirm
if(!confirm("Are you sure you want to wipe the eeprom on this device? This will take about 30 seconds. An alert will show when the eeprom wipe has finished.")){ if(!confirm("Are you sure you want to wipe the eeprom on this device? This will take about 30 seconds. An alert will show when the eeprom wipe has finished.")){
return; await rnode.close();
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1298,18 +1360,9 @@
}, },
async provision() { async provision() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
await rnode.close();
return; return;
} }
@@ -1489,17 +1542,9 @@
}, },
async setFirmwareHash() { async setFirmwareHash() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1546,17 +1591,9 @@
}, },
async enableTncMode() { async enableTncMode() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1602,17 +1639,9 @@
}, },
async disableTncMode() { async disableTncMode() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1643,17 +1672,9 @@
}, },
async enableBluetooth() { async enableBluetooth() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1671,26 +1692,18 @@
await rnode.enableBluetooth(); await rnode.enableBluetooth();
console.log("enabling bluetooth: done"); console.log("enabling bluetooth: done");
await Utils.sleepMillis(1000); alert("Bluetooth has been enabled!");
// done // done
await Utils.sleepMillis(1000);
await rnode.close(); await rnode.close();
alert("Bluetooth has been enabled!");
}, },
async disableBluetooth() { async disableBluetooth() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1707,27 +1720,18 @@
console.log("disabling bluetooth"); console.log("disabling bluetooth");
await rnode.disableBluetooth(); await rnode.disableBluetooth();
console.log("disabling bluetooth: done"); console.log("disabling bluetooth: done");
alert("Bluetooth has been disabled!");
await Utils.sleepMillis(1000);
// done // done
await Utils.sleepMillis(1000);
await rnode.close(); await rnode.close();
alert("Bluetooth has been disabled!");
}, },
async startBluetoothPairing() { async startBluetoothPairing() {
// ask for serial port // ask for rnode
const serialPort = await this.askForSerialPort(); const rnode = await this.askForRNode();
if(!serialPort){ if(!rnode){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return; return;
} }
@@ -1743,13 +1747,72 @@
// start bluetooth pairing // start bluetooth pairing
try { try {
console.log("start bluetooth pairing"); console.log("start bluetooth pairing");
const pin = await rnode.startBluetoothPairing(); await rnode.startBluetoothPairing(async (pin) => {
alert("Bluetooth Pairing Pin: " + pin);
await rnode.close();
});
console.log("start bluetooth pairing: done"); console.log("start bluetooth pairing: done");
} catch(error) { } catch(error) {
alert(error); alert(error);
} }
alert("RNode should now be in Bluetooth Pairing mode. A pin will be shown on the screen when you pair with it from Android bluetooth settings."); // tell user device is in pairing mode, and how to pair
alert([
"- RNode is in Bluetooth Pairing Mode for 30 seconds.",
"- Close this alert before performing the next steps.",
"- Open bluetooth settings on your Android device.",
"- Click pair on the RNode device that shows up.",
"- Bluetooth pin will shown on your RNode screen and on this page.",
].join("\n"));
},
async setDisplayRotation(rotation) {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
return;
}
// check if device has been provisioned
const rom = await rnode.getRomAsObject();
const details = rom.parse();
if(!details || !details.is_provisioned){
alert("Eeprom is not provisioned. You must do this first!");
await rnode.close();
return;
}
// configure
console.log("setting display rotation");
await rnode.setDisplayRotation(rotation);
console.log("setting display rotation: done");
// done
await rnode.close();
},
async startDisplayReconditioning() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
return;
}
// check if device has been provisioned
const rom = await rnode.getRomAsObject();
const details = rom.parse();
if(!details || !details.is_provisioned){
alert("Eeprom is not provisioned. You must do this first!");
await rnode.close();
return;
}
// configure
console.log("starting display reconditioning");
await rnode.startDisplayReconditioning();
console.log("starting display reconditioning: done");
// done // done
await rnode.close(); await rnode.close();

View File

@@ -79,6 +79,8 @@ class RNode {
ROM_UNLOCK_BYTE = 0xF8; ROM_UNLOCK_BYTE = 0xF8;
CMD_HASHES = 0x60; CMD_HASHES = 0x60;
CMD_FW_UPD = 0x61; CMD_FW_UPD = 0x61;
CMD_DISP_ROT = 0x67;
CMD_DISP_RCND = 0x68;
CMD_BT_CTRL = 0x46; CMD_BT_CTRL = 0x46;
CMD_BT_PIN = 0x62; CMD_BT_PIN = 0x62;
@@ -121,8 +123,10 @@ class RNode {
constructor(serialPort) { constructor(serialPort) {
this.serialPort = serialPort; this.serialPort = serialPort;
this.readable = serialPort.readable; this.reader = serialPort.readable.getReader();
this.writable = serialPort.writable; this.writable = serialPort.writable;
this.callbacks = {};
this.readLoop();
} }
static async fromSerialPort(serialPort) { static async fromSerialPort(serialPort) {
@@ -137,11 +141,21 @@ class RNode {
} }
async close() { async close() {
// release reader lock
try {
this.reader.releaseLock();
} catch(e) {
//console.log("failed to release lock on serial port readable, ignoring...", e);
}
// close serial port
try { try {
await this.serialPort.close(); await this.serialPort.close();
} catch(e) { } catch(e) {
console.log("failed to close serial port, ignoring...", e); //console.log("failed to close serial port, ignoring...", e);
} }
} }
async write(bytes) { async write(bytes) {
@@ -153,79 +167,100 @@ class RNode {
} }
} }
async readFromSerialPort(timeoutMillis) { async readLoop() {
return new Promise(async (resolve, reject) => { try {
let buffer = [];
let inFrame = false;
while(true){
// create reader // read kiss frames until reader indicates it's done
const reader = this.readable.getReader(); const { value, done } = await this.reader.read();
if(done){
break;
}
// timeout after provided millis // read kiss frames
if(timeoutMillis != null){ for(const byte of value){
setTimeout(() => { if(byte === this.KISS_FEND){
reader.releaseLock(); if(inFrame){
reject("timeout"); // End of frame
}, timeoutMillis); const decodedFrame = this.decodeKissFrame(buffer);
} if(decodedFrame){
this.onCommandReceived(decodedFrame);
// attempt to read kiss frame } else {
try { console.warn("Invalid frame ignored.");
let buffer = [];
while(true){
const { value, done } = await reader.read();
if(done){
break;
}
if(value){
for(let byte of value){
buffer.push(byte);
if(byte === this.KISS_FEND){
if(buffer.length > 1){
resolve(this.handleKISSFrame(buffer));
return;
}
buffer = [this.KISS_FEND]; // Start new frame
} }
buffer = [];
} }
inFrame = !inFrame;
} else if(inFrame) {
buffer.push(byte);
} }
} }
} catch (error) {
console.error('Error reading from serial port: ', error); }
} finally { } catch(error) {
reader.releaseLock();
// ignore error if reader was released
if(error instanceof TypeError){
return;
} }
}); console.error('Error reading from serial port: ', error);
} finally {
this.reader.releaseLock();
}
} }
handleKISSFrame(frame) { onCommandReceived(data) {
try {
let data = []; // get received command and bytes from data
const [ command, ...bytes ] = data;
console.log("onCommandReceived", "0x" + command.toString(16), bytes);
// find callback for received command
const callback = this.callbacks[command];
if(!callback){
return;
}
// fire callback
callback(bytes);
// forget callback
delete this.callbacks[command];
} catch(e) {
console.log("failed to handle received command", data, e);
}
}
decodeKissFrame(frame) {
const data = [];
let escaping = false; let escaping = false;
// Skip the initial 0xC0 and process the rest for(const byte of frame){
for(let i = 1; i < frame.length; i++){ if(escaping){
let byte = frame[i]; if(byte === this.KISS_TFEND){
if (escaping) {
if (byte === this.KISS_TFEND) {
data.push(this.KISS_FEND); data.push(this.KISS_FEND);
} else if (byte === this.KISS_TFESC) { } else if(byte === this.KISS_TFESC) {
data.push(this.KISS_FESC); data.push(this.KISS_FESC);
} else {
return null; // Invalid escape sequence
} }
escaping = false; escaping = false;
} else if(byte === this.KISS_FESC) {
escaping = true;
} else { } else {
if (byte === this.KISS_FESC) { data.push(byte);
escaping = true;
} else if (byte === this.KISS_FEND) {
// Ignore the end frame delimiter
break;
} else {
data.push(byte);
}
} }
} }
//console.log('Received KISS frame data:', new Uint8Array(data)); // return null if incomplete escape at end
return data; return escaping ? null : data;
} }
@@ -248,6 +283,28 @@ class RNode {
await this.write(this.createKissFrame(data)); await this.write(this.createKissFrame(data));
} }
// sends a command to the rnode, and resolves the promise with the result
async sendCommand(command, data) {
return new Promise(async (resolve, reject) => {
try {
// listen for response
this.callbacks[command] = (response) => {
resolve(response);
};
// send command
await this.sendKissCommand([
command,
...data,
]);
} catch(e) {
reject(e);
}
});
}
async reset() { async reset() {
await this.sendKissCommand([ await this.sendKissCommand([
this.CMD_RESET, this.CMD_RESET,
@@ -256,30 +313,42 @@ class RNode {
} }
async detect() { async detect() {
return new Promise(async (resolve) => {
try {
// ask if device is rnode // timeout after provided millis
await this.sendKissCommand([ const timeout = setTimeout(() => {
this.CMD_DETECT, resolve(false);
this.DETECT_REQ, }, 2000);
]);
// read response from device // detect rnode
const [ command, responseByte ] = await this.readFromSerialPort(); const response = await this.sendCommand(this.CMD_DETECT, [
this.DETECT_REQ,
]);
// device is an rnode if response is as expected // we no longer want to timeout
return command === this.CMD_DETECT && responseByte === this.DETECT_RESP; clearTimeout(timeout);
// device is an rnode if response is as expected
const [ responseByte ] = response;
const isRnode = responseByte === this.DETECT_RESP;
resolve(isRnode);
} catch(e) {
resolve(false);
}
});
} }
async getFirmwareVersion() { async getFirmwareVersion() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_FW_VERSION, [
this.CMD_FW_VERSION,
0x00, 0x00,
]); ]);
// read response from device // read response from device
var [ command, majorVersion, minorVersion ] = await this.readFromSerialPort(); var [ majorVersion, minorVersion ] = response;
if(minorVersion.length === 1){ if(minorVersion.length === 1){
minorVersion = "0" + minorVersion; minorVersion = "0" + minorVersion;
} }
@@ -291,99 +360,91 @@ class RNode {
async getPlatform() { async getPlatform() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_PLATFORM, [
this.CMD_PLATFORM,
0x00, 0x00,
]); ]);
// read response from device // read response from device
const [ command, platformByte ] = await this.readFromSerialPort(); const [ platformByte ] = response;
return platformByte; return platformByte;
} }
async getMcu() { async getMcu() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_MCU, [
this.CMD_MCU,
0x00, 0x00,
]); ]);
// read response from device // read response from device
const [ command, mcuByte ] = await this.readFromSerialPort(); const [ mcuByte ] = response;
return mcuByte; return mcuByte;
} }
async getBoard() { async getBoard() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_BOARD, [
this.CMD_BOARD,
0x00, 0x00,
]); ]);
// read response from device // read response from device
const [ command, boardByte ] = await this.readFromSerialPort(); const [ boardByte ] = response;
return boardByte; return boardByte;
} }
async getDeviceHash() { async getDeviceHash() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_DEV_HASH, [
this.CMD_DEV_HASH,
0x01, // anything != 0x00 0x01, // anything != 0x00
]); ]);
// read response from device // read response from device
const [ command, ...deviceHash ] = await this.readFromSerialPort(); const [ ...deviceHash ] = response;
return deviceHash; return deviceHash;
} }
async getTargetFirmwareHash() { async getTargetFirmwareHash() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_HASHES, [
this.CMD_HASHES,
this.HASH_TYPE_TARGET_FIRMWARE, this.HASH_TYPE_TARGET_FIRMWARE,
]); ]);
// read response from device // read response from device
const [ command, hashType, ...targetFirmwareHash ] = await this.readFromSerialPort(); const [ hashType, ...targetFirmwareHash ] = response;
return targetFirmwareHash; return targetFirmwareHash;
} }
async getFirmwareHash() { async getFirmwareHash() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_HASHES, [
this.CMD_HASHES,
this.HASH_TYPE_FIRMWARE, this.HASH_TYPE_FIRMWARE,
]); ]);
// read response from device // read response from device
const [ command, hashType, ...firmwareHash ] = await this.readFromSerialPort(); const [ hashType, ...firmwareHash ] = response;
return firmwareHash; return firmwareHash;
} }
async getRom() { async getRom() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_ROM_READ, [
this.CMD_ROM_READ,
0x00, 0x00,
]); ]);
// read response from device // read response from device
const [ command, ...eepromBytes ] = await this.readFromSerialPort(); const [ ...eepromBytes ] = response;
return eepromBytes; return eepromBytes;
} }
async getFrequency() { async getFrequency() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_FREQUENCY, [
this.CMD_FREQUENCY,
// request frequency by sending zero as 4 bytes // request frequency by sending zero as 4 bytes
0x00, 0x00,
0x00, 0x00,
@@ -392,7 +453,7 @@ class RNode {
]); ]);
// read response from device // read response from device
const [ command, ...frequencyBytes ] = await this.readFromSerialPort(); const [ ...frequencyBytes ] = response;
// convert 4 bytes to 32bit integer representing frequency in hertz // convert 4 bytes to 32bit integer representing frequency in hertz
const frequencyInHz = frequencyBytes[0] << 24 | frequencyBytes[1] << 16 | frequencyBytes[2] << 8 | frequencyBytes[3]; const frequencyInHz = frequencyBytes[0] << 24 | frequencyBytes[1] << 16 | frequencyBytes[2] << 8 | frequencyBytes[3];
@@ -402,8 +463,7 @@ class RNode {
async getBandwidth() { async getBandwidth() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_BANDWIDTH, [
this.CMD_BANDWIDTH,
// request bandwidth by sending zero as 4 bytes // request bandwidth by sending zero as 4 bytes
0x00, 0x00,
0x00, 0x00,
@@ -412,7 +472,7 @@ class RNode {
]); ]);
// read response from device // read response from device
const [ command, ...bandwidthBytes ] = await this.readFromSerialPort(); const [ ...bandwidthBytes ] = response;
// convert 4 bytes to 32bit integer representing bandwidth in hertz // convert 4 bytes to 32bit integer representing bandwidth in hertz
const bandwidthInHz = bandwidthBytes[0] << 24 | bandwidthBytes[1] << 16 | bandwidthBytes[2] << 8 | bandwidthBytes[3]; const bandwidthInHz = bandwidthBytes[0] << 24 | bandwidthBytes[1] << 16 | bandwidthBytes[2] << 8 | bandwidthBytes[3];
@@ -422,69 +482,60 @@ class RNode {
async getTxPower() { async getTxPower() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_TXPOWER, [
this.CMD_TXPOWER,
0xFF, // request tx power 0xFF, // request tx power
]); ]);
// read response from device // read response from device
const [ command, txPower ] = await this.readFromSerialPort(); const [ txPower ] = response;
return txPower; return txPower;
} }
async getSpreadingFactor() { async getSpreadingFactor() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_SF, [
this.CMD_SF,
0xFF, // request spreading factor 0xFF, // request spreading factor
]); ]);
// read response from device // read response from device
const [ command, spreadingFactor ] = await this.readFromSerialPort(); const [ spreadingFactor ] = response;
return spreadingFactor; return spreadingFactor;
} }
async getCodingRate() { async getCodingRate() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_CR, [
this.CMD_CR,
0xFF, // request coding rate 0xFF, // request coding rate
]); ]);
// read response from device // read response from device
const [ command, codingRate ] = await this.readFromSerialPort(); const [ codingRate ] = response;
return codingRate; return codingRate;
} }
async getRadioState() { async getRadioState() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_RADIO_STATE, [
this.CMD_RADIO_STATE,
0xFF, // request radio state 0xFF, // request radio state
]); ]);
// read response from device // read response from device
const [ command, radioState ] = await this.readFromSerialPort(); const [ radioState ] = response;
return radioState; return radioState;
} }
async getRxStat() { async getRxStat() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_STAT_RX, [
this.CMD_STAT_RX,
0x00, 0x00,
]); ]);
// read response from device // read response from device
const [ command, ...statBytes ] = await this.readFromSerialPort(); const [ ...statBytes ] = response;
// convert 4 bytes to 32bit integer // convert 4 bytes to 32bit integer
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3]; const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
@@ -494,13 +545,12 @@ class RNode {
async getTxStat() { async getTxStat() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_STAT_TX, [
this.CMD_STAT_TX,
0x00, 0x00,
]); ]);
// read response from device // read response from device
const [ command, ...statBytes ] = await this.readFromSerialPort(); const [ ...statBytes ] = response;
// convert 4 bytes to 32bit integer // convert 4 bytes to 32bit integer
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3]; const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
@@ -510,14 +560,12 @@ class RNode {
async getRssiStat() { async getRssiStat() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_STAT_RSSI, [
this.CMD_STAT_RSSI,
0x00, 0x00,
]); ]);
// read response from device // read response from device
const [ command, rssi ] = await this.readFromSerialPort(); const [ rssi ] = response;
return rssi; return rssi;
} }
@@ -536,7 +584,23 @@ class RNode {
]); ]);
} }
async startBluetoothPairing() { async startBluetoothPairing(pinCallback) {
// listen for bluetooth pin
// pin will be available once the user has initiated pairing from an Android device
this.callbacks[this.CMD_BT_PIN] = (response) => {
// read response from device
const [ ...pinBytes ] = response;
// convert 4 bytes to 32bit integer
const pin = pinBytes[0] << 24 | pinBytes[1] << 16 | pinBytes[2] << 8 | pinBytes[3];
// tell user what the bluetooth pin is
console.log("Bluetooth Pairing Pin: " + pin);
pinCallback(pin);
};
// enable pairing // enable pairing
await this.sendKissCommand([ await this.sendKissCommand([
@@ -544,43 +608,16 @@ class RNode {
0x02, // enable pairing 0x02, // enable pairing
]); ]);
// todo: listen for packets, pin will be available once user has initiated pairing from Android device
// // attempt to get bluetooth pairing pin
// try {
//
// // read response from device
// const [ command, ...pinBytes ] = await this.readFromSerialPort(5000);
// if(command !== this.CMD_BT_PIN){
// throw `unexpected command response: ${command}`;
// }
//
// // convert 4 bytes to 32bit integer
// const pin = pinBytes[0] << 24 | pinBytes[1] << 16 | pinBytes[2] << 8 | pinBytes[3];
//
// // todo: remove logs
// console.log(pinBytes);
// console.log(pin);
//
// // todo: convert to string
// return pin;
//
// } catch(error) {
// throw `failed to get bluetooth pin: ${error}`;
// }
} }
async readDisplay() { async readDisplay() {
await this.sendKissCommand([ const response = await this.sendCommand(this.CMD_DISP_READ, [
this.CMD_DISP_READ,
0x01, 0x01,
]); ]);
// read response from device // read response from device
const [ command, ...displayBuffer ] = await this.readFromSerialPort(); const [ ...displayBuffer ] = response;
return displayBuffer; return displayBuffer;
} }
@@ -715,6 +752,20 @@ class RNode {
return new ROM(rom); return new ROM(rom);
} }
async setDisplayRotation(rotation) {
await this.sendKissCommand([
this.CMD_DISP_ROT,
rotation & 0xFF,
]);
}
async startDisplayReconditioning() {
await this.sendKissCommand([
this.CMD_DISP_RCND,
0x01,
]);
}
} }
class ROM { class ROM {
@@ -743,6 +794,7 @@ class ROM {
static MODEL_A7 = 0xA7 static MODEL_A7 = 0xA7
static MODEL_A5 = 0xA5; static MODEL_A5 = 0xA5;
static MODEL_AA = 0xAA; static MODEL_AA = 0xAA;
static MODEL_AC = 0xAC;
static PRODUCT_T32_10 = 0xB2 static PRODUCT_T32_10 = 0xB2
static MODEL_BA = 0xBA static MODEL_BA = 0xBA
@@ -766,6 +818,10 @@ class ROM {
static MODEL_C5 = 0xC5 static MODEL_C5 = 0xC5
static MODEL_CA = 0xCA static MODEL_CA = 0xCA
static PRODUCT_HELTEC_T114 = 0xC2
static MODEL_C6 = 0xC6
static MODEL_C7 = 0xC7
static PRODUCT_TBEAM = 0xE0 static PRODUCT_TBEAM = 0xE0
static MODEL_E4 = 0xE4 static MODEL_E4 = 0xE4
static MODEL_E9 = 0xE9 static MODEL_E9 = 0xE9
@@ -781,8 +837,8 @@ class ROM {
static MODEL_D9 = 0xD9; static MODEL_D9 = 0xD9;
static PRODUCT_TECHO = 0x15; static PRODUCT_TECHO = 0x15;
static MODEL_T4 = 0x16; static MODEL_16 = 0x16;
static MODEL_T9 = 0x17; static MODEL_17 = 0x17;
static PRODUCT_HMBRW = 0xF0 static PRODUCT_HMBRW = 0xF0
static MODEL_FF = 0xFF static MODEL_FF = 0xFF