mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-13 21:06:04 +02:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a4af5e602 | |||
| 1895e6a919 | |||
| 975bf7f03f | |||
| c7d5d3887d | |||
| 5c93d8487e | |||
| 5d2834a9fb | |||
| cfe485bf29 | |||
| e7f6bd0397 | |||
| 1e7dc6af46 | |||
| af40cc3c8e | |||
| 2561b70fed | |||
| 44f145b646 | |||
| 55e2dc478d | |||
| 0932800e1f | |||
| c333eb25e3 | |||
| 580aa1cefd | |||
| 30de09f71b | |||
| 93d31adecd | |||
| 5f969017f7 | |||
| 967dd05fad | |||
| c808f0930b | |||
| 87df4b4aa1 | |||
| 0511d6f69b | |||
| 78b5598f67 | |||
| 5e1bdb2cc1 | |||
| 4420d44838 | |||
| ead1774cd3 | |||
| 0d45cbd849 | |||
| 456f739f51 | |||
| 80c6cc44e5 | |||
| 35265d8ae8 | |||
| 4a2d7ed100 | |||
| 47c4f038fe | |||
| 630ba67ef0 | |||
| fd1188abcd | |||
| 94513d7177 | |||
| fbff9821be | |||
| 1fd281121b | |||
| 5653a43941 | |||
| 7f07aedb8a | |||
| e437ce74c6 | |||
| 4ff6d2018a | |||
| 1c634da687 | |||
| 738c21dd66 | |||
| 7d72448ebf | |||
| b4f3d1f14c | |||
| 416166b07c | |||
| 480798e117 | |||
| 704a3d8a87 | |||
| 96e108037c | |||
| 97aade3632 | |||
| e43584912b | |||
| fccde36ecb |
@@ -30,4 +30,3 @@ references/
|
|||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
docker-compose.yaml
|
docker-compose.yaml
|
||||||
.docker-certs/
|
.docker-certs/
|
||||||
.docker-nginx/
|
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
|
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
|
||||||
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
||||||
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
||||||
|
| POST | `/api/contacts/bulk-delete` | Delete multiple contacts |
|
||||||
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
||||||
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
||||||
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
||||||
@@ -350,6 +351,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| GET | `/api/channels` | List channels |
|
| GET | `/api/channels` | List channels |
|
||||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||||
| POST | `/api/channels` | Create channel |
|
| POST | `/api/channels` | Create channel |
|
||||||
|
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
|
||||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||||
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
||||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||||
@@ -463,7 +465,7 @@ mc.subscribe(EventType.ACK, handler)
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
||||||
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
|
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
|
||||||
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||||
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
|
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
|
||||||
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
||||||
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
|
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
|
||||||
@@ -475,7 +477,7 @@ mc.subscribe(EventType.ACK, handler)
|
|||||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||||
|
|
||||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. The backend still carries `sidebar_sort_order` for compatibility and migration, but the current frontend sidebar stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in localStorage rather than treating it as one shared server-backed preference. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, and `discovery_blocked_types`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||||
|
|
||||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||||
|
|
||||||
|
|||||||
+373
-334
@@ -1,172 +1,211 @@
|
|||||||
|
## [3.7.1] - 2026-04-02
|
||||||
|
|
||||||
|
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
|
||||||
|
|
||||||
|
## [3.7.0] - 2026-04-02
|
||||||
|
|
||||||
|
* Feature: Repeater battery tracking
|
||||||
|
* Feature: Repeater info pane just like contacts
|
||||||
|
* Feature: Make repeaters blockable
|
||||||
|
* Feature: Add new-node advert blocking
|
||||||
|
* Feature: Add bulk deletion interface
|
||||||
|
* Feature: Bulk room add on alt+click of new channel button
|
||||||
|
* Feature: More info in debug endpoint
|
||||||
|
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
|
||||||
|
* Misc: Default auto-DM decrypt to true
|
||||||
|
* Misc: Reorganize some settings panes
|
||||||
|
* Misc: Enable FK pragma
|
||||||
|
* Misc: Various performance and correctness fixes
|
||||||
|
* Misc: Correct TCP default port
|
||||||
|
|
||||||
|
## [3.6.7] - 2026-03-31
|
||||||
|
|
||||||
|
* Misc: Remove armv7 (for now)
|
||||||
|
|
||||||
|
## [3.6.6] - 2026-03-31
|
||||||
|
|
||||||
|
* Misc: Please I'm begging for the build scripts to be working now
|
||||||
|
|
||||||
|
## [3.6.5] - 2026-03-31
|
||||||
|
|
||||||
|
* Bugfix: Maybe fix problem with publish script
|
||||||
|
|
||||||
|
## [3.6.4] - 2026-03-31
|
||||||
|
|
||||||
|
* Feature: Clarify New Channel/Contact button
|
||||||
|
* Bugfix: Rename "Best RSSI" to "Strongest Neighbor"
|
||||||
|
* Bugfix: Improve layout of Trace pane
|
||||||
|
* Misc: Docker setup improvements
|
||||||
|
|
||||||
## [3.6.3] - 2026-03-30
|
## [3.6.3] - 2026-03-30
|
||||||
|
|
||||||
Feature: Add multi-byte trace
|
* Feature: Add multi-byte trace
|
||||||
Feature: Show node name on discovered node if we know it
|
* Feature: Show node name on discovered node if we know it
|
||||||
Feature: Add docker installation script
|
* Feature: Add docker installation script
|
||||||
Feature: Add historical noise floor to stats
|
* Feature: Add historical noise floor to stats
|
||||||
Feature: Add trace tool
|
* Feature: Add trace tool
|
||||||
Bugfix: 100x performance on statistics endpoint with indices and better queries
|
* Bugfix: 100x performance on statistics endpoint with indices and better queries
|
||||||
Misc: Performance and correctness improvements for backend-of-the-frontend
|
* Misc: Performance and correctness improvements for backend-of-the-frontend
|
||||||
Misc: Reorganize scripts
|
* Misc: Reorganize scripts
|
||||||
|
|
||||||
## [3.6.2] - 2026-03-29
|
## [3.6.2] - 2026-03-29
|
||||||
|
|
||||||
Feature: Be more flexible about timing and volume of full contact offload
|
* Feature: Be more flexible about timing and volume of full contact offload
|
||||||
Feature: Improve room server and repeater ops to be much more clearer about auth status
|
* Feature: Improve room server and repeater ops to be much more clearer about auth status
|
||||||
Feature: Show last error status on integrations
|
* Feature: Show last error status on integrations
|
||||||
Feature: Push multi-platform docker builds
|
* Feature: Push multi-platform docker builds
|
||||||
Bugfix: Fix advert interval time unit display
|
* Bugfix: Fix advert interval time unit display
|
||||||
Bugfix: Don't cast RSSI/SNR to string for community MQTT
|
* Bugfix: Don't cast RSSI/SNR to string for community MQTT
|
||||||
Bugfix: Map uploader follows redirect
|
* Bugfix: Map uploader follows redirect
|
||||||
Misc: Thin out unnecessary cruft in unreads endpoint
|
* Misc: Thin out unnecessary cruft in unreads endpoint
|
||||||
Misc: Fall back gracefully if linked to an unknown contact
|
* Misc: Fall back gracefully if linked to an unknown contact
|
||||||
|
|
||||||
## [3.6.1] - 2026-03-26
|
## [3.6.1] - 2026-03-26
|
||||||
|
|
||||||
Feature: MeshCore Map integration
|
* Feature: MeshCore Map integration
|
||||||
Feature: Add warning screen about bots
|
* Feature: Add warning screen about bots
|
||||||
Feature: Favicon reflects unread message state
|
* Feature: Favicon reflects unread message state
|
||||||
Feature: Show hop map in larger modal
|
* Feature: Show hop map in larger modal
|
||||||
Feature: Add prebuilt frontend install script
|
* Feature: Add prebuilt frontend install script
|
||||||
Feature: Add clean service installer script
|
* Feature: Add clean service installer script
|
||||||
Feature: Swipe in to show menu
|
* Feature: Swipe in to show menu
|
||||||
Bugfix: Invalid backend API path serves error, not fallback index
|
* Bugfix: Invalid backend API path serves error, not fallback index
|
||||||
Bugfix: Fix some spacing/page height issues
|
* Bugfix: Fix some spacing/page height issues
|
||||||
Misc: Misc. bugfixes and performance and test improvements
|
* Misc: Misc. bugfixes and performance and test improvements
|
||||||
|
|
||||||
## [3.6.0] - 2026-03-22
|
## [3.6.0] - 2026-03-22
|
||||||
|
|
||||||
Feature: Add incoming-packet analytics
|
* Feature: Add incoming-packet analytics
|
||||||
Feature: BYOPacket for analysis
|
* Feature: BYOPacket for analysis
|
||||||
Feature: Add room activity to stats view
|
* Feature: Add room activity to stats view
|
||||||
Bugfix: Handle Heltec v3 serial noise
|
* Bugfix: Handle Heltec v3 serial noise
|
||||||
Misc: Swap repeaters and room servers for better ordering
|
* Misc: Swap repeaters and room servers for better ordering
|
||||||
|
|
||||||
## [3.5.0] - 2026-03-19
|
## [3.5.0] - 2026-03-19
|
||||||
|
|
||||||
Feature: Add room server alpha support
|
* Feature: Add room server alpha support
|
||||||
Feature: Add option to force-reset node clock when it's too far ahead
|
* Feature: Add option to force-reset node clock when it's too far ahead
|
||||||
Feature: DMs auto-retry before resorting to flood
|
* Feature: DMs auto-retry before resorting to flood
|
||||||
Feature: Add impulse zero-hop advert
|
* Feature: Add impulse zero-hop advert
|
||||||
Feature: Utilize PATH packets to correctly source a contact's route
|
* Feature: Utilize PATH packets to correctly source a contact's route
|
||||||
Feature: Metrics view on raw packet pane
|
* Feature: Metrics view on raw packet pane
|
||||||
Feature: Metric, Imperial, and Smoots are now selectable for distance display
|
* Feature: Metric, Imperial, and Smoots are now selectable for distance display
|
||||||
Feature: Allow favorites to be sorted
|
* Feature: Allow favorites to be sorted
|
||||||
Feature: Add multi-ack support
|
* Feature: Add multi-ack support
|
||||||
Feature: Password-remember checkbox on repeaters + room servers
|
* Feature: Password-remember checkbox on repeaters + room servers
|
||||||
Bugfix: Serialize radio disconnect in a lock
|
* Bugfix: Serialize radio disconnect in a lock
|
||||||
Bugfix: Fix contact bar layout issues
|
* Bugfix: Fix contact bar layout issues
|
||||||
Bugfix: Fix sidebar ordering for contacts by advert recency
|
* Bugfix: Fix sidebar ordering for contacts by advert recency
|
||||||
Bugfix: Fix version reporting in community MQTT
|
* Bugfix: Fix version reporting in community MQTT
|
||||||
Bugfix: Fix Apprise duplicate names
|
* Bugfix: Fix Apprise duplicate names
|
||||||
Bugfix: Be better about identity resolution in the stats pane
|
* Bugfix: Be better about identity resolution in the stats pane
|
||||||
Misc: Docs, test, and performance enhancements
|
* Misc: Docs, test, and performance enhancements
|
||||||
Misc: Don't prompt "Are you sure" when leaving an unedited interation
|
* Misc: Don't prompt "Are you sure" when leaving an unedited interation
|
||||||
Misc: Log node time on startup
|
* Misc: Log node time on startup
|
||||||
Misc: Improve community MQTT error bubble-up
|
* Misc: Improve community MQTT error bubble-up
|
||||||
Misc: Unread DMs always have a red unread counter
|
* Misc: Unread DMs always have a red unread counter
|
||||||
Misc: Improve information in the debug view to show DB status
|
* Misc: Improve information in the debug view to show DB status
|
||||||
|
|
||||||
## [3.4.1] - 2026-03-16
|
## [3.4.1] - 2026-03-16
|
||||||
|
|
||||||
Bugfix: Improve handling of version information on prebuilt bundles
|
* Bugfix: Improve handling of version information on prebuilt bundles
|
||||||
Bugfix: Improve frontend usability on disconnected radio
|
* Bugfix: Improve frontend usability on disconnected radio
|
||||||
Misc: Docs and readme updates
|
* Misc: Docs and readme updates
|
||||||
Misc: Overhaul DM ingest and frontend state handling
|
* Misc: Overhaul DM ingest and frontend state handling
|
||||||
|
|
||||||
## [3.4.0] - 2026-03-16
|
## [3.4.0] - 2026-03-16
|
||||||
|
|
||||||
Feature: Add radio model and stats display
|
* Feature: Add radio model and stats display
|
||||||
Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
|
* Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
|
||||||
Bugfix: Misc. frontend performance and correctness fixes
|
* Bugfix: Misc. frontend performance and correctness fixes
|
||||||
Bugfix: Fix same-second same-content DM send collition
|
* Bugfix: Fix same-second same-content DM send collition
|
||||||
Bugfix: Discard clearly-wrong GPS data
|
* Bugfix: Discard clearly-wrong GPS data
|
||||||
Bugfix: Prevent repeater clock skew drift on page nav
|
* Bugfix: Prevent repeater clock skew drift on page nav
|
||||||
Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
|
* Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
|
||||||
Misc: Don't permit invalid fanout configs to be saved ever`
|
* Misc: Don't permit invalid fanout configs to be saved ever`
|
||||||
|
|
||||||
## [3.3.0] - 2026-03-13
|
## [3.3.0] - 2026-03-13
|
||||||
|
|
||||||
Feature: Use dashed lines to show collapsed ambiguous router results
|
* Feature: Use dashed lines to show collapsed ambiguous router results
|
||||||
Feature: Jump to unred
|
* Feature: Jump to unred
|
||||||
Feature: Local channel management to prevent need to reload channel every time
|
* Feature: Local channel management to prevent need to reload channel every time
|
||||||
Feature: Debug endpoint
|
* Feature: Debug endpoint
|
||||||
Feature: Force-singleton channel management
|
* Feature: Force-singleton channel management
|
||||||
Feature: Local node discovery
|
* Feature: Local node discovery
|
||||||
Feature: Node routing discovery
|
* Feature: Node routing discovery
|
||||||
Bugfix: Don't tell users to us npm ci
|
* Bugfix: Don't tell users to us npm ci
|
||||||
Bugfix: Fallback polling dm message persistence
|
* Bugfix: Fallback polling dm message persistence
|
||||||
Bugfix: All native-JS inputs are now modals
|
* Bugfix: All native-JS inputs are now modals
|
||||||
Bugfix: Same-second send collision resolution
|
* Bugfix: Same-second send collision resolution
|
||||||
Bugfix: Proper browser updates on resend
|
* Bugfix: Proper browser updates on resend
|
||||||
Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
|
* Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
|
||||||
Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
|
* Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
|
||||||
Misc: Visualizer data layer overhaul for future map work
|
* Misc: Visualizer data layer overhaul for future map work
|
||||||
Misc: Parallelize docker tests
|
* Misc: Parallelize docker tests
|
||||||
|
|
||||||
## [3.2.0] - 2026-03-12
|
## [3.2.0] - 2026-03-12
|
||||||
|
|
||||||
Feature: Improve ambiguous-sender DM handling and visibility
|
* Feature: Improve ambiguous-sender DM handling and visibility
|
||||||
Feature: Allow for toggling of node GPS broadcast
|
* Feature: Allow for toggling of node GPS broadcast
|
||||||
Feature: Add path width to bot and move example to full kwargs
|
* Feature: Add path width to bot and move example to full kwargs
|
||||||
Feature: Improve node map color contrast
|
* Feature: Improve node map color contrast
|
||||||
Bugfix: More accurate tracking of contact data
|
* Bugfix: More accurate tracking of contact data
|
||||||
Bugfix: Misc. frontend performance and bugfixes
|
* Bugfix: Misc. frontend performance and bugfixes
|
||||||
Misc: Clearer warnings on user-key linkage
|
* Misc: Clearer warnings on user-key linkage
|
||||||
Misc: Documentation improvements
|
* Misc: Documentation improvements
|
||||||
|
|
||||||
## [3.1.1] - 2026-03-11
|
## [3.1.1] - 2026-03-11
|
||||||
|
|
||||||
Feature: Add basic auth
|
* Feature: Add basic auth
|
||||||
Feature: SQS fanout
|
* Feature: SQS fanout
|
||||||
Feature: Enrich contact info pane
|
* Feature: Enrich contact info pane
|
||||||
Feature: Search operators for node and channel
|
* Feature: Search operators for node and channel
|
||||||
Feature: Pause radio connection attempts from Radio settings
|
* Feature: Pause radio connection attempts from Radio settings
|
||||||
Feature: New themes! What a great use of time!
|
* Feature: New themes! What a great use of time!
|
||||||
Feature: Github workflows runs for validation
|
* Feature: Github workflows runs for validation
|
||||||
Bugfix: More consistent log format with times
|
* Bugfix: More consistent log format with times
|
||||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||||
|
|
||||||
## [3.1.0] - 2026-03-11
|
## [3.1.0] - 2026-03-11
|
||||||
|
|
||||||
Feature: Add basic auth
|
* Feature: Add basic auth
|
||||||
Feature: SQS fanout
|
* Feature: SQS fanout
|
||||||
Feature: Enrich contact info pane
|
* Feature: Enrich contact info pane
|
||||||
Feature: Search operators for node and channel
|
* Feature: Search operators for node and channel
|
||||||
Feature: Pause radio connection attempts from Radio settings
|
* Feature: Pause radio connection attempts from Radio settings
|
||||||
Feature: New themes! What a great use of time!
|
* Feature: New themes! What a great use of time!
|
||||||
Feature: Github workflows runs for validation
|
* Feature: Github workflows runs for validation
|
||||||
Bugfix: More consistent log format with times
|
* Bugfix: More consistent log format with times
|
||||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||||
|
|
||||||
## [3.0.0] - 2026-03-10
|
## [3.0.0] - 2026-03-10
|
||||||
|
|
||||||
Feature: Custom regions per-channel
|
* Feature: Custom regions per-channel
|
||||||
Feature: Add custom contact pathing
|
* Feature: Add custom contact pathing
|
||||||
Feature: Corrupt packets are more clear that they're corrupt
|
* Feature: Corrupt packets are more clear that they're corrupt
|
||||||
Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
* Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
||||||
Feature: More consistent icons
|
* Feature: More consistent icons
|
||||||
Feature: Add per-channel local notifications
|
* Feature: Add per-channel local notifications
|
||||||
Feature: New themes
|
* Feature: New themes
|
||||||
Feature: Massive codebase refactor and overhaul
|
* Feature: Massive codebase refactor and overhaul
|
||||||
Bugfix: Fix packet parsing for trace packets
|
* Bugfix: Fix packet parsing for trace packets
|
||||||
Bugfix: Refetch channels on reconnect
|
* Bugfix: Refetch channels on reconnect
|
||||||
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
|
* Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
|
||||||
Bugfix: Timestamps in logs
|
* Bugfix: Timestamps in logs
|
||||||
Bugfix: Correct wrong clock sync command
|
* Bugfix: Correct wrong clock sync command
|
||||||
Misc: Improve bot error bubble up
|
* Misc: Improve bot error bubble up
|
||||||
Misc: Update to non-lib-included meshcore-decoder version
|
* Misc: Update to non-lib-included meshcore-decoder version
|
||||||
Misc: Revise refactors to be more LLM friendly
|
* Misc: Revise refactors to be more LLM friendly
|
||||||
Misc: Fix script executability
|
* Misc: Fix script executability
|
||||||
Misc: Better logging format with timestamp
|
* Misc: Better logging format with timestamp
|
||||||
Misc: Repeater advert buttons separate flood and one-hop
|
* Misc: Repeater advert buttons separate flood and one-hop
|
||||||
Misc: Preserve repeater pane on navigation away
|
* Misc: Preserve repeater pane on navigation away
|
||||||
Misc: Clearer iconography and coloring for status bar buttons
|
* Misc: Clearer iconography and coloring for status bar buttons
|
||||||
Misc: Search bar to top bar
|
* Misc: Search bar to top bar
|
||||||
|
|
||||||
## [2.7.9] - 2026-03-08
|
## [2.7.9] - 2026-03-08
|
||||||
|
|
||||||
Bugfix: Don't obscure new integration dropdown on session boundary
|
* Bugfix: Don't obscure new integration dropdown on session boundary
|
||||||
|
|
||||||
## [2.7.8] - 2026-03-08
|
## [2.7.8] - 2026-03-08
|
||||||
|
|
||||||
@@ -174,287 +213,287 @@ Bugfix: Don't obscure new integration dropdown on session boundary
|
|||||||
|
|
||||||
## [2.7.8] - 2026-03-08
|
## [2.7.8] - 2026-03-08
|
||||||
|
|
||||||
Bugfix: Improve frontend asset resolution and fixup the build/push script
|
* Bugfix: Improve frontend asset resolution and fixup the build/push script
|
||||||
|
|
||||||
## [2.7.1] - 2026-03-08
|
## [2.7.1] - 2026-03-08
|
||||||
|
|
||||||
Bugfix: Fix historical DM packet length passing
|
* Bugfix: Fix historical DM packet length passing
|
||||||
Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
|
* Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
|
||||||
Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
|
* Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
|
||||||
|
|
||||||
## [2.7.0] - 2026-03-08
|
## [2.7.0] - 2026-03-08
|
||||||
|
|
||||||
Feature: Multibyte path support
|
* Feature: Multibyte path support
|
||||||
Feature: Add multibyte statistics to statistics pane
|
* Feature: Add multibyte statistics to statistics pane
|
||||||
Feature: Add path bittage to contact info pane
|
* Feature: Add path bittage to contact info pane
|
||||||
Feature: Put tools in a collapsible
|
* Feature: Put tools in a collapsible
|
||||||
|
|
||||||
## [2.6.1] - 2026-03-08
|
## [2.6.1] - 2026-03-08
|
||||||
|
|
||||||
Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
|
* Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
|
||||||
|
|
||||||
## [2.6.0] - 2026-03-08
|
## [2.6.0] - 2026-03-08
|
||||||
|
|
||||||
Feature: A11y improvements
|
* Feature: A11y improvements
|
||||||
Feature: New themes
|
* Feature: New themes
|
||||||
Feature: Backfill channel sender identity when available
|
* Feature: Backfill channel sender identity when available
|
||||||
Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
|
* Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
|
||||||
Bugfix: Unreads now respect blocklist
|
* Bugfix: Unreads now respect blocklist
|
||||||
Bugfix: Unreads can't accumulate on an open thread
|
* Bugfix: Unreads can't accumulate on an open thread
|
||||||
Bugfix: Channel name in broadcasts
|
* Bugfix: Channel name in broadcasts
|
||||||
Bugfix: Add missing httpx dependency
|
* Bugfix: Add missing httpx dependency
|
||||||
Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
|
* Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
|
||||||
Misc: Improved button signage for app movement
|
* Misc: Improved button signage for app movement
|
||||||
Misc: Test, performance, and documentation improvements
|
* Misc: Test, performance, and documentation improvements
|
||||||
|
|
||||||
## [2.5.0] - 2026-03-05
|
## [2.5.0] - 2026-03-05
|
||||||
|
|
||||||
Feature: Far better accessibility across the app (with far to go)
|
* Feature: Far better accessibility across the app (with far to go)
|
||||||
Feature: Add community MQTT stats reporting, and improve over a few commits
|
* Feature: Add community MQTT stats reporting, and improve over a few commits
|
||||||
Feature: Color schemes and misc. settings reorg
|
* Feature: Color schemes and misc. settings reorg
|
||||||
Feature: Add why-active to filtered nodes
|
* Feature: Add why-active to filtered nodes
|
||||||
Feature: Add channel and contact info box
|
* Feature: Add channel and contact info box
|
||||||
Feature: Add contact blocking
|
* Feature: Add contact blocking
|
||||||
Feature: Add potential repeater path map display
|
* Feature: Add potential repeater path map display
|
||||||
Feature: Add flood scoping/regions
|
* Feature: Add flood scoping/regions
|
||||||
Feature: Global message search
|
* Feature: Global message search
|
||||||
Feature: Fully safe bot disable
|
* Feature: Fully safe bot disable
|
||||||
Feature: Add default #remoteterm channel (lol sorry I had to)
|
* Feature: Add default #remoteterm channel (lol sorry I had to)
|
||||||
Feature: Custom recency pruning in visualizer
|
* Feature: Custom recency pruning in visualizer
|
||||||
Bugfix: Be more cautious around null byte stripping
|
* Bugfix: Be more cautious around null byte stripping
|
||||||
Bugfix: Clear channel-add interface on not-add-another
|
* Bugfix: Clear channel-add interface on not-add-another
|
||||||
Bugfix: Add status/name/MQTT LWT
|
* Bugfix: Add status/name/MQTT LWT
|
||||||
Bugfix: Channel deletion propagates over WS
|
* Bugfix: Channel deletion propagates over WS
|
||||||
Bugfix: Show map location for all nodes on link, not 7-day-limited
|
* Bugfix: Show map location for all nodes on link, not 7-day-limited
|
||||||
Bugfix: Hide private key channel keys by default
|
* Bugfix: Hide private key channel keys by default
|
||||||
Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
|
* Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
|
||||||
Misc: Doc, changelog, and test improvements
|
* Misc: Doc, changelog, and test improvements
|
||||||
Misc: Add, and remove, package lock (sorry Windows users)
|
* Misc: Add, and remove, package lock (sorry Windows users)
|
||||||
Misc: Don't show mark all as read if not necessary
|
* Misc: Don't show mark all as read if not necessary
|
||||||
Misc: Fix stale closures and misc. frontend perf/correctness improvements
|
* Misc: Fix stale closures and misc. frontend perf/correctness improvements
|
||||||
Misc: Add Windows startup notes
|
* Misc: Add Windows startup notes
|
||||||
Misc: E2E expansion + improvement
|
* Misc: E2E expansion + improvement
|
||||||
Misc: Move around visualizer settings
|
* Misc: Move around visualizer settings
|
||||||
|
|
||||||
## [2.4.0] - 2026-03-02
|
## [2.4.0] - 2026-03-02
|
||||||
|
|
||||||
Feature: Add community MQTT reporting (e.g. LetsMesh.net)
|
* Feature: Add community MQTT reporting (e.g. LetsMesh.net)
|
||||||
Misc: Build scripts and library attribution
|
* Misc: Build scripts and library attribution
|
||||||
Misc: Add sign of life to E2E tests
|
* Misc: Add sign of life to E2E tests
|
||||||
|
|
||||||
## [2.3.0] - 2026-03-01
|
## [2.3.0] - 2026-03-01
|
||||||
|
|
||||||
Feature: Click path description to reset to flood
|
* Feature: Click path description to reset to flood
|
||||||
Feature: Add MQTT publishing
|
* Feature: Add MQTT publishing
|
||||||
Feature: Visualizer remembers settings
|
* Feature: Visualizer remembers settings
|
||||||
Bugfix: Fix prefetch usage
|
* Bugfix: Fix prefetch usage
|
||||||
Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
|
* Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
|
||||||
Misc: Drop py3.12 requirement
|
* Misc: Drop py3.12 requirement
|
||||||
Misc: Performance, documentation, test, and file structure optimizations
|
* Misc: Performance, documentation, test, and file structure optimizations
|
||||||
Misc: Add arrows between route nodes on contact info
|
* Misc: Add arrows between route nodes on contact info
|
||||||
Misc: Show repeater path/type in title bar
|
* Misc: Show repeater path/type in title bar
|
||||||
|
|
||||||
## [2.2.0] - 2026-02-28
|
## [2.2.0] - 2026-02-28
|
||||||
|
|
||||||
Feature: Track advert paths and use to disambiguate repeater identity in visualizer
|
* Feature: Track advert paths and use to disambiguate repeater identity in visualizer
|
||||||
Feature: Contact info pane
|
* Feature: Contact info pane
|
||||||
Feature: Overhaul repeater interface
|
* Feature: Overhaul repeater interface
|
||||||
Bugfix: Misc. frontend rendering + perf improvements
|
* Bugfix: Misc. frontend rendering + perf improvements
|
||||||
Bugfix: Better behavior around radio locking and autofetch/polling
|
* Bugfix: Better behavior around radio locking and autofetch/polling
|
||||||
Bugfix: Clear channel name field on new-channel modal tab change
|
* Bugfix: Clear channel name field on new-channel modal tab change
|
||||||
Bugfix: Repeater inforbox can scroll
|
* Bugfix: Repeater inforbox can scroll
|
||||||
Bugfix: Better handling of historical DM encrypts
|
* Bugfix: Better handling of historical DM encrypts
|
||||||
Bugfix: Handle errors if returned in prefetch phase
|
* Bugfix: Handle errors if returned in prefetch phase
|
||||||
Misc: Radio event response failure is logged/surfaced better
|
* Misc: Radio event response failure is logged/surfaced better
|
||||||
Misc: Improve test coverage and remove dead code
|
* Misc: Improve test coverage and remove dead code
|
||||||
Misc: Documentation and errata improvements
|
* Misc: Documentation and errata improvements
|
||||||
Misc: Database storage optimization
|
* Misc: Database storage optimization
|
||||||
|
|
||||||
## [2.1.0] - 2026-02-23
|
## [2.1.0] - 2026-02-23
|
||||||
|
|
||||||
Feature: Add ability to remember last-used channel on load
|
* Feature: Add ability to remember last-used channel on load
|
||||||
Feature: Add `docker compose` support (thanks @suymur !)
|
* Feature: Add `docker compose` support (thanks @suymur !)
|
||||||
Feature: Better-aligned favicon (lol)
|
* Feature: Better-aligned favicon (lol)
|
||||||
Bugfix: Disable autocomplete on message field
|
* Bugfix: Disable autocomplete on message field
|
||||||
Bugfix: Legacy hash restoration on page load
|
* Bugfix: Legacy hash restoration on page load
|
||||||
Bugfix: Align resend buttons in pathing modal
|
* Bugfix: Align resend buttons in pathing modal
|
||||||
Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
|
* Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
|
||||||
Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
|
* Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
|
||||||
Bugfix: Improved repeater comms on busy meshes
|
* Bugfix: Improved repeater comms on busy meshes
|
||||||
Bugfix: Drain before autofetch from radio
|
* Bugfix: Drain before autofetch from radio
|
||||||
Bugfix: Fix, or document exceptions to, sub-second resolution message failure
|
* Bugfix: Fix, or document exceptions to, sub-second resolution message failure
|
||||||
Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
|
* Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
|
||||||
Bugfix: Force server-side keystore update when radio key changes
|
* Bugfix: Force server-side keystore update when radio key changes
|
||||||
Bugfix: Reduce WS churn for incoming message handling
|
* Bugfix: Reduce WS churn for incoming message handling
|
||||||
Bugfix: Fix content type signalling for irrelevant endpoints
|
* Bugfix: Fix content type signalling for irrelevant endpoints
|
||||||
Bugfix: Handle stuck post-connect failure state
|
* Bugfix: Handle stuck post-connect failure state
|
||||||
Misc: Documentation & version parsing improvements
|
* Misc: Documentation & version parsing improvements
|
||||||
Misc: Hide char counter on mobile for short messages
|
* Misc: Hide char counter on mobile for short messages
|
||||||
Misc: Typo fixes in docs and settings
|
* Misc: Typo fixes in docs and settings
|
||||||
Misc: Add dynamic webmanifest for hosts that can support it
|
* Misc: Add dynamic webmanifest for hosts that can support it
|
||||||
Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
|
* Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
|
||||||
Misc: Drop weird rounded bounding box for settings
|
* Misc: Drop weird rounded bounding box for settings
|
||||||
Misc: Move resend buttons to pathing modal
|
* Misc: Move resend buttons to pathing modal
|
||||||
Misc: Improved comments around database ownership on *nix systems
|
* Misc: Improved comments around database ownership on *nix systems
|
||||||
Misc: Move to SSoT for message dedupe on frontend
|
* Misc: Move to SSoT for message dedupe on frontend
|
||||||
Misc: Move DM ack clearing to standard poll, and increase hold time between polling
|
* Misc: Move DM ack clearing to standard poll, and increase hold time between polling
|
||||||
Misc: Holistic testing overhaul
|
* Misc: Holistic testing overhaul
|
||||||
|
|
||||||
## [2.0.1] - 2026-02-16
|
## [2.0.1] - 2026-02-16
|
||||||
|
|
||||||
Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
* Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
||||||
|
|
||||||
## [2.0.0] - 2026-02-16
|
## [2.0.0] - 2026-02-16
|
||||||
|
|
||||||
Feature: Frontend UX + log overhaul
|
* Feature: Frontend UX + log overhaul
|
||||||
Bugfix: Use contact object from DB for broadcast rather than handrolling
|
* Bugfix: Use contact object from DB for broadcast rather than handrolling
|
||||||
Bugfix: Fix out of order path WS messages overwriting each other
|
* Bugfix: Fix out of order path WS messages overwriting each other
|
||||||
Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
* Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
||||||
Bugfix: Fix repeater command timestamp selection logic
|
* Bugfix: Fix repeater command timestamp selection logic
|
||||||
Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
* Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
||||||
Bugfix: Add missing radio operation locks in a few spots
|
* Bugfix: Add missing radio operation locks in a few spots
|
||||||
Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
* Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
||||||
Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
* Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
||||||
Misc: Visualizer layout refinement & option labels
|
* Misc: Visualizer layout refinement & option labels
|
||||||
|
|
||||||
## [1.10.0] - 2026-02-16
|
## [1.10.0] - 2026-02-16
|
||||||
|
|
||||||
Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
|
* Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
|
||||||
Feature: 3D mesh visualizer
|
* Feature: 3D mesh visualizer
|
||||||
Feature: Statistics pane
|
* Feature: Statistics pane
|
||||||
Feature: Support incoming/outgoing indication for bot invocations
|
* Feature: Support incoming/outgoing indication for bot invocations
|
||||||
Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
|
* Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
|
||||||
Bugfix: Fix top padding out outgoing message
|
* Bugfix: Fix top padding out outgoing message
|
||||||
Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
|
* Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
|
||||||
Bugfix: Multiple-sent messages had path appearing delays until rerender
|
* Bugfix: Multiple-sent messages had path appearing delays until rerender
|
||||||
Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
|
* Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
|
||||||
Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
|
* Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
|
||||||
Misc: s/stopped/idle/ for room finder
|
* Misc: s/stopped/idle/ for room finder
|
||||||
|
|
||||||
## [1.9.3] - 2026-02-12
|
## [1.9.3] - 2026-02-12
|
||||||
|
|
||||||
Feature: Upgrade the room finder to support two-word rooms
|
* Feature: Upgrade the room finder to support two-word rooms
|
||||||
|
|
||||||
## [1.9.2] - 2026-02-12
|
## [1.9.2] - 2026-02-12
|
||||||
|
|
||||||
Feature: Options dialog sucks less
|
* Feature: Options dialog sucks less
|
||||||
Bugfix: Move tests to isolated memory DB
|
* Bugfix: Move tests to isolated memory DB
|
||||||
Bugfix: Mention case sensitivity
|
* Bugfix: Mention case sensitivity
|
||||||
Bugfix: Stale header retention on settings page view
|
* Bugfix: Stale header retention on settings page view
|
||||||
Bugfix: Non-isolated path writing
|
* Bugfix: Non-isolated path writing
|
||||||
Bugfix: Nullable contact fields are now passed as real nulls
|
* Bugfix: Nullable contact fields are now passed as real nulls
|
||||||
Bugfix: Look at all fields on message reconcile, not just text
|
* Bugfix: Look at all fields on message reconcile, not just text
|
||||||
Bugfix: Make mark-all-as-read atomic
|
* Bugfix: Make mark-all-as-read atomic
|
||||||
Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
|
* Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
|
||||||
Misc: Massive test and AGENTS.md overhauls and additions
|
* Misc: Massive test and AGENTS.md overhauls and additions
|
||||||
|
|
||||||
## [1.9.1] - 2026-02-10
|
## [1.9.1] - 2026-02-10
|
||||||
|
|
||||||
Feature: Contacts and channels use keys, not names
|
* Feature: Contacts and channels use keys, not names
|
||||||
Bugfix: Fix falsy casting of 0 in lat lon and timing data
|
* Bugfix: Fix falsy casting of 0 in lat lon and timing data
|
||||||
Bugfix: Show message length in bytes, not chars
|
* Bugfix: Show message length in bytes, not chars
|
||||||
Bugfix: Fix phantom unread badges on focused convos
|
* Bugfix: Fix phantom unread badges on focused convos
|
||||||
Misc: Bot invocation to async
|
* Misc: Bot invocation to async
|
||||||
Misc: Use full key, not prefix, where we can
|
* Misc: Use full key, not prefix, where we can
|
||||||
|
|
||||||
## [1.9.0] - 2026-02-10
|
## [1.9.0] - 2026-02-10
|
||||||
|
|
||||||
Feature: Favorited contacts are preferentially loaded onto the radio
|
* Feature: Favorited contacts are preferentially loaded onto the radio
|
||||||
Feature: Add recent-message caching for fast switching
|
* Feature: Add recent-message caching for fast switching
|
||||||
Feature: Add echo paths modal when echo-heard checkbox is clicked
|
* Feature: Add echo paths modal when echo-heard checkbox is clicked
|
||||||
Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
|
* Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
|
||||||
Frontend: Better styling on echo + message path display
|
Frontend: Better styling on echo + message path display
|
||||||
Bugfix: Prevent frontend static file serving path traversal vuln
|
* Bugfix: Prevent frontend static file serving path traversal vuln
|
||||||
Bugfix: Safer prefix-claiming for DMs we don't have the key for
|
* Bugfix: Safer prefix-claiming for DMs we don't have the key for
|
||||||
Bugfix: Prevent injection from mentions with special characters
|
* Bugfix: Prevent injection from mentions with special characters
|
||||||
Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
|
* Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
|
||||||
Bugfix: App can boot and test without a frontend dir
|
* Bugfix: App can boot and test without a frontend dir
|
||||||
Misc: Improve and consistent-ify (?) backend radio operation lock management
|
* Misc: Improve and consistent-ify (?) backend radio operation lock management
|
||||||
Misc: Frontend performance and safety enhancements
|
* Misc: Frontend performance and safety enhancements
|
||||||
Misc: Move builds to non-bundled; usage requires building the Frontend
|
* Misc: Move builds to non-bundled; usage requires building the Frontend
|
||||||
Misc: Update tests and agent docs
|
* Misc: Update tests and agent docs
|
||||||
|
|
||||||
## [1.8.0] - 2026-02-07
|
## [1.8.0] - 2026-02-07
|
||||||
|
|
||||||
Feature: Single hop ping
|
* Feature: Single hop ping
|
||||||
Feature: PWA viewport fixes(thanks @rgregg)
|
* Feature: PWA viewport fixes(thanks @rgregg)
|
||||||
Feature (?): No frontend distribution; build it yourself ;P
|
Feature (?): No frontend distribution; build it yourself ;P
|
||||||
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
* Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
||||||
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
* Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
||||||
Bugfix: Better guarding around reconnection
|
* Bugfix: Better guarding around reconnection
|
||||||
Bugfix: Duplicate websocket connection fixes
|
* Bugfix: Duplicate websocket connection fixes
|
||||||
Bugfix: Settings tab error cleanliness on tab swap
|
* Bugfix: Settings tab error cleanliness on tab swap
|
||||||
Bugfix: Fix path traversal vuln
|
* Bugfix: Fix path traversal vuln
|
||||||
UI: Swap visualizer legend ordering (yay prettier)
|
UI: Swap visualizer legend ordering (yay prettier)
|
||||||
Misc: Perf and locking improvements
|
* Misc: Perf and locking improvements
|
||||||
Misc: Always flood advertisements
|
* Misc: Always flood advertisements
|
||||||
Misc: Better packet dupe handling
|
* Misc: Better packet dupe handling
|
||||||
Misc: Dead code cleanup, test improvements
|
* Misc: Dead code cleanup, test improvements
|
||||||
|
|
||||||
## [1.7.1] - 2026-02-03
|
## [1.7.1] - 2026-02-03
|
||||||
|
|
||||||
Feature: Clickable hyperlinks
|
* Feature: Clickable hyperlinks
|
||||||
Bugfix: More consistent public key normalization
|
* Bugfix: More consistent public key normalization
|
||||||
Bugfix: Use more reliable cursor paging
|
* Bugfix: Use more reliable cursor paging
|
||||||
Bugfix: Fix null timestamp dedupe failure
|
* Bugfix: Fix null timestamp dedupe failure
|
||||||
Bugfix: More consistent prefix-based message claiming on key receipt
|
* Bugfix: More consistent prefix-based message claiming on key receipt
|
||||||
Misc: Bot can respond to its own messages
|
* Misc: Bot can respond to its own messages
|
||||||
Misc: Additional tests
|
* Misc: Additional tests
|
||||||
Misc: Remove unneeded message dedupe logic
|
* Misc: Remove unneeded message dedupe logic
|
||||||
Misc: Resync settings after radio settings mutation
|
* Misc: Resync settings after radio settings mutation
|
||||||
|
|
||||||
## [1.7.0] - 2026-01-27
|
## [1.7.0] - 2026-01-27
|
||||||
|
|
||||||
Feature: Multi-bot functionality
|
* Feature: Multi-bot functionality
|
||||||
Bugfix: Adjust bot code editor display and add line numbers
|
* Bugfix: Adjust bot code editor display and add line numbers
|
||||||
Bugfix: Fix clock filtering and contact lookup behavior bugs
|
* Bugfix: Fix clock filtering and contact lookup behavior bugs
|
||||||
Bugfix: Fix repeater message duplication issue
|
* Bugfix: Fix repeater message duplication issue
|
||||||
Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
|
* Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
|
||||||
UI: Move advertise button to identity tab
|
UI: Move advertise button to identity tab
|
||||||
Misc: Clarify fallback functionality for missing private key export in logs
|
* Misc: Clarify fallback functionality for missing private key export in logs
|
||||||
|
|
||||||
## [1.6.0] - 2026-01-26
|
## [1.6.0] - 2026-01-26
|
||||||
|
|
||||||
Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
|
* Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
|
||||||
Feature: Customizable advertising interval
|
* Feature: Customizable advertising interval
|
||||||
Feature: In-app bot setup
|
* Feature: In-app bot setup
|
||||||
Bugfix: Force contact onto radio before DM send
|
* Bugfix: Force contact onto radio before DM send
|
||||||
Misc: Remove unused code
|
* Misc: Remove unused code
|
||||||
|
|
||||||
## [1.5.0] - 2026-01-19
|
## [1.5.0] - 2026-01-19
|
||||||
|
|
||||||
Feature: Network visualizer
|
* Feature: Network visualizer
|
||||||
|
|
||||||
## [1.4.1] - 2026-01-19
|
## [1.4.1] - 2026-01-19
|
||||||
|
|
||||||
Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
|
* Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
|
||||||
Feature: Server-side preference management for favorites, read status, etc.
|
* Feature: Server-side preference management for favorites, read status, etc.
|
||||||
UI: More compact hop labelling
|
UI: More compact hop labelling
|
||||||
Bugfix: Misc. race conditions and websocket handling
|
* Bugfix: Misc. race conditions and websocket handling
|
||||||
Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
|
* Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
|
||||||
|
|
||||||
## [1.4.0] - 2026-01-18
|
## [1.4.0] - 2026-01-18
|
||||||
|
|
||||||
UI: Improve button layout for room searcher
|
UI: Improve button layout for room searcher
|
||||||
UI: Improve favicon coloring
|
UI: Improve favicon coloring
|
||||||
UI: Improve status bar button layout on small screen
|
UI: Improve status bar button layout on small screen
|
||||||
Feature: Show multi-path hop display with distance estimates
|
* Feature: Show multi-path hop display with distance estimates
|
||||||
Feature: Search rooms and contacts by key, not just name
|
* Feature: Search rooms and contacts by key, not just name
|
||||||
Bugfix: Historical DM decryption now works as expected
|
* Bugfix: Historical DM decryption now works as expected
|
||||||
Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
|
* Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
|
||||||
|
|
||||||
## [1.3.1] - 2026-01-17
|
## [1.3.1] - 2026-01-17
|
||||||
|
|
||||||
UI: Rework restart handling
|
UI: Rework restart handling
|
||||||
Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
|
* Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
|
||||||
Bug: Improve error message rendering from server-side errors
|
Bug: Improve error message rendering from server-side errors
|
||||||
UI: Remove octothorpe from channel listing
|
UI: Remove octothorpe from channel listing
|
||||||
|
|
||||||
## [1.3.0] - 2026-01-17
|
## [1.3.0] - 2026-01-17
|
||||||
|
|
||||||
Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
|
* Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
|
||||||
Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
|
* Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
|
||||||
Feature: Drop repeater login wait time; vestigial from debugging a different issue
|
* Feature: Drop repeater login wait time; vestigial from debugging a different issue
|
||||||
|
|
||||||
## [1.2.1] - 2026-01-17
|
## [1.2.1] - 2026-01-17
|
||||||
|
|
||||||
@@ -462,27 +501,27 @@ Update: Update meshcore-hashtag-cracker to include sender-identification correct
|
|||||||
|
|
||||||
## [1.2.0] - 2026-01-16
|
## [1.2.0] - 2026-01-16
|
||||||
|
|
||||||
Feature: Add favorites
|
* Feature: Add favorites
|
||||||
|
|
||||||
## [1.1.0] - 2026-01-14
|
## [1.1.0] - 2026-01-14
|
||||||
|
|
||||||
Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
|
* Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
|
||||||
Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
|
* Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
|
||||||
|
|
||||||
## [1.0.3] - 2026-01-13
|
## [1.0.3] - 2026-01-13
|
||||||
|
|
||||||
Bugfix: Add missing test management packages
|
* Bugfix: Add missing test management packages
|
||||||
Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
|
* Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
|
||||||
|
|
||||||
## [1.0.2] - 2026-01-13
|
## [1.0.2] - 2026-01-13
|
||||||
|
|
||||||
Improvement: Add delays between router ops to prevent traffic collisions
|
* Improvement: Add delays between router ops to prevent traffic collisions
|
||||||
|
|
||||||
## [1.0.1] - 2026-01-13
|
## [1.0.1] - 2026-01-13
|
||||||
|
|
||||||
Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
|
* Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
|
||||||
|
|
||||||
## [1.0.0] - 2026-01-13
|
## [1.0.0] - 2026-01-13
|
||||||
|
|
||||||
Initial full release!
|
* Initial full release!
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ Only one transport may be active at a time. If multiple are set, the server will
|
|||||||
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
|
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
|
||||||
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
|
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
|
||||||
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
|
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
|
||||||
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
|
| `MESHCORE_TCP_PORT` | 5000 | TCP port |
|
||||||
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
|
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
|
||||||
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
||||||
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||||
@@ -193,7 +193,7 @@ Common launch patterns:
|
|||||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
# TCP
|
# TCP
|
||||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
# BLE
|
# BLE
|
||||||
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|||||||
+3
-4
@@ -190,6 +190,7 @@ app/
|
|||||||
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
|
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
|
||||||
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
||||||
- `POST /contacts`
|
- `POST /contacts`
|
||||||
|
- `POST /contacts/bulk-delete`
|
||||||
- `DELETE /contacts/{public_key}`
|
- `DELETE /contacts/{public_key}`
|
||||||
- `POST /contacts/{public_key}/mark-read`
|
- `POST /contacts/{public_key}/mark-read`
|
||||||
- `POST /contacts/{public_key}/command`
|
- `POST /contacts/{public_key}/command`
|
||||||
@@ -214,6 +215,7 @@ app/
|
|||||||
- `GET /channels`
|
- `GET /channels`
|
||||||
- `GET /channels/{key}/detail`
|
- `GET /channels/{key}/detail`
|
||||||
- `POST /channels`
|
- `POST /channels`
|
||||||
|
- `POST /channels/bulk-hashtag`
|
||||||
- `DELETE /channels/{key}`
|
- `DELETE /channels/{key}`
|
||||||
- `POST /channels/{key}/flood-scope-override`
|
- `POST /channels/{key}/flood-scope-override`
|
||||||
- `POST /channels/{key}/mark-read`
|
- `POST /channels/{key}/mark-read`
|
||||||
@@ -300,15 +302,12 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
|
|||||||
- `max_radio_contacts`
|
- `max_radio_contacts`
|
||||||
- `favorites`
|
- `favorites`
|
||||||
- `auto_decrypt_dm_on_advert`
|
- `auto_decrypt_dm_on_advert`
|
||||||
- `sidebar_sort_order`
|
|
||||||
- `last_message_times`
|
- `last_message_times`
|
||||||
- `preferences_migrated`
|
- `preferences_migrated`
|
||||||
- `advert_interval`
|
- `advert_interval`
|
||||||
- `last_advert_time`
|
- `last_advert_time`
|
||||||
- `flood_scope`
|
- `flood_scope`
|
||||||
- `blocked_keys`, `blocked_names`
|
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||||
|
|
||||||
Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode.
|
|
||||||
|
|
||||||
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
|
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
|
|||||||
serial_port: str = "" # Empty string triggers auto-detection
|
serial_port: str = "" # Empty string triggers auto-detection
|
||||||
serial_baudrate: int = 115200
|
serial_baudrate: int = 115200
|
||||||
tcp_host: str = ""
|
tcp_host: str = ""
|
||||||
tcp_port: int = 4000
|
tcp_port: int = 5000
|
||||||
ble_address: str = ""
|
ble_address: str = ""
|
||||||
ble_pin: str = ""
|
ble_pin: str = ""
|
||||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||||
)
|
)
|
||||||
|
skip_post_connect_sync: bool = False
|
||||||
basic_auth_username: str = ""
|
basic_auth_username: str = ""
|
||||||
basic_auth_password: str = ""
|
basic_auth_password: str = ""
|
||||||
|
|
||||||
|
|||||||
+59
-7
@@ -46,7 +46,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
sender_timestamp INTEGER,
|
sender_timestamp INTEGER,
|
||||||
received_at INTEGER NOT NULL,
|
received_at INTEGER NOT NULL,
|
||||||
path TEXT,
|
paths TEXT,
|
||||||
txt_type INTEGER DEFAULT 0,
|
txt_type INTEGER DEFAULT 0,
|
||||||
signature TEXT,
|
signature TEXT,
|
||||||
outgoing INTEGER DEFAULT 0,
|
outgoing INTEGER DEFAULT 0,
|
||||||
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
|
|||||||
data BLOB NOT NULL,
|
data BLOB NOT NULL,
|
||||||
message_id INTEGER,
|
message_id INTEGER,
|
||||||
payload_hash BLOB,
|
payload_hash BLOB,
|
||||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||||
@@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
|||||||
last_seen INTEGER NOT NULL,
|
last_seen INTEGER NOT NULL,
|
||||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||||
UNIQUE(public_key, path_hex, path_len),
|
UNIQUE(public_key, path_hex, path_len),
|
||||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS contact_name_history (
|
CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||||
@@ -88,26 +88,68 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
|
|||||||
first_seen INTEGER NOT NULL,
|
first_seen INTEGER NOT NULL,
|
||||||
last_seen INTEGER NOT NULL,
|
last_seen INTEGER NOT NULL,
|
||||||
UNIQUE(public_key, name),
|
UNIQUE(public_key, name),
|
||||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
max_radio_contacts INTEGER DEFAULT 200,
|
||||||
|
favorites TEXT DEFAULT '[]',
|
||||||
|
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
|
||||||
|
last_message_times TEXT DEFAULT '{}',
|
||||||
|
preferences_migrated INTEGER DEFAULT 0,
|
||||||
|
advert_interval INTEGER DEFAULT 0,
|
||||||
|
last_advert_time INTEGER DEFAULT 0,
|
||||||
|
flood_scope TEXT DEFAULT '',
|
||||||
|
blocked_keys TEXT DEFAULT '[]',
|
||||||
|
blocked_names TEXT DEFAULT '[]',
|
||||||
|
discovery_blocked_types TEXT DEFAULT '[]'
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fanout_configs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
enabled INTEGER DEFAULT 0,
|
||||||
|
config TEXT NOT NULL DEFAULT '{}',
|
||||||
|
scope TEXT NOT NULL DEFAULT '{}',
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
||||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||||
WHERE type = 'CHAN';
|
WHERE type = 'CHAN';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
|
||||||
|
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||||
|
WHERE type = 'PRIV' AND outgoing = 0;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_pagination
|
||||||
|
ON messages(type, conversation_key, received_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_unread_covering
|
||||||
|
ON messages(type, conversation_key, outgoing, received_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
|
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
|
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||||
ON messages(type, received_at, conversation_key);
|
ON messages(type, received_at, conversation_key);
|
||||||
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
|
||||||
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
||||||
ON contact_advert_paths(public_key, last_seen DESC);
|
ON contact_advert_paths(public_key, last_seen DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
|
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
|
||||||
ON contact_name_history(public_key, last_seen DESC);
|
ON contact_name_history(public_key, last_seen DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
|
||||||
|
ON repeater_telemetry_history(public_key, timestamp);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -132,6 +174,12 @@ class Database:
|
|||||||
# migration 20 handles the one-time VACUUM to restructure the file.
|
# migration 20 handles the one-time VACUUM to restructure the file.
|
||||||
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||||
|
|
||||||
|
# Foreign key enforcement: must be set per-connection (not persisted).
|
||||||
|
# Disabled during schema init and migrations to avoid issues with
|
||||||
|
# historical table-rebuild migrations that may temporarily violate
|
||||||
|
# constraints, then re-enabled for all subsequent application queries.
|
||||||
|
await self._connection.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
|
||||||
await self._connection.executescript(SCHEMA)
|
await self._connection.executescript(SCHEMA)
|
||||||
await self._connection.commit()
|
await self._connection.commit()
|
||||||
logger.debug("Database schema initialized")
|
logger.debug("Database schema initialized")
|
||||||
@@ -141,6 +189,10 @@ class Database:
|
|||||||
|
|
||||||
await run_migrations(self._connection)
|
await run_migrations(self._connection)
|
||||||
|
|
||||||
|
# Enable FK enforcement for all application queries from this point on.
|
||||||
|
await self._connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
logger.debug("Foreign key enforcement enabled")
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
if self._connection:
|
if self._connection:
|
||||||
await self._connection.close()
|
await self._connection.close()
|
||||||
|
|||||||
@@ -202,7 +202,6 @@ async def on_path_update(event: "Event") -> None:
|
|||||||
# Legacy firmware/library payloads only support 1-byte hop hashes.
|
# Legacy firmware/library payloads only support 1-byte hop hashes.
|
||||||
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
|
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
|
||||||
else:
|
else:
|
||||||
normalized_path_hash_mode = None
|
|
||||||
try:
|
try:
|
||||||
normalized_path_hash_mode = int(path_hash_mode)
|
normalized_path_hash_mode = int(path_hash_mode)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|||||||
@@ -80,14 +80,6 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
|
|
||||||
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
|
|
||||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
|
||||||
if adapter is None:
|
|
||||||
return data
|
|
||||||
return adapter.validate_python(data)
|
|
||||||
|
|
||||||
|
|
||||||
def dump_ws_event(event_type: str, data: Any) -> str:
|
def dump_ws_event(event_type: str, data: Any) -> str:
|
||||||
"""Serialize a WebSocket event envelope with validation for known event types."""
|
"""Serialize a WebSocket event envelope with validation for known event types."""
|
||||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||||
@@ -104,13 +96,3 @@ def dump_ws_event(event_type: str, data: Any) -> str:
|
|||||||
event_type,
|
event_type,
|
||||||
)
|
)
|
||||||
return json.dumps({"type": event_type, "data": data})
|
return json.dumps({"type": event_type, "data": data})
|
||||||
|
|
||||||
|
|
||||||
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
|
|
||||||
"""Return the JSON-serializable payload for a WebSocket event."""
|
|
||||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
|
||||||
if adapter is None:
|
|
||||||
return data
|
|
||||||
|
|
||||||
validated = adapter.validate_python(data)
|
|
||||||
return adapter.dump_python(validated, mode="json")
|
|
||||||
|
|||||||
@@ -144,11 +144,8 @@ class MapUploadModule(FanoutModule):
|
|||||||
if advert is None:
|
if advert is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# TODO: advert Ed25519 signature verification is skipped here.
|
# Advert Ed25519 signature verification is intentionally skipped.
|
||||||
# The radio has already validated the packet before passing it to RT,
|
# The radio validates packets before passing them to RT.
|
||||||
# so re-verification is redundant in practice. If added, verify that
|
|
||||||
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
|
|
||||||
# advert.public_key_bytes) succeeds before proceeding.
|
|
||||||
|
|
||||||
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
||||||
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
||||||
|
|||||||
+248
-8
@@ -367,6 +367,34 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
|||||||
await set_version(conn, 47)
|
await set_version(conn, 47)
|
||||||
applied += 1
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 48: Add discovery_blocked_types column to app_settings
|
||||||
|
if version < 48:
|
||||||
|
logger.info("Applying migration 48: add discovery_blocked_types to app_settings")
|
||||||
|
await _migrate_048_discovery_blocked_types(conn)
|
||||||
|
await set_version(conn, 48)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 49: Enable foreign key enforcement — rebuild tables with
|
||||||
|
# CASCADE / SET NULL and clean up any orphaned rows first.
|
||||||
|
if version < 49:
|
||||||
|
logger.info("Applying migration 49: add foreign key cascade/set-null and clean orphans")
|
||||||
|
await _migrate_049_foreign_key_cascade(conn)
|
||||||
|
await set_version(conn, 49)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 50: Repeater telemetry history table + tracking opt-in column
|
||||||
|
if version < 50:
|
||||||
|
logger.info("Applying migration 50: repeater telemetry history")
|
||||||
|
await _migrate_050_repeater_telemetry_history(conn)
|
||||||
|
await set_version(conn, 50)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
if version < 51:
|
||||||
|
logger.info("Applying migration 51: drop sidebar_sort_order from app_settings")
|
||||||
|
await _migrate_051_drop_sidebar_sort_order(conn)
|
||||||
|
await set_version(conn, 51)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
if applied > 0:
|
if applied > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||||
@@ -829,7 +857,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
|||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
max_radio_contacts INTEGER DEFAULT 200,
|
max_radio_contacts INTEGER DEFAULT 200,
|
||||||
favorites TEXT DEFAULT '[]',
|
favorites TEXT DEFAULT '[]',
|
||||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
|
||||||
sidebar_sort_order TEXT DEFAULT 'recent',
|
sidebar_sort_order TEXT DEFAULT 'recent',
|
||||||
last_message_times TEXT DEFAULT '{}',
|
last_message_times TEXT DEFAULT '{}',
|
||||||
preferences_migrated INTEGER DEFAULT 0
|
preferences_migrated INTEGER DEFAULT 0
|
||||||
@@ -837,13 +865,9 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize with default row
|
# Initialize with default row (use only the id column so this works
|
||||||
await conn.execute(
|
# regardless of which columns exist — defaults fill the rest).
|
||||||
"""
|
await conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
|
||||||
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
|
|
||||||
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
logger.debug("Created app_settings table with default values")
|
logger.debug("Created app_settings table with default values")
|
||||||
@@ -2909,3 +2933,219 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_048_discovery_blocked_types(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add discovery_blocked_types column to app_settings.
|
||||||
|
|
||||||
|
Stores a JSON array of integer contact type codes (1=Client, 2=Repeater,
|
||||||
|
3=Room, 4=Sensor) whose advertisements should not create new contacts.
|
||||||
|
Empty list means all types are accepted.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE app_settings ADD COLUMN discovery_blocked_types TEXT DEFAULT '[]'"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
if "duplicate column" in error_msg:
|
||||||
|
logger.debug("discovery_blocked_types column already exists, skipping")
|
||||||
|
elif "no such table" in error_msg:
|
||||||
|
logger.debug("app_settings table not ready, skipping discovery_blocked_types migration")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Rebuild FK tables with CASCADE/SET NULL and clean orphaned rows.
|
||||||
|
|
||||||
|
SQLite cannot ALTER existing FK constraints, so each table is rebuilt.
|
||||||
|
Orphaned child rows are cleaned up before the rebuild to ensure the
|
||||||
|
INSERT...SELECT into the new table (which has enforced FKs) succeeds.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Back up the database before table rebuilds (skip for in-memory DBs).
|
||||||
|
cursor = await conn.execute("PRAGMA database_list")
|
||||||
|
db_row = await cursor.fetchone()
|
||||||
|
db_path = db_row[2] if db_row else ""
|
||||||
|
if db_path and db_path != ":memory:" and Path(db_path).exists():
|
||||||
|
backup_path = db_path + ".pre-fk-migration.bak"
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
src = Path(db_path + suffix)
|
||||||
|
if src.exists():
|
||||||
|
shutil.copy2(str(src), backup_path + suffix)
|
||||||
|
logger.info("Database backed up to %s before FK migration", backup_path)
|
||||||
|
|
||||||
|
# --- Phase 1: clean orphans (guard each table's existence) ---
|
||||||
|
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
|
||||||
|
|
||||||
|
if "contact_advert_paths" in existing_tables and "contacts" in existing_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM contact_advert_paths "
|
||||||
|
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
|
||||||
|
)
|
||||||
|
if "contact_name_history" in existing_tables and "contacts" in existing_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM contact_name_history "
|
||||||
|
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
|
||||||
|
)
|
||||||
|
if "raw_packets" in existing_tables and "messages" in existing_tables:
|
||||||
|
# Guard: message_id column may not exist on very old schemas
|
||||||
|
col_cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||||
|
raw_cols = {row[1] for row in await col_cursor.fetchall()}
|
||||||
|
if "message_id" in raw_cols:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE raw_packets SET message_id = NULL WHERE message_id IS NOT NULL "
|
||||||
|
"AND message_id NOT IN (SELECT id FROM messages)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
logger.debug("Cleaned orphaned child rows before FK rebuild")
|
||||||
|
|
||||||
|
# --- Phase 2: rebuild raw_packets with ON DELETE SET NULL ---
|
||||||
|
# Skip if raw_packets doesn't have message_id (pre-migration-18 schema)
|
||||||
|
raw_has_message_id = False
|
||||||
|
if "raw_packets" in existing_tables:
|
||||||
|
col_cursor2 = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||||
|
raw_has_message_id = "message_id" in {row[1] for row in await col_cursor2.fetchall()}
|
||||||
|
|
||||||
|
if raw_has_message_id:
|
||||||
|
# Dynamically build column list based on what the old table actually has,
|
||||||
|
# since very old schemas may lack payload_hash (added in migration 28).
|
||||||
|
col_cursor3 = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||||
|
old_cols = [row[1] for row in await col_cursor3.fetchall()]
|
||||||
|
|
||||||
|
new_col_defs = [
|
||||||
|
"id INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"timestamp INTEGER NOT NULL",
|
||||||
|
"data BLOB NOT NULL",
|
||||||
|
"message_id INTEGER",
|
||||||
|
]
|
||||||
|
copy_cols = ["id", "timestamp", "data", "message_id"]
|
||||||
|
if "payload_hash" in old_cols:
|
||||||
|
new_col_defs.append("payload_hash BLOB")
|
||||||
|
copy_cols.append("payload_hash")
|
||||||
|
new_col_defs.append("FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL")
|
||||||
|
|
||||||
|
cols_sql = ", ".join(new_col_defs)
|
||||||
|
copy_sql = ", ".join(copy_cols)
|
||||||
|
await conn.execute(f"CREATE TABLE raw_packets_fk ({cols_sql})")
|
||||||
|
await conn.execute(
|
||||||
|
f"INSERT INTO raw_packets_fk ({copy_sql}) SELECT {copy_sql} FROM raw_packets"
|
||||||
|
)
|
||||||
|
await conn.execute("DROP TABLE raw_packets")
|
||||||
|
await conn.execute("ALTER TABLE raw_packets_fk RENAME TO raw_packets")
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id)"
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
|
||||||
|
)
|
||||||
|
if "payload_hash" in old_cols:
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
logger.debug("Rebuilt raw_packets with ON DELETE SET NULL")
|
||||||
|
|
||||||
|
# --- Phase 3: rebuild contact_advert_paths with ON DELETE CASCADE ---
|
||||||
|
if "contact_advert_paths" in existing_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE contact_advert_paths_fk (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
path_hex TEXT NOT NULL,
|
||||||
|
path_len INTEGER NOT NULL,
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE(public_key, path_hex, path_len),
|
||||||
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO contact_advert_paths_fk (id, public_key, path_hex, path_len, first_seen, last_seen, heard_count) "
|
||||||
|
"SELECT id, public_key, path_hex, path_len, first_seen, last_seen, heard_count FROM contact_advert_paths"
|
||||||
|
)
|
||||||
|
await conn.execute("DROP TABLE contact_advert_paths")
|
||||||
|
await conn.execute("ALTER TABLE contact_advert_paths_fk RENAME TO contact_advert_paths")
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
|
||||||
|
"ON contact_advert_paths(public_key, last_seen DESC)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
logger.debug("Rebuilt contact_advert_paths with ON DELETE CASCADE")
|
||||||
|
|
||||||
|
# --- Phase 4: rebuild contact_name_history with ON DELETE CASCADE ---
|
||||||
|
if "contact_name_history" in existing_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE contact_name_history_fk (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
UNIQUE(public_key, name),
|
||||||
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO contact_name_history_fk (id, public_key, name, first_seen, last_seen) "
|
||||||
|
"SELECT id, public_key, name, first_seen, last_seen FROM contact_name_history"
|
||||||
|
)
|
||||||
|
await conn.execute("DROP TABLE contact_name_history")
|
||||||
|
await conn.execute("ALTER TABLE contact_name_history_fk RENAME TO contact_name_history")
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_contact_name_history_key "
|
||||||
|
"ON contact_name_history(public_key, last_seen DESC)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots."""
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
|
||||||
|
ON repeater_telemetry_history (public_key, timestamp)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_051_drop_sidebar_sort_order(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Remove vestigial sidebar_sort_order column from app_settings."""
|
||||||
|
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||||
|
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||||
|
if "sidebar_sort_order" in columns:
|
||||||
|
try:
|
||||||
|
await conn.execute("ALTER TABLE app_settings DROP COLUMN sidebar_sort_order")
|
||||||
|
await conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
if "syntax error" in error_msg or "drop column" in error_msg:
|
||||||
|
logger.debug(
|
||||||
|
"SQLite doesn't support DROP COLUMN, sidebar_sort_order column will remain"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|||||||
+16
-29
@@ -283,30 +283,6 @@ class NearestRepeater(BaseModel):
|
|||||||
heard_count: int
|
heard_count: int
|
||||||
|
|
||||||
|
|
||||||
class ContactDetail(BaseModel):
|
|
||||||
"""Comprehensive contact profile data."""
|
|
||||||
|
|
||||||
contact: Contact
|
|
||||||
name_history: list[ContactNameHistory] = Field(default_factory=list)
|
|
||||||
dm_message_count: int = 0
|
|
||||||
channel_message_count: int = 0
|
|
||||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
|
||||||
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
|
|
||||||
advert_frequency: float | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Advert observations per hour (includes multi-path arrivals of same advert)",
|
|
||||||
)
|
|
||||||
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class NameOnlyContactDetail(BaseModel):
|
|
||||||
"""Channel activity summary for a sender name that is not tied to a known key."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
channel_message_count: int = 0
|
|
||||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class ContactAnalyticsHourlyBucket(BaseModel):
|
class ContactAnalyticsHourlyBucket(BaseModel):
|
||||||
"""A single hourly activity bucket for contact analytics."""
|
"""A single hourly activity bucket for contact analytics."""
|
||||||
|
|
||||||
@@ -530,6 +506,9 @@ class RepeaterStatusResponse(BaseModel):
|
|||||||
flood_dups: int = Field(description="Duplicate flood packets")
|
flood_dups: int = Field(description="Duplicate flood packets")
|
||||||
direct_dups: int = Field(description="Duplicate direct packets")
|
direct_dups: int = Field(description="Duplicate direct packets")
|
||||||
full_events: int = Field(description="Full event queue count")
|
full_events: int = Field(description="Full event queue count")
|
||||||
|
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||||
|
default_factory=list, description="Recent telemetry history snapshots"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RepeaterNodeInfoResponse(BaseModel):
|
class RepeaterNodeInfoResponse(BaseModel):
|
||||||
@@ -805,13 +784,9 @@ class AppSettings(BaseModel):
|
|||||||
default_factory=list, description="List of favorited conversations"
|
default_factory=list, description="List of favorited conversations"
|
||||||
)
|
)
|
||||||
auto_decrypt_dm_on_advert: bool = Field(
|
auto_decrypt_dm_on_advert: bool = Field(
|
||||||
default=False,
|
default=True,
|
||||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||||
)
|
)
|
||||||
sidebar_sort_order: Literal["recent", "alpha"] = Field(
|
|
||||||
default="recent",
|
|
||||||
description="Sidebar sort order: 'recent' or 'alpha'",
|
|
||||||
)
|
|
||||||
last_message_times: dict[str, int] = Field(
|
last_message_times: dict[str, int] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Map of conversation state keys to last message timestamps",
|
description="Map of conversation state keys to last message timestamps",
|
||||||
@@ -840,6 +815,13 @@ class AppSettings(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Display names whose messages are hidden from the UI",
|
description="Display names whose messages are hidden from the UI",
|
||||||
)
|
)
|
||||||
|
discovery_blocked_types: list[int] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description=(
|
||||||
|
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||||
|
"advertisements should not create new contacts; existing contacts are still updated"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FanoutConfig(BaseModel):
|
class FanoutConfig(BaseModel):
|
||||||
@@ -914,3 +896,8 @@ class StatisticsResponse(BaseModel):
|
|||||||
known_channels_active: ContactActivityCounts
|
known_channels_active: ContactActivityCounts
|
||||||
path_hash_width_24h: PathHashWidthStats
|
path_hash_width_24h: PathHashWidthStats
|
||||||
noise_floor_24h: NoiseFloorHistoryStats
|
noise_floor_24h: NoiseFloorHistoryStats
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryHistoryEntry(BaseModel):
|
||||||
|
timestamp: int
|
||||||
|
data: dict
|
||||||
|
|||||||
+24
-8
@@ -462,14 +462,19 @@ async def _process_advertisement(
|
|||||||
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Keep recent unique advert paths for all contacts.
|
# Check discovery_blocked_types: skip new contacts whose type is blocked.
|
||||||
await ContactAdvertPathRepository.record_observation(
|
# Existing contacts are always updated (location, name, last_seen, etc.).
|
||||||
public_key=advert.public_key.lower(),
|
if existing is None and contact_type > 0:
|
||||||
path_hex=new_path_hex,
|
from app.repository import AppSettingsRepository
|
||||||
timestamp=timestamp,
|
|
||||||
max_paths=10,
|
settings = await AppSettingsRepository.get()
|
||||||
hop_count=new_path_len,
|
if contact_type in settings.discovery_blocked_types:
|
||||||
)
|
logger.debug(
|
||||||
|
"Skipping new contact %s: type %d is in discovery_blocked_types",
|
||||||
|
advert.public_key[:12],
|
||||||
|
contact_type,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
contact_upsert = ContactUpsert(
|
contact_upsert = ContactUpsert(
|
||||||
public_key=advert.public_key.lower(),
|
public_key=advert.public_key.lower(),
|
||||||
@@ -482,7 +487,18 @@ async def _process_advertisement(
|
|||||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Upsert the contact BEFORE recording advert paths so the parent row
|
||||||
|
# exists when foreign key enforcement is enabled.
|
||||||
await ContactRepository.upsert(contact_upsert)
|
await ContactRepository.upsert(contact_upsert)
|
||||||
|
|
||||||
|
# Keep recent unique advert paths for all contacts.
|
||||||
|
await ContactAdvertPathRepository.record_observation(
|
||||||
|
public_key=advert.public_key.lower(),
|
||||||
|
path_hex=new_path_hex,
|
||||||
|
timestamp=timestamp,
|
||||||
|
max_paths=10,
|
||||||
|
hop_count=new_path_len,
|
||||||
|
)
|
||||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
public_key=advert.public_key,
|
public_key=advert.public_key,
|
||||||
log=logger,
|
log=logger,
|
||||||
|
|||||||
+35
-68
@@ -29,7 +29,10 @@ from app.repository import (
|
|||||||
ChannelRepository,
|
ChannelRepository,
|
||||||
ContactRepository,
|
ContactRepository,
|
||||||
)
|
)
|
||||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
from app.services.contact_reconciliation import (
|
||||||
|
promote_prefix_contacts_for_contact,
|
||||||
|
reconcile_contact_messages,
|
||||||
|
)
|
||||||
from app.services.messages import create_fallback_channel_message
|
from app.services.messages import create_fallback_channel_message
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
from app.websocket import broadcast_error, broadcast_event
|
from app.websocket import broadcast_error, broadcast_event
|
||||||
@@ -63,13 +66,25 @@ async def _reconcile_contact_messages_background(
|
|||||||
public_key: str,
|
public_key: str,
|
||||||
contact_name: str | None,
|
contact_name: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run contact/message reconciliation outside the radio critical path."""
|
"""Run prefix promotion and contact/message reconciliation outside the radio critical path."""
|
||||||
try:
|
try:
|
||||||
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
|
public_key=public_key,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
await reconcile_contact_messages(
|
await reconcile_contact_messages(
|
||||||
public_key=public_key,
|
public_key=public_key,
|
||||||
contact_name=contact_name,
|
contact_name=contact_name,
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
if promoted_keys:
|
||||||
|
contact = await ContactRepository.get_by_key(public_key.lower())
|
||||||
|
if contact is not None:
|
||||||
|
for old_key in promoted_keys:
|
||||||
|
broadcast_event(
|
||||||
|
"contact_resolved",
|
||||||
|
{"previous_public_key": old_key, "contact": contact.model_dump()},
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Background contact reconciliation failed for %s: %s",
|
"Background contact reconciliation failed for %s: %s",
|
||||||
@@ -179,6 +194,22 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
|
|||||||
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_radio_capacity(configured: int) -> int:
|
||||||
|
"""Return the effective radio contact capacity.
|
||||||
|
|
||||||
|
Uses the lower of the user-configured ``max_radio_contacts`` and the
|
||||||
|
hardware limit reported by the radio at connect time. The existing
|
||||||
|
80% refill ratio already reserves headroom for the radio to
|
||||||
|
organically add contacts it hears via adverts, so no additional
|
||||||
|
reduction is applied here.
|
||||||
|
"""
|
||||||
|
capacity = max(1, configured)
|
||||||
|
hw_limit = radio_manager.max_contacts
|
||||||
|
if hw_limit is not None:
|
||||||
|
capacity = min(capacity, hw_limit)
|
||||||
|
return max(1, capacity)
|
||||||
|
|
||||||
|
|
||||||
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||||
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
||||||
capacity = max(1, max_contacts)
|
capacity = max(1, max_contacts)
|
||||||
@@ -193,7 +224,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
|||||||
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||||
"""Check current radio occupancy and decide whether to offload/reload."""
|
"""Check current radio occupancy and decide whether to offload/reload."""
|
||||||
app_settings = await AppSettingsRepository.get()
|
app_settings = await AppSettingsRepository.get()
|
||||||
capacity = app_settings.max_radio_contacts
|
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||||
|
|
||||||
result = await mc.commands.get_contacts()
|
result = await mc.commands.get_contacts()
|
||||||
@@ -222,70 +253,6 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
|
||||||
"""
|
|
||||||
Sync contacts from radio to database, then remove them from radio.
|
|
||||||
Returns counts of synced and removed contacts.
|
|
||||||
"""
|
|
||||||
synced = 0
|
|
||||||
removed = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get all contacts from radio
|
|
||||||
result = await mc.commands.get_contacts()
|
|
||||||
|
|
||||||
if result is None or result.type == EventType.ERROR:
|
|
||||||
logger.error(
|
|
||||||
"Failed to get contacts from radio: %s. "
|
|
||||||
"If you see this repeatedly, the radio may be visible on the "
|
|
||||||
"serial/TCP/BLE port but not responding to commands. Check for "
|
|
||||||
"another process with the serial port open (other RemoteTerm "
|
|
||||||
"instances, serial monitors, etc.), verify the firmware is "
|
|
||||||
"up-to-date and in client mode (not repeater), or try a "
|
|
||||||
"power cycle.",
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
return {"synced": 0, "removed": 0, "error": str(result)}
|
|
||||||
|
|
||||||
contacts = result.payload or {}
|
|
||||||
logger.info("Found %d contacts on radio", len(contacts))
|
|
||||||
|
|
||||||
# Sync each contact to database, then remove from radio
|
|
||||||
for public_key, contact_data in contacts.items():
|
|
||||||
# Save to database
|
|
||||||
await ContactRepository.upsert(
|
|
||||||
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
|
|
||||||
)
|
|
||||||
asyncio.create_task(
|
|
||||||
_reconcile_contact_messages_background(
|
|
||||||
public_key,
|
|
||||||
contact_data.get("adv_name"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
synced += 1
|
|
||||||
|
|
||||||
# Remove from radio
|
|
||||||
try:
|
|
||||||
remove_result = await mc.commands.remove_contact(contact_data)
|
|
||||||
if remove_result.type == EventType.OK:
|
|
||||||
removed += 1
|
|
||||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error removing contact %s: %s", public_key[:12], e)
|
|
||||||
|
|
||||||
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error during contact sync: %s", e)
|
|
||||||
return {"synced": synced, "removed": removed, "error": str(e)}
|
|
||||||
|
|
||||||
return {"synced": synced, "removed": removed}
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
|
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Sync channels from radio to database, then clear them from radio.
|
Sync channels from radio to database, then clear them from radio.
|
||||||
@@ -1301,7 +1268,7 @@ async def stop_background_contact_reconciliation() -> None:
|
|||||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||||
"""Return the contacts that would be loaded onto the radio right now."""
|
"""Return the contacts that would be loaded onto the radio right now."""
|
||||||
app_settings = await AppSettingsRepository.get()
|
app_settings = await AppSettingsRepository.get()
|
||||||
max_contacts = app_settings.max_radio_contacts
|
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||||
selected_contacts: list[Contact] = []
|
selected_contacts: list[Contact] = []
|
||||||
selected_keys: set[str] = set()
|
selected_keys: set[str] = set()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.repository.contacts import (
|
|||||||
from app.repository.fanout import FanoutConfigRepository
|
from app.repository.fanout import FanoutConfigRepository
|
||||||
from app.repository.messages import MessageRepository
|
from app.repository.messages import MessageRepository
|
||||||
from app.repository.raw_packets import RawPacketRepository
|
from app.repository.raw_packets import RawPacketRepository
|
||||||
|
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
|
||||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -20,5 +21,6 @@ __all__ = [
|
|||||||
"FanoutConfigRepository",
|
"FanoutConfigRepository",
|
||||||
"MessageRepository",
|
"MessageRepository",
|
||||||
"RawPacketRepository",
|
"RawPacketRepository",
|
||||||
|
"RepeaterTelemetryRepository",
|
||||||
"StatisticsRepository",
|
"StatisticsRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
+66
-57
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -12,6 +13,8 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||||
"""Raised when a public key prefix matches multiple contacts."""
|
"""Raised when a public key prefix matches multiple contacts."""
|
||||||
@@ -392,12 +395,9 @@ class ContactRepository:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete(public_key: str) -> None:
|
async def delete(public_key: str) -> None:
|
||||||
normalized = public_key.lower()
|
normalized = public_key.lower()
|
||||||
await db.conn.execute(
|
# contact_name_history and contact_advert_paths cascade via FK.
|
||||||
"DELETE FROM contact_name_history WHERE public_key = ?", (normalized,)
|
# Messages are intentionally preserved so history re-surfaces
|
||||||
)
|
# if the contact is re-added later.
|
||||||
await db.conn.execute(
|
|
||||||
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
|
|
||||||
)
|
|
||||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
|
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
|
|
||||||
@@ -484,7 +484,6 @@ class ContactRepository:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
promoted_keys: list[str] = []
|
promoted_keys: list[str] = []
|
||||||
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
|
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
old_key = row["public_key"]
|
old_key = row["public_key"]
|
||||||
@@ -501,60 +500,70 @@ class ContactRepository:
|
|||||||
(old_key,),
|
(old_key,),
|
||||||
)
|
)
|
||||||
match_row = await match_cursor.fetchone()
|
match_row = await match_cursor.fetchone()
|
||||||
if (match_row["match_count"] if match_row is not None else 0) != 1:
|
match_count = match_row["match_count"] if match_row is not None else 0
|
||||||
|
if match_count != 1:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
|
||||||
|
old_key,
|
||||||
|
match_count,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await migrate_child_rows(old_key, normalized_full_key)
|
await migrate_child_rows(old_key, normalized_full_key)
|
||||||
|
|
||||||
if full_exists:
|
# Merge timestamp metadata from the old prefix contact into the
|
||||||
await db.conn.execute(
|
# full-key contact (which all callers guarantee already exists),
|
||||||
"""
|
# then delete the prefix placeholder.
|
||||||
UPDATE contacts
|
await db.conn.execute(
|
||||||
SET last_seen = CASE
|
"""
|
||||||
WHEN contacts.last_seen IS NULL THEN ?
|
UPDATE contacts
|
||||||
WHEN ? IS NULL THEN contacts.last_seen
|
SET last_seen = CASE
|
||||||
WHEN ? > contacts.last_seen THEN ?
|
WHEN contacts.last_seen IS NULL THEN ?
|
||||||
ELSE contacts.last_seen
|
WHEN ? IS NULL THEN contacts.last_seen
|
||||||
END,
|
WHEN ? > contacts.last_seen THEN ?
|
||||||
last_contacted = CASE
|
ELSE contacts.last_seen
|
||||||
WHEN contacts.last_contacted IS NULL THEN ?
|
END,
|
||||||
WHEN ? IS NULL THEN contacts.last_contacted
|
last_contacted = CASE
|
||||||
WHEN ? > contacts.last_contacted THEN ?
|
WHEN contacts.last_contacted IS NULL THEN ?
|
||||||
ELSE contacts.last_contacted
|
WHEN ? IS NULL THEN contacts.last_contacted
|
||||||
END,
|
WHEN ? > contacts.last_contacted THEN ?
|
||||||
first_seen = CASE
|
ELSE contacts.last_contacted
|
||||||
WHEN contacts.first_seen IS NULL THEN ?
|
END,
|
||||||
WHEN ? IS NULL THEN contacts.first_seen
|
first_seen = CASE
|
||||||
WHEN ? < contacts.first_seen THEN ?
|
WHEN contacts.first_seen IS NULL THEN ?
|
||||||
ELSE contacts.first_seen
|
WHEN ? IS NULL THEN contacts.first_seen
|
||||||
END,
|
WHEN ? < contacts.first_seen THEN ?
|
||||||
last_read_at = COALESCE(contacts.last_read_at, ?)
|
ELSE contacts.first_seen
|
||||||
WHERE public_key = ?
|
END,
|
||||||
""",
|
last_read_at = CASE
|
||||||
(
|
WHEN contacts.last_read_at IS NULL THEN ?
|
||||||
row["last_seen"],
|
WHEN ? IS NULL THEN contacts.last_read_at
|
||||||
row["last_seen"],
|
WHEN ? > contacts.last_read_at THEN ?
|
||||||
row["last_seen"],
|
ELSE contacts.last_read_at
|
||||||
row["last_seen"],
|
END
|
||||||
row["last_contacted"],
|
WHERE public_key = ?
|
||||||
row["last_contacted"],
|
""",
|
||||||
row["last_contacted"],
|
(
|
||||||
row["last_contacted"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_contacted"],
|
||||||
row["last_read_at"],
|
row["last_contacted"],
|
||||||
normalized_full_key,
|
row["last_contacted"],
|
||||||
),
|
row["last_contacted"],
|
||||||
)
|
row["first_seen"],
|
||||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
row["first_seen"],
|
||||||
else:
|
row["first_seen"],
|
||||||
await db.conn.execute(
|
row["first_seen"],
|
||||||
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
|
row["last_read_at"],
|
||||||
(normalized_full_key, old_key),
|
row["last_read_at"],
|
||||||
)
|
row["last_read_at"],
|
||||||
full_exists = True
|
row["last_read_at"],
|
||||||
|
normalized_full_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||||
|
|
||||||
promoted_keys.append(old_key)
|
promoted_keys.append(old_key)
|
||||||
|
|
||||||
|
|||||||
+25
-16
@@ -29,8 +29,7 @@ class MessageRepository:
|
|||||||
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
|
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
|
||||||
lower_key = public_key.lower()
|
lower_key = public_key.lower()
|
||||||
return (
|
return (
|
||||||
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
|
"((type = 'PRIV' AND conversation_key = ?) OR (type = 'CHAN' AND sender_key = ?))",
|
||||||
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
|
|
||||||
[lower_key, lower_key],
|
[lower_key, lower_key],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,6 +80,9 @@ class MessageRepository:
|
|||||||
entry["path_len"] = path_len
|
entry["path_len"] = path_len
|
||||||
paths_json = json.dumps([entry])
|
paths_json = json.dumps([entry])
|
||||||
|
|
||||||
|
# Normalize sender_key to lowercase so queries can match without LOWER().
|
||||||
|
normalized_sender_key = sender_key.lower() if sender_key else sender_key
|
||||||
|
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
||||||
@@ -99,7 +101,7 @@ class MessageRepository:
|
|||||||
signature,
|
signature,
|
||||||
outgoing,
|
outgoing,
|
||||||
sender_name,
|
sender_name,
|
||||||
sender_key,
|
normalized_sender_key,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
@@ -158,7 +160,11 @@ class MessageRepository:
|
|||||||
"""
|
"""
|
||||||
lower_key = full_key.lower()
|
lower_key = full_key.lower()
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""UPDATE messages SET conversation_key = ?
|
"""UPDATE messages SET conversation_key = ?,
|
||||||
|
sender_key = CASE
|
||||||
|
WHEN sender_key IS NOT NULL AND length(sender_key) < 64
|
||||||
|
AND ? LIKE sender_key || '%'
|
||||||
|
THEN ? ELSE sender_key END
|
||||||
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||||
AND ? LIKE conversation_key || '%'
|
AND ? LIKE conversation_key || '%'
|
||||||
AND (
|
AND (
|
||||||
@@ -166,7 +172,7 @@ class MessageRepository:
|
|||||||
WHERE length(public_key) = 64
|
WHERE length(public_key) = 64
|
||||||
AND public_key LIKE messages.conversation_key || '%'
|
AND public_key LIKE messages.conversation_key || '%'
|
||||||
) = 1""",
|
) = 1""",
|
||||||
(lower_key, lower_key),
|
(lower_key, lower_key, lower_key, lower_key),
|
||||||
)
|
)
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
@@ -255,10 +261,10 @@ class MessageRepository:
|
|||||||
|
|
||||||
if MessageRepository._looks_like_hex_prefix(value):
|
if MessageRepository._looks_like_hex_prefix(value):
|
||||||
if len(value) == 32:
|
if len(value) == 32:
|
||||||
clause += " OR UPPER(messages.conversation_key) = ?"
|
clause += " OR messages.conversation_key = ?"
|
||||||
params.append(value.upper())
|
params.append(value.upper())
|
||||||
else:
|
else:
|
||||||
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
clause += " OR messages.conversation_key LIKE ? ESCAPE '\\'"
|
||||||
params.append(f"{MessageRepository._escape_like(value.upper())}%")
|
params.append(f"{MessageRepository._escape_like(value.upper())}%")
|
||||||
|
|
||||||
clause += "))"
|
clause += "))"
|
||||||
@@ -277,13 +283,13 @@ class MessageRepository:
|
|||||||
priv_key_clause: str
|
priv_key_clause: str
|
||||||
chan_key_clause: str
|
chan_key_clause: str
|
||||||
if len(value) == 64:
|
if len(value) == 64:
|
||||||
priv_key_clause = "LOWER(messages.conversation_key) = ?"
|
priv_key_clause = "messages.conversation_key = ?"
|
||||||
chan_key_clause = "LOWER(sender_key) = ?"
|
chan_key_clause = "sender_key = ?"
|
||||||
params.extend([lower_value, lower_value])
|
params.extend([lower_value, lower_value])
|
||||||
else:
|
else:
|
||||||
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
|
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
|
||||||
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
priv_key_clause = "messages.conversation_key LIKE ? ESCAPE '\\'"
|
||||||
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
|
chan_key_clause = "sender_key LIKE ? ESCAPE '\\'"
|
||||||
params.extend([escaped_prefix, escaped_prefix])
|
params.extend([escaped_prefix, escaped_prefix])
|
||||||
|
|
||||||
clause += (
|
clause += (
|
||||||
@@ -307,12 +313,12 @@ class MessageRepository:
|
|||||||
if blocked_keys:
|
if blocked_keys:
|
||||||
placeholders = ",".join("?" for _ in blocked_keys)
|
placeholders = ",".join("?" for _ in blocked_keys)
|
||||||
blocked_matchers.append(
|
blocked_matchers.append(
|
||||||
f"({prefix}type = 'PRIV' AND LOWER({prefix}conversation_key) IN ({placeholders}))"
|
f"({prefix}type = 'PRIV' AND {prefix}conversation_key IN ({placeholders}))"
|
||||||
)
|
)
|
||||||
params.extend(blocked_keys)
|
params.extend(blocked_keys)
|
||||||
blocked_matchers.append(
|
blocked_matchers.append(
|
||||||
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
|
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
|
||||||
f" AND LOWER({prefix}sender_key) IN ({placeholders}))"
|
f" AND {prefix}sender_key IN ({placeholders}))"
|
||||||
)
|
)
|
||||||
params.extend(blocked_keys)
|
params.extend(blocked_keys)
|
||||||
|
|
||||||
@@ -379,9 +385,9 @@ class MessageRepository:
|
|||||||
query = (
|
query = (
|
||||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
|
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
|
||||||
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
||||||
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
"AND messages.conversation_key = contacts.public_key "
|
||||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||||
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
|
"AND messages.conversation_key = channels.key "
|
||||||
"WHERE 1=1"
|
"WHERE 1=1"
|
||||||
)
|
)
|
||||||
params: list[Any] = []
|
params: list[Any] = []
|
||||||
@@ -572,6 +578,9 @@ class MessageRepository:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete_by_id(message_id: int) -> None:
|
async def delete_by_id(message_id: int) -> None:
|
||||||
"""Delete a message row by ID."""
|
"""Delete a message row by ID."""
|
||||||
|
await db.conn.execute(
|
||||||
|
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
|
||||||
|
)
|
||||||
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
|
|
||||||
@@ -666,7 +675,7 @@ class MessageRepository:
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) > 0 as has_mention
|
END) > 0 as has_mention
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN contacts ct ON m.conversation_key = ct.public_key
|
LEFT JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||||
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
||||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||||
{blocked_sql}
|
{blocked_sql}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
@@ -35,46 +34,23 @@ class RawPacketRepository:
|
|||||||
# For malformed packets, hash the full data
|
# For malformed packets, hash the full data
|
||||||
payload_hash = sha256(data).digest()
|
payload_hash = sha256(data).digest()
|
||||||
|
|
||||||
# Check if this payload already exists
|
cursor = await db.conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||||
|
(ts, data, payload_hash),
|
||||||
|
)
|
||||||
|
await db.conn.commit()
|
||||||
|
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
assert cursor.lastrowid is not None
|
||||||
|
return (cursor.lastrowid, True)
|
||||||
|
|
||||||
|
# Duplicate payload — look up the existing row.
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||||
)
|
)
|
||||||
existing = await cursor.fetchone()
|
existing = await cursor.fetchone()
|
||||||
|
assert existing is not None
|
||||||
if existing:
|
return (existing["id"], False)
|
||||||
# Duplicate - return existing packet ID
|
|
||||||
logger.debug(
|
|
||||||
"Duplicate payload detected (hash=%s..., existing_id=%d)",
|
|
||||||
payload_hash.hex()[:12],
|
|
||||||
existing["id"],
|
|
||||||
)
|
|
||||||
return (existing["id"], False)
|
|
||||||
|
|
||||||
# New packet - insert with hash
|
|
||||||
try:
|
|
||||||
cursor = await db.conn.execute(
|
|
||||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
|
||||||
(ts, data, payload_hash),
|
|
||||||
)
|
|
||||||
await db.conn.commit()
|
|
||||||
assert cursor.lastrowid is not None # INSERT always returns a row ID
|
|
||||||
return (cursor.lastrowid, True)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
# Race condition: another insert with same payload_hash happened between
|
|
||||||
# our SELECT and INSERT. This is expected for duplicate packets arriving
|
|
||||||
# close together. Query again to get the existing ID.
|
|
||||||
logger.debug(
|
|
||||||
"Duplicate packet detected via race condition (payload_hash=%s), dropping",
|
|
||||||
payload_hash.hex()[:16],
|
|
||||||
)
|
|
||||||
cursor = await db.conn.execute(
|
|
||||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
|
||||||
)
|
|
||||||
existing = await cursor.fetchone()
|
|
||||||
if existing:
|
|
||||||
return (existing["id"], False)
|
|
||||||
# This shouldn't happen, but if it does, re-raise
|
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_undecrypted_count() -> int:
|
async def get_undecrypted_count() -> int:
|
||||||
@@ -95,13 +71,22 @@ class RawPacketRepository:
|
|||||||
return row["oldest"] if row and row["oldest"] is not None else None
|
return row["oldest"] if row and row["oldest"] is not None else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
|
async def stream_all_undecrypted(
|
||||||
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
|
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||||
|
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||||
|
"""Yield all undecrypted packets as (id, data, timestamp) in bounded batches."""
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
try:
|
||||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
while True:
|
||||||
|
rows = await cursor.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for row in rows:
|
||||||
|
yield (row["id"], bytes(row["data"]), row["timestamp"])
|
||||||
|
finally:
|
||||||
|
await cursor.close()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def stream_undecrypted_text_messages(
|
async def stream_undecrypted_text_messages(
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum age for telemetry history entries (30 days)
|
||||||
|
_MAX_AGE_SECONDS = 30 * 86400
|
||||||
|
|
||||||
|
# Maximum entries to keep per repeater (sanity cap)
|
||||||
|
_MAX_ENTRIES_PER_REPEATER = 1000
|
||||||
|
|
||||||
|
|
||||||
|
class RepeaterTelemetryRepository:
|
||||||
|
@staticmethod
|
||||||
|
async def record(
|
||||||
|
public_key: str,
|
||||||
|
timestamp: int,
|
||||||
|
data: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Insert a telemetry history row and prune stale entries."""
|
||||||
|
await db.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO repeater_telemetry_history
|
||||||
|
(public_key, timestamp, data)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(public_key, timestamp, json.dumps(data)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prune entries older than 30 days
|
||||||
|
cutoff = int(time.time()) - _MAX_AGE_SECONDS
|
||||||
|
await db.conn.execute(
|
||||||
|
"DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?",
|
||||||
|
(public_key, cutoff),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cap at _MAX_ENTRIES_PER_REPEATER (keep newest)
|
||||||
|
await db.conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM repeater_telemetry_history
|
||||||
|
WHERE public_key = ? AND id NOT IN (
|
||||||
|
SELECT id FROM repeater_telemetry_history
|
||||||
|
WHERE public_key = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(public_key, public_key, _MAX_ENTRIES_PER_REPEATER),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.conn.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
|
||||||
|
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timestamp, data
|
||||||
|
FROM repeater_telemetry_history
|
||||||
|
WHERE public_key = ? AND timestamp >= ?
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
""",
|
||||||
|
(public_key, since_timestamp),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"timestamp": row["timestamp"],
|
||||||
|
"data": json.loads(row["data"]),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
+15
-13
@@ -27,9 +27,9 @@ class AppSettingsRepository:
|
|||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
last_message_times, preferences_migrated,
|
||||||
advert_interval, last_advert_time, flood_scope,
|
advert_interval, last_advert_time, flood_scope,
|
||||||
blocked_keys, blocked_names
|
blocked_keys, blocked_names, discovery_blocked_types
|
||||||
FROM app_settings WHERE id = 1
|
FROM app_settings WHERE id = 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -81,16 +81,18 @@ class AppSettingsRepository:
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
blocked_names = []
|
blocked_names = []
|
||||||
|
|
||||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
# Parse discovery_blocked_types JSON
|
||||||
sort_order = row["sidebar_sort_order"]
|
discovery_blocked_types: list[int] = []
|
||||||
if sort_order not in ("recent", "alpha"):
|
if row["discovery_blocked_types"]:
|
||||||
sort_order = "recent"
|
try:
|
||||||
|
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
discovery_blocked_types = []
|
||||||
|
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
max_radio_contacts=row["max_radio_contacts"],
|
max_radio_contacts=row["max_radio_contacts"],
|
||||||
favorites=favorites,
|
favorites=favorites,
|
||||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||||
sidebar_sort_order=sort_order,
|
|
||||||
last_message_times=last_message_times,
|
last_message_times=last_message_times,
|
||||||
preferences_migrated=bool(row["preferences_migrated"]),
|
preferences_migrated=bool(row["preferences_migrated"]),
|
||||||
advert_interval=row["advert_interval"] or 0,
|
advert_interval=row["advert_interval"] or 0,
|
||||||
@@ -98,6 +100,7 @@ class AppSettingsRepository:
|
|||||||
flood_scope=row["flood_scope"] or "",
|
flood_scope=row["flood_scope"] or "",
|
||||||
blocked_keys=blocked_keys,
|
blocked_keys=blocked_keys,
|
||||||
blocked_names=blocked_names,
|
blocked_names=blocked_names,
|
||||||
|
discovery_blocked_types=discovery_blocked_types,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -105,7 +108,6 @@ class AppSettingsRepository:
|
|||||||
max_radio_contacts: int | None = None,
|
max_radio_contacts: int | None = None,
|
||||||
favorites: list[Favorite] | None = None,
|
favorites: list[Favorite] | None = None,
|
||||||
auto_decrypt_dm_on_advert: bool | None = None,
|
auto_decrypt_dm_on_advert: bool | None = None,
|
||||||
sidebar_sort_order: str | None = None,
|
|
||||||
last_message_times: dict[str, int] | None = None,
|
last_message_times: dict[str, int] | None = None,
|
||||||
preferences_migrated: bool | None = None,
|
preferences_migrated: bool | None = None,
|
||||||
advert_interval: int | None = None,
|
advert_interval: int | None = None,
|
||||||
@@ -113,6 +115,7 @@ class AppSettingsRepository:
|
|||||||
flood_scope: str | None = None,
|
flood_scope: str | None = None,
|
||||||
blocked_keys: list[str] | None = None,
|
blocked_keys: list[str] | None = None,
|
||||||
blocked_names: list[str] | None = None,
|
blocked_names: list[str] | None = None,
|
||||||
|
discovery_blocked_types: list[int] | None = None,
|
||||||
) -> AppSettings:
|
) -> AppSettings:
|
||||||
"""Update app settings. Only provided fields are updated."""
|
"""Update app settings. Only provided fields are updated."""
|
||||||
updates = []
|
updates = []
|
||||||
@@ -131,10 +134,6 @@ class AppSettingsRepository:
|
|||||||
updates.append("auto_decrypt_dm_on_advert = ?")
|
updates.append("auto_decrypt_dm_on_advert = ?")
|
||||||
params.append(1 if auto_decrypt_dm_on_advert else 0)
|
params.append(1 if auto_decrypt_dm_on_advert else 0)
|
||||||
|
|
||||||
if sidebar_sort_order is not None:
|
|
||||||
updates.append("sidebar_sort_order = ?")
|
|
||||||
params.append(sidebar_sort_order)
|
|
||||||
|
|
||||||
if last_message_times is not None:
|
if last_message_times is not None:
|
||||||
updates.append("last_message_times = ?")
|
updates.append("last_message_times = ?")
|
||||||
params.append(json.dumps(last_message_times))
|
params.append(json.dumps(last_message_times))
|
||||||
@@ -163,6 +162,10 @@ class AppSettingsRepository:
|
|||||||
updates.append("blocked_names = ?")
|
updates.append("blocked_names = ?")
|
||||||
params.append(json.dumps(blocked_names))
|
params.append(json.dumps(blocked_names))
|
||||||
|
|
||||||
|
if discovery_blocked_types is not None:
|
||||||
|
updates.append("discovery_blocked_types = ?")
|
||||||
|
params.append(json.dumps(discovery_blocked_types))
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||||
await db.conn.execute(query, params)
|
await db.conn.execute(query, params)
|
||||||
@@ -238,7 +241,6 @@ class AppSettingsRepository:
|
|||||||
# Update with migrated preferences and mark as migrated
|
# Update with migrated preferences and mark as migrated
|
||||||
settings = await AppSettingsRepository.update(
|
settings = await AppSettingsRepository.update(
|
||||||
favorites=new_favorites,
|
favorites=new_favorites,
|
||||||
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
|
|
||||||
last_message_times=last_message_times,
|
last_message_times=last_message_times,
|
||||||
preferences_migrated=True,
|
preferences_migrated=True,
|
||||||
)
|
)
|
||||||
|
|||||||
+227
-47
@@ -1,7 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.channel_constants import (
|
from app.channel_constants import (
|
||||||
@@ -10,10 +11,12 @@ from app.channel_constants import (
|
|||||||
is_public_channel_key,
|
is_public_channel_key,
|
||||||
is_public_channel_name,
|
is_public_channel_name,
|
||||||
)
|
)
|
||||||
|
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||||
|
from app.packet_processor import create_message_from_decrypted
|
||||||
from app.region_scope import normalize_region_scope
|
from app.region_scope import normalize_region_scope
|
||||||
from app.repository import ChannelRepository, MessageRepository
|
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||||
from app.websocket import broadcast_event
|
from app.websocket import broadcast_event, broadcast_success
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||||
@@ -31,12 +34,157 @@ class CreateChannelRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkCreateHashtagChannelsRequest(BaseModel):
|
||||||
|
channel_names: list[str] = Field(
|
||||||
|
min_length=1,
|
||||||
|
description="List of hashtag room names. Leading # is optional per entry.",
|
||||||
|
)
|
||||||
|
try_historical: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Attempt one background historical decrypt sweep for the newly added rooms.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkCreateHashtagChannelsResponse(BaseModel):
|
||||||
|
created_channels: list[Channel]
|
||||||
|
existing_count: int
|
||||||
|
invalid_names: list[str]
|
||||||
|
decrypt_started: bool = False
|
||||||
|
decrypt_total_packets: int = 0
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||||
flood_scope_override: str = Field(
|
flood_scope_override: str = Field(
|
||||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
description="Blank clears the override; non-empty values temporarily override flood scope"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_channel_identity(
|
||||||
|
requested_name: str,
|
||||||
|
request_key: str | None = None,
|
||||||
|
) -> tuple[str, str, bool]:
|
||||||
|
is_hashtag = requested_name.startswith("#")
|
||||||
|
|
||||||
|
if is_public_channel_name(requested_name):
|
||||||
|
if request_key:
|
||||||
|
try:
|
||||||
|
key_bytes = bytes.fromhex(request_key)
|
||||||
|
if len(key_bytes) != 16:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||||
|
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
||||||
|
)
|
||||||
|
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
|
||||||
|
|
||||||
|
if request_key and not is_hashtag:
|
||||||
|
try:
|
||||||
|
key_bytes = bytes.fromhex(request_key)
|
||||||
|
if len(key_bytes) != 16:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||||
|
key_hex = key_bytes.hex().upper()
|
||||||
|
if is_public_channel_key(key_hex):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
||||||
|
)
|
||||||
|
return key_hex, requested_name, False
|
||||||
|
|
||||||
|
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
||||||
|
return key_bytes.hex().upper(), requested_name, is_hashtag
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_bulk_hashtag_name(name: str) -> str | None:
|
||||||
|
trimmed = name.strip()
|
||||||
|
if not trimmed:
|
||||||
|
return None
|
||||||
|
normalized = trimmed.lstrip("#").strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if len(normalized) > 31:
|
||||||
|
return None
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
|
||||||
|
return None
|
||||||
|
return f"#{normalized}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_historical_channel_decryption_for_channels(
|
||||||
|
channels: list[tuple[bytes, str, str]],
|
||||||
|
) -> None:
|
||||||
|
total = await RawPacketRepository.get_undecrypted_count()
|
||||||
|
decrypted_count = 0
|
||||||
|
matched_channel_names: set[str] = set()
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
logger.info("No undecrypted packets to process for bulk channel decrypt")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Starting bulk historical channel decryption of %d packets across %d channels",
|
||||||
|
total,
|
||||||
|
len(channels),
|
||||||
|
)
|
||||||
|
|
||||||
|
async for (
|
||||||
|
packet_id,
|
||||||
|
packet_data,
|
||||||
|
packet_timestamp,
|
||||||
|
) in RawPacketRepository.stream_all_undecrypted():
|
||||||
|
packet_info = parse_packet(packet_data)
|
||||||
|
path_hex = packet_info.path.hex() if packet_info else None
|
||||||
|
path_len = packet_info.path_length if packet_info else None
|
||||||
|
|
||||||
|
for channel_key_bytes, channel_key_hex, channel_name in channels:
|
||||||
|
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg_id = await create_message_from_decrypted(
|
||||||
|
packet_id=packet_id,
|
||||||
|
channel_key=channel_key_hex,
|
||||||
|
channel_name=channel_name,
|
||||||
|
sender=result.sender,
|
||||||
|
message_text=result.message,
|
||||||
|
timestamp=result.timestamp,
|
||||||
|
received_at=packet_timestamp,
|
||||||
|
path=path_hex,
|
||||||
|
path_len=path_len,
|
||||||
|
realtime=False,
|
||||||
|
)
|
||||||
|
if msg_id is not None:
|
||||||
|
decrypted_count += 1
|
||||||
|
matched_channel_names.add(channel_name)
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
|
||||||
|
decrypted_count,
|
||||||
|
total,
|
||||||
|
len(matched_channel_names),
|
||||||
|
)
|
||||||
|
|
||||||
|
if decrypted_count > 0:
|
||||||
|
broadcast_success(
|
||||||
|
"Bulk historical decrypt complete",
|
||||||
|
(
|
||||||
|
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
|
||||||
|
f"across {len(matched_channel_names)} room"
|
||||||
|
f"{'s' if len(matched_channel_names) != 1 else ''}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[Channel])
|
@router.get("", response_model=list[Channel])
|
||||||
async def list_channels() -> list[Channel]:
|
async def list_channels() -> list[Channel]:
|
||||||
"""List all channels from the database."""
|
"""List all channels from the database."""
|
||||||
@@ -69,50 +217,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
|||||||
automatically when sending a message (see messages.py send_channel_message).
|
automatically when sending a message (see messages.py send_channel_message).
|
||||||
"""
|
"""
|
||||||
requested_name = request.name
|
requested_name = request.name
|
||||||
is_hashtag = requested_name.startswith("#")
|
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
|
||||||
|
|
||||||
# Reserve the canonical Public channel so it cannot drift to another key,
|
|
||||||
# and the well-known Public key cannot be renamed to something else.
|
|
||||||
if is_public_channel_name(requested_name):
|
|
||||||
if request.key:
|
|
||||||
try:
|
|
||||||
key_bytes = bytes.fromhex(request.key)
|
|
||||||
if len(key_bytes) != 16:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
|
||||||
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
|
||||||
)
|
|
||||||
key_hex = PUBLIC_CHANNEL_KEY
|
|
||||||
channel_name = PUBLIC_CHANNEL_NAME
|
|
||||||
is_hashtag = False
|
|
||||||
elif request.key and not is_hashtag:
|
|
||||||
try:
|
|
||||||
key_bytes = bytes.fromhex(request.key)
|
|
||||||
if len(key_bytes) != 16:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
|
||||||
key_hex = key_bytes.hex().upper()
|
|
||||||
if is_public_channel_key(key_hex):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
|
||||||
)
|
|
||||||
channel_name = requested_name
|
|
||||||
else:
|
|
||||||
# Derive key from name hash (same as meshcore library does)
|
|
||||||
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
|
||||||
key_hex = key_bytes.hex().upper()
|
|
||||||
channel_name = requested_name
|
|
||||||
|
|
||||||
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
||||||
|
|
||||||
@@ -132,6 +237,81 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
|||||||
return stored
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
|
||||||
|
async def bulk_create_hashtag_channels(
|
||||||
|
request: BulkCreateHashtagChannelsRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
response: Response,
|
||||||
|
) -> BulkCreateHashtagChannelsResponse:
|
||||||
|
created_channels: list[Channel] = []
|
||||||
|
existing_count = 0
|
||||||
|
invalid_names: list[str] = []
|
||||||
|
decrypt_started = False
|
||||||
|
decrypt_total_packets = 0
|
||||||
|
decrypt_targets: list[tuple[bytes, str, str]] = []
|
||||||
|
|
||||||
|
for raw_name in request.channel_names:
|
||||||
|
normalized_name = _normalize_bulk_hashtag_name(raw_name)
|
||||||
|
if normalized_name is None:
|
||||||
|
invalid_names.append(raw_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
|
||||||
|
existing = await ChannelRepository.get_by_key(key_hex)
|
||||||
|
if existing is not None:
|
||||||
|
existing_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
await ChannelRepository.upsert(
|
||||||
|
key=key_hex,
|
||||||
|
name=channel_name,
|
||||||
|
is_hashtag=is_hashtag,
|
||||||
|
on_radio=False,
|
||||||
|
)
|
||||||
|
stored = await ChannelRepository.get_by_key(key_hex)
|
||||||
|
if stored is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Channel was created but could not be reloaded",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_channels.append(stored)
|
||||||
|
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
|
||||||
|
_broadcast_channel_update(stored)
|
||||||
|
|
||||||
|
if request.try_historical and decrypt_targets:
|
||||||
|
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
|
||||||
|
if decrypt_total_packets > 0:
|
||||||
|
background_tasks.add_task(
|
||||||
|
_run_historical_channel_decryption_for_channels, decrypt_targets
|
||||||
|
)
|
||||||
|
decrypt_started = True
|
||||||
|
response.status_code = status.HTTP_202_ACCEPTED
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
|
||||||
|
if created_channels
|
||||||
|
else "No new rooms were added"
|
||||||
|
)
|
||||||
|
if request.try_historical and decrypt_targets:
|
||||||
|
if decrypt_started:
|
||||||
|
message += (
|
||||||
|
f" and started background decrypt of {decrypt_total_packets} packet"
|
||||||
|
f"{'s' if decrypt_total_packets != 1 else ''}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message += "; no undecrypted packets were available"
|
||||||
|
|
||||||
|
return BulkCreateHashtagChannelsResponse(
|
||||||
|
created_channels=created_channels,
|
||||||
|
existing_count=existing_count,
|
||||||
|
invalid_names=invalid_names,
|
||||||
|
decrypt_started=decrypt_started,
|
||||||
|
decrypt_total_packets=decrypt_total_packets,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{key}/mark-read")
|
@router.post("/{key}/mark-read")
|
||||||
async def mark_channel_read(key: str) -> dict:
|
async def mark_channel_read(key: str) -> dict:
|
||||||
"""Mark a channel as read (update last_read_at timestamp)."""
|
"""Mark a channel as read (update last_read_at timestamp)."""
|
||||||
|
|||||||
+50
-3
@@ -1,10 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.dependencies import require_connected
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -31,7 +33,7 @@ from app.repository import (
|
|||||||
)
|
)
|
||||||
from app.services.contact_reconciliation import (
|
from app.services.contact_reconciliation import (
|
||||||
promote_prefix_contacts_for_contact,
|
promote_prefix_contacts_for_contact,
|
||||||
reconcile_contact_messages,
|
record_contact_name_and_reconcile,
|
||||||
)
|
)
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
@@ -277,12 +279,18 @@ async def create_contact(
|
|||||||
# Check if contact already exists
|
# Check if contact already exists
|
||||||
existing = await ContactRepository.get_by_key(request.public_key)
|
existing = await ContactRepository.get_by_key(request.public_key)
|
||||||
if existing:
|
if existing:
|
||||||
# Update name if provided
|
# Update name if provided and record name history
|
||||||
if request.name:
|
if request.name:
|
||||||
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
||||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||||
if refreshed is not None:
|
if refreshed is not None:
|
||||||
existing = refreshed
|
existing = refreshed
|
||||||
|
await record_contact_name_and_reconcile(
|
||||||
|
public_key=request.public_key,
|
||||||
|
contact_name=request.name,
|
||||||
|
timestamp=int(time.time()),
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
|
|
||||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
public_key=request.public_key,
|
public_key=request.public_key,
|
||||||
@@ -317,9 +325,10 @@ async def create_contact(
|
|||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
await reconcile_contact_messages(
|
await record_contact_name_and_reconcile(
|
||||||
public_key=lower_key,
|
public_key=lower_key,
|
||||||
contact_name=request.name,
|
contact_name=request.name,
|
||||||
|
timestamp=int(time.time()),
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -347,6 +356,44 @@ async def mark_contact_read(public_key: str) -> dict:
|
|||||||
return {"status": "ok", "public_key": contact.public_key}
|
return {"status": "ok", "public_key": contact.public_key}
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeleteRequest(BaseModel):
|
||||||
|
public_keys: list[str] = Field(description="Public keys to delete")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-delete")
|
||||||
|
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
|
||||||
|
"""Delete multiple contacts from the database (and radio if present)."""
|
||||||
|
from app.websocket import broadcast_event
|
||||||
|
|
||||||
|
# Resolve all contacts first
|
||||||
|
contacts_to_delete: list[Contact] = []
|
||||||
|
for key in request.public_keys:
|
||||||
|
contact = await ContactRepository.get_by_key(key.lower())
|
||||||
|
if contact:
|
||||||
|
contacts_to_delete.append(contact)
|
||||||
|
|
||||||
|
# Remove from radio in a single locked operation (blocks until radio is free)
|
||||||
|
if radio_manager.is_connected and contacts_to_delete:
|
||||||
|
try:
|
||||||
|
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
|
||||||
|
for contact in contacts_to_delete:
|
||||||
|
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||||
|
if radio_contact:
|
||||||
|
await mc.commands.remove_contact(radio_contact)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Radio removal during bulk delete failed: %s", e)
|
||||||
|
|
||||||
|
# Delete from database and broadcast events
|
||||||
|
deleted = 0
|
||||||
|
for contact in contacts_to_delete:
|
||||||
|
await ContactRepository.delete(contact.public_key)
|
||||||
|
broadcast_event("contact_deleted", {"public_key": contact.public_key})
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{public_key}")
|
@router.delete("/{public_key}")
|
||||||
async def delete_contact(public_key: str) -> dict:
|
async def delete_contact(public_key: str) -> dict:
|
||||||
"""Delete a contact from the database (and radio if present)."""
|
"""Delete a contact from the database (and radio if present)."""
|
||||||
|
|||||||
+138
-19
@@ -1,17 +1,21 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import struct
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import get_recent_log_lines, settings
|
from app.config import get_recent_log_lines, settings
|
||||||
|
from app.models import AppSettings
|
||||||
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
||||||
from app.repository import MessageRepository, StatisticsRepository
|
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
|
||||||
from app.routers.health import HealthResponse, build_health_data
|
from app.routers.health import FanoutStatusResponse, build_health_data
|
||||||
from app.services.radio_runtime import radio_runtime
|
from app.services.radio_runtime import radio_runtime
|
||||||
from app.version_info import get_app_build_info, git_output
|
from app.version_info import get_app_build_info, git_output
|
||||||
|
|
||||||
@@ -34,6 +38,13 @@ LOG_COPY_BOUNDARY_PREFIX = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DebugSystemInfo(BaseModel):
|
||||||
|
os: str
|
||||||
|
arch: str
|
||||||
|
arch_bits: int
|
||||||
|
total_ram_mb: int
|
||||||
|
|
||||||
|
|
||||||
class DebugApplicationInfo(BaseModel):
|
class DebugApplicationInfo(BaseModel):
|
||||||
version: str
|
version: str
|
||||||
version_source: str
|
version_source: str
|
||||||
@@ -50,8 +61,6 @@ class DebugRuntimeInfo(BaseModel):
|
|||||||
setup_in_progress: bool
|
setup_in_progress: bool
|
||||||
setup_complete: bool
|
setup_complete: bool
|
||||||
channels_with_incoming_messages: int
|
channels_with_incoming_messages: int
|
||||||
max_channels: int
|
|
||||||
path_hash_mode: int
|
|
||||||
path_hash_mode_supported: bool
|
path_hash_mode_supported: bool
|
||||||
channel_slot_reuse_enabled: bool
|
channel_slot_reuse_enabled: bool
|
||||||
channel_send_cache_capacity: int
|
channel_send_cache_capacity: int
|
||||||
@@ -78,7 +87,6 @@ class DebugChannelAudit(BaseModel):
|
|||||||
class DebugRadioProbe(BaseModel):
|
class DebugRadioProbe(BaseModel):
|
||||||
performed: bool
|
performed: bool
|
||||||
errors: list[str] = Field(default_factory=list)
|
errors: list[str] = Field(default_factory=list)
|
||||||
multi_acks_enabled: bool | None = None
|
|
||||||
self_info: dict[str, Any] | None = None
|
self_info: dict[str, Any] | None = None
|
||||||
device_info: dict[str, Any] | None = None
|
device_info: dict[str, Any] | None = None
|
||||||
stats_core: dict[str, Any] | None = None
|
stats_core: dict[str, Any] | None = None
|
||||||
@@ -93,16 +101,53 @@ class DebugDatabaseInfo(BaseModel):
|
|||||||
total_outgoing: int
|
total_outgoing: int
|
||||||
|
|
||||||
|
|
||||||
|
class DebugHealthSummary(BaseModel):
|
||||||
|
radio_state: str
|
||||||
|
database_size_mb: float
|
||||||
|
oldest_undecrypted_timestamp: int | None
|
||||||
|
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
|
||||||
|
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||||
|
basic_auth_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DebugAppSettings(BaseModel):
|
||||||
|
max_radio_contacts: int
|
||||||
|
auto_decrypt_dm_on_advert: bool
|
||||||
|
advert_interval: int
|
||||||
|
flood_scope: str
|
||||||
|
blocked_keys_count: int
|
||||||
|
blocked_names_count: int
|
||||||
|
|
||||||
|
|
||||||
class DebugSnapshotResponse(BaseModel):
|
class DebugSnapshotResponse(BaseModel):
|
||||||
captured_at: str
|
captured_at: str
|
||||||
|
system: DebugSystemInfo
|
||||||
application: DebugApplicationInfo
|
application: DebugApplicationInfo
|
||||||
health: HealthResponse
|
health: DebugHealthSummary
|
||||||
|
settings: DebugAppSettings
|
||||||
runtime: DebugRuntimeInfo
|
runtime: DebugRuntimeInfo
|
||||||
database: DebugDatabaseInfo
|
database: DebugDatabaseInfo
|
||||||
radio_probe: DebugRadioProbe
|
radio_probe: DebugRadioProbe
|
||||||
logs: list[str]
|
logs: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_system_info() -> DebugSystemInfo:
|
||||||
|
try:
|
||||||
|
# os.sysconf is available on Linux/macOS
|
||||||
|
page_size = os.sysconf("SC_PAGE_SIZE")
|
||||||
|
page_count = os.sysconf("SC_PHYS_PAGES")
|
||||||
|
total_ram_mb = (page_size * page_count) // (1024 * 1024)
|
||||||
|
except (AttributeError, ValueError, OSError):
|
||||||
|
total_ram_mb = 0
|
||||||
|
|
||||||
|
return DebugSystemInfo(
|
||||||
|
os=f"{platform.system()} {platform.release()}",
|
||||||
|
arch=platform.machine(),
|
||||||
|
arch_bits=struct.calcsize("P") * 8,
|
||||||
|
total_ram_mb=total_ram_mb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_application_info() -> DebugApplicationInfo:
|
def _build_application_info() -> DebugApplicationInfo:
|
||||||
build_info = get_app_build_info()
|
build_info = get_app_build_info()
|
||||||
dirty_output = git_output("status", "--porcelain")
|
dirty_output = git_output("status", "--porcelain")
|
||||||
@@ -158,6 +203,68 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
||||||
|
return DebugAppSettings(
|
||||||
|
max_radio_contacts=app_settings.max_radio_contacts,
|
||||||
|
auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert,
|
||||||
|
advert_interval=app_settings.advert_interval,
|
||||||
|
flood_scope=app_settings.flood_scope,
|
||||||
|
blocked_keys_count=len(app_settings.blocked_keys),
|
||||||
|
blocked_names_count=len(app_settings.blocked_names),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_debug_radio_state(
|
||||||
|
*,
|
||||||
|
radio_connected: bool,
|
||||||
|
connection_desired: bool,
|
||||||
|
setup_in_progress: bool,
|
||||||
|
setup_complete: bool,
|
||||||
|
is_reconnecting: bool,
|
||||||
|
) -> str:
|
||||||
|
if not connection_desired:
|
||||||
|
return "paused"
|
||||||
|
if radio_connected and (setup_in_progress or not setup_complete):
|
||||||
|
return "initializing"
|
||||||
|
if radio_connected:
|
||||||
|
return "connected"
|
||||||
|
if is_reconnecting:
|
||||||
|
return "connecting"
|
||||||
|
return "disconnected"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_debug_health_summary(
|
||||||
|
health_data: dict[str, Any], *, radio_state: str
|
||||||
|
) -> DebugHealthSummary:
|
||||||
|
def _fanout_last_error(status: Any) -> str | None:
|
||||||
|
if isinstance(status, dict):
|
||||||
|
value = status.get("last_error")
|
||||||
|
else:
|
||||||
|
value = getattr(status, "last_error", None)
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
fanouts_with_errors = {
|
||||||
|
config_id: status
|
||||||
|
for config_id, status in health_data["fanout_statuses"].items()
|
||||||
|
if _fanout_last_error(status)
|
||||||
|
}
|
||||||
|
return DebugHealthSummary(
|
||||||
|
radio_state=radio_state,
|
||||||
|
database_size_mb=health_data["database_size_mb"],
|
||||||
|
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
|
||||||
|
fanouts_with_errors=fanouts_with_errors,
|
||||||
|
bots_disabled_source=health_data["bots_disabled_source"],
|
||||||
|
basic_auth_enabled=health_data["basic_auth_enabled"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
sanitized = dict(self_info or {})
|
||||||
|
sanitized.pop("adv_lat", None)
|
||||||
|
sanitized.pop("adv_lon", None)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
async def _build_contact_audit(
|
async def _build_contact_audit(
|
||||||
observed_contacts_payload: dict[str, dict[str, Any]],
|
observed_contacts_payload: dict[str, dict[str, Any]],
|
||||||
) -> DebugContactAudit:
|
) -> DebugContactAudit:
|
||||||
@@ -242,10 +349,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
|||||||
return DebugRadioProbe(
|
return DebugRadioProbe(
|
||||||
performed=True,
|
performed=True,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
|
self_info=_sanitize_radio_probe_self_info(mc.self_info),
|
||||||
if mc.self_info is not None
|
|
||||||
else None,
|
|
||||||
self_info=dict(mc.self_info or {}),
|
|
||||||
device_info=device_info,
|
device_info=device_info,
|
||||||
stats_core=stats_core,
|
stats_core=stats_core,
|
||||||
stats_radio=stats_radio,
|
stats_radio=stats_radio,
|
||||||
@@ -264,24 +368,39 @@ async def _probe_radio() -> DebugRadioProbe:
|
|||||||
@router.get("/debug", response_model=DebugSnapshotResponse)
|
@router.get("/debug", response_model=DebugSnapshotResponse)
|
||||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
connection_info = radio_runtime.connection_info
|
||||||
|
connection_desired = radio_runtime.connection_desired
|
||||||
|
setup_in_progress = radio_runtime.is_setup_in_progress
|
||||||
|
setup_complete = radio_runtime.is_setup_complete
|
||||||
|
radio_connected = radio_runtime.is_connected
|
||||||
|
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
|
||||||
|
|
||||||
|
health_data = await build_health_data(radio_connected, connection_info)
|
||||||
|
app_settings = await AppSettingsRepository.get()
|
||||||
message_totals = await StatisticsRepository.get_database_message_totals()
|
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||||
radio_probe = await _probe_radio()
|
radio_probe = await _probe_radio()
|
||||||
channels_with_incoming_messages = (
|
channels_with_incoming_messages = (
|
||||||
await MessageRepository.count_channels_with_incoming_messages()
|
await MessageRepository.count_channels_with_incoming_messages()
|
||||||
)
|
)
|
||||||
|
radio_state = _derive_debug_radio_state(
|
||||||
|
radio_connected=radio_connected,
|
||||||
|
connection_desired=connection_desired,
|
||||||
|
setup_in_progress=setup_in_progress,
|
||||||
|
setup_complete=setup_complete,
|
||||||
|
is_reconnecting=is_reconnecting,
|
||||||
|
)
|
||||||
return DebugSnapshotResponse(
|
return DebugSnapshotResponse(
|
||||||
captured_at=datetime.now(timezone.utc).isoformat(),
|
captured_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
system=_build_system_info(),
|
||||||
application=_build_application_info(),
|
application=_build_application_info(),
|
||||||
health=HealthResponse(**health_data),
|
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||||
|
settings=_build_debug_app_settings(app_settings),
|
||||||
runtime=DebugRuntimeInfo(
|
runtime=DebugRuntimeInfo(
|
||||||
connection_info=radio_runtime.connection_info,
|
connection_info=connection_info,
|
||||||
connection_desired=radio_runtime.connection_desired,
|
connection_desired=connection_desired,
|
||||||
setup_in_progress=radio_runtime.is_setup_in_progress,
|
setup_in_progress=setup_in_progress,
|
||||||
setup_complete=radio_runtime.is_setup_complete,
|
setup_complete=setup_complete,
|
||||||
channels_with_incoming_messages=channels_with_incoming_messages,
|
channels_with_incoming_messages=channels_with_incoming_messages,
|
||||||
max_channels=radio_runtime.max_channels,
|
|
||||||
path_hash_mode=radio_runtime.path_hash_mode,
|
|
||||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ async def _run_historical_channel_decryption(
|
|||||||
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
|
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Background task to decrypt historical packets with a channel key."""
|
"""Background task to decrypt historical packets with a channel key."""
|
||||||
packets = await RawPacketRepository.get_all_undecrypted()
|
total = await RawPacketRepository.get_undecrypted_count()
|
||||||
total = len(packets)
|
|
||||||
decrypted_count = 0
|
decrypted_count = 0
|
||||||
|
|
||||||
if total == 0:
|
if total == 0:
|
||||||
@@ -59,7 +58,11 @@ async def _run_historical_channel_decryption(
|
|||||||
|
|
||||||
logger.info("Starting historical channel decryption of %d packets", total)
|
logger.info("Starting historical channel decryption of %d packets", total)
|
||||||
|
|
||||||
for packet_id, packet_data, packet_timestamp in packets:
|
async for (
|
||||||
|
packet_id,
|
||||||
|
packet_data,
|
||||||
|
packet_timestamp,
|
||||||
|
) in RawPacketRepository.stream_all_undecrypted():
|
||||||
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||||
|
|
||||||
if result is not None:
|
if result is not None:
|
||||||
|
|||||||
+14
-3
@@ -24,7 +24,10 @@ from app.models import (
|
|||||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||||
from app.radio_sync import sync_radio_time
|
from app.radio_sync import sync_radio_time
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
|
from app.services.contact_reconciliation import (
|
||||||
|
promote_prefix_contacts_for_contact,
|
||||||
|
reconcile_contact_messages,
|
||||||
|
)
|
||||||
from app.services.radio_commands import (
|
from app.services.radio_commands import (
|
||||||
KeystoreRefreshError,
|
KeystoreRefreshError,
|
||||||
PathHashModeUnsupportedError,
|
PathHashModeUnsupportedError,
|
||||||
@@ -214,11 +217,19 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
|
|||||||
public_key=result.public_key,
|
public_key=result.public_key,
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
await reconcile_contact_messages(
|
||||||
|
public_key=result.public_key,
|
||||||
|
contact_name=result.name,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
created = await ContactRepository.get_by_key(result.public_key)
|
created = await ContactRepository.get_by_key(result.public_key)
|
||||||
if created is not None:
|
if created is not None:
|
||||||
broadcast_event("contact", created.model_dump())
|
broadcast_event("contact", created.model_dump())
|
||||||
for old_key in promoted_keys:
|
for old_key in promoted_keys:
|
||||||
broadcast_event("contact_deleted", {"public_key": old_key})
|
broadcast_event(
|
||||||
|
"contact_resolved",
|
||||||
|
{"previous_public_key": old_key, "contact": created.model_dump()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
@@ -21,8 +22,9 @@ from app.models import (
|
|||||||
RepeaterOwnerInfoResponse,
|
RepeaterOwnerInfoResponse,
|
||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
RepeaterStatusResponse,
|
||||||
|
TelemetryHistoryEntry,
|
||||||
)
|
)
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository, RepeaterTelemetryRepository
|
||||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||||
from app.routers.server_control import (
|
from app.routers.server_control import (
|
||||||
batch_cli_fetch,
|
batch_cli_fetch,
|
||||||
@@ -108,7 +110,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||||
|
|
||||||
return RepeaterStatusResponse(
|
response = RepeaterStatusResponse(
|
||||||
battery_volts=status.get("bat", 0) / 1000.0,
|
battery_volts=status.get("bat", 0) / 1000.0,
|
||||||
tx_queue_len=status.get("tx_queue_len", 0),
|
tx_queue_len=status.get("tx_queue_len", 0),
|
||||||
noise_floor_dbm=status.get("noise_floor", 0),
|
noise_floor_dbm=status.get("noise_floor", 0),
|
||||||
@@ -128,6 +130,42 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
full_events=status.get("full_evts", 0),
|
full_events=status.get("full_evts", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Record to telemetry history as a JSON blob (best-effort)
|
||||||
|
now = int(time.time())
|
||||||
|
status_dict = response.model_dump(exclude={"telemetry_history"})
|
||||||
|
try:
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
public_key=contact.public_key,
|
||||||
|
timestamp=now,
|
||||||
|
data=status_dict,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to record telemetry history: %s", e)
|
||||||
|
|
||||||
|
# Fetch recent history and embed in response
|
||||||
|
try:
|
||||||
|
since = now - 30 * 86400 # last 30 days
|
||||||
|
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||||
|
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to fetch telemetry history: %s", e)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{public_key}/repeater/telemetry-history",
|
||||||
|
response_model=list[TelemetryHistoryEntry],
|
||||||
|
)
|
||||||
|
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
|
||||||
|
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
|
||||||
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
_require_repeater(contact)
|
||||||
|
|
||||||
|
since = int(time.time()) - 30 * 86400
|
||||||
|
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||||
|
return [TelemetryHistoryEntry(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||||
|
|||||||
@@ -230,20 +230,27 @@ async def batch_cli_fetch(
|
|||||||
operation_name: str,
|
operation_name: str,
|
||||||
commands: list[tuple[str, str]],
|
commands: list[tuple[str, str]],
|
||||||
) -> dict[str, str | None]:
|
) -> dict[str, str | None]:
|
||||||
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
|
"""Send a batch of CLI commands to a server-capable contact and collect responses.
|
||||||
|
|
||||||
|
Each command acquires and releases the radio lock independently so that
|
||||||
|
other operations (sends, syncs) can slip in between commands.
|
||||||
|
"""
|
||||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||||
|
|
||||||
async with radio_manager.radio_operation(
|
for index, (cmd, field) in enumerate(commands):
|
||||||
operation_name,
|
if index > 0:
|
||||||
pause_polling=True,
|
# Yield briefly so queued operations can acquire the lock.
|
||||||
suspend_auto_fetch=True,
|
await asyncio.sleep(0.25)
|
||||||
) as mc:
|
|
||||||
await _ensure_on_radio(mc, contact)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
for index, (cmd, field) in enumerate(commands):
|
async with radio_manager.radio_operation(
|
||||||
if index > 0:
|
operation_name,
|
||||||
await asyncio.sleep(1.0)
|
pause_polling=True,
|
||||||
|
suspend_auto_fetch=True,
|
||||||
|
) as mc:
|
||||||
|
# Re-ensure contact is loaded each iteration; another operation
|
||||||
|
# may have evicted it while we didn't hold the lock.
|
||||||
|
await _ensure_on_radio(mc, contact)
|
||||||
|
await asyncio.sleep(1.0) # settle after add_contact
|
||||||
|
|
||||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||||
if send_result.type == EventType.ERROR:
|
if send_result.type == EventType.ERROR:
|
||||||
|
|||||||
+13
-8
@@ -27,10 +27,6 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||||
)
|
)
|
||||||
sidebar_sort_order: Literal["recent", "alpha"] | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Sidebar sort order: 'recent' or 'alpha'",
|
|
||||||
)
|
|
||||||
advert_interval: int | None = Field(
|
advert_interval: int | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
ge=0,
|
ge=0,
|
||||||
@@ -48,6 +44,13 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Display names whose messages are hidden from the UI",
|
description="Display names whose messages are hidden from the UI",
|
||||||
)
|
)
|
||||||
|
discovery_blocked_types: list[int] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||||
|
"advertisements should not create new contacts"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BlockKeyRequest(BaseModel):
|
class BlockKeyRequest(BaseModel):
|
||||||
@@ -104,10 +107,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
|||||||
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
|
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
|
||||||
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
|
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
|
||||||
|
|
||||||
if update.sidebar_sort_order is not None:
|
|
||||||
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
|
|
||||||
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
|
|
||||||
|
|
||||||
if update.advert_interval is not None:
|
if update.advert_interval is not None:
|
||||||
# Enforce minimum 1-hour interval; 0 means disabled
|
# Enforce minimum 1-hour interval; 0 means disabled
|
||||||
interval = update.advert_interval
|
interval = update.advert_interval
|
||||||
@@ -122,6 +121,12 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
|||||||
if update.blocked_names is not None:
|
if update.blocked_names is not None:
|
||||||
kwargs["blocked_names"] = update.blocked_names
|
kwargs["blocked_names"] = update.blocked_names
|
||||||
|
|
||||||
|
# Discovery blocked types
|
||||||
|
if update.discovery_blocked_types is not None:
|
||||||
|
# Only allow valid contact type codes (1-4)
|
||||||
|
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
|
||||||
|
kwargs["discovery_blocked_types"] = sorted(set(valid))
|
||||||
|
|
||||||
# Flood scope
|
# Flood scope
|
||||||
flood_scope_changed = False
|
flood_scope_changed = False
|
||||||
if update.flood_scope is not None:
|
if update.flood_scope is not None:
|
||||||
|
|||||||
@@ -204,35 +204,43 @@ async def run_post_connect_setup(radio_manager) -> None:
|
|||||||
finally:
|
finally:
|
||||||
reader.handle_rx = _original_handle_rx
|
reader.handle_rx = _original_handle_rx
|
||||||
|
|
||||||
# Sync contacts/channels from radio to DB and clear radio
|
from app.config import settings as app_settings_config
|
||||||
logger.info("Syncing and offloading radio data...")
|
|
||||||
result = await sync_and_offload_all(mc)
|
|
||||||
logger.info("Sync complete: %s", result)
|
|
||||||
|
|
||||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
if app_settings_config.skip_post_connect_sync:
|
||||||
if await send_advertisement(mc):
|
logger.info(
|
||||||
logger.info("Advertisement sent")
|
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
# Sync contacts/channels from radio to DB and clear radio
|
||||||
|
logger.info("Syncing and offloading radio data...")
|
||||||
|
result = await sync_and_offload_all(mc)
|
||||||
|
logger.info("Sync complete: %s", result)
|
||||||
|
|
||||||
# Drain any messages that were queued before we connected.
|
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
if await send_advertisement(mc):
|
||||||
# compete on get_msg() with interleaved radio I/O.
|
logger.info("Advertisement sent")
|
||||||
drained = await drain_pending_messages(mc)
|
else:
|
||||||
if drained > 0:
|
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||||
logger.info("Drained %d pending message(s)", drained)
|
|
||||||
radio_manager.clear_pending_message_channel_slots()
|
# Drain any messages that were queued before we connected.
|
||||||
|
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||||
|
# compete on get_msg() with interleaved radio I/O.
|
||||||
|
drained = await drain_pending_messages(mc)
|
||||||
|
if drained > 0:
|
||||||
|
logger.info("Drained %d pending message(s)", drained)
|
||||||
|
radio_manager.clear_pending_message_channel_slots()
|
||||||
|
|
||||||
await mc.start_auto_message_fetching()
|
await mc.start_auto_message_fetching()
|
||||||
logger.info("Auto message fetching started")
|
logger.info("Auto message fetching started")
|
||||||
finally:
|
finally:
|
||||||
radio_manager._release_operation_lock("post_connect_setup")
|
radio_manager._release_operation_lock("post_connect_setup")
|
||||||
|
|
||||||
# Start background tasks AFTER releasing the operation lock.
|
if not app_settings_config.skip_post_connect_sync:
|
||||||
# These tasks acquire their own locks when they need radio access.
|
# Start background tasks AFTER releasing the operation lock.
|
||||||
start_periodic_sync()
|
# These tasks acquire their own locks when they need radio access.
|
||||||
start_periodic_advert()
|
start_periodic_sync()
|
||||||
start_message_polling()
|
start_periodic_advert()
|
||||||
|
start_message_polling()
|
||||||
|
|
||||||
radio_manager._setup_complete = True
|
radio_manager._setup_complete = True
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 109 KiB |
+1
-3
@@ -350,15 +350,13 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
|||||||
- `max_radio_contacts`
|
- `max_radio_contacts`
|
||||||
- `favorites`
|
- `favorites`
|
||||||
- `auto_decrypt_dm_on_advert`
|
- `auto_decrypt_dm_on_advert`
|
||||||
- `sidebar_sort_order`
|
|
||||||
- `last_message_times`
|
- `last_message_times`
|
||||||
- `preferences_migrated`
|
- `preferences_migrated`
|
||||||
- `advert_interval`
|
- `advert_interval`
|
||||||
- `last_advert_time`
|
- `last_advert_time`
|
||||||
- `flood_scope`
|
- `flood_scope`
|
||||||
- `blocked_keys`, `blocked_names`
|
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||||
|
|
||||||
The backend still carries `sidebar_sort_order` for compatibility and old preference migration, but the current sidebar UI stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in frontend localStorage rather than treating it as one global server-backed setting.
|
|
||||||
|
|
||||||
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
|
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.6.2",
|
"version": "3.6.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.6.2",
|
"version": "3.6.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.6.3",
|
"version": "3.7.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+43
-7
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import { takePrefetchOrFetch } from './prefetch';
|
import { takePrefetchOrFetch } from './prefetch';
|
||||||
import { useWebSocket } from './useWebSocket';
|
import { useWebSocket } from './useWebSocket';
|
||||||
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
|
|||||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||||
import { messageContainsMention } from './utils/messageParser';
|
import { messageContainsMention } from './utils/messageParser';
|
||||||
import { getStateKey } from './utils/conversationState';
|
import { getStateKey } from './utils/conversationState';
|
||||||
import type { Conversation, Message, RawPacket } from './types';
|
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||||
import { CONTACT_TYPE_ROOM } from './types';
|
import { CONTACT_TYPE_ROOM } from './types';
|
||||||
|
|
||||||
interface ChannelUnreadMarker {
|
interface ChannelUnreadMarker {
|
||||||
@@ -85,6 +85,8 @@ export function App() {
|
|||||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||||
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||||
useState<NewMessagePrefillRequest | null>(null);
|
useState<NewMessagePrefillRequest | null>(null);
|
||||||
|
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||||
|
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -190,6 +192,7 @@ export function App() {
|
|||||||
handleCreateContact,
|
handleCreateContact,
|
||||||
handleCreateChannel,
|
handleCreateChannel,
|
||||||
handleCreateHashtagChannel,
|
handleCreateHashtagChannel,
|
||||||
|
handleBulkCreateHashtagChannels,
|
||||||
handleDeleteChannel,
|
handleDeleteChannel,
|
||||||
handleDeleteContact,
|
handleDeleteContact,
|
||||||
} = useContactsAndChannels({
|
} = useContactsAndChannels({
|
||||||
@@ -421,16 +424,25 @@ export function App() {
|
|||||||
[fetchUndecryptedCount, setChannels]
|
[fetchUndecryptedCount, setChannels]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenNewMessage = useCallback(() => {
|
const handleOpenNewMessage = useCallback(
|
||||||
setNewMessagePrefillRequest(null);
|
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||||
openNewMessageModal();
|
setNewMessagePrefillRequest(null);
|
||||||
}, [openNewMessageModal]);
|
setShowBulkAddChannelTab(event?.altKey === true);
|
||||||
|
openNewMessageModal();
|
||||||
|
},
|
||||||
|
[openNewMessageModal]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCloseNewMessage = useCallback(() => {
|
const handleCloseNewMessage = useCallback(() => {
|
||||||
setNewMessagePrefillRequest(null);
|
setNewMessagePrefillRequest(null);
|
||||||
|
setShowBulkAddChannelTab(false);
|
||||||
closeNewMessageModal();
|
closeNewMessageModal();
|
||||||
}, [closeNewMessageModal]);
|
}, [closeNewMessageModal]);
|
||||||
|
|
||||||
|
const handleCloseBulkAddResults = useCallback(() => {
|
||||||
|
setBulkAddResult(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleChannelReferenceClick = useCallback(
|
const handleChannelReferenceClick = useCallback(
|
||||||
(channelName: string) => {
|
(channelName: string) => {
|
||||||
const existingChannel = channels.find((channel) => channel.name === channelName);
|
const existingChannel = channels.find((channel) => channel.name === channelName);
|
||||||
@@ -444,11 +456,20 @@ export function App() {
|
|||||||
hashtagName: channelName.slice(1),
|
hashtagName: channelName.slice(1),
|
||||||
nonce: (previous?.nonce ?? 0) + 1,
|
nonce: (previous?.nonce ?? 0) + 1,
|
||||||
}));
|
}));
|
||||||
|
setShowBulkAddChannelTab(false);
|
||||||
openNewMessageModal();
|
openNewMessageModal();
|
||||||
},
|
},
|
||||||
[channels, handleNavigateToChannel, openNewMessageModal]
|
[channels, handleNavigateToChannel, openNewMessageModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleBulkAddChannels = useCallback(
|
||||||
|
async (channelNames: string[], tryHistorical: boolean) => {
|
||||||
|
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||||
|
setBulkAddResult(result);
|
||||||
|
},
|
||||||
|
[handleBulkCreateHashtagChannels]
|
||||||
|
);
|
||||||
|
|
||||||
const statusProps = {
|
const statusProps = {
|
||||||
health,
|
health,
|
||||||
config,
|
config,
|
||||||
@@ -469,8 +490,12 @@ export function App() {
|
|||||||
void markAllRead();
|
void markAllRead();
|
||||||
},
|
},
|
||||||
favorites,
|
favorites,
|
||||||
legacySortOrder: appSettings?.sidebar_sort_order,
|
|
||||||
isConversationNotificationsEnabled,
|
isConversationNotificationsEnabled,
|
||||||
|
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||||
|
blockedNames: appSettings?.blocked_names ?? [],
|
||||||
|
};
|
||||||
|
const bulkAddChannelResultModalProps = {
|
||||||
|
result: bulkAddResult,
|
||||||
};
|
};
|
||||||
const conversationPaneProps = {
|
const conversationPaneProps = {
|
||||||
activeConversation,
|
activeConversation,
|
||||||
@@ -482,6 +507,7 @@ export function App() {
|
|||||||
health,
|
health,
|
||||||
favorites,
|
favorites,
|
||||||
messages: sortedMessages,
|
messages: sortedMessages,
|
||||||
|
preSorted: activeContactIsRoom,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
hasOlderMessages,
|
hasOlderMessages,
|
||||||
@@ -555,6 +581,11 @@ export function App() {
|
|||||||
blockedNames: appSettings?.blocked_names,
|
blockedNames: appSettings?.blocked_names,
|
||||||
onToggleBlockedKey: handleBlockKey,
|
onToggleBlockedKey: handleBlockKey,
|
||||||
onToggleBlockedName: handleBlockName,
|
onToggleBlockedName: handleBlockName,
|
||||||
|
contacts,
|
||||||
|
onBulkDeleteContacts: (deletedKeys: string[]) => {
|
||||||
|
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
||||||
|
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const crackerProps = {
|
const crackerProps = {
|
||||||
packets: rawPackets,
|
packets: rawPackets,
|
||||||
@@ -563,10 +594,12 @@ export function App() {
|
|||||||
};
|
};
|
||||||
const newMessageModalProps = {
|
const newMessageModalProps = {
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
showBulkAddChannelTab,
|
||||||
prefillRequest: newMessagePrefillRequest,
|
prefillRequest: newMessagePrefillRequest,
|
||||||
onCreateContact: handleCreateContact,
|
onCreateContact: handleCreateContact,
|
||||||
onCreateChannel: handleCreateChannel,
|
onCreateChannel: handleCreateChannel,
|
||||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||||
|
onBulkAddHashtagChannels: handleBulkAddChannels,
|
||||||
};
|
};
|
||||||
const contactInfoPaneProps = {
|
const contactInfoPaneProps = {
|
||||||
contactKey: infoPaneContactKey,
|
contactKey: infoPaneContactKey,
|
||||||
@@ -630,6 +663,7 @@ export function App() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
localLabel={localLabel}
|
localLabel={localLabel}
|
||||||
showNewMessage={showNewMessage}
|
showNewMessage={showNewMessage}
|
||||||
|
showBulkAddResults={bulkAddResult !== null}
|
||||||
showSettings={showSettings}
|
showSettings={showSettings}
|
||||||
settingsSection={settingsSection}
|
settingsSection={settingsSection}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
@@ -640,6 +674,7 @@ export function App() {
|
|||||||
onToggleSettingsView={handleToggleSettingsView}
|
onToggleSettingsView={handleToggleSettingsView}
|
||||||
onCloseSettingsView={handleCloseSettingsView}
|
onCloseSettingsView={handleCloseSettingsView}
|
||||||
onCloseNewMessage={handleCloseNewMessage}
|
onCloseNewMessage={handleCloseNewMessage}
|
||||||
|
onCloseBulkAddResults={handleCloseBulkAddResults}
|
||||||
onLocalLabelChange={setLocalLabel}
|
onLocalLabelChange={setLocalLabel}
|
||||||
statusProps={statusProps}
|
statusProps={statusProps}
|
||||||
sidebarProps={sidebarProps}
|
sidebarProps={sidebarProps}
|
||||||
@@ -648,6 +683,7 @@ export function App() {
|
|||||||
settingsProps={settingsProps}
|
settingsProps={settingsProps}
|
||||||
crackerProps={crackerProps}
|
crackerProps={crackerProps}
|
||||||
newMessageModalProps={newMessageModalProps}
|
newMessageModalProps={newMessageModalProps}
|
||||||
|
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||||
contactInfoPaneProps={contactInfoPaneProps}
|
contactInfoPaneProps={contactInfoPaneProps}
|
||||||
channelInfoPaneProps={channelInfoPaneProps}
|
channelInfoPaneProps={channelInfoPaneProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
|
BulkCreateHashtagChannelsResult,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelDetail,
|
ChannelDetail,
|
||||||
CommandResponse,
|
CommandResponse,
|
||||||
@@ -34,6 +35,7 @@ import type {
|
|||||||
RepeaterOwnerInfoResponse,
|
RepeaterOwnerInfoResponse,
|
||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
RepeaterStatusResponse,
|
||||||
|
TelemetryHistoryEntry,
|
||||||
StatisticsResponse,
|
StatisticsResponse,
|
||||||
TraceResponse,
|
TraceResponse,
|
||||||
UnreadCounts,
|
UnreadCounts,
|
||||||
@@ -149,6 +151,12 @@ export const api = {
|
|||||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
|
bulkDeleteContacts: (publicKeys: string[]) =>
|
||||||
|
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ public_keys: publicKeys }),
|
||||||
|
}),
|
||||||
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
||||||
fetchJson<Contact>('/contacts', {
|
fetchJson<Contact>('/contacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -184,6 +192,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, key }),
|
body: JSON.stringify({ name, key }),
|
||||||
}),
|
}),
|
||||||
|
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
|
||||||
|
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
|
||||||
|
}),
|
||||||
deleteChannel: (key: string) =>
|
deleteChannel: (key: string) =>
|
||||||
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
||||||
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
||||||
@@ -402,6 +415,8 @@ export const api = {
|
|||||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
repeaterTelemetryHistory: (publicKey: string) =>
|
||||||
|
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
|
||||||
roomLogin: (publicKey: string, password: string) =>
|
roomLogin: (publicKey: string, password: string) =>
|
||||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { StatusBar } from './StatusBar';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { ConversationPane } from './ConversationPane';
|
import { ConversationPane } from './ConversationPane';
|
||||||
import { NewMessageModal } from './NewMessageModal';
|
import { NewMessageModal } from './NewMessageModal';
|
||||||
|
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
|
||||||
import { ContactInfoPane } from './ContactInfoPane';
|
import { ContactInfoPane } from './ContactInfoPane';
|
||||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||||
@@ -33,12 +34,17 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
|
|||||||
type SidebarProps = ComponentProps<typeof Sidebar>;
|
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||||
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
||||||
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
||||||
|
type BulkAddChannelResultModalProps = Omit<
|
||||||
|
ComponentProps<typeof BulkAddChannelResultModal>,
|
||||||
|
'open' | 'onClose'
|
||||||
|
>;
|
||||||
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
||||||
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
localLabel: LocalLabel;
|
localLabel: LocalLabel;
|
||||||
showNewMessage: boolean;
|
showNewMessage: boolean;
|
||||||
|
showBulkAddResults: boolean;
|
||||||
showSettings: boolean;
|
showSettings: boolean;
|
||||||
settingsSection: SettingsSection;
|
settingsSection: SettingsSection;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
@@ -50,6 +56,7 @@ interface AppShellProps {
|
|||||||
onToggleSettingsView: () => void;
|
onToggleSettingsView: () => void;
|
||||||
onCloseSettingsView: () => void;
|
onCloseSettingsView: () => void;
|
||||||
onCloseNewMessage: () => void;
|
onCloseNewMessage: () => void;
|
||||||
|
onCloseBulkAddResults: () => void;
|
||||||
onLocalLabelChange: (label: LocalLabel) => void;
|
onLocalLabelChange: (label: LocalLabel) => void;
|
||||||
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
||||||
sidebarProps: SidebarProps;
|
sidebarProps: SidebarProps;
|
||||||
@@ -61,6 +68,7 @@ interface AppShellProps {
|
|||||||
>;
|
>;
|
||||||
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
||||||
newMessageModalProps: NewMessageModalProps;
|
newMessageModalProps: NewMessageModalProps;
|
||||||
|
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||||
contactInfoPaneProps: ContactInfoPaneProps;
|
contactInfoPaneProps: ContactInfoPaneProps;
|
||||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||||
}
|
}
|
||||||
@@ -68,6 +76,7 @@ interface AppShellProps {
|
|||||||
export function AppShell({
|
export function AppShell({
|
||||||
localLabel,
|
localLabel,
|
||||||
showNewMessage,
|
showNewMessage,
|
||||||
|
showBulkAddResults,
|
||||||
showSettings,
|
showSettings,
|
||||||
settingsSection,
|
settingsSection,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
@@ -79,6 +88,7 @@ export function AppShell({
|
|||||||
onToggleSettingsView,
|
onToggleSettingsView,
|
||||||
onCloseSettingsView,
|
onCloseSettingsView,
|
||||||
onCloseNewMessage,
|
onCloseNewMessage,
|
||||||
|
onCloseBulkAddResults,
|
||||||
onLocalLabelChange,
|
onLocalLabelChange,
|
||||||
statusProps,
|
statusProps,
|
||||||
sidebarProps,
|
sidebarProps,
|
||||||
@@ -87,6 +97,7 @@ export function AppShell({
|
|||||||
settingsProps,
|
settingsProps,
|
||||||
crackerProps,
|
crackerProps,
|
||||||
newMessageModalProps,
|
newMessageModalProps,
|
||||||
|
bulkAddChannelResultModalProps,
|
||||||
contactInfoPaneProps,
|
contactInfoPaneProps,
|
||||||
channelInfoPaneProps,
|
channelInfoPaneProps,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
@@ -306,6 +317,11 @@ export function AppShell({
|
|||||||
open={showNewMessage}
|
open={showNewMessage}
|
||||||
onClose={onCloseNewMessage}
|
onClose={onCloseNewMessage}
|
||||||
/>
|
/>
|
||||||
|
<BulkAddChannelResultModal
|
||||||
|
{...bulkAddChannelResultModalProps}
|
||||||
|
open={showBulkAddResults}
|
||||||
|
onClose={onCloseBulkAddResults}
|
||||||
|
/>
|
||||||
|
|
||||||
<SecurityWarningModal health={statusProps.health} />
|
<SecurityWarningModal health={statusProps.health} />
|
||||||
<ContactInfoPane {...contactInfoPaneProps} />
|
<ContactInfoPane {...contactInfoPaneProps} />
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { BulkCreateHashtagChannelsResult, Channel } from '../types';
|
||||||
|
import { getConversationHash } from '../utils/urlHash';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
|
||||||
|
interface BulkAddChannelResultModalProps {
|
||||||
|
open: boolean;
|
||||||
|
result: BulkCreateHashtagChannelsResult | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChannelHref(channel: Channel): string {
|
||||||
|
const hash = getConversationHash({
|
||||||
|
type: 'channel',
|
||||||
|
id: channel.key,
|
||||||
|
name: channel.name,
|
||||||
|
});
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
return `${window.location.origin}${window.location.pathname}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkAddChannelResultModal({
|
||||||
|
open,
|
||||||
|
result,
|
||||||
|
onClose,
|
||||||
|
}: BulkAddChannelResultModalProps) {
|
||||||
|
const createdChannels = result?.created_channels ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Add Complete</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{result?.message ?? 'Review the newly added rooms below.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{result && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">Created</div>
|
||||||
|
<div className="mt-1 font-medium">{createdChannels.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Already Present
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-medium">{result.existing_count}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createdChannels.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Ctrl+click any room to open it in a new tab.
|
||||||
|
</p>
|
||||||
|
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
|
||||||
|
<ul className="divide-y divide-border/70">
|
||||||
|
{createdChannels.map((channel) => (
|
||||||
|
<li key={channel.key}>
|
||||||
|
<a
|
||||||
|
href={getChannelHref(channel)}
|
||||||
|
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
|
||||||
|
>
|
||||||
|
{channel.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.invalid_names.length > 0 && (
|
||||||
|
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||||
|
Ignored invalid room names: {result.invalid_names.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ReactNode, useEffect, useState } from 'react';
|
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||||
import { Ban, Search, Star } from 'lucide-react';
|
import { Ban, Search, Star } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -35,6 +35,7 @@ import { ContactAvatar } from './ContactAvatar';
|
|||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||||
|
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
import type {
|
import type {
|
||||||
Contact,
|
Contact,
|
||||||
ContactActiveRoom,
|
ContactActiveRoom,
|
||||||
@@ -158,6 +159,7 @@ export function ContactInfoPane({
|
|||||||
contact !== null &&
|
contact !== null &&
|
||||||
!isPrefixOnlyResolvedContact &&
|
!isPrefixOnlyResolvedContact &&
|
||||||
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
||||||
|
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||||
@@ -440,7 +442,7 @@ export function ContactInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onSearchMessagesByKey && (
|
{!isRepeater && onSearchMessagesByKey && (
|
||||||
<div className="px-5 py-3 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -453,40 +455,60 @@ export function ContactInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nearest Repeaters */}
|
{/* Nearest Repeaters (Hops) — last 7 days only */}
|
||||||
{analytics && analytics.nearest_repeaters.length > 0 && (
|
{analytics &&
|
||||||
<div className="px-5 py-3 border-b border-border">
|
(() => {
|
||||||
<SectionLabel>Nearest Repeaters</SectionLabel>
|
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||||
<div className="space-y-1">
|
const recent = analytics.nearest_repeaters.filter(
|
||||||
{analytics.nearest_repeaters.map((r) => (
|
(r) => r.last_seen >= sevenDaysAgo
|
||||||
<div key={r.public_key} className="flex justify-between items-center text-sm">
|
);
|
||||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
if (recent.length === 0) return null;
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
return (
|
||||||
{r.path_len === 0
|
<div className="px-5 py-3 border-b border-border">
|
||||||
? 'direct'
|
<SectionLabel>Nearest Repeaters — Hops (last 7 days)</SectionLabel>
|
||||||
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
<div className="space-y-1">
|
||||||
· {r.heard_count}x
|
{recent.map((r) => (
|
||||||
</span>
|
<div
|
||||||
|
key={r.public_key}
|
||||||
|
className="flex justify-between items-center text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||||
|
{r.path_len === 0
|
||||||
|
? 'direct'
|
||||||
|
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
||||||
|
· {r.heard_count}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})()}
|
||||||
|
|
||||||
|
{/* Geographically nearest repeaters (repeaters only) */}
|
||||||
|
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
|
||||||
|
<NearbyRepeatersSection
|
||||||
|
contact={contact}
|
||||||
|
contacts={contacts}
|
||||||
|
distanceUnit={distanceUnit}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Advert Paths */}
|
{/* Advert Paths */}
|
||||||
{analytics && analytics.advert_paths.length > 0 && (
|
{analytics && analytics.advert_paths.length > 0 && (
|
||||||
<div className="px-5 py-3 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<SectionLabel>Recent Advert Paths</SectionLabel>
|
<SectionLabel>Recent Advert Paths</SectionLabel>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
{analytics.advert_paths.map((p) => (
|
{analytics.advert_paths.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.path + p.first_seen}
|
key={p.path + p.first_seen}
|
||||||
className="flex justify-between items-center text-sm"
|
className="flex justify-between items-start gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs truncate">
|
<span className="font-mono text-xs break-all">
|
||||||
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
|
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||||
{p.heard_count}x · {formatTime(p.last_seen)}
|
{p.heard_count}x · {formatTime(p.last_seen)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -518,17 +540,21 @@ export function ContactInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageStatsSection
|
{!isRepeater && (
|
||||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
<>
|
||||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
<MessageStatsSection
|
||||||
/>
|
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||||
|
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||||
|
/>
|
||||||
|
|
||||||
<ActivityChartsSection analytics={analytics} />
|
<ActivityChartsSection analytics={analytics} />
|
||||||
|
|
||||||
<MostActiveChannelsSection
|
<MostActiveChannelsSection
|
||||||
channels={analytics?.most_active_rooms ?? []}
|
channels={analytics?.most_active_rooms ?? []}
|
||||||
onNavigateToChannel={onNavigateToChannel}
|
onNavigateToChannel={onNavigateToChannel}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
@@ -826,6 +852,60 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NearbyRepeatersSection({
|
||||||
|
contact,
|
||||||
|
contacts,
|
||||||
|
distanceUnit,
|
||||||
|
}: {
|
||||||
|
contact: Contact;
|
||||||
|
contacts: Contact[];
|
||||||
|
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
|
||||||
|
}) {
|
||||||
|
const nearby = useMemo(() => {
|
||||||
|
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||||
|
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
|
||||||
|
for (const other of contacts) {
|
||||||
|
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
|
||||||
|
if (
|
||||||
|
other.public_key === contact.public_key ||
|
||||||
|
other.type !== CONTACT_TYPE_REPEATER ||
|
||||||
|
!isValidLocation(other.lat, other.lon) ||
|
||||||
|
heardAt < sevenDaysAgo
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
|
||||||
|
if (dist !== null) {
|
||||||
|
results.push({
|
||||||
|
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
|
||||||
|
publicKey: other.public_key,
|
||||||
|
distance: dist,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort((a, b) => a.distance - b.distance);
|
||||||
|
return results.slice(0, 5);
|
||||||
|
}, [contact.public_key, contact.lat, contact.lon, contacts]);
|
||||||
|
|
||||||
|
if (nearby.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<SectionLabel>Nearest Repeaters — Geo (last 7 days)</SectionLabel>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{nearby.map((r) => (
|
||||||
|
<div key={r.publicKey} className="flex justify-between items-center text-sm">
|
||||||
|
<span className="truncate">{r.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||||
|
{formatDistance(r.distance, distanceUnit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface ConversationPaneProps {
|
|||||||
notificationsPermission: NotificationPermission | 'unsupported';
|
notificationsPermission: NotificationPermission | 'unsupported';
|
||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
|
preSorted?: boolean;
|
||||||
messagesLoading: boolean;
|
messagesLoading: boolean;
|
||||||
loadingOlder: boolean;
|
loadingOlder: boolean;
|
||||||
hasOlderMessages: boolean;
|
hasOlderMessages: boolean;
|
||||||
@@ -114,6 +115,7 @@ export function ConversationPane({
|
|||||||
notificationsPermission,
|
notificationsPermission,
|
||||||
favorites,
|
favorites,
|
||||||
messages,
|
messages,
|
||||||
|
preSorted,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
hasOlderMessages,
|
hasOlderMessages,
|
||||||
@@ -233,6 +235,7 @@ export function ConversationPane({
|
|||||||
onToggleNotifications={onToggleNotifications}
|
onToggleNotifications={onToggleNotifications}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onDeleteContact={onDeleteContact}
|
onDeleteContact={onDeleteContact}
|
||||||
|
onOpenContactInfo={onOpenContactInfo}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
@@ -274,6 +277,7 @@ export function ConversationPane({
|
|||||||
<MessageList
|
<MessageList
|
||||||
key={activeConversation.id}
|
key={activeConversation.id}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
preSorted={preSorted}
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
channels={channels}
|
channels={channels}
|
||||||
loading={messagesLoading}
|
loading={messagesLoading}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface MessageListProps {
|
|||||||
loadingNewer?: boolean;
|
loadingNewer?: boolean;
|
||||||
onLoadNewer?: () => void;
|
onLoadNewer?: () => void;
|
||||||
onJumpToBottom?: () => void;
|
onJumpToBottom?: () => void;
|
||||||
|
preSorted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL regex for linkifying plain text
|
// URL regex for linkifying plain text
|
||||||
@@ -283,6 +284,7 @@ export function MessageList({
|
|||||||
loadingNewer = false,
|
loadingNewer = false,
|
||||||
onLoadNewer,
|
onLoadNewer,
|
||||||
onJumpToBottom,
|
onJumpToBottom,
|
||||||
|
preSorted = false,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const prevMessagesLengthRef = useRef<number>(0);
|
const prevMessagesLengthRef = useRef<number>(0);
|
||||||
@@ -486,8 +488,11 @@ export function MessageList({
|
|||||||
// Note: Deduplication is handled by useConversationMessages.observeMessage()
|
// Note: Deduplication is handled by useConversationMessages.observeMessage()
|
||||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||||
const sortedMessages = useMemo(
|
const sortedMessages = useMemo(
|
||||||
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
() =>
|
||||||
[messages]
|
preSorted
|
||||||
|
? messages
|
||||||
|
: [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
||||||
|
[messages, preSorted]
|
||||||
);
|
);
|
||||||
const unreadMarkerIndex = useMemo(() => {
|
const unreadMarkerIndex = useMemo(() => {
|
||||||
if (unreadMarkerLastReadAt === undefined) {
|
if (unreadMarkerLastReadAt === undefined) {
|
||||||
|
|||||||
@@ -3,23 +3,29 @@ import { Dice5 } from 'lucide-react';
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
} from './ui/dialog';
|
} from './ui/dialog';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { Checkbox } from './ui/checkbox';
|
import { Checkbox } from './ui/checkbox';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
|
|
||||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
|
||||||
|
|
||||||
|
interface BulkParseResult {
|
||||||
|
channelNames: string[];
|
||||||
|
invalidNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface NewMessageModalProps {
|
interface NewMessageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
undecryptedCount: number;
|
undecryptedCount: number;
|
||||||
|
showBulkAddChannelTab?: boolean;
|
||||||
prefillRequest?: {
|
prefillRequest?: {
|
||||||
tab: 'hashtag';
|
tab: 'hashtag';
|
||||||
hashtagName: string;
|
hashtagName: string;
|
||||||
@@ -29,53 +35,121 @@ interface NewMessageModalProps {
|
|||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||||
|
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHashtagName(channelName: string): string | null {
|
||||||
|
if (!channelName) {
|
||||||
|
return 'Channel name is required';
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||||
|
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
|
||||||
|
const tokens = rawText
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const invalidNames: string[] = [];
|
||||||
|
const channelNames: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
const stripped = token.replace(/^#+/, '');
|
||||||
|
const validationError = validateHashtagName(stripped);
|
||||||
|
if (validationError) {
|
||||||
|
invalidNames.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
|
||||||
|
const channelName = `#${normalized}`;
|
||||||
|
if (seen.has(channelName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(channelName);
|
||||||
|
channelNames.push(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channelNames, invalidNames };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewMessageModal({
|
export function NewMessageModal({
|
||||||
open,
|
open,
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
showBulkAddChannelTab = false,
|
||||||
prefillRequest = null,
|
prefillRequest = null,
|
||||||
onClose,
|
onClose,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
onCreateChannel,
|
onCreateChannel,
|
||||||
onCreateHashtagChannel,
|
onCreateHashtagChannel,
|
||||||
|
onBulkAddHashtagChannels,
|
||||||
}: NewMessageModalProps) {
|
}: NewMessageModalProps) {
|
||||||
const [tab, setTab] = useState<Tab>('new-contact');
|
const [tab, setTab] = useState<Tab>('new-contact');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [contactKey, setContactKey] = useState('');
|
const [contactKey, setContactKey] = useState('');
|
||||||
const [channelKey, setChannelKey] = useState('');
|
const [channelKey, setChannelKey] = useState('');
|
||||||
|
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||||
const [tryHistorical, setTryHistorical] = useState(false);
|
const [tryHistorical, setTryHistorical] = useState(false);
|
||||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('');
|
setName('');
|
||||||
setContactKey('');
|
setContactKey('');
|
||||||
setChannelKey('');
|
setChannelKey('');
|
||||||
|
setBulkChannelText('');
|
||||||
setTryHistorical(false);
|
setTryHistorical(false);
|
||||||
setPermitCapitals(false);
|
setPermitCapitals(false);
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !prefillRequest) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTab(prefillRequest.tab);
|
if (prefillRequest) {
|
||||||
setName(prefillRequest.hashtagName);
|
setTab(prefillRequest.tab);
|
||||||
setContactKey('');
|
setName(prefillRequest.hashtagName);
|
||||||
setChannelKey('');
|
setContactKey('');
|
||||||
setTryHistorical(false);
|
setChannelKey('');
|
||||||
setPermitCapitals(false);
|
setBulkChannelText('');
|
||||||
setError('');
|
setTryHistorical(false);
|
||||||
setLoading(false);
|
setPermitCapitals(false);
|
||||||
requestAnimationFrame(() => {
|
setError('');
|
||||||
hashtagInputRef.current?.focus();
|
setLoading(false);
|
||||||
});
|
requestAnimationFrame(() => {
|
||||||
}, [open, prefillRequest]);
|
hashtagInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBulkAddChannelTab) {
|
||||||
|
setTab('bulk-hashtag');
|
||||||
|
setName('');
|
||||||
|
setContactKey('');
|
||||||
|
setChannelKey('');
|
||||||
|
setBulkChannelText('');
|
||||||
|
setTryHistorical(false);
|
||||||
|
setPermitCapitals(false);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
bulkTextareaRef.current?.focus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTab('new-contact');
|
||||||
|
}, [open, prefillRequest, showBulkAddChannelTab]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
@@ -87,7 +161,6 @@ export function NewMessageModal({
|
|||||||
setError('Name and public key are required');
|
setError('Name and public key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
|
||||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||||
} else if (tab === 'new-channel') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !channelKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
@@ -102,10 +175,24 @@ export function NewMessageModal({
|
|||||||
setError(validationError);
|
setError(validationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Normalize to lowercase unless user explicitly permits capitals
|
|
||||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||||
|
} else {
|
||||||
|
const { channelNames, invalidNames } = parseBulkHashtagNames(
|
||||||
|
bulkChannelText,
|
||||||
|
permitCapitals
|
||||||
|
);
|
||||||
|
if (channelNames.length === 0) {
|
||||||
|
setError('Enter at least one valid room name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (invalidNames.length > 0) {
|
||||||
|
setError(`Invalid room names: ${invalidNames.join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onBulkAddHashtagChannels(channelNames, tryHistorical);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -118,16 +205,6 @@ export function NewMessageModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateHashtagName = (channelName: string): string | null => {
|
|
||||||
if (!channelName) {
|
|
||||||
return 'Channel name is required';
|
|
||||||
}
|
|
||||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
|
||||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateAndAddAnother = async () => {
|
const handleCreateAndAddAnother = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
const channelName = name.trim();
|
const channelName = name.trim();
|
||||||
@@ -139,7 +216,6 @@ export function NewMessageModal({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Normalize to lowercase unless user explicitly permits capitals
|
|
||||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||||
setName('');
|
setName('');
|
||||||
@@ -166,28 +242,36 @@ export function NewMessageModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New Conversation</DialogTitle>
|
<DialogTitle>New Conversation</DialogTitle>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
||||||
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||||
|
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(v) => {
|
onValueChange={(value) => {
|
||||||
setTab(v as Tab);
|
setTab(value as Tab);
|
||||||
resetForm();
|
resetForm();
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList
|
||||||
|
className={
|
||||||
|
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
|
||||||
|
}
|
||||||
|
>
|
||||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||||
|
{showBulkAddChannelTab && (
|
||||||
|
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||||
@@ -239,7 +323,7 @@ export function NewMessageModal({
|
|||||||
const bytes = new Uint8Array(16);
|
const bytes = new Uint8Array(16);
|
||||||
crypto.getRandomValues(bytes);
|
crypto.getRandomValues(bytes);
|
||||||
const hex = Array.from(bytes)
|
const hex = Array.from(bytes)
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
setChannelKey(hex);
|
setChannelKey(hex);
|
||||||
}}
|
}}
|
||||||
@@ -268,20 +352,55 @@ export function NewMessageModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-1">
|
<div className="mt-3 space-y-1">
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={permitCapitals}
|
checked={permitCapitals}
|
||||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||||
className="w-4 h-4 rounded border-input accent-primary"
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-muted-foreground pl-7">
|
<p className="pl-7 text-xs text-muted-foreground">
|
||||||
Not recommended; most companions normalize to lowercase
|
Not recommended; most companions normalize to lowercase
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{showBulkAddChannelTab && (
|
||||||
|
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
|
||||||
|
<textarea
|
||||||
|
ref={bulkTextareaRef}
|
||||||
|
id="bulk-hashtag-names"
|
||||||
|
aria-label="Bulk channel names"
|
||||||
|
value={bulkChannelText}
|
||||||
|
onChange={(e) => setBulkChannelText(e.target.value)}
|
||||||
|
placeholder={'#ops\nmesh-room\nanother-room'}
|
||||||
|
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Paste room names separated by lines, spaces, or commas. Leading # marks are
|
||||||
|
stripped automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={permitCapitals}
|
||||||
|
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||||
|
</label>
|
||||||
|
<p className="pl-7 text-xs text-muted-foreground">
|
||||||
|
Not recommended; most companions normalize to lowercase
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{showHistoricalOption && (
|
{showHistoricalOption && (
|
||||||
@@ -289,7 +408,7 @@ export function NewMessageModal({
|
|||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="try-historical"
|
htmlFor="try-historical"
|
||||||
className="text-sm text-muted-foreground cursor-pointer"
|
className="cursor-pointer text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
||||||
{undecryptedCount !== 1 ? 's' : ''}
|
{undecryptedCount !== 1 ? 's' : ''}
|
||||||
@@ -301,7 +420,7 @@ export function NewMessageModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{tryHistorical && (
|
{tryHistorical && (
|
||||||
<p className="text-xs text-muted-foreground text-right">
|
<p className="text-right text-xs text-muted-foreground">
|
||||||
Messages will stream in as they decrypt in the background
|
Messages will stream in as they decrypt in the background
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -330,7 +449,13 @@ export function NewMessageModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleCreate} disabled={loading}>
|
<Button onClick={handleCreate} disabled={loading}>
|
||||||
{loading ? 'Creating...' : 'Create'}
|
{loading
|
||||||
|
? tab === 'bulk-hashtag'
|
||||||
|
? 'Adding...'
|
||||||
|
: 'Creating...'
|
||||||
|
: tab === 'bulk-hashtag'
|
||||||
|
? 'Add Channels'
|
||||||
|
: 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -406,9 +406,12 @@ interface HopNodeProps {
|
|||||||
distanceUnit: DistanceUnit;
|
distanceUnit: DistanceUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
|
||||||
|
|
||||||
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||||
const isAmbiguous = hop.matches.length > 1;
|
const isAmbiguous = hop.matches.length > 1;
|
||||||
const isUnknown = hop.matches.length === 0;
|
const isUnknown = hop.matches.length === 0;
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
// Calculate distance from previous location for a contact
|
// Calculate distance from previous location for a contact
|
||||||
// Returns null if prev location unknown/ambiguous or contact has no valid location
|
// Returns null if prev location unknown/ambiguous or contact has no valid location
|
||||||
@@ -447,27 +450,38 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
|||||||
<div className="font-medium text-muted-foreground"><UNKNOWN></div>
|
<div className="font-medium text-muted-foreground"><UNKNOWN></div>
|
||||||
) : isAmbiguous ? (
|
) : isAmbiguous ? (
|
||||||
<div>
|
<div>
|
||||||
{hop.matches.map((contact) => {
|
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map(
|
||||||
const dist = getDistanceForContact(contact);
|
(contact) => {
|
||||||
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
const dist = getDistanceForContact(contact);
|
||||||
return (
|
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
||||||
<div key={contact.public_key} className="font-medium truncate">
|
return (
|
||||||
{contact.name || contact.public_key.slice(0, 12)}
|
<div key={contact.public_key} className="font-medium truncate">
|
||||||
{dist !== null && (
|
{contact.name || contact.public_key.slice(0, 12)}
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
{dist !== null && (
|
||||||
- {formatDistance(dist, distanceUnit)}
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
</span>
|
- {formatDistance(dist, distanceUnit)}
|
||||||
)}
|
</span>
|
||||||
{hasLocation && (
|
)}
|
||||||
<CoordinateLink
|
{hasLocation && (
|
||||||
lat={contact.lat!}
|
<CoordinateLink
|
||||||
lon={contact.lon!}
|
lat={contact.lat!}
|
||||||
publicKey={contact.public_key}
|
lon={contact.lon!}
|
||||||
/>
|
publicKey={contact.public_key}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{!expanded && hop.matches.length > AMBIGUOUS_MATCH_PREVIEW_LIMIT && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary hover:underline cursor-pointer"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
|
(and {hop.matches.length - AMBIGUOUS_MATCH_PREVIEW_LIMIT} more)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../api';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||||
import { RepeaterLogin } from './RepeaterLogin';
|
import { RepeaterLogin } from './RepeaterLogin';
|
||||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||||
@@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites';
|
|||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import { isValidLocation } from '../utils/pathUtils';
|
import { isValidLocation } from '../utils/pathUtils';
|
||||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||||
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
import type {
|
||||||
|
Contact,
|
||||||
|
Conversation,
|
||||||
|
Favorite,
|
||||||
|
PathDiscoveryResponse,
|
||||||
|
TelemetryHistoryEntry,
|
||||||
|
} from '../types';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||||
@@ -23,6 +30,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
|||||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||||
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
||||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||||
|
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
|
||||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||||
|
|
||||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||||
@@ -45,6 +53,7 @@ interface RepeaterDashboardProps {
|
|||||||
onToggleNotifications: () => void;
|
onToggleNotifications: () => void;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||||
onDeleteContact: (publicKey: string) => void;
|
onDeleteContact: (publicKey: string) => void;
|
||||||
|
onOpenContactInfo?: (publicKey: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterDashboard({
|
export function RepeaterDashboard({
|
||||||
@@ -62,6 +71,7 @@ export function RepeaterDashboard({
|
|||||||
onToggleNotifications,
|
onToggleNotifications,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
|
onOpenContactInfo,
|
||||||
}: RepeaterDashboardProps) {
|
}: RepeaterDashboardProps) {
|
||||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||||
@@ -88,7 +98,40 @@ export function RepeaterDashboard({
|
|||||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||||
useRememberedServerPassword('repeater', conversation.id);
|
useRememberedServerPassword('repeater', conversation.id);
|
||||||
|
|
||||||
|
// Telemetry history: preload from stored data, refresh from live status
|
||||||
|
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
|
||||||
|
const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none');
|
||||||
|
const telemetryHistoryRequestRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
telemetryHistoryRequestRef.current += 1;
|
||||||
|
telemetryHistorySourceRef.current = 'none';
|
||||||
|
setTelemetryHistory([]);
|
||||||
|
|
||||||
|
if (!loggedIn) return;
|
||||||
|
|
||||||
|
const requestId = telemetryHistoryRequestRef.current;
|
||||||
|
api
|
||||||
|
.repeaterTelemetryHistory(conversation.id)
|
||||||
|
.then((history) => {
|
||||||
|
if (telemetryHistoryRequestRef.current !== requestId) return;
|
||||||
|
if (telemetryHistorySourceRef.current === 'live') return;
|
||||||
|
telemetryHistorySourceRef.current = 'preload';
|
||||||
|
setTelemetryHistory(history);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [loggedIn, conversation.id]);
|
||||||
|
|
||||||
|
// When a live status fetch returns embedded telemetry_history, replace local state
|
||||||
|
useEffect(() => {
|
||||||
|
const liveHistory = paneData.status?.telemetry_history;
|
||||||
|
if (!liveHistory) return;
|
||||||
|
telemetryHistorySourceRef.current = 'live';
|
||||||
|
setTelemetryHistory(liveHistory);
|
||||||
|
}, [paneData.status?.telemetry_history]);
|
||||||
|
|
||||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||||
|
|
||||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||||
await login(nextPassword);
|
await login(nextPassword);
|
||||||
persistAfterLogin(nextPassword);
|
persistAfterLogin(nextPassword);
|
||||||
@@ -115,9 +158,24 @@ export function RepeaterDashboard({
|
|||||||
<span className="flex min-w-0 flex-col">
|
<span className="flex min-w-0 flex-col">
|
||||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||||
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
|
<h2 className="min-w-0 flex-shrink font-semibold text-base">
|
||||||
{conversation.name}
|
{onOpenContactInfo ? (
|
||||||
</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label={`View info for ${conversation.name}`}
|
||||||
|
onClick={() => onOpenContactInfo(conversation.id)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{conversation.name}</span>
|
||||||
|
<Info
|
||||||
|
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{conversation.name}</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
<span
|
<span
|
||||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -336,6 +394,9 @@ export function RepeaterDashboard({
|
|||||||
loading={consoleLoading}
|
loading={consoleLoading}
|
||||||
onSend={sendConsoleCommand}
|
onSend={sendConsoleCommand}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Telemetry history chart — full width, below console */}
|
||||||
|
<TelemetryHistoryPane entries={telemetryHistory} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
|
Contact,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
RadioAdvertMode,
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
@@ -47,6 +48,8 @@ interface SettingsModalBaseProps {
|
|||||||
blockedNames?: string[];
|
blockedNames?: string[];
|
||||||
onToggleBlockedKey?: (key: string) => void;
|
onToggleBlockedKey?: (key: string) => void;
|
||||||
onToggleBlockedName?: (name: string) => void;
|
onToggleBlockedName?: (name: string) => void;
|
||||||
|
contacts?: Contact[];
|
||||||
|
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsModalProps = SettingsModalBaseProps &
|
export type SettingsModalProps = SettingsModalBaseProps &
|
||||||
@@ -80,6 +83,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
blockedNames,
|
blockedNames,
|
||||||
onToggleBlockedKey,
|
onToggleBlockedKey,
|
||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
|
contacts,
|
||||||
|
onBulkDeleteContacts,
|
||||||
} = props;
|
} = props;
|
||||||
const externalSidebarNav = props.externalSidebarNav === true;
|
const externalSidebarNav = props.externalSidebarNav === true;
|
||||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||||
@@ -239,6 +244,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
blockedNames={blockedNames}
|
blockedNames={blockedNames}
|
||||||
onToggleBlockedKey={onToggleBlockedKey}
|
onToggleBlockedKey={onToggleBlockedKey}
|
||||||
onToggleBlockedName={onToggleBlockedName}
|
onToggleBlockedName={onToggleBlockedName}
|
||||||
|
contacts={contacts}
|
||||||
|
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||||
className={sectionContentClass}
|
className={sectionContentClass}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ interface SidebarProps {
|
|||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
activeConversation: Conversation | null;
|
activeConversation: Conversation | null;
|
||||||
onSelectConversation: (conversation: Conversation) => void;
|
onSelectConversation: (conversation: Conversation) => void;
|
||||||
onNewMessage: () => void;
|
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
lastMessageTimes: ConversationTimes;
|
lastMessageTimes: ConversationTimes;
|
||||||
unreadCounts: Record<string, number>;
|
unreadCounts: Record<string, number>;
|
||||||
/** Tracks which conversations have unread messages that mention the user */
|
/** Tracks which conversations have unread messages that mention the user */
|
||||||
@@ -107,34 +107,19 @@ interface SidebarProps {
|
|||||||
onToggleCracker: () => void;
|
onToggleCracker: () => void;
|
||||||
onMarkAllRead: () => void;
|
onMarkAllRead: () => void;
|
||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
/** Legacy global sort order, used only to seed per-section local preferences. */
|
|
||||||
legacySortOrder?: SortOrder;
|
|
||||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||||
|
blockedKeys?: string[];
|
||||||
|
blockedNames?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type InitialSectionSortState = {
|
function loadInitialSectionSortOrders(): SidebarSectionSortOrders {
|
||||||
orders: SidebarSectionSortOrders;
|
|
||||||
source: 'section' | 'legacy' | 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadInitialSectionSortOrders(): InitialSectionSortState {
|
|
||||||
const storedOrders = loadLocalStorageSidebarSectionSortOrders();
|
const storedOrders = loadLocalStorageSidebarSectionSortOrders();
|
||||||
if (storedOrders) {
|
if (storedOrders) return storedOrders;
|
||||||
return { orders: storedOrders, source: 'section' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyOrder = loadLegacyLocalStorageSortOrder();
|
const legacyOrder = loadLegacyLocalStorageSortOrder();
|
||||||
if (legacyOrder) {
|
const orders = buildSidebarSectionSortOrders(legacyOrder ?? undefined);
|
||||||
return {
|
saveLocalStorageSidebarSectionSortOrders(orders);
|
||||||
orders: buildSidebarSectionSortOrders(legacyOrder),
|
return orders;
|
||||||
source: 'legacy',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
orders: buildSidebarSectionSortOrders(),
|
|
||||||
source: 'none',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
@@ -151,12 +136,20 @@ export function Sidebar({
|
|||||||
onToggleCracker,
|
onToggleCracker,
|
||||||
onMarkAllRead,
|
onMarkAllRead,
|
||||||
favorites,
|
favorites,
|
||||||
legacySortOrder,
|
|
||||||
isConversationNotificationsEnabled,
|
isConversationNotificationsEnabled,
|
||||||
|
blockedKeys = [],
|
||||||
|
blockedNames = [],
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const isContactBlocked = useCallback(
|
||||||
|
(c: Contact) =>
|
||||||
|
blockedKeys.includes(c.public_key.toLowerCase()) ||
|
||||||
|
(c.name != null && blockedNames.includes(c.name)),
|
||||||
|
[blockedKeys, blockedNames]
|
||||||
|
);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
|
const initialSectionSortOrders = useMemo(loadInitialSectionSortOrders, []);
|
||||||
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
|
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortOrders);
|
||||||
const initialCollapsedState = useMemo(loadCollapsedState, []);
|
const initialCollapsedState = useMemo(loadCollapsedState, []);
|
||||||
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
|
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
|
||||||
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
||||||
@@ -165,29 +158,12 @@ export function Sidebar({
|
|||||||
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
|
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
|
||||||
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
||||||
const collapseSnapshotRef = useRef<CollapseState | null>(null);
|
const collapseSnapshotRef = useRef<CollapseState | null>(null);
|
||||||
const sectionSortSourceRef = useRef(initialSectionSortState.source);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sectionSortSourceRef.current === 'legacy') {
|
|
||||||
saveLocalStorageSidebarSectionSortOrders(sectionSortOrders);
|
|
||||||
sectionSortSourceRef.current = 'section';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sectionSortSourceRef.current !== 'none' || legacySortOrder === undefined) return;
|
|
||||||
|
|
||||||
const seededOrders = buildSidebarSectionSortOrders(legacySortOrder);
|
|
||||||
setSectionSortOrders(seededOrders);
|
|
||||||
saveLocalStorageSidebarSectionSortOrders(seededOrders);
|
|
||||||
sectionSortSourceRef.current = 'section';
|
|
||||||
}, [legacySortOrder, sectionSortOrders]);
|
|
||||||
|
|
||||||
const handleSortToggle = (section: SidebarSortableSection) => {
|
const handleSortToggle = (section: SidebarSortableSection) => {
|
||||||
setSectionSortOrders((prev) => {
|
setSectionSortOrders((prev) => {
|
||||||
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
|
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
|
||||||
const updated = { ...prev, [section]: nextOrder };
|
const updated = { ...prev, [section]: nextOrder };
|
||||||
saveLocalStorageSidebarSectionSortOrders(updated);
|
saveLocalStorageSidebarSectionSortOrders(updated);
|
||||||
sectionSortSourceRef.current = 'section';
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -398,38 +374,32 @@ export function Sidebar({
|
|||||||
[sortedChannels, query]
|
[sortedChannels, query]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredNonRepeaterContacts = useMemo(
|
const filteredNonRepeaterContacts = useMemo(() => {
|
||||||
() =>
|
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c));
|
||||||
query
|
return query
|
||||||
? sortedNonRepeaterContacts.filter(
|
? visible.filter(
|
||||||
(c) =>
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
)
|
||||||
)
|
: visible;
|
||||||
: sortedNonRepeaterContacts,
|
}, [sortedNonRepeaterContacts, query, isContactBlocked]);
|
||||||
[sortedNonRepeaterContacts, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredRooms = useMemo(
|
const filteredRooms = useMemo(() => {
|
||||||
() =>
|
const visible = sortedRooms.filter((c) => !isContactBlocked(c));
|
||||||
query
|
return query
|
||||||
? sortedRooms.filter(
|
? visible.filter(
|
||||||
(c) =>
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
)
|
||||||
)
|
: visible;
|
||||||
: sortedRooms,
|
}, [sortedRooms, query, isContactBlocked]);
|
||||||
[sortedRooms, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredRepeaters = useMemo(
|
const filteredRepeaters = useMemo(() => {
|
||||||
() =>
|
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
|
||||||
query
|
return query
|
||||||
? sortedRepeaters.filter(
|
? visible.filter(
|
||||||
(c) =>
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
)
|
||||||
)
|
: visible;
|
||||||
: sortedRepeaters,
|
}, [sortedRepeaters, query, isContactBlocked]);
|
||||||
[sortedRepeaters, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expand sections while searching; restore prior collapse state when search ends.
|
// Expand sections while searching; restore prior collapse state when search ends.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -654,8 +624,9 @@ export function Sidebar({
|
|||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
|
data-active={active ? 'true' : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
active && 'bg-accent border-l-primary'
|
active && 'bg-accent border-l-primary'
|
||||||
)}
|
)}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -664,10 +635,10 @@ export function Sidebar({
|
|||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
|
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
|
<span className="sidebar-tool-label flex-1 truncate">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -860,7 +831,7 @@ export function Sidebar({
|
|||||||
onClick={onNewMessage}
|
onClick={onNewMessage}
|
||||||
title="Add channel or contact"
|
title="Add channel or contact"
|
||||||
aria-label="Add channel or contact"
|
aria-label="Add channel or contact"
|
||||||
className="h-8 w-full justify-start gap-2 px-3 text-[13px]"
|
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[13px] text-primary hover:bg-primary/10 hover:text-primary"
|
||||||
>
|
>
|
||||||
<SquarePen className="h-4 w-4" />
|
<SquarePen className="h-4 w-4" />
|
||||||
<span>Add Channel/Contact</span>
|
<span>Add Channel/Contact</span>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TelemetryHistoryEntry } from '../../types';
|
||||||
|
|
||||||
|
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
||||||
|
|
||||||
|
const METRIC_CONFIG: Record<Metric, { label: string; unit: string; color: string }> = {
|
||||||
|
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||||
|
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||||
|
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||||
|
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||||
|
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
||||||
|
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
|
||||||
|
return `${(seconds / 86400).toFixed(1)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
|
||||||
|
const [metric, setMetric] = useState<Metric>('battery_volts');
|
||||||
|
|
||||||
|
const config = METRIC_CONFIG[metric];
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
return entries.map((e) => {
|
||||||
|
const d = e.data;
|
||||||
|
return {
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
battery_volts: d.battery_volts,
|
||||||
|
noise_floor_dbm: d.noise_floor_dbm,
|
||||||
|
packets_received: d.packets_received,
|
||||||
|
packets_sent: d.packets_sent,
|
||||||
|
uptime_seconds: d.uptime_seconds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||||
|
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
{/* Metric selector */}
|
||||||
|
<div className="flex gap-1 mb-2">
|
||||||
|
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMetric(m)}
|
||||||
|
className={cn(
|
||||||
|
'text-[11px] px-2 py-0.5 rounded transition-colors',
|
||||||
|
metric === m
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{METRIC_CONFIG[m].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No history yet. Fetch status above to record data points.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
type="number"
|
||||||
|
domain={['dataMin', 'dataMax']}
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={formatTime}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{
|
||||||
|
stroke: 'hsl(var(--muted-foreground))',
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: '3 3',
|
||||||
|
}}
|
||||||
|
labelFormatter={(ts) => formatTime(Number(ts))}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={(value: any, name: any) => {
|
||||||
|
const numVal = typeof value === 'number' ? value : Number(value);
|
||||||
|
const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
|
||||||
|
const suffix =
|
||||||
|
metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
|
||||||
|
const label =
|
||||||
|
metric === 'packets'
|
||||||
|
? name === 'packets_received'
|
||||||
|
? 'Received'
|
||||||
|
: 'Sent'
|
||||||
|
: config.label;
|
||||||
|
return [`${display}${suffix}`, label];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{dataKeys.map((key, i) => (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
type="linear"
|
||||||
|
dataKey={key}
|
||||||
|
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||||
|
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{
|
||||||
|
r: 4,
|
||||||
|
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||||
|
strokeWidth: 2,
|
||||||
|
stroke: 'hsl(var(--popover))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { getContactDisplayName } from '../../utils/pubkey';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
|
||||||
|
import { toast } from '../ui/sonner';
|
||||||
|
import type { Contact } from '../../types';
|
||||||
|
|
||||||
|
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||||
|
0: 'Unknown',
|
||||||
|
1: 'Client',
|
||||||
|
2: 'Repeater',
|
||||||
|
3: 'Room',
|
||||||
|
4: 'Sensor',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleDateString([], {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateISO(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function datetimeToUnix(datetimeStr: string): number {
|
||||||
|
const d = new Date(datetimeStr);
|
||||||
|
return Math.floor(d.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkDeleteContactsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
contacts: Contact[];
|
||||||
|
onDeleted: (deletedKeys: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkDeleteContactsModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
contacts,
|
||||||
|
onDeleted,
|
||||||
|
}: BulkDeleteContactsModalProps) {
|
||||||
|
const [step, setStep] = useState<'select' | 'confirm'>('select');
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const lastClickedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const resetAndClose = useCallback(() => {
|
||||||
|
setStep('select');
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
setTypeFilter('all');
|
||||||
|
lastClickedKeyRef.current = null;
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const filteredContacts = useMemo(() => {
|
||||||
|
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
||||||
|
if (typeFilter !== 'all') {
|
||||||
|
list = list.filter((c) => c.type === typeFilter);
|
||||||
|
}
|
||||||
|
if (startDate) {
|
||||||
|
const start = datetimeToUnix(startDate);
|
||||||
|
list = list.filter((c) => (c.first_seen ?? 0) >= start);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
const end = datetimeToUnix(endDate);
|
||||||
|
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [contacts, typeFilter, startDate, endDate]);
|
||||||
|
|
||||||
|
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||||
|
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||||
|
const keys = filteredContacts.map((c) => c.public_key);
|
||||||
|
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
|
||||||
|
const curIdx = keys.indexOf(key);
|
||||||
|
if (lastIdx >= 0 && curIdx >= 0) {
|
||||||
|
const from = Math.min(lastIdx, curIdx);
|
||||||
|
const to = Math.max(lastIdx, curIdx);
|
||||||
|
const rangeKeys = keys.slice(from, to + 1);
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const k of rangeKeys) next.add(k);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
lastClickedKeyRef.current = key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
lastClickedKeyRef.current = key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectNone = () => {
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedContacts = useMemo(
|
||||||
|
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
|
||||||
|
[contacts, selectedKeys]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
|
||||||
|
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
|
||||||
|
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
|
||||||
|
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
|
||||||
|
|
||||||
|
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
|
||||||
|
const minDate =
|
||||||
|
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
|
||||||
|
const maxDate =
|
||||||
|
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const keysToDelete = [...selectedKeys];
|
||||||
|
const result = await api.bulkDeleteContacts(keysToDelete);
|
||||||
|
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
|
||||||
|
onDeleted(keysToDelete);
|
||||||
|
resetAndClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Bulk delete failed:', err);
|
||||||
|
toast.error('Bulk delete failed', {
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 'select'
|
||||||
|
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
|
||||||
|
: 'Review the contacts that will be permanently deleted.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'select' && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Show</label>
|
||||||
|
<select
|
||||||
|
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="1">Clients</option>
|
||||||
|
<option value="2">Repeaters</option>
|
||||||
|
<option value="3">Room Servers</option>
|
||||||
|
<option value="4">Sensors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Created after</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Created before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
|
||||||
|
Select none
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||||
|
{(startDate || endDate) && ' (filtered)'}
|
||||||
|
{' · '}
|
||||||
|
{selectedKeys.size} selected
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||||
|
{filteredContacts.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
No contacts match the selected date range.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||||
|
<tr className="text-left text-xs text-muted-foreground">
|
||||||
|
<th className="px-3 py-1.5 w-8" />
|
||||||
|
<th className="px-3 py-1.5">Name</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
||||||
|
<th className="px-3 py-1.5">Key</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredContacts.map((c) => (
|
||||||
|
<tr
|
||||||
|
key={c.public_key}
|
||||||
|
className="border-t border-border hover:bg-accent/50 cursor-pointer"
|
||||||
|
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedKeys.has(c.public_key)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleToggle(
|
||||||
|
c.public_key,
|
||||||
|
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 truncate max-w-[10rem]">
|
||||||
|
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||||
|
{c.public_key.slice(0, 12)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={resetAndClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
|
||||||
|
disabled={selectedKeys.size === 0}
|
||||||
|
onClick={() => setStep('confirm')}
|
||||||
|
>
|
||||||
|
Proceed to confirmation ({selectedKeys.size})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'confirm' && (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||||
|
<tr className="text-left text-xs text-muted-foreground">
|
||||||
|
<th className="px-3 py-1.5">Name</th>
|
||||||
|
<th className="px-3 py-1.5">Type</th>
|
||||||
|
<th className="px-3 py-1.5">Key</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{selectedContacts.map((c) => (
|
||||||
|
<tr key={c.public_key} className="border-t border-border">
|
||||||
|
<td className="px-3 py-1.5 truncate max-w-[12rem]">
|
||||||
|
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
|
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||||
|
{c.public_key.slice(0, 12)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full h-auto py-3 text-wrap"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{deleting
|
||||||
|
? 'Deleting...'
|
||||||
|
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
|
||||||
|
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
|
||||||
|
repeaterCount > 0 &&
|
||||||
|
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
|
||||||
|
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
|
||||||
|
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { Separator } from '../ui/separator';
|
|||||||
import { toast } from '../ui/sonner';
|
import { toast } from '../ui/sonner';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
import { formatTime } from '../../utils/messageParser';
|
import { formatTime } from '../../utils/messageParser';
|
||||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||||
|
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
|
||||||
|
|
||||||
export function SettingsDatabaseSection({
|
export function SettingsDatabaseSection({
|
||||||
appSettings,
|
appSettings,
|
||||||
@@ -17,6 +18,8 @@ export function SettingsDatabaseSection({
|
|||||||
blockedNames = [],
|
blockedNames = [],
|
||||||
onToggleBlockedKey,
|
onToggleBlockedKey,
|
||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
|
contacts = [],
|
||||||
|
onBulkDeleteContacts,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
appSettings: AppSettings;
|
appSettings: AppSettings;
|
||||||
@@ -27,18 +30,23 @@ export function SettingsDatabaseSection({
|
|||||||
blockedNames?: string[];
|
blockedNames?: string[];
|
||||||
onToggleBlockedKey?: (key: string) => void;
|
onToggleBlockedKey?: (key: string) => void;
|
||||||
onToggleBlockedName?: (name: string) => void;
|
onToggleBlockedName?: (name: string) => void;
|
||||||
|
contacts?: Contact[];
|
||||||
|
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [retentionDays, setRetentionDays] = useState('14');
|
const [retentionDays, setRetentionDays] = useState('14');
|
||||||
const [cleaning, setCleaning] = useState(false);
|
const [cleaning, setCleaning] = useState(false);
|
||||||
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
||||||
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
||||||
|
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
|
||||||
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||||
|
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||||
}, [appSettings]);
|
}, [appSettings]);
|
||||||
|
|
||||||
const handleCleanup = async () => {
|
const handleCleanup = async () => {
|
||||||
@@ -92,7 +100,15 @@ export function SettingsDatabaseSection({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
|
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
|
||||||
|
const currentBlocked = appSettings.discovery_blocked_types ?? [];
|
||||||
|
if (
|
||||||
|
discoveryBlockedTypes.length !== currentBlocked.length ||
|
||||||
|
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
|
||||||
|
) {
|
||||||
|
update.discovery_blocked_types = discoveryBlockedTypes;
|
||||||
|
}
|
||||||
|
await onSaveAppSettings(update);
|
||||||
toast.success('Database settings saved');
|
toast.success('Database settings saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save database settings:', err);
|
console.error('Failed to save database settings:', err);
|
||||||
@@ -105,93 +121,93 @@ export function SettingsDatabaseSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
|
{/* ── Database Overview ── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
<Label className="text-base">Database Overview</Label>
|
||||||
<span className="text-sm text-muted-foreground">Database size</span>
|
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
||||||
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{health?.oldest_undecrypted_timestamp ? (
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
<span className="text-sm">Database size</span>
|
||||||
<span className="font-medium">
|
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
|
||||||
{formatTime(health.oldest_undecrypted_timestamp)}
|
</div>
|
||||||
<span className="text-muted-foreground ml-1">
|
<div className="flex justify-between items-center">
|
||||||
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
<span className="text-sm">Oldest undecrypted packet</span>
|
||||||
days old)
|
{health?.oldest_undecrypted_timestamp ? (
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{formatTime(health.oldest_undecrypted_timestamp)}
|
||||||
|
<span className="font-normal text-muted-foreground ml-1">
|
||||||
|
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
||||||
|
days)
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">None</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
|
||||||
<span className="text-muted-foreground">None</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* ── Storage Cleanup ── */}
|
||||||
<Label>Delete Undecrypted Packets</Label>
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<Label className="text-base">Storage Cleanup</Label>
|
||||||
Permanently deletes stored raw packets containing DMs and channel messages that have not
|
|
||||||
yet been decrypted. These packets are retained in case you later obtain the correct key —
|
<div className="rounded-md border border-border p-3 space-y-2">
|
||||||
once deleted, these messages can never be recovered or decrypted.
|
<Label className="text-sm">Delete Undecrypted Packets</Label>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="flex gap-2 items-end">
|
Permanently deletes stored raw packets that have not yet been decrypted. These are
|
||||||
<div className="space-y-1">
|
retained in case you later obtain the correct key — once deleted, these messages can
|
||||||
<Label htmlFor="retention-days" className="text-xs">
|
never be recovered.
|
||||||
Older than (days)
|
</p>
|
||||||
</Label>
|
<div className="flex gap-2 items-end">
|
||||||
<Input
|
<div className="space-y-1">
|
||||||
id="retention-days"
|
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
|
||||||
type="number"
|
Older than (days)
|
||||||
min="1"
|
</Label>
|
||||||
max="365"
|
<Input
|
||||||
value={retentionDays}
|
id="retention-days"
|
||||||
onChange={(e) => setRetentionDays(e.target.value)}
|
type="number"
|
||||||
className="w-24"
|
min="1"
|
||||||
/>
|
max="365"
|
||||||
|
value={retentionDays}
|
||||||
|
onChange={(e) => setRetentionDays(e.target.value)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCleanup}
|
||||||
|
disabled={cleaning}
|
||||||
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
{cleaning ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-2">
|
||||||
|
<Label className="text-sm">Purge Archival Raw Packets</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Deletes the raw packet bytes behind messages that are already decrypted and visible in
|
||||||
|
chat. This frees space but removes packet-analysis availability for those messages. It
|
||||||
|
does not affect displayed messages or future decryption.
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCleanup}
|
onClick={handlePurgeDecryptedRawPackets}
|
||||||
disabled={cleaning}
|
disabled={purgingDecryptedRaw}
|
||||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
||||||
>
|
>
|
||||||
{cleaning ? 'Deleting...' : 'Permanently Delete'}
|
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── DM Decryption ── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Purge Archival Raw Packets</Label>
|
<Label className="text-base">DM Decryption</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
|
||||||
visible in your chat history.{' '}
|
|
||||||
<em className="text-muted-foreground/80">
|
|
||||||
This will not affect any displayed messages or your ability to do historical decryption,
|
|
||||||
but it will remove packet-analysis availability for those historical messages.
|
|
||||||
</em>{' '}
|
|
||||||
The raw bytes are only useful for manual packet analysis.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handlePurgeDecryptedRawPackets}
|
|
||||||
disabled={purgingDecryptedRaw}
|
|
||||||
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
|
||||||
>
|
|
||||||
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>DM Decryption</Label>
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -207,17 +223,87 @@ export function SettingsDatabaseSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||||
|
{busy ? 'Saving...' : 'Save Settings'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Contact Management ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base">Contact Management</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Block discovery of new node types */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Block Discovery of New Node Types</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||||
|
types are still updated. This does not affect contacts added manually or via DM.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
[1, 'Block clients'],
|
||||||
|
[2, 'Block repeaters'],
|
||||||
|
[3, 'Block room servers'],
|
||||||
|
[4, 'Block sensors'],
|
||||||
|
] as const
|
||||||
|
).map(([typeCode, label]) => {
|
||||||
|
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||||
|
return (
|
||||||
|
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() =>
|
||||||
|
setDiscoveryBlockedTypes((prev) =>
|
||||||
|
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{discoveryBlockedTypes.length > 0 && (
|
||||||
|
<p className="text-xs text-warning">
|
||||||
|
New{' '}
|
||||||
|
{discoveryBlockedTypes
|
||||||
|
.map((t) =>
|
||||||
|
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||||
|
)
|
||||||
|
.join(', ')}{' '}
|
||||||
|
heard via advertisement will not be added to your contact list.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Blocked contacts list */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Blocked Contacts</Label>
|
<Label>Blocked Contacts</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
|
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||||
affected. Messages are still stored and will reappear if unblocked.
|
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||||
|
reappear if unblocked.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||||
|
avatar in any channel, or their name within the top status bar with the conversation
|
||||||
|
open.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{blockedKeys.length > 0 && (
|
{blockedKeys.length > 0 && (
|
||||||
@@ -268,15 +354,25 @@ export function SettingsDatabaseSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
<Separator />
|
||||||
<div className="text-sm text-destructive" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
{/* Bulk delete */}
|
||||||
{busy ? 'Saving...' : 'Save Settings'}
|
<div className="space-y-3">
|
||||||
</Button>
|
<Label>Bulk Delete Contacts</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||||
|
nodes. Message history will be preserved.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||||
|
Open Bulk Delete
|
||||||
|
</Button>
|
||||||
|
<BulkDeleteContactsModal
|
||||||
|
open={bulkDeleteOpen}
|
||||||
|
onClose={() => setBulkDeleteOpen(false)}
|
||||||
|
contacts={contacts}
|
||||||
|
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -643,16 +643,20 @@ function formatPrivateTopicSummary(config: Record<string, unknown>) {
|
|||||||
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
|
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
|
function censorAppriseUrl(url: string): string {
|
||||||
|
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
|
||||||
|
if (protoMatch) return `${protoMatch[0]}********`;
|
||||||
|
return '********';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAppriseTargets(urls: string | undefined) {
|
||||||
const targets = (urls || '')
|
const targets = (urls || '')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (targets.length === 0) return 'No targets configured';
|
if (targets.length === 0) return 'No targets configured';
|
||||||
|
|
||||||
const joined = targets.join(', ');
|
return targets.map(censorAppriseUrl).join(', ');
|
||||||
if (joined.length <= maxLength) return joined;
|
|
||||||
return `${joined.slice(0, maxLength - 3)}...`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSqsQueueSummary(config: Record<string, unknown>) {
|
function formatSqsQueueSummary(config: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -347,17 +347,20 @@ function PreviewSidebarRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
data-active={active ? 'true' : undefined}
|
||||||
|
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
||||||
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{leading}
|
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||||
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
|
{leading}
|
||||||
|
</span>
|
||||||
|
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{badge}
|
{badge}
|
||||||
{!badge && (
|
{!badge && (
|
||||||
<span className="text-muted-foreground" aria-hidden="true">
|
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||||
<MessageSquare className="h-3.5 w-3.5" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{/* Connection display */}
|
{/* ── Connection ── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Connection</Label>
|
<Label className="text-base">Connection</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${
|
className={`w-2 h-2 rounded-full ${
|
||||||
@@ -428,15 +428,58 @@ export function SettingsRadioSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Radio Name */}
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Identity ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base">Identity</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Radio Name</Label>
|
<Label htmlFor="name">Radio Name</Label>
|
||||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
|
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="public-key">Public Key</Label>
|
||||||
|
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
||||||
|
<Input
|
||||||
|
id="private-key"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={privateKey}
|
||||||
|
onChange={(e) => setPrivateKey(e.target.value)}
|
||||||
|
placeholder="64-character hex private key"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSetPrivateKey}
|
||||||
|
disabled={identityBusy || identityRebooting || !privateKey.trim()}
|
||||||
|
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{identityBusy || identityRebooting
|
||||||
|
? 'Setting & Rebooting...'
|
||||||
|
: 'Set Private Key & Reboot'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{identityError && (
|
||||||
|
<div className="text-sm text-destructive" role="alert">
|
||||||
|
{identityError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Radio Config */}
|
{/* ── Radio Parameters ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base">Radio Parameters</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="preset">Preset</Label>
|
<Label htmlFor="preset">Preset</Label>
|
||||||
<select
|
<select
|
||||||
@@ -518,11 +561,36 @@ export function SettingsRadioSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{config.path_hash_mode_supported && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
|
||||||
|
<select
|
||||||
|
id="path-hash-mode"
|
||||||
|
value={pathHashMode}
|
||||||
|
onChange={(e) => setPathHashMode(e.target.value)}
|
||||||
|
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
<option value="0">1 byte (default)</option>
|
||||||
|
<option value="1">2 bytes</option>
|
||||||
|
<option value="2">3 bytes</option>
|
||||||
|
</select>
|
||||||
|
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
||||||
|
<p className="font-semibold mb-1">Compatibility Warning</p>
|
||||||
|
<p>
|
||||||
|
ALL nodes along a message's route — your radio, every repeater, and the
|
||||||
|
recipient — must be running firmware that supports the selected mode. Messages
|
||||||
|
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Location ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Location</Label>
|
<Label className="text-base">Location</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -585,53 +653,8 @@ export function SettingsRadioSection({
|
|||||||
library.
|
library.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
|
||||||
<Checkbox
|
|
||||||
id="multi-acks-enabled"
|
|
||||||
checked={multiAcksEnabled}
|
|
||||||
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
When enabled, the radio sends one extra direct ACK transmission before the normal
|
|
||||||
ACK for received direct messages. This is a firmware-level receive behavior, not a
|
|
||||||
RemoteTerm retry setting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.path_hash_mode_supported && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
|
|
||||||
<select
|
|
||||||
id="path-hash-mode"
|
|
||||||
value={pathHashMode}
|
|
||||||
onChange={(e) => setPathHashMode(e.target.value)}
|
|
||||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
<option value="0">1 byte (default)</option>
|
|
||||||
<option value="1">2 bytes</option>
|
|
||||||
<option value="2">3 bytes</option>
|
|
||||||
</select>
|
|
||||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
|
||||||
<p className="font-semibold mb-1">Compatibility Warning</p>
|
|
||||||
<p>
|
|
||||||
ALL nodes along a message's route — your radio, every repeater, and the
|
|
||||||
recipient — must be running firmware that supports the selected mode. Messages
|
|
||||||
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-destructive" role="alert">
|
<div className="text-sm text-destructive" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -657,64 +680,28 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Keys */}
|
{/* ── Messaging ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="public-key">Public Key</Label>
|
<Label className="text-base">Messaging</Label>
|
||||||
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||||
<Input
|
<Checkbox
|
||||||
id="private-key"
|
id="multi-acks-enabled"
|
||||||
type="password"
|
checked={multiAcksEnabled}
|
||||||
autoComplete="off"
|
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
|
||||||
value={privateKey}
|
className="mt-0.5"
|
||||||
onChange={(e) => setPrivateKey(e.target.value)}
|
|
||||||
placeholder="64-character hex private key"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSetPrivateKey}
|
|
||||||
disabled={identityBusy || identityRebooting || !privateKey.trim()}
|
|
||||||
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{identityBusy || identityRebooting
|
|
||||||
? 'Setting & Rebooting...'
|
|
||||||
: 'Set Private Key & Reboot'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{identityError && (
|
|
||||||
<div className="text-sm text-destructive" role="alert">
|
|
||||||
{identityError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Flood & Advert Control */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-base">Flood & Advert Control</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
id="advert-interval"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={advertIntervalHours}
|
|
||||||
onChange={(e) => setAdvertIntervalHours(e.target.value)}
|
|
||||||
className="w-28"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
|
||||||
|
for received direct messages. This is a firmware-level receive behavior, not a
|
||||||
|
RemoteTerm retry setting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
|
||||||
Recommended: 24 hours or higher.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -746,6 +733,13 @@ export function SettingsRadioSection({
|
|||||||
Configured radio contact capacity. Favorites reload first, then background maintenance
|
Configured radio contact capacity. Favorites reload first, then background maintenance
|
||||||
refills to about 80% of this value and offloads once occupancy reaches about 95%.
|
refills to about 80% of this value and offloads once occupancy reaches about 95%.
|
||||||
</p>
|
</p>
|
||||||
|
{health?.radio_device_info?.max_contacts != null &&
|
||||||
|
Number(maxRadioContacts) > health.radio_device_info.max_contacts && (
|
||||||
|
<p className="text-xs text-warning">
|
||||||
|
Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '}
|
||||||
|
contacts. The effective cap will be limited to what the radio supports.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{floodError && (
|
{floodError && (
|
||||||
@@ -760,8 +754,28 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Advertising & Discovery ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-base">Hear & Be Heard</Label>
|
<Label className="text-base">Advertising & Discovery</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="advert-interval"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={advertIntervalHours}
|
||||||
|
onChange={(e) => setAdvertIntervalHours(e.target.value)}
|
||||||
|
className="w-28"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||||
|
Recommended: 24 hours or higher.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
|
|||||||
import { toast } from '../components/ui/sonner';
|
import { toast } from '../components/ui/sonner';
|
||||||
import { getContactDisplayName } from '../utils/pubkey';
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
|
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
|
||||||
import type { Channel, Contact, Conversation } from '../types';
|
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types';
|
||||||
|
|
||||||
interface UseContactsAndChannelsArgs {
|
interface UseContactsAndChannelsArgs {
|
||||||
setActiveConversation: (conv: Conversation | null) => void;
|
setActiveConversation: (conv: Conversation | null) => void;
|
||||||
@@ -112,6 +112,24 @@ export function useContactsAndChannels({
|
|||||||
[fetchUndecryptedCountInternal, setActiveConversation]
|
[fetchUndecryptedCountInternal, setActiveConversation]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleBulkCreateHashtagChannels = useCallback(
|
||||||
|
async (
|
||||||
|
channelNames: string[],
|
||||||
|
tryHistorical: boolean
|
||||||
|
): Promise<BulkCreateHashtagChannelsResult> => {
|
||||||
|
const result = await api.bulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||||
|
const data = await api.getChannels();
|
||||||
|
setChannels(data);
|
||||||
|
|
||||||
|
if (tryHistorical && result.decrypt_started) {
|
||||||
|
fetchUndecryptedCountInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[fetchUndecryptedCountInternal]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeleteChannel = useCallback(
|
const handleDeleteChannel = useCallback(
|
||||||
async (key: string) => {
|
async (key: string) => {
|
||||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||||
@@ -190,6 +208,7 @@ export function useContactsAndChannels({
|
|||||||
handleCreateContact,
|
handleCreateContact,
|
||||||
handleCreateChannel,
|
handleCreateChannel,
|
||||||
handleCreateHashtagChannel,
|
handleCreateHashtagChannel,
|
||||||
|
handleBulkCreateHashtagChannels,
|
||||||
handleDeleteChannel,
|
handleDeleteChannel,
|
||||||
handleDeleteContact,
|
handleDeleteContact,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,6 +56,14 @@
|
|||||||
--badge-mention: var(--destructive);
|
--badge-mention: var(--destructive);
|
||||||
--badge-mention-foreground: var(--destructive-foreground);
|
--badge-mention-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
|
/* Sidebar navigation accents */
|
||||||
|
--sidebar-icon-color: hsl(var(--foreground));
|
||||||
|
--sidebar-icon-hover-color: hsl(var(--foreground));
|
||||||
|
--sidebar-icon-active-color: hsl(var(--foreground));
|
||||||
|
--sidebar-label-color: hsl(var(--muted-foreground));
|
||||||
|
--sidebar-label-hover-color: hsl(var(--foreground));
|
||||||
|
--sidebar-label-active-color: hsl(var(--foreground));
|
||||||
|
|
||||||
/* Error toast */
|
/* Error toast */
|
||||||
--toast-error: 0 30% 14%;
|
--toast-error: 0 30% 14%;
|
||||||
--toast-error-foreground: 0 56% 77%;
|
--toast-error-foreground: 0 56% 77%;
|
||||||
@@ -126,6 +134,50 @@
|
|||||||
animation: message-highlight 2s ease-out forwards;
|
animation: message-highlight 2s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 1.5rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
color: var(--sidebar-icon-color);
|
||||||
|
opacity: 1;
|
||||||
|
transition:
|
||||||
|
color 150ms ease,
|
||||||
|
opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-icon svg {
|
||||||
|
stroke-width: 2.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-label {
|
||||||
|
color: var(--sidebar-label-color);
|
||||||
|
transition: color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-row:hover .sidebar-tool-icon,
|
||||||
|
.sidebar-action-row:focus-visible .sidebar-tool-icon {
|
||||||
|
color: var(--sidebar-icon-hover-color);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-row:hover .sidebar-tool-label,
|
||||||
|
.sidebar-action-row:focus-visible .sidebar-tool-label {
|
||||||
|
color: var(--sidebar-label-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-row[data-active='true'] .sidebar-tool-icon {
|
||||||
|
color: var(--sidebar-icon-active-color);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-row[data-active='true'] .sidebar-tool-label {
|
||||||
|
color: var(--sidebar-label-active-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* Constrain CodeMirror editor width */
|
/* Constrain CodeMirror editor width */
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ const baseSettings = {
|
|||||||
max_radio_contacts: 200,
|
max_radio_contacts: 200,
|
||||||
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
sidebar_sort_order: 'recent' as const,
|
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: false,
|
preferences_migrated: false,
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ describe('App search jump target handling', () => {
|
|||||||
max_radio_contacts: 200,
|
max_radio_contacts: 200,
|
||||||
favorites: [],
|
favorites: [],
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
sidebar_sort_order: 'recent',
|
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: true,
|
preferences_migrated: true,
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ describe('App startup hash resolution', () => {
|
|||||||
max_radio_contacts: 200,
|
max_radio_contacts: 200,
|
||||||
favorites: [],
|
favorites: [],
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
sidebar_sort_order: 'recent',
|
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: true,
|
preferences_migrated: true,
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { BulkAddChannelResultModal } from '../components/BulkAddChannelResultModal';
|
||||||
|
|
||||||
|
describe('BulkAddChannelResultModal', () => {
|
||||||
|
it('renders links only for newly created rooms', () => {
|
||||||
|
render(
|
||||||
|
<BulkAddChannelResultModal
|
||||||
|
open
|
||||||
|
onClose={() => {}}
|
||||||
|
result={{
|
||||||
|
created_channels: [
|
||||||
|
{
|
||||||
|
key: 'AA'.repeat(16),
|
||||||
|
name: '#ops',
|
||||||
|
is_hashtag: true,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'BB'.repeat(16),
|
||||||
|
name: '#mesh-room',
|
||||||
|
is_hashtag: true,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
existing_count: 3,
|
||||||
|
invalid_names: ['bad_room'],
|
||||||
|
decrypt_started: true,
|
||||||
|
decrypt_total_packets: 8,
|
||||||
|
message: 'Created 2 rooms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const opsLink = screen.getByRole('link', { name: '#ops' });
|
||||||
|
const meshLink = screen.getByRole('link', { name: '#mesh-room' });
|
||||||
|
|
||||||
|
expect(opsLink.getAttribute('href')).toContain('#channel/');
|
||||||
|
expect(meshLink.getAttribute('href')).toContain('#channel/');
|
||||||
|
expect(screen.queryByRole('link', { name: /bad_room/i })).toBeNull();
|
||||||
|
expect(screen.getByText(/Ignored invalid room names: bad_room/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1227,7 +1227,7 @@ describe('SettingsFanoutSection', () => {
|
|||||||
|
|
||||||
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
||||||
expect(
|
expect(
|
||||||
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
|
within(group).getByText(/discord:\/\/\*{8}, mailto:\/\/\*{8}, mailto:\/\/\*{8}/)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,32 @@ describe('MessageList channel sender rendering', () => {
|
|||||||
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
|
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links valid channel references when followed by clause punctuation', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChannelReferenceClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
messages={[
|
||||||
|
createMessage({
|
||||||
|
text: 'Alice: Check #mesh-room, then #ops-room; then #alpha-room.',
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
contacts={[]}
|
||||||
|
loading={false}
|
||||||
|
onChannelReferenceClick={onChannelReferenceClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '#mesh-room' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '#ops-room' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '#alpha-room' }));
|
||||||
|
|
||||||
|
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(1, '#mesh-room');
|
||||||
|
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(2, '#ops-room');
|
||||||
|
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(3, '#alpha-room');
|
||||||
|
});
|
||||||
|
|
||||||
it('links valid channel references in direct messages too', async () => {
|
it('links valid channel references in direct messages too', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChannelReferenceClick = vi.fn();
|
const onChannelReferenceClick = vi.fn();
|
||||||
|
|||||||
@@ -122,11 +122,21 @@ describe('linked channel references', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid or embedded channel-like text', () => {
|
it('finds linked channel references terminated by clause punctuation', () => {
|
||||||
expect(
|
expect(
|
||||||
findLinkedChannelReferences(
|
findLinkedChannelReferences('Join #mesh-room, then #ops2; finally #alpha-room.')
|
||||||
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
).toEqual([
|
||||||
)
|
{ label: '#mesh-room', start: 5, end: 15 },
|
||||||
).toEqual([]);
|
{ label: '#ops2', start: 22, end: 27 },
|
||||||
|
{ label: '#alpha-room', start: 37, end: 48 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid or embedded channel-like text', () => {
|
||||||
|
const references = findLinkedChannelReferences(
|
||||||
|
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(references.map((reference) => reference.label)).toEqual(['#good-room']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -44,6 +45,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
onCreateContact={onCreateContact}
|
onCreateContact={onCreateContact}
|
||||||
onCreateChannel={onCreateChannel}
|
onCreateChannel={onCreateChannel}
|
||||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||||
|
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
|
||||||
{...overrides}
|
{...overrides}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -111,6 +113,53 @@ describe('NewMessageModal form reset', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('bulk hashtag tab', () => {
|
||||||
|
it('is only visible when enabled', () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.queryByRole('tab', { name: 'Bulk Add Channel' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens on the bulk tab when enabled and submits normalized room names', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderModal(true, { showBulkAddChannelTab: true });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('tab', { name: 'Bulk Add Channel' })).toHaveAttribute(
|
||||||
|
'data-state',
|
||||||
|
'active'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole('textbox', { name: 'Bulk channel names' }),
|
||||||
|
'#Ops{enter}mesh-room another-room #Ops'
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onBulkAddHashtagChannels).toHaveBeenCalledWith(
|
||||||
|
['#ops', '#mesh-room', '#another-room'],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows invalid bulk room names before submitting', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderModal(true, { showBulkAddChannelTab: true });
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole('textbox', { name: 'Bulk channel names' }),
|
||||||
|
'good-room bad_room'
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
|
||||||
|
|
||||||
|
expect(onBulkAddHashtagChannels).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('Invalid room names: bad_room')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('new-contact tab', () => {
|
describe('new-contact tab', () => {
|
||||||
it('clears name and key after successful Create', async () => {
|
it('clears name and key after successful Create', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
|
|||||||
useRepeaterDashboard: () => mockHook,
|
useRepeaterDashboard: () => mockHook,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock api module (TelemetryHistoryPane fetches on mount)
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
api: {
|
||||||
|
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock sonner toast
|
// Mock sonner toast
|
||||||
vi.mock('../components/ui/sonner', () => ({
|
vi.mock('../components/ui/sonner', () => ({
|
||||||
toast: {
|
toast: {
|
||||||
@@ -118,6 +126,16 @@ const defaultProps = {
|
|||||||
onDeleteContact: vi.fn(),
|
onDeleteContact: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
describe('RepeaterDashboard', () => {
|
describe('RepeaterDashboard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -418,6 +436,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
telemetry_history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<RepeaterDashboard {...defaultProps} />);
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
@@ -634,4 +653,106 @@ describe('RepeaterDashboard', () => {
|
|||||||
overrideSpy.mockRestore();
|
overrideSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('telemetry history', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { api } = await import('../api');
|
||||||
|
vi.mocked(api.repeaterTelemetryHistory).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads telemetry history on mount when logged in', async () => {
|
||||||
|
const { api } = await import('../api');
|
||||||
|
mockHook.loggedIn = true;
|
||||||
|
|
||||||
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows telemetry history pane in logged-in view even before status fetch', () => {
|
||||||
|
mockHook.loggedIn = true;
|
||||||
|
|
||||||
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('0 samples')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates history from live status fetch', async () => {
|
||||||
|
const { api } = await import('../api');
|
||||||
|
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
|
||||||
|
const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } };
|
||||||
|
historySpy.mockResolvedValue([]);
|
||||||
|
|
||||||
|
mockHook.loggedIn = true;
|
||||||
|
mockHook.paneData.status = {
|
||||||
|
battery_volts: 4.2,
|
||||||
|
tx_queue_len: 0,
|
||||||
|
noise_floor_dbm: -120,
|
||||||
|
last_rssi_dbm: -85,
|
||||||
|
last_snr_db: 7.5,
|
||||||
|
packets_received: 100,
|
||||||
|
packets_sent: 50,
|
||||||
|
airtime_seconds: 600,
|
||||||
|
rx_airtime_seconds: 1200,
|
||||||
|
uptime_seconds: 86400,
|
||||||
|
sent_flood: 10,
|
||||||
|
sent_direct: 40,
|
||||||
|
recv_flood: 30,
|
||||||
|
recv_direct: 70,
|
||||||
|
flood_dups: 1,
|
||||||
|
direct_dups: 0,
|
||||||
|
full_events: 0,
|
||||||
|
telemetry_history: [liveEntry],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let an older preload overwrite newer live status history', async () => {
|
||||||
|
const { api } = await import('../api');
|
||||||
|
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
|
||||||
|
const deferred = createDeferred<{ timestamp: number; data: { battery_volts: number } }[]>();
|
||||||
|
historySpy.mockReturnValue(deferred.promise);
|
||||||
|
|
||||||
|
mockHook.loggedIn = true;
|
||||||
|
mockHook.paneData.status = {
|
||||||
|
battery_volts: 4.2,
|
||||||
|
tx_queue_len: 0,
|
||||||
|
noise_floor_dbm: -120,
|
||||||
|
last_rssi_dbm: -85,
|
||||||
|
last_snr_db: 7.5,
|
||||||
|
packets_received: 100,
|
||||||
|
packets_sent: 50,
|
||||||
|
airtime_seconds: 600,
|
||||||
|
rx_airtime_seconds: 1200,
|
||||||
|
uptime_seconds: 86400,
|
||||||
|
sent_flood: 10,
|
||||||
|
sent_direct: 40,
|
||||||
|
recv_flood: 30,
|
||||||
|
recv_direct: 70,
|
||||||
|
flood_dups: 1,
|
||||||
|
direct_dups: 0,
|
||||||
|
full_events: 0,
|
||||||
|
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
deferred.resolve([{ timestamp: 1690000000, data: { battery_volts: 3.9 } }]);
|
||||||
|
await deferred.promise;
|
||||||
|
|
||||||
|
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ const baseSettings: AppSettings = {
|
|||||||
max_radio_contacts: 200,
|
max_radio_contacts: 200,
|
||||||
favorites: [],
|
favorites: [],
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
sidebar_sort_order: 'recent',
|
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: false,
|
preferences_migrated: false,
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
@@ -69,6 +68,7 @@ const baseSettings: AppSettings = {
|
|||||||
flood_scope: '',
|
flood_scope: '',
|
||||||
blocked_keys: [],
|
blocked_keys: [],
|
||||||
blocked_names: [],
|
blocked_names: [],
|
||||||
|
discovery_blocked_types: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderModal(overrides?: {
|
function renderModal(overrides?: {
|
||||||
@@ -615,10 +615,10 @@ describe('SettingsModal', () => {
|
|||||||
openDatabaseSection();
|
openDatabaseSection();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/remove packet-analysis availability for those historical messages/i)
|
screen.getByText(/removes packet-analysis availability for those messages/i)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
||||||
|
|||||||
@@ -7,3 +7,19 @@ class ResizeObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
globalThis.ResizeObserver = ResizeObserver;
|
globalThis.ResizeObserver = ResizeObserver;
|
||||||
|
|
||||||
|
// Several components call matchMedia at import time for responsive detection
|
||||||
|
if (typeof globalThis.matchMedia === 'undefined') {
|
||||||
|
Object.defineProperty(globalThis, 'matchMedia', {
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ function renderSidebar(overrides?: {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={favorites}
|
favorites={favorites}
|
||||||
legacySortOrder="recent"
|
|
||||||
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
|
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -140,7 +139,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -300,7 +298,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -397,7 +394,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker: vi.fn(),
|
onToggleCracker: vi.fn(),
|
||||||
onMarkAllRead: vi.fn(),
|
onMarkAllRead: vi.fn(),
|
||||||
favorites: [],
|
favorites: [],
|
||||||
legacySortOrder: 'recent' as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
|
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
|
||||||
@@ -469,7 +465,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -504,7 +499,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -553,7 +547,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -586,7 +579,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="alpha"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -623,7 +615,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
{ type: 'contact', id: zed.public_key },
|
{ type: 'contact', id: zed.public_key },
|
||||||
{ type: 'contact', id: amy.public_key },
|
{ type: 'contact', id: amy.public_key },
|
||||||
] satisfies Favorite[],
|
] satisfies Favorite[],
|
||||||
legacySortOrder: 'recent' as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFavoritesOrder = () =>
|
const getFavoritesOrder = () =>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react';
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
|
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
|
||||||
import type { Contact } from '../types';
|
import type { BulkCreateHashtagChannelsResult, Contact } from '../types';
|
||||||
|
|
||||||
// Mock api module
|
// Mock api module
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
@@ -18,6 +18,7 @@ vi.mock('../api', () => ({
|
|||||||
getChannels: vi.fn(),
|
getChannels: vi.fn(),
|
||||||
createContact: vi.fn(),
|
createContact: vi.fn(),
|
||||||
createChannel: vi.fn(),
|
createChannel: vi.fn(),
|
||||||
|
bulkCreateHashtagChannels: vi.fn(),
|
||||||
deleteContact: vi.fn(),
|
deleteContact: vi.fn(),
|
||||||
deleteChannel: vi.fn(),
|
deleteChannel: vi.fn(),
|
||||||
decryptHistoricalPackets: vi.fn(),
|
decryptHistoricalPackets: vi.fn(),
|
||||||
@@ -171,4 +172,41 @@ describe('useContactsAndChannels', () => {
|
|||||||
expect(api.getContacts).toHaveBeenCalledTimes(2);
|
expect(api.getContacts).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('bulk hashtag creation', () => {
|
||||||
|
it('refreshes channels and returns the backend result', async () => {
|
||||||
|
const { api } = await import('../api');
|
||||||
|
const resultPayload: BulkCreateHashtagChannelsResult = {
|
||||||
|
created_channels: [
|
||||||
|
{
|
||||||
|
key: 'AA'.repeat(16),
|
||||||
|
name: '#ops',
|
||||||
|
is_hashtag: true,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
existing_count: 1,
|
||||||
|
invalid_names: [],
|
||||||
|
decrypt_started: true,
|
||||||
|
decrypt_total_packets: 12,
|
||||||
|
message: 'Created 1 room',
|
||||||
|
};
|
||||||
|
vi.mocked(api.bulkCreateHashtagChannels).mockResolvedValueOnce(resultPayload);
|
||||||
|
vi.mocked(api.getChannels).mockResolvedValueOnce(resultPayload.created_channels);
|
||||||
|
vi.mocked(api.getUndecryptedPacketCount).mockResolvedValueOnce({ count: 9 });
|
||||||
|
|
||||||
|
const { result } = renderUseContactsAndChannels();
|
||||||
|
|
||||||
|
let response: BulkCreateHashtagChannelsResult | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
response = await result.current.handleBulkCreateHashtagChannels(['#ops'], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api.bulkCreateHashtagChannels).toHaveBeenCalledWith(['#ops'], true);
|
||||||
|
expect(api.getChannels).toHaveBeenCalled();
|
||||||
|
expect(api.getUndecryptedPacketCount).toHaveBeenCalled();
|
||||||
|
expect(response).toEqual(resultPayload);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,10 +49,6 @@
|
|||||||
--overlay: 220 20% 10%;
|
--overlay: 220 20% 10%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='light'] .sidebar-tool-label {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Windows 95 ───────────────────────────────────────────── */
|
/* ── Windows 95 ───────────────────────────────────────────── */
|
||||||
:root[data-theme='windows-95'] {
|
:root[data-theme='windows-95'] {
|
||||||
--background: 180 100% 25%;
|
--background: 180 100% 25%;
|
||||||
|
|||||||
+17
-19
@@ -166,23 +166,6 @@ export interface NearestRepeater {
|
|||||||
heard_count: number;
|
heard_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContactDetail {
|
|
||||||
contact: Contact;
|
|
||||||
name_history: ContactNameHistory[];
|
|
||||||
dm_message_count: number;
|
|
||||||
channel_message_count: number;
|
|
||||||
most_active_rooms: ContactActiveRoom[];
|
|
||||||
advert_paths: ContactAdvertPath[];
|
|
||||||
advert_frequency: number | null;
|
|
||||||
nearest_repeaters: NearestRepeater[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NameOnlyContactDetail {
|
|
||||||
name: string;
|
|
||||||
channel_message_count: number;
|
|
||||||
most_active_rooms: ContactActiveRoom[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContactAnalyticsHourlyBucket {
|
export interface ContactAnalyticsHourlyBucket {
|
||||||
bucket_start: number;
|
bucket_start: number;
|
||||||
last_24h_count: number;
|
last_24h_count: number;
|
||||||
@@ -235,6 +218,15 @@ export interface ChannelTopSender {
|
|||||||
message_count: number;
|
message_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BulkCreateHashtagChannelsResult {
|
||||||
|
created_channels: Channel[];
|
||||||
|
existing_count: number;
|
||||||
|
invalid_names: string[];
|
||||||
|
decrypt_started: boolean;
|
||||||
|
decrypt_total_packets: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChannelDetail {
|
export interface ChannelDetail {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
message_counts: ChannelMessageCounts;
|
message_counts: ChannelMessageCounts;
|
||||||
@@ -324,7 +316,6 @@ export interface AppSettings {
|
|||||||
max_radio_contacts: number;
|
max_radio_contacts: number;
|
||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
auto_decrypt_dm_on_advert: boolean;
|
auto_decrypt_dm_on_advert: boolean;
|
||||||
sidebar_sort_order: 'recent' | 'alpha';
|
|
||||||
last_message_times: Record<string, number>;
|
last_message_times: Record<string, number>;
|
||||||
preferences_migrated: boolean;
|
preferences_migrated: boolean;
|
||||||
advert_interval: number;
|
advert_interval: number;
|
||||||
@@ -332,16 +323,17 @@ export interface AppSettings {
|
|||||||
flood_scope: string;
|
flood_scope: string;
|
||||||
blocked_keys: string[];
|
blocked_keys: string[];
|
||||||
blocked_names: string[];
|
blocked_names: string[];
|
||||||
|
discovery_blocked_types: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettingsUpdate {
|
export interface AppSettingsUpdate {
|
||||||
max_radio_contacts?: number;
|
max_radio_contacts?: number;
|
||||||
auto_decrypt_dm_on_advert?: boolean;
|
auto_decrypt_dm_on_advert?: boolean;
|
||||||
sidebar_sort_order?: 'recent' | 'alpha';
|
|
||||||
advert_interval?: number;
|
advert_interval?: number;
|
||||||
flood_scope?: string;
|
flood_scope?: string;
|
||||||
blocked_keys?: string[];
|
blocked_keys?: string[];
|
||||||
blocked_names?: string[];
|
blocked_names?: string[];
|
||||||
|
discovery_blocked_types?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigratePreferencesRequest {
|
export interface MigratePreferencesRequest {
|
||||||
@@ -405,6 +397,7 @@ export interface RepeaterStatusResponse {
|
|||||||
flood_dups: number;
|
flood_dups: number;
|
||||||
direct_dups: number;
|
direct_dups: number;
|
||||||
full_events: number;
|
full_events: number;
|
||||||
|
telemetry_history: TelemetryHistoryEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepeaterNeighborsResponse {
|
export interface RepeaterNeighborsResponse {
|
||||||
@@ -468,6 +461,11 @@ export interface PaneState {
|
|||||||
fetched_at?: number | null;
|
fetched_at?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelemetryHistoryEntry {
|
||||||
|
timestamp: number;
|
||||||
|
data: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TraceResponse {
|
export interface TraceResponse {
|
||||||
remote_snr: number | null;
|
remote_snr: number | null;
|
||||||
local_snr: number | null;
|
local_snr: number | null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Channel messages have format "sender: message".
|
* Channel messages have format "sender: message".
|
||||||
*/
|
*/
|
||||||
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
|
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|[\s.,;:])/g;
|
||||||
|
|
||||||
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||||
const colonIndex = text.indexOf(': ');
|
const colonIndex = text.indexOf(': ');
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function getMapFocusHash(publicKeyPrefix: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate URL hash from conversation
|
// Generate URL hash from conversation
|
||||||
function getConversationHash(conv: Conversation | null): string {
|
export function getConversationHash(conv: Conversation | null): string {
|
||||||
if (!conv) return '';
|
if (!conv) return '';
|
||||||
if (conv.type === 'raw') return '#raw';
|
if (conv.type === 'raw') return '#raw';
|
||||||
if (conv.type === 'map') return '#map';
|
if (conv.type === 'map') return '#map';
|
||||||
|
|||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.6.3"
|
version = "3.7.1"
|
||||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"uvicorn[standard]>=0.32.0",
|
"uvicorn[standard]>=0.32.0",
|
||||||
@@ -57,7 +57,7 @@ ignore = [
|
|||||||
known-first-party = ["app"]
|
known-first-party = ["app"]
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
pythonVersion = "3.10"
|
pythonVersion = "3.11"
|
||||||
typeCheckingMode = "basic"
|
typeCheckingMode = "basic"
|
||||||
include = ["app"]
|
include = ["app"]
|
||||||
exclude = ["references", ".venv", "tests"]
|
exclude = ["references", ".venv", "tests"]
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=scripts/build/release_common.sh
|
||||||
|
source "$SCRIPT_DIR/release_common.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: scripts/build/create_github_release.sh --version X.Y.Z --asset PATH [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--version VERSION Release version / tag (required)
|
||||||
|
--asset PATH Asset to attach; may be specified multiple times
|
||||||
|
--notes-file PATH Markdown release notes file; defaults to CHANGELOG section
|
||||||
|
--full-git-hash HASH Commit to tag if the tag does not already exist locally
|
||||||
|
--title TITLE Release title (default: version)
|
||||||
|
--help Show this message
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
TITLE=""
|
||||||
|
NOTES_FILE=""
|
||||||
|
FULL_GIT_HASH=""
|
||||||
|
ASSETS=()
|
||||||
|
TEMP_NOTES_FILE=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$TEMP_NOTES_FILE" ] && [ -f "$TEMP_NOTES_FILE" ]; then
|
||||||
|
rm -f "$TEMP_NOTES_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
VERSION="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--asset)
|
||||||
|
ASSETS+=("${2:-}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--notes-file)
|
||||||
|
NOTES_FILE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--full-git-hash)
|
||||||
|
FULL_GIT_HASH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--title)
|
||||||
|
TITLE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
release_die "Unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$VERSION" ] || release_die "--version is required"
|
||||||
|
[ "${#ASSETS[@]}" -gt 0 ] || release_die "At least one --asset is required"
|
||||||
|
release_validate_version "$VERSION"
|
||||||
|
|
||||||
|
REPO_ROOT="$(release_repo_root)"
|
||||||
|
TITLE="${TITLE:-$VERSION}"
|
||||||
|
FULL_GIT_HASH="${FULL_GIT_HASH:-$(release_resolve_full_hash "$REPO_ROOT")}"
|
||||||
|
|
||||||
|
for asset in "${ASSETS[@]}"; do
|
||||||
|
[ -f "$asset" ] || release_die "Asset not found: $asset"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$NOTES_FILE" ]; then
|
||||||
|
TEMP_NOTES_FILE="$(mktemp)"
|
||||||
|
release_extract_changelog_section "$REPO_ROOT" "$VERSION" "$TEMP_NOTES_FILE"
|
||||||
|
NOTES_FILE="$TEMP_NOTES_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f "$NOTES_FILE" ] || release_die "Notes file not found: $NOTES_FILE"
|
||||||
|
|
||||||
|
if ! git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
|
||||||
|
echo "[create_github_release] Creating local tag $VERSION at $FULL_GIT_HASH..." >&2
|
||||||
|
git -C "$REPO_ROOT" tag -a "$VERSION" "$FULL_GIT_HASH" -F "$NOTES_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
|
||||||
|
echo "[create_github_release] Pushing tag $VERSION to origin..." >&2
|
||||||
|
git -C "$REPO_ROOT" push origin "$VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if gh release view "$VERSION" >/dev/null 2>&1; then
|
||||||
|
echo "[create_github_release] Updating existing GitHub release $VERSION..." >&2
|
||||||
|
gh release upload "$VERSION" "${ASSETS[@]}" --clobber
|
||||||
|
gh release edit "$VERSION" --title "$TITLE" --notes-file "$NOTES_FILE"
|
||||||
|
else
|
||||||
|
echo "[create_github_release] Creating GitHub release $VERSION..." >&2
|
||||||
|
gh release create "$VERSION" "${ASSETS[@]}" --title "$TITLE" --notes-file "$NOTES_FILE" --verify-tag
|
||||||
|
fi
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=scripts/build/release_common.sh
|
||||||
|
source "$SCRIPT_DIR/release_common.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: scripts/build/extract_release_notes.sh --version X.Y.Z --output PATH
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--version VERSION Release version to extract from CHANGELOG.md
|
||||||
|
--output PATH Output markdown file path
|
||||||
|
--changelog PATH Override changelog path
|
||||||
|
--help Show this message
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
OUTPUT_FILE=""
|
||||||
|
CHANGELOG_PATH=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
VERSION="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
OUTPUT_FILE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--changelog)
|
||||||
|
CHANGELOG_PATH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
release_die "Unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$VERSION" ] || release_die "--version is required"
|
||||||
|
[ -n "$OUTPUT_FILE" ] || release_die "--output is required"
|
||||||
|
release_validate_version "$VERSION"
|
||||||
|
|
||||||
|
REPO_ROOT="$(release_repo_root)"
|
||||||
|
release_extract_changelog_section "$REPO_ROOT" "$VERSION" "$OUTPUT_FILE" "${CHANGELOG_PATH:-$REPO_ROOT/CHANGELOG.md}"
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=scripts/build/release_common.sh
|
||||||
|
source "$SCRIPT_DIR/release_common.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: scripts/build/package_release_artifact.sh --version X.Y.Z [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--version VERSION Release version (required)
|
||||||
|
--git-hash HASH Short git hash to embed in artifact naming
|
||||||
|
--full-git-hash HASH Full git hash to archive
|
||||||
|
--output PATH Output zip path
|
||||||
|
--bundle-name NAME Bundle folder name inside the zip
|
||||||
|
--skip-prebuilt-build Reuse existing frontend/prebuilt instead of rebuilding it
|
||||||
|
--help Show this message
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
GIT_HASH=""
|
||||||
|
FULL_GIT_HASH=""
|
||||||
|
OUTPUT_PATH=""
|
||||||
|
BUNDLE_NAME="Remote-Terminal-for-MeshCore"
|
||||||
|
SKIP_PREBUILT_BUILD=0
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
VERSION="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--git-hash)
|
||||||
|
GIT_HASH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--full-git-hash)
|
||||||
|
FULL_GIT_HASH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
OUTPUT_PATH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--bundle-name)
|
||||||
|
BUNDLE_NAME="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-prebuilt-build)
|
||||||
|
SKIP_PREBUILT_BUILD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
release_die "Unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$VERSION" ] || release_die "--version is required"
|
||||||
|
release_validate_version "$VERSION"
|
||||||
|
|
||||||
|
REPO_ROOT="$(release_repo_root)"
|
||||||
|
FULL_GIT_HASH="${FULL_GIT_HASH:-$(release_resolve_full_hash "$REPO_ROOT")}"
|
||||||
|
GIT_HASH="${GIT_HASH:-$(release_resolve_short_hash "$REPO_ROOT" "$FULL_GIT_HASH")}"
|
||||||
|
OUTPUT_PATH="${OUTPUT_PATH:-$REPO_ROOT/remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip}"
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
BUNDLE_DIR="$WORK_DIR/$BUNDLE_NAME"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [ "$SKIP_PREBUILT_BUILD" -eq 0 ]; then
|
||||||
|
echo "[package_release_artifact] Building frontend prebuilt bundle..." >&2
|
||||||
|
(
|
||||||
|
cd "$REPO_ROOT/frontend"
|
||||||
|
npm run packaged-build
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -d "$REPO_ROOT/frontend/prebuilt" ] || release_die "frontend/prebuilt is missing; run with frontend built or omit --skip-prebuilt-build"
|
||||||
|
|
||||||
|
mkdir -p "$BUNDLE_DIR/frontend"
|
||||||
|
git -C "$REPO_ROOT" archive "$FULL_GIT_HASH" | tar -x -C "$BUNDLE_DIR"
|
||||||
|
cp -R "$REPO_ROOT/frontend/prebuilt" "$BUNDLE_DIR/frontend/prebuilt"
|
||||||
|
|
||||||
|
cat > "$BUNDLE_DIR/build_info.json" <<EOF
|
||||||
|
{
|
||||||
|
"version": "$VERSION",
|
||||||
|
"commit_hash": "$GIT_HASH",
|
||||||
|
"build_source": "prebuilt-release"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
rm -f "$OUTPUT_PATH"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
zip -qr "$OUTPUT_PATH" "$BUNDLE_NAME"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "$OUTPUT_PATH"
|
||||||
+93
-155
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -10,95 +10,63 @@ NC='\033[0m' # No Color
|
|||||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
RELEASE_WORK_DIR=""
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore"
|
# shellcheck source=scripts/build/release_common.sh
|
||||||
RELEASE_ASSET=""
|
source "$SCRIPT_DIR/release_common.sh"
|
||||||
DOCKER_IMAGE="jkingsman/remoteterm-meshcore"
|
|
||||||
|
DOCKER_IMAGE="docker.io/jkingsman/remoteterm-meshcore"
|
||||||
DOCKER_PLATFORMS="linux/amd64,linux/arm64"
|
DOCKER_PLATFORMS="linux/amd64,linux/arm64"
|
||||||
|
VERSION=""
|
||||||
|
NOTES_FILE=""
|
||||||
|
SKIP_QUALITY=0
|
||||||
|
RELEASE_ASSET_PATH=""
|
||||||
|
|
||||||
cleanup_release_build_artifacts() {
|
usage() {
|
||||||
if [ -d "$REPO_ROOT/frontend/prebuilt" ]; then
|
cat <<'EOF'
|
||||||
rm -rf "$REPO_ROOT/frontend/prebuilt"
|
Usage: scripts/build/publish.sh [options]
|
||||||
fi
|
|
||||||
if [ -n "$RELEASE_WORK_DIR" ] && [ -d "$RELEASE_WORK_DIR" ]; then
|
Options:
|
||||||
rm -rf "$RELEASE_WORK_DIR"
|
--version VERSION Release version; prompts if omitted
|
||||||
fi
|
--notes-file PATH File containing changelog entry lines; prompts if omitted
|
||||||
if [ -n "$RELEASE_ASSET" ] && [ -f "$REPO_ROOT/$RELEASE_ASSET" ]; then
|
--skip-quality Skip ./scripts/quality/all_quality.sh
|
||||||
rm -f "$REPO_ROOT/$RELEASE_ASSET"
|
--help Show this message
|
||||||
fi
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
trap cleanup_release_build_artifacts EXIT
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
ensure_buildx_builder() {
|
--version)
|
||||||
if ! docker buildx version >/dev/null 2>&1; then
|
VERSION="${2:-}"
|
||||||
echo -e "${RED}Error: docker buildx is required for multi-arch Docker builds.${NC}"
|
shift 2
|
||||||
exit 1
|
;;
|
||||||
fi
|
--notes-file)
|
||||||
|
NOTES_FILE="${2:-}"
|
||||||
local current_builder
|
shift 2
|
||||||
current_builder="$(docker buildx inspect --format '{{ .Name }}' 2>/dev/null || true)"
|
;;
|
||||||
|
--skip-quality)
|
||||||
if [ -n "$current_builder" ]; then
|
SKIP_QUALITY=1
|
||||||
docker buildx inspect --bootstrap >/dev/null
|
shift
|
||||||
return
|
;;
|
||||||
fi
|
--help)
|
||||||
|
usage
|
||||||
if docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then
|
exit 0
|
||||||
docker buildx use remoteterm-multiarch >/dev/null
|
;;
|
||||||
else
|
*)
|
||||||
docker buildx create --name remoteterm-multiarch --use >/dev/null
|
usage >&2
|
||||||
fi
|
release_die "Unknown argument: $1"
|
||||||
docker buildx inspect --bootstrap >/dev/null
|
;;
|
||||||
}
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
echo -e "${YELLOW}=== RemoteTerm for MeshCore Publish Script ===${NC}"
|
echo -e "${YELLOW}=== RemoteTerm for MeshCore Publish Script ===${NC}"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# Run backend linting and type checking
|
if [ "$SKIP_QUALITY" -eq 0 ]; then
|
||||||
echo -e "${YELLOW}Running backend lint (Ruff)...${NC}"
|
echo -e "${YELLOW}Running repo quality gate...${NC}"
|
||||||
uv run ruff check app/ tests/ --fix
|
./scripts/quality/all_quality.sh
|
||||||
uv run ruff format app/ tests/
|
echo -e "${GREEN}Quality gate passed!${NC}"
|
||||||
# validate
|
echo
|
||||||
uv run ruff check app/ tests/
|
fi
|
||||||
uv run ruff format --check app/ tests/
|
|
||||||
echo -e "${GREEN}Backend lint passed!${NC}"
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo -e "${YELLOW}Running backend type check (Pyright)...${NC}"
|
|
||||||
uv run pyright app/
|
|
||||||
echo -e "${GREEN}Backend type check passed!${NC}"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Run backend tests
|
|
||||||
echo -e "${YELLOW}Running backend tests...${NC}"
|
|
||||||
PYTHONPATH=. uv run pytest tests/ -v
|
|
||||||
echo -e "${GREEN}Backend tests passed!${NC}"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Run frontend linting and formatting check
|
|
||||||
echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}"
|
|
||||||
cd "$REPO_ROOT/frontend"
|
|
||||||
npm run lint
|
|
||||||
echo -e "${GREEN}Frontend lint passed!${NC}"
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo -e "${YELLOW}Checking frontend formatting (Prettier)...${NC}"
|
|
||||||
npm run format:check
|
|
||||||
echo -e "${GREEN}Frontend formatting OK!${NC}"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Run frontend tests and build
|
|
||||||
echo -e "${YELLOW}Running frontend tests...${NC}"
|
|
||||||
npm run test:run
|
|
||||||
echo -e "${GREEN}Frontend tests passed!${NC}"
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo -e "${YELLOW}Building frontend...${NC}"
|
|
||||||
npm run build
|
|
||||||
echo -e "${GREEN}Frontend build complete!${NC}"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
|
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
|
||||||
bash scripts/build/collect_licenses.sh LICENSES.md
|
bash scripts/build/collect_licenses.sh LICENSES.md
|
||||||
@@ -113,13 +81,11 @@ echo -n " package.json: "
|
|||||||
grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
|
grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
|
||||||
echo
|
echo
|
||||||
|
|
||||||
read -r -p "Enter new version (e.g., 1.2.3): " VERSION
|
if [ -z "$VERSION" ]; then
|
||||||
VERSION="$(printf '%s' "$VERSION" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
read -r -p "Enter new version (e.g., 1.2.3): " VERSION
|
||||||
|
|
||||||
if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
||||||
echo -e "${RED}Error: Version must be in format X.Y.Z${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
VERSION="$(release_trim "$VERSION")"
|
||||||
|
release_validate_version "$VERSION"
|
||||||
|
|
||||||
# Update pyproject.toml
|
# Update pyproject.toml
|
||||||
echo -e "${YELLOW}Updating pyproject.toml...${NC}"
|
echo -e "${YELLOW}Updating pyproject.toml...${NC}"
|
||||||
@@ -137,11 +103,28 @@ echo -e "${GREEN}Version updated to $VERSION${NC}"
|
|||||||
echo
|
echo
|
||||||
|
|
||||||
# Prompt for changelog entry
|
# Prompt for changelog entry
|
||||||
echo -e "${YELLOW}Enter changelog entry for version $VERSION${NC}"
|
RAW_CHANGELOG_INPUT_FILE="$(mktemp)"
|
||||||
echo -e "${YELLOW}(Enter your changes, then press Ctrl+D when done):${NC}"
|
FORMATTED_CHANGELOG_INPUT_FILE="$(mktemp)"
|
||||||
echo
|
cleanup() {
|
||||||
|
rm -f "$RAW_CHANGELOG_INPUT_FILE" "$FORMATTED_CHANGELOG_INPUT_FILE"
|
||||||
|
rm -rf "${REPO_ROOT:?}/frontend/prebuilt"
|
||||||
|
if [ -n "$RELEASE_ASSET_PATH" ] && [ -f "$RELEASE_ASSET_PATH" ]; then
|
||||||
|
rm -f "$RELEASE_ASSET_PATH"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
CHANGELOG_ENTRY=$(cat)
|
if [ -n "$NOTES_FILE" ]; then
|
||||||
|
cp "$NOTES_FILE" "$RAW_CHANGELOG_INPUT_FILE"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Enter changelog entry for version $VERSION${NC}"
|
||||||
|
echo -e "${YELLOW}(Enter your changes, then press Ctrl+D when done):${NC}"
|
||||||
|
echo
|
||||||
|
cat > "$RAW_CHANGELOG_INPUT_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_format_markdown_list "$RAW_CHANGELOG_INPUT_FILE" "$FORMATTED_CHANGELOG_INPUT_FILE"
|
||||||
|
[ -s "$FORMATTED_CHANGELOG_INPUT_FILE" ] || release_die "Changelog entry cannot be empty"
|
||||||
|
|
||||||
# Create changelog entry with date
|
# Create changelog entry with date
|
||||||
DATE=$(date +%Y-%m-%d)
|
DATE=$(date +%Y-%m-%d)
|
||||||
@@ -157,7 +140,7 @@ if [ -f CHANGELOG.md ]; then
|
|||||||
echo
|
echo
|
||||||
echo "$CHANGELOG_HEADER"
|
echo "$CHANGELOG_HEADER"
|
||||||
echo
|
echo
|
||||||
echo "$CHANGELOG_ENTRY"
|
cat "$FORMATTED_CHANGELOG_INPUT_FILE"
|
||||||
echo
|
echo
|
||||||
tail -n +2 CHANGELOG.md
|
tail -n +2 CHANGELOG.md
|
||||||
} > CHANGELOG.md.tmp
|
} > CHANGELOG.md.tmp
|
||||||
@@ -167,7 +150,7 @@ if [ -f CHANGELOG.md ]; then
|
|||||||
{
|
{
|
||||||
echo "$CHANGELOG_HEADER"
|
echo "$CHANGELOG_HEADER"
|
||||||
echo
|
echo
|
||||||
echo "$CHANGELOG_ENTRY"
|
cat "$FORMATTED_CHANGELOG_INPUT_FILE"
|
||||||
echo
|
echo
|
||||||
cat CHANGELOG.md
|
cat CHANGELOG.md
|
||||||
} > CHANGELOG.md.tmp
|
} > CHANGELOG.md.tmp
|
||||||
@@ -180,7 +163,7 @@ else
|
|||||||
echo
|
echo
|
||||||
echo "$CHANGELOG_HEADER"
|
echo "$CHANGELOG_HEADER"
|
||||||
echo
|
echo
|
||||||
echo "$CHANGELOG_ENTRY"
|
cat "$FORMATTED_CHANGELOG_INPUT_FILE"
|
||||||
} > CHANGELOG.md
|
} > CHANGELOG.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -200,78 +183,33 @@ echo
|
|||||||
GIT_HASH=$(git rev-parse --short HEAD)
|
GIT_HASH=$(git rev-parse --short HEAD)
|
||||||
FULL_GIT_HASH=$(git rev-parse HEAD)
|
FULL_GIT_HASH=$(git rev-parse HEAD)
|
||||||
RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip"
|
RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip"
|
||||||
|
RELEASE_ASSET_PATH="$REPO_ROOT/$RELEASE_ASSET"
|
||||||
|
|
||||||
echo -e "${YELLOW}Building packaged frontend artifact...${NC}"
|
echo -e "${YELLOW}Building packaged frontend artifact...${NC}"
|
||||||
cd "$REPO_ROOT/frontend"
|
scripts/build/package_release_artifact.sh \
|
||||||
npm run packaged-build
|
--version "$VERSION" \
|
||||||
cd "$REPO_ROOT"
|
--git-hash "$GIT_HASH" \
|
||||||
|
--full-git-hash "$FULL_GIT_HASH" \
|
||||||
RELEASE_WORK_DIR=$(mktemp -d)
|
--output "$RELEASE_ASSET_PATH"
|
||||||
RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME"
|
|
||||||
mkdir -p "$RELEASE_BUNDLE_DIR"
|
|
||||||
git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR"
|
|
||||||
mkdir -p "$RELEASE_BUNDLE_DIR/frontend"
|
|
||||||
cp -R "$REPO_ROOT/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
|
|
||||||
cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
|
|
||||||
{
|
|
||||||
"version": "$VERSION",
|
|
||||||
"commit_hash": "$GIT_HASH",
|
|
||||||
"build_source": "prebuilt-release"
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
rm -f "$REPO_ROOT/$RELEASE_ASSET"
|
|
||||||
(
|
|
||||||
cd "$RELEASE_WORK_DIR"
|
|
||||||
zip -qr "$REPO_ROOT/$RELEASE_ASSET" "$(basename "$RELEASE_BUNDLE_DIR")"
|
|
||||||
)
|
|
||||||
echo -e "${GREEN}Packaged release artifact created: $RELEASE_ASSET${NC}"
|
echo -e "${GREEN}Packaged release artifact created: $RELEASE_ASSET${NC}"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# Build and push multi-arch docker image
|
# Build and push multi-arch docker image
|
||||||
echo -e "${YELLOW}Building and pushing multi-arch Docker image...${NC}"
|
echo -e "${YELLOW}Building and pushing multi-arch Docker image...${NC}"
|
||||||
ensure_buildx_builder
|
scripts/build/push_docker_multiarch.sh \
|
||||||
docker buildx build \
|
--version "$VERSION" \
|
||||||
--platform "$DOCKER_PLATFORMS" \
|
--git-hash "$GIT_HASH" \
|
||||||
--build-arg COMMIT_HASH="$GIT_HASH" \
|
--image "$DOCKER_IMAGE" \
|
||||||
-t "$DOCKER_IMAGE:latest" \
|
--platforms "$DOCKER_PLATFORMS"
|
||||||
-t "$DOCKER_IMAGE:$VERSION" \
|
|
||||||
-t "$DOCKER_IMAGE:$GIT_HASH" \
|
|
||||||
--push \
|
|
||||||
.
|
|
||||||
echo -e "${GREEN}Multi-arch Docker build + push complete!${NC}"
|
echo -e "${GREEN}Multi-arch Docker build + push complete!${NC}"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# Create GitHub release using the changelog notes for this version.
|
# Create GitHub release using the changelog notes for this version.
|
||||||
echo -e "${YELLOW}Creating GitHub release...${NC}"
|
echo -e "${YELLOW}Creating GitHub release...${NC}"
|
||||||
RELEASE_NOTES_FILE=$(mktemp)
|
scripts/build/create_github_release.sh \
|
||||||
{
|
--version "$VERSION" \
|
||||||
echo "$CHANGELOG_HEADER"
|
--full-git-hash "$FULL_GIT_HASH" \
|
||||||
echo
|
--asset "$RELEASE_ASSET_PATH"
|
||||||
echo "$CHANGELOG_ENTRY"
|
|
||||||
} > "$RELEASE_NOTES_FILE"
|
|
||||||
|
|
||||||
# Create and push the release tag first so GitHub release creation does not
|
|
||||||
# depend on resolving a symbolic ref like HEAD on the remote side. Use the same
|
|
||||||
# changelog-derived notes for the annotated tag message.
|
|
||||||
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
|
|
||||||
echo -e "${YELLOW}Tag $VERSION already exists locally; reusing it.${NC}"
|
|
||||||
else
|
|
||||||
git tag -a "$VERSION" "$FULL_GIT_HASH" -F "$RELEASE_NOTES_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
|
|
||||||
echo -e "${YELLOW}Tag $VERSION already exists on origin; not pushing it again.${NC}"
|
|
||||||
else
|
|
||||||
git push origin "$VERSION"
|
|
||||||
fi
|
|
||||||
|
|
||||||
gh release create "$VERSION" \
|
|
||||||
"$RELEASE_ASSET" \
|
|
||||||
--title "$VERSION" \
|
|
||||||
--notes-file "$RELEASE_NOTES_FILE" \
|
|
||||||
--verify-tag
|
|
||||||
|
|
||||||
rm -f "$RELEASE_NOTES_FILE"
|
|
||||||
echo -e "${GREEN}GitHub release created!${NC}"
|
echo -e "${GREEN}GitHub release created!${NC}"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=scripts/build/release_common.sh
|
||||||
|
source "$SCRIPT_DIR/release_common.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: scripts/build/push_docker_multiarch.sh --version X.Y.Z [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--version VERSION Release version (required)
|
||||||
|
--git-hash HASH Short git hash to tag alongside the version
|
||||||
|
--image IMAGE Docker image name (default: docker.io/jkingsman/remoteterm-meshcore)
|
||||||
|
--platforms CSV Buildx platforms CSV (default: linux/amd64,linux/arm64)
|
||||||
|
--help Show this message
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
GIT_HASH=""
|
||||||
|
IMAGE="docker.io/jkingsman/remoteterm-meshcore"
|
||||||
|
PLATFORMS="linux/amd64,linux/arm64"
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
VERSION="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--git-hash)
|
||||||
|
GIT_HASH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--image)
|
||||||
|
IMAGE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--platforms)
|
||||||
|
PLATFORMS="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
release_die "Unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$VERSION" ] || release_die "--version is required"
|
||||||
|
release_validate_version "$VERSION"
|
||||||
|
|
||||||
|
REPO_ROOT="$(release_repo_root)"
|
||||||
|
GIT_HASH="${GIT_HASH:-$(release_resolve_short_hash "$REPO_ROOT")}"
|
||||||
|
|
||||||
|
echo "[push_docker_multiarch] Ensuring docker buildx builder..." >&2
|
||||||
|
release_ensure_buildx_builder
|
||||||
|
|
||||||
|
docker_buildx_args=(
|
||||||
|
build
|
||||||
|
--platform "$PLATFORMS"
|
||||||
|
--build-arg "COMMIT_HASH=$GIT_HASH"
|
||||||
|
-t "$IMAGE:latest"
|
||||||
|
-t "$IMAGE:$VERSION"
|
||||||
|
-t "$IMAGE:$GIT_HASH"
|
||||||
|
--push
|
||||||
|
.
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[push_docker_multiarch] Building and pushing $IMAGE for $PLATFORMS..." >&2
|
||||||
|
(
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
docker buildx "${docker_buildx_args[@]}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
release_repo_root() {
|
||||||
|
(
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
release_die() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
release_trim() {
|
||||||
|
printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
release_validate_version() {
|
||||||
|
local version="$1"
|
||||||
|
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || release_die "Version must be in format X.Y.Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_resolve_full_hash() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local ref="${2:-HEAD}"
|
||||||
|
git -C "$repo_root" rev-parse "$ref"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_resolve_short_hash() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local ref="${2:-HEAD}"
|
||||||
|
git -C "$repo_root" rev-parse --short "$ref"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_format_markdown_list() {
|
||||||
|
local input_file="$1"
|
||||||
|
local output_file="$2"
|
||||||
|
awk '
|
||||||
|
/^[[:space:]]*$/ { next }
|
||||||
|
{
|
||||||
|
sub(/^[[:space:]]+/, "", $0)
|
||||||
|
if ($0 ~ /^\* /) {
|
||||||
|
print
|
||||||
|
} else if ($0 ~ /^- /) {
|
||||||
|
sub(/^- /, "* ", $0)
|
||||||
|
print
|
||||||
|
} else {
|
||||||
|
print "* " $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$input_file" > "$output_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_extract_changelog_section() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local version="$2"
|
||||||
|
local output_file="$3"
|
||||||
|
local changelog_path="${4:-$repo_root/CHANGELOG.md}"
|
||||||
|
|
||||||
|
# Use index() for literal matching so dots in version strings are not
|
||||||
|
# treated as regex wildcards (e.g. 3.6.5 won't match 31615).
|
||||||
|
awk -v ver="$version" '
|
||||||
|
BEGIN { header = "## [" ver "]" }
|
||||||
|
index($0, header) == 1 { capture = 1; print; next }
|
||||||
|
capture && /^## \[/ { exit }
|
||||||
|
capture { print }
|
||||||
|
' "$changelog_path" > "$output_file"
|
||||||
|
|
||||||
|
[ -s "$output_file" ] || release_die "Could not find CHANGELOG entry for version $version"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_ensure_buildx_builder() {
|
||||||
|
if ! docker buildx version >/dev/null 2>&1; then
|
||||||
|
release_die "docker buildx is required for multi-arch Docker builds"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Multi-platform builds require the docker-container driver. The default
|
||||||
|
# builder uses the "docker" driver which only supports the host platform.
|
||||||
|
# Check the current builder's driver first; only create a new one if needed.
|
||||||
|
local current_driver
|
||||||
|
current_driver="$(docker buildx inspect --format '{{ .Driver }}' 2>/dev/null || true)"
|
||||||
|
if [ "$current_driver" = "docker-container" ]; then
|
||||||
|
docker buildx inspect --bootstrap >/dev/null
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then
|
||||||
|
docker buildx use remoteterm-multiarch >/dev/null
|
||||||
|
else
|
||||||
|
docker buildx create --name remoteterm-multiarch --use >/dev/null
|
||||||
|
fi
|
||||||
|
docker buildx inspect --bootstrap >/dev/null
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
|
|||||||
echo -ne "${BLUE}[backend lint]${NC} "
|
echo -ne "${BLUE}[backend lint]${NC} "
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
uv run ruff check app/ tests/ --fix --quiet
|
uv run ruff check app/ tests/ --fix --quiet
|
||||||
uv run ruff format app/ tests/ --check --quiet
|
uv run ruff format app/ tests/ --quiet
|
||||||
echo -e "${GREEN}Passed!${NC}"
|
echo -e "${GREEN}Passed!${NC}"
|
||||||
|
|
||||||
echo -ne "${BLUE}[frontend lint]${NC} "
|
echo -ne "${BLUE}[frontend lint]${NC} "
|
||||||
|
|||||||
@@ -22,15 +22,12 @@ REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
|||||||
COMPOSE_FILE="$REPO_DIR/docker-compose.yml"
|
COMPOSE_FILE="$REPO_DIR/docker-compose.yml"
|
||||||
EXAMPLE_FILE="$REPO_DIR/docker-compose.example.yml"
|
EXAMPLE_FILE="$REPO_DIR/docker-compose.example.yml"
|
||||||
SNAKEOIL_CERT_DIR="$REPO_DIR/.docker-certs"
|
SNAKEOIL_CERT_DIR="$REPO_DIR/.docker-certs"
|
||||||
NGINX_CONFIG_DIR="$REPO_DIR/.docker-nginx"
|
|
||||||
NGINX_CONFIG_BASENAME="remoteterm.conf"
|
|
||||||
NGINX_CONFIG_HOST_PATH="$NGINX_CONFIG_DIR/$NGINX_CONFIG_BASENAME"
|
|
||||||
SNAKEOIL_CERT_BASENAME="remoteterm-snakeoil.crt"
|
SNAKEOIL_CERT_BASENAME="remoteterm-snakeoil.crt"
|
||||||
SNAKEOIL_KEY_BASENAME="remoteterm-snakeoil.key"
|
SNAKEOIL_KEY_BASENAME="remoteterm-snakeoil.key"
|
||||||
SNAKEOIL_CERT_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_CERT_BASENAME"
|
SNAKEOIL_CERT_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_CERT_BASENAME"
|
||||||
SNAKEOIL_KEY_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_KEY_BASENAME"
|
SNAKEOIL_KEY_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_KEY_BASENAME"
|
||||||
SNAKEOIL_CERT_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_CERT_BASENAME"
|
SNAKEOIL_CERT_CONTAINER_PATH="/app/certs/$SNAKEOIL_CERT_BASENAME"
|
||||||
SNAKEOIL_KEY_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_KEY_BASENAME"
|
SNAKEOIL_KEY_CONTAINER_PATH="/app/certs/$SNAKEOIL_KEY_BASENAME"
|
||||||
|
|
||||||
IMAGE_MODE="image"
|
IMAGE_MODE="image"
|
||||||
TRANSPORT_MODE="serial"
|
TRANSPORT_MODE="serial"
|
||||||
@@ -214,49 +211,6 @@ EOF
|
|||||||
chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
|
chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
|
||||||
}
|
}
|
||||||
|
|
||||||
generate_nginx_tls_config() {
|
|
||||||
mkdir -p "$NGINX_CONFIG_DIR"
|
|
||||||
|
|
||||||
cat >"$NGINX_CONFIG_HOST_PATH" <<EOF
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
return 308 https://\$host:8000\$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
ssl_certificate $SNAKEOIL_CERT_CONTAINER_PATH;
|
|
||||||
ssl_certificate_key $SNAKEOIL_KEY_CONTAINER_PATH;
|
|
||||||
ssl_session_cache shared:SSL:10m;
|
|
||||||
ssl_session_timeout 10m;
|
|
||||||
|
|
||||||
location /api/ws {
|
|
||||||
proxy_pass http://remoteterm:8000/api/ws;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade \$http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
proxy_set_header X-Forwarded-Host \$host;
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header X-Forwarded-Port 8000;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://remoteterm:8000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
proxy_set_header X-Forwarded-Host \$host;
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header X-Forwarded-Port 8000;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
|
echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
|
||||||
echo
|
echo
|
||||||
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
|
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
|
||||||
@@ -446,9 +400,7 @@ LOCAL_ACCESS_IP="$(detect_primary_local_ip)"
|
|||||||
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
||||||
ensure_snakeoil_requirements
|
ensure_snakeoil_requirements
|
||||||
generate_snakeoil_certificate "$LOCAL_ACCESS_IP"
|
generate_snakeoil_certificate "$LOCAL_ACCESS_IP"
|
||||||
generate_nginx_tls_config
|
|
||||||
echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_DIR}.${NC}"
|
echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_DIR}.${NC}"
|
||||||
echo -e "${GREEN}Generated nginx TLS proxy config in ${NGINX_CONFIG_DIR}.${NC}"
|
|
||||||
echo -e "${YELLOW}Browsers will show an untrusted/self-signed certificate warning.${NC}"
|
echo -e "${YELLOW}Browsers will show an untrusted/self-signed certificate warning.${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${GREEN}Skipping snakeoil TLS generation. The container will serve plain HTTP.${NC}"
|
echo -e "${GREEN}Skipping snakeoil TLS generation. The container will serve plain HTTP.${NC}"
|
||||||
@@ -489,19 +441,32 @@ mkdir -p "$REPO_DIR/data"
|
|||||||
if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then
|
if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then
|
||||||
echo " user: \"$(id -u):$(id -g)\""
|
echo " user: \"$(id -u):$(id -g)\""
|
||||||
fi
|
fi
|
||||||
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
echo " ports:"
|
||||||
echo " expose:"
|
echo " - \"8000:8000\""
|
||||||
echo " - \"8000\""
|
|
||||||
else
|
|
||||||
echo " ports:"
|
|
||||||
echo " - \"8000:8000\""
|
|
||||||
fi
|
|
||||||
echo " volumes:"
|
echo " volumes:"
|
||||||
echo " - ./data:/app/data"
|
echo " - ./data:/app/data"
|
||||||
|
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
||||||
|
echo " - ./.docker-certs:/app/certs:ro"
|
||||||
|
fi
|
||||||
if [ "$TRANSPORT_MODE" = "serial" ]; then
|
if [ "$TRANSPORT_MODE" = "serial" ]; then
|
||||||
echo " devices:"
|
echo " devices:"
|
||||||
echo " - ${SERIAL_COMPOSE_HOST_PATH}:${SERIAL_CONTAINER_PATH}"
|
echo " - ${SERIAL_COMPOSE_HOST_PATH}:${SERIAL_CONTAINER_PATH}"
|
||||||
fi
|
fi
|
||||||
|
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
||||||
|
echo " command:"
|
||||||
|
echo " - uv"
|
||||||
|
echo " - run"
|
||||||
|
echo " - uvicorn"
|
||||||
|
echo " - app.main:app"
|
||||||
|
echo " - --host"
|
||||||
|
echo " - 0.0.0.0"
|
||||||
|
echo " - --port"
|
||||||
|
echo " - \"8000\""
|
||||||
|
echo " - --ssl-keyfile"
|
||||||
|
echo " - $SNAKEOIL_KEY_CONTAINER_PATH"
|
||||||
|
echo " - --ssl-certfile"
|
||||||
|
echo " - $SNAKEOIL_CERT_CONTAINER_PATH"
|
||||||
|
fi
|
||||||
echo " environment:"
|
echo " environment:"
|
||||||
echo " MESHCORE_DATABASE_PATH: $(yaml_quote "data/meshcore.db")"
|
echo " MESHCORE_DATABASE_PATH: $(yaml_quote "data/meshcore.db")"
|
||||||
if [ "$TRANSPORT_MODE" = "serial" ]; then
|
if [ "$TRANSPORT_MODE" = "serial" ]; then
|
||||||
@@ -521,19 +486,6 @@ mkdir -p "$REPO_DIR/data"
|
|||||||
echo " MESHCORE_BASIC_AUTH_PASSWORD: $(yaml_quote "$AUTH_PASSWORD")"
|
echo " MESHCORE_BASIC_AUTH_PASSWORD: $(yaml_quote "$AUTH_PASSWORD")"
|
||||||
fi
|
fi
|
||||||
echo " restart: unless-stopped"
|
echo " restart: unless-stopped"
|
||||||
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
|
||||||
echo " nginx:"
|
|
||||||
echo " image: nginx:alpine"
|
|
||||||
echo " depends_on:"
|
|
||||||
echo " - remoteterm"
|
|
||||||
echo " ports:"
|
|
||||||
echo " - \"80:80\""
|
|
||||||
echo " - \"8000:443\""
|
|
||||||
echo " volumes:"
|
|
||||||
echo " - ./.docker-certs:/etc/nginx/certs:ro"
|
|
||||||
echo " - ./.docker-nginx/$NGINX_CONFIG_BASENAME:/etc/nginx/conf.d/default.conf:ro"
|
|
||||||
echo " restart: unless-stopped"
|
|
||||||
fi
|
|
||||||
} >"$COMPOSE_FILE"
|
} >"$COMPOSE_FILE"
|
||||||
|
|
||||||
echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}"
|
echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}"
|
||||||
@@ -552,11 +504,6 @@ echo " sudo docker compose pull && sudo docker compose up -d # upgrade to the
|
|||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}Note:${NC} serial passthrough generally needs ${BOLD}rootful Docker${NC}."
|
echo -e "${YELLOW}Note:${NC} serial passthrough generally needs ${BOLD}rootful Docker${NC}."
|
||||||
echo "If Docker is running rootless on this host, serial-device mappings may fail even with a valid compose file."
|
echo "If Docker is running rootless on this host, serial-device mappings may fail even with a valid compose file."
|
||||||
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
|
||||||
echo
|
|
||||||
echo -e "${GREEN}HTTPS will be handled by an nginx sidecar.${NC}"
|
|
||||||
echo "Host port 80 will redirect to HTTPS on port 8000."
|
|
||||||
fi
|
|
||||||
if [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then
|
if [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then
|
||||||
echo
|
echo
|
||||||
echo -e "${RED}BLE requires more than the generated env vars.${NC}"
|
echo -e "${RED}BLE requires more than the generated env vars.${NC}"
|
||||||
@@ -572,7 +519,6 @@ echo -e "${PURPLE}└───────────────────
|
|||||||
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
||||||
echo
|
echo
|
||||||
echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router."
|
echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router."
|
||||||
echo -e "Plain HTTP on ${CYAN}http://${LOCAL_ACCESS_IP}${NC} will redirect there automatically."
|
|
||||||
echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}"
|
echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}"
|
||||||
else
|
else
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -222,7 +222,6 @@ export interface AppSettings {
|
|||||||
max_radio_contacts: number;
|
max_radio_contacts: number;
|
||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
auto_decrypt_dm_on_advert: boolean;
|
auto_decrypt_dm_on_advert: boolean;
|
||||||
sidebar_sort_order: string;
|
|
||||||
last_message_times: Record<string, number>;
|
last_message_times: Record<string, number>;
|
||||||
preferences_migrated: boolean;
|
preferences_migrated: boolean;
|
||||||
advert_interval: number;
|
advert_interval: number;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export default defineConfig({
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
env: {
|
env: {
|
||||||
MESHCORE_DATABASE_PATH: path.join(tmpDir, 'e2e-test.db'),
|
MESHCORE_DATABASE_PATH: path.join(tmpDir, 'e2e-test.db'),
|
||||||
|
MESHCORE_SKIP_POST_CONNECT_SYNC: 'true',
|
||||||
// Pass through the serial port from the environment
|
// Pass through the serial port from the environment
|
||||||
...(process.env.MESHCORE_SERIAL_PORT
|
...(process.env.MESHCORE_SERIAL_PORT
|
||||||
? { MESHCORE_SERIAL_PORT: process.env.MESHCORE_SERIAL_PORT }
|
? { MESHCORE_SERIAL_PORT: process.env.MESHCORE_SERIAL_PORT }
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ test.describe('Apprise integration settings', () => {
|
|||||||
receiver.close();
|
receiver.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
// Clean up any stale configs from previous failed runs
|
||||||
|
const configs = await getFanoutConfigs();
|
||||||
|
for (const c of configs.filter((c) => c.name === 'E2E Apprise')) {
|
||||||
|
try {
|
||||||
|
await deleteFanoutConfig(c.id);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test.afterEach(async () => {
|
test.afterEach(async () => {
|
||||||
if (createdAppriseId) {
|
if (createdAppriseId) {
|
||||||
try {
|
try {
|
||||||
@@ -66,16 +76,15 @@ test.describe('Apprise integration settings', () => {
|
|||||||
await page.getByRole('button', { name: /Save as Enabled/i }).click();
|
await page.getByRole('button', { name: /Save as Enabled/i }).click();
|
||||||
await expect(page.getByText('Integration saved and enabled')).toBeVisible();
|
await expect(page.getByText('Integration saved and enabled')).toBeVisible();
|
||||||
|
|
||||||
// Should be back on list view with our apprise config visible
|
// Capture ID for cleanup before assertions that might fail
|
||||||
await expect(page.getByText('E2E Apprise')).toBeVisible();
|
|
||||||
await expect(page.getByText(appriseUrl)).toBeVisible();
|
|
||||||
|
|
||||||
// Clean up via API
|
|
||||||
const configs = await getFanoutConfigs();
|
const configs = await getFanoutConfigs();
|
||||||
const apprise = configs.find((c) => c.name === 'E2E Apprise');
|
const apprise = configs.find((c) => c.name === 'E2E Apprise');
|
||||||
if (apprise) {
|
if (apprise) {
|
||||||
createdAppriseId = apprise.id;
|
createdAppriseId = apprise.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Should be back on list view with our apprise config visible
|
||||||
|
await expect(fanoutHeader(page, 'E2E Apprise')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create apprise via API, verify options persist after edit', async ({ page }) => {
|
test('create apprise via API, verify options persist after edit', async ({ page }) => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test.describe('Create contact flow', () => {
|
|||||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||||
|
|
||||||
// Open new message modal
|
// Open new message modal
|
||||||
await page.getByTitle('New Message').click();
|
await page.getByRole('button', { name: /add channel or contact/i }).click();
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole('dialog');
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ test.describe('Create hashtag channel flow', () => {
|
|||||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||||
|
|
||||||
// Open new message modal
|
// Open new message modal
|
||||||
await page.getByTitle('New Message').click();
|
await page.getByRole('button', { name: /add channel or contact/i }).click();
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole('dialog');
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ test.describe('Create hashtag channel flow', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByTitle('New Message').click();
|
await page.getByRole('button', { name: /add channel or contact/i }).click();
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole('dialog');
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ test.describe('Historical packet decryption', () => {
|
|||||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||||
|
|
||||||
// Open new message modal → Hashtag tab
|
// Open new message modal → Hashtag tab
|
||||||
await page.getByTitle('New Message').click();
|
await page.getByRole('button', { name: /add channel or contact/i }).click();
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole('dialog');
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
await dialog.getByRole('tab', { name: /Hashtag/i }).click();
|
await dialog.getByRole('tab', { name: /Hashtag/i }).click();
|
||||||
|
|||||||
+149
-5
@@ -131,6 +131,62 @@ class TestHealthEndpoint:
|
|||||||
class TestDebugEndpoint:
|
class TestDebugEndpoint:
|
||||||
"""Test the debug support snapshot endpoint."""
|
"""Test the debug support snapshot endpoint."""
|
||||||
|
|
||||||
|
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
|
||||||
|
"""Debug radio probe should redact advertised lat/lon from self_info."""
|
||||||
|
from app.routers.debug import _sanitize_radio_probe_self_info
|
||||||
|
|
||||||
|
sanitized = _sanitize_radio_probe_self_info(
|
||||||
|
{
|
||||||
|
"name": "FlightlessTestNode",
|
||||||
|
"adv_lat": 47.786445,
|
||||||
|
"adv_lon": -122.344011,
|
||||||
|
"radio_freq": 910.525,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sanitized == {
|
||||||
|
"name": "FlightlessTestNode",
|
||||||
|
"radio_freq": 910.525,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_support_snapshot_only_keeps_erroring_fanouts_in_health_summary(self):
|
||||||
|
"""Debug health summary should only include fanouts with non-empty last_error."""
|
||||||
|
from app.routers.debug import _build_debug_health_summary
|
||||||
|
from app.routers.health import FanoutStatusResponse
|
||||||
|
|
||||||
|
summary = _build_debug_health_summary(
|
||||||
|
{
|
||||||
|
"database_size_mb": 1.23,
|
||||||
|
"oldest_undecrypted_timestamp": 123,
|
||||||
|
"fanout_statuses": {
|
||||||
|
"ok-id": {
|
||||||
|
"name": "OK Fanout",
|
||||||
|
"type": "bot",
|
||||||
|
"status": "connected",
|
||||||
|
"last_error": None,
|
||||||
|
},
|
||||||
|
"err-id": {
|
||||||
|
"name": "Broken Fanout",
|
||||||
|
"type": "mqtt_private",
|
||||||
|
"status": "error",
|
||||||
|
"last_error": "broker down",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"bots_disabled_source": None,
|
||||||
|
"basic_auth_enabled": False,
|
||||||
|
},
|
||||||
|
radio_state="connected",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary.fanouts_with_errors == {
|
||||||
|
"err-id": FanoutStatusResponse(
|
||||||
|
name="Broken Fanout",
|
||||||
|
type="mqtt_private",
|
||||||
|
status="error",
|
||||||
|
last_error="broker down",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client):
|
async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client):
|
||||||
"""Debug snapshot should still return logs and runtime state when radio is disconnected."""
|
"""Debug snapshot should still return logs and runtime state when radio is disconnected."""
|
||||||
@@ -157,8 +213,21 @@ class TestDebugEndpoint:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
|
assert "app_info" not in payload["health"]
|
||||||
|
assert "bots_disabled" not in payload["health"]
|
||||||
|
assert "connection_info" not in payload["health"]
|
||||||
|
assert "fanout_statuses" not in payload["health"]
|
||||||
|
assert "radio_connected" not in payload["health"]
|
||||||
|
assert "radio_device_info" not in payload["health"]
|
||||||
|
assert "radio_initializing" not in payload["health"]
|
||||||
|
assert "status" not in payload["health"]
|
||||||
|
assert payload["health"]["fanouts_with_errors"] == {}
|
||||||
|
assert payload["health"]["radio_state"] == "disconnected"
|
||||||
assert payload["radio_probe"]["performed"] is False
|
assert payload["radio_probe"]["performed"] is False
|
||||||
assert payload["radio_probe"]["errors"] == ["Radio not connected"]
|
assert payload["radio_probe"]["errors"] == ["Radio not connected"]
|
||||||
|
assert "multi_acks_enabled" not in payload["radio_probe"]
|
||||||
|
assert "max_channels" not in payload["runtime"]
|
||||||
|
assert "path_hash_mode" not in payload["runtime"]
|
||||||
assert payload["runtime"]["channels_with_incoming_messages"] == 0
|
assert payload["runtime"]["channels_with_incoming_messages"] == 0
|
||||||
assert payload["database"]["total_dms"] == 0
|
assert payload["database"]["total_dms"] == 0
|
||||||
assert payload["database"]["total_channel_messages"] == 0
|
assert payload["database"]["total_channel_messages"] == 0
|
||||||
@@ -213,6 +282,46 @@ class TestDebugEndpoint:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_support_snapshot_includes_persisted_app_settings(self, test_db, client):
|
||||||
|
"""Debug snapshot should expose the stored app settings row."""
|
||||||
|
pub_key = "ab" * 32
|
||||||
|
await _insert_contact(pub_key, "Alice")
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
"/api/settings",
|
||||||
|
json={
|
||||||
|
"max_radio_contacts": 321,
|
||||||
|
"auto_decrypt_dm_on_advert": True,
|
||||||
|
"advert_interval": 7200,
|
||||||
|
"flood_scope": "US-CA",
|
||||||
|
"blocked_keys": [pub_key],
|
||||||
|
"blocked_names": ["Mallory"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/settings/favorites/toggle",
|
||||||
|
json={"type": "contact", "id": pub_key},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = await client.get("/api/debug")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["settings"]["max_radio_contacts"] == 321
|
||||||
|
assert payload["settings"]["auto_decrypt_dm_on_advert"] is True
|
||||||
|
assert payload["settings"]["advert_interval"] == 7200
|
||||||
|
assert payload["settings"]["flood_scope"] == "#US-CA"
|
||||||
|
assert payload["settings"]["blocked_keys_count"] == 1
|
||||||
|
assert payload["settings"]["blocked_names_count"] == 1
|
||||||
|
assert "favorites" not in payload["settings"]
|
||||||
|
assert "blocked_keys" not in payload["settings"]
|
||||||
|
assert "blocked_names" not in payload["settings"]
|
||||||
|
assert "sidebar_sort_order" not in payload["settings"]
|
||||||
|
|
||||||
|
|
||||||
class TestRadioDisconnectedHandler:
|
class TestRadioDisconnectedHandler:
|
||||||
"""Test that RadioDisconnectedError maps to 503."""
|
"""Test that RadioDisconnectedError maps to 503."""
|
||||||
@@ -1057,7 +1166,14 @@ class TestRawPacketRepository:
|
|||||||
await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp)
|
await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp)
|
||||||
# Insert old but decrypted packet (should NOT be deleted)
|
# Insert old but decrypted packet (should NOT be deleted)
|
||||||
old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp)
|
old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp)
|
||||||
await RawPacketRepository.mark_decrypted(old_id, 1)
|
msg_id = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="test_key",
|
||||||
|
text="test",
|
||||||
|
sender_timestamp=old_timestamp,
|
||||||
|
received_at=old_timestamp,
|
||||||
|
)
|
||||||
|
await RawPacketRepository.mark_decrypted(old_id, msg_id)
|
||||||
|
|
||||||
# Prune packets older than 10 days
|
# Prune packets older than 10 days
|
||||||
deleted = await RawPacketRepository.prune_old_undecrypted(10)
|
deleted = await RawPacketRepository.prune_old_undecrypted(10)
|
||||||
@@ -1081,10 +1197,24 @@ class TestRawPacketRepository:
|
|||||||
async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db):
|
async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db):
|
||||||
"""Purge linked raw packets removes only rows with a message_id."""
|
"""Purge linked raw packets removes only rows with a message_id."""
|
||||||
ts = int(time.time())
|
ts = int(time.time())
|
||||||
|
msg_id_1 = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="k1",
|
||||||
|
text="t1",
|
||||||
|
sender_timestamp=ts,
|
||||||
|
received_at=ts,
|
||||||
|
)
|
||||||
|
msg_id_2 = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="k2",
|
||||||
|
text="t2",
|
||||||
|
sender_timestamp=ts,
|
||||||
|
received_at=ts,
|
||||||
|
)
|
||||||
linked_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts)
|
linked_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts)
|
||||||
linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts)
|
linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts)
|
||||||
await RawPacketRepository.mark_decrypted(linked_1, 101)
|
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
|
||||||
await RawPacketRepository.mark_decrypted(linked_2, 102)
|
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
|
||||||
|
|
||||||
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
|
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
|
||||||
|
|
||||||
@@ -1122,10 +1252,24 @@ class TestMaintenanceEndpoint:
|
|||||||
from app.routers.packets import MaintenanceRequest, run_maintenance
|
from app.routers.packets import MaintenanceRequest, run_maintenance
|
||||||
|
|
||||||
ts = int(time.time())
|
ts = int(time.time())
|
||||||
|
msg_id_1 = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="k1",
|
||||||
|
text="t1",
|
||||||
|
sender_timestamp=ts,
|
||||||
|
received_at=ts,
|
||||||
|
)
|
||||||
|
msg_id_2 = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="k2",
|
||||||
|
text="t2",
|
||||||
|
sender_timestamp=ts,
|
||||||
|
received_at=ts,
|
||||||
|
)
|
||||||
linked_1, _ = await RawPacketRepository.create(b"\x0a\x0b\x0c", ts)
|
linked_1, _ = await RawPacketRepository.create(b"\x0a\x0b\x0c", ts)
|
||||||
linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts)
|
linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts)
|
||||||
await RawPacketRepository.mark_decrypted(linked_1, 201)
|
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
|
||||||
await RawPacketRepository.mark_decrypted(linked_2, 202)
|
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
|
||||||
|
|
||||||
request = MaintenanceRequest(purge_linked_raw_packets=True)
|
request = MaintenanceRequest(purge_linked_raw_packets=True)
|
||||||
result = await run_maintenance(request)
|
result = await run_maintenance(request)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Tests for the channels router endpoints."""
|
"""Tests for the channels router endpoints."""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from unittest.mock import patch
|
from hashlib import sha256
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -77,6 +78,55 @@ class TestCreateChannel:
|
|||||||
assert channel is not None
|
assert channel is not None
|
||||||
assert channel.flood_scope_override is None
|
assert channel.flood_scope_override is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_hashtag_create_adds_only_new_rooms(self, test_db, client):
|
||||||
|
ops_key = sha256(b"#ops").digest()[:16].hex().upper()
|
||||||
|
await ChannelRepository.upsert(key=ops_key, name="#ops", is_hashtag=True)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/channels/bulk-hashtag",
|
||||||
|
json={
|
||||||
|
"channel_names": ["#ops", "mesh-room", "bad_room", "mesh-room", "another-room"],
|
||||||
|
"try_historical": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert [channel["name"] for channel in data["created_channels"]] == [
|
||||||
|
"#mesh-room",
|
||||||
|
"#another-room",
|
||||||
|
]
|
||||||
|
assert data["existing_count"] == 2
|
||||||
|
assert data["invalid_names"] == ["bad_room"]
|
||||||
|
assert data["decrypt_started"] is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_hashtag_create_can_start_one_decrypt_job(self, test_db, client):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.routers.channels.RawPacketRepository.get_undecrypted_count",
|
||||||
|
new=AsyncMock(return_value=7),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.routers.channels._run_historical_channel_decryption_for_channels",
|
||||||
|
new=AsyncMock(),
|
||||||
|
) as mock_decrypt,
|
||||||
|
):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/channels/bulk-hashtag",
|
||||||
|
json={
|
||||||
|
"channel_names": ["ops", "mesh-room"],
|
||||||
|
"try_historical": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
data = response.json()
|
||||||
|
assert data["decrypt_started"] is True
|
||||||
|
assert data["decrypt_total_packets"] == 7
|
||||||
|
mock_decrypt.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
class TestPublicChannelProtection:
|
class TestPublicChannelProtection:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class TestTransportExclusivity:
|
|||||||
|
|
||||||
def test_tcp_default_port(self):
|
def test_tcp_default_port(self):
|
||||||
s = Settings(tcp_host="192.168.1.1")
|
s = Settings(tcp_host="192.168.1.1")
|
||||||
assert s.tcp_port == 4000
|
assert s.tcp_port == 5000
|
||||||
|
|
||||||
def test_ble_only(self):
|
def test_ble_only(self):
|
||||||
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
|
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
|
||||||
|
|||||||
@@ -363,6 +363,48 @@ class TestDeleteContactCascade:
|
|||||||
assert len(await ContactNameHistoryRepository.get_history(KEY_A)) == 0
|
assert len(await ContactNameHistoryRepository.get_history(KEY_A)) == 0
|
||||||
assert len(await ContactAdvertPathRepository.get_recent_for_contact(KEY_A)) == 0
|
assert len(await ContactAdvertPathRepository.get_recent_for_contact(KEY_A)) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_preserves_dms_and_readd_resurfaces_them(self, test_db, client):
|
||||||
|
await _insert_contact(KEY_A, "Alice")
|
||||||
|
|
||||||
|
# Create an incoming DM for this contact
|
||||||
|
await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key=KEY_A,
|
||||||
|
text="hello",
|
||||||
|
sender_timestamp=1000,
|
||||||
|
received_at=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unread count should include the DM
|
||||||
|
unreads = await MessageRepository.get_unread_counts()
|
||||||
|
assert unreads["counts"].get(f"contact-{KEY_A.lower()}", 0) == 1
|
||||||
|
|
||||||
|
with patch("app.routers.contacts.radio_manager") as mock_rm:
|
||||||
|
mock_rm.is_connected = False
|
||||||
|
mock_rm.meshcore = None
|
||||||
|
mock_rm.radio_operation = _noop_radio_operation()
|
||||||
|
|
||||||
|
response = await client.delete(f"/api/contacts/{KEY_A}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# DMs are preserved in the database
|
||||||
|
msgs = await MessageRepository.get_all(msg_type="PRIV", conversation_key=KEY_A)
|
||||||
|
assert len(msgs) == 1
|
||||||
|
|
||||||
|
# Orphaned DMs still appear in unread counts (LEFT JOIN)
|
||||||
|
unreads = await MessageRepository.get_unread_counts()
|
||||||
|
assert unreads["counts"].get(f"contact-{KEY_A.lower()}", 0) == 1
|
||||||
|
|
||||||
|
# Re-add the contact
|
||||||
|
await _insert_contact(KEY_A, "Alice Returns")
|
||||||
|
|
||||||
|
# Messages re-surface with the re-added contact
|
||||||
|
msgs = await MessageRepository.get_all(msg_type="PRIV", conversation_key=KEY_A)
|
||||||
|
assert len(msgs) == 1
|
||||||
|
unreads = await MessageRepository.get_unread_counts()
|
||||||
|
assert unreads["counts"].get(f"contact-{KEY_A.lower()}", 0) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestMarkRead:
|
class TestMarkRead:
|
||||||
"""Test POST /api/contacts/{public_key}/mark-read."""
|
"""Test POST /api/contacts/{public_key}/mark-read."""
|
||||||
|
|||||||
+19
-17
@@ -513,7 +513,9 @@ class TestMigration018:
|
|||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest()
|
assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest()
|
||||||
assert rows[1]["message_id"] == 42
|
# message_id=42 was orphaned (no matching messages row), so
|
||||||
|
# migration 49's orphan cleanup NULLs it out.
|
||||||
|
assert rows[1]["message_id"] is None
|
||||||
|
|
||||||
# Verify payload_hash unique index still works
|
# Verify payload_hash unique index still works
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
@@ -1247,8 +1249,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 9
|
assert applied == 13
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 51
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1319,8 +1321,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 9
|
assert applied == 13
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 51
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1386,8 +1388,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 3
|
assert applied == 7
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 51
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1439,8 +1441,8 @@ class TestMigration040:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 8
|
assert applied == 12
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 51
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1501,8 +1503,8 @@ class TestMigration041:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 7
|
assert applied == 11
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 51
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1554,8 +1556,8 @@ class TestMigration042:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 6
|
assert applied == 10
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 51
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1694,8 +1696,8 @@ class TestMigration046:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 2
|
assert applied == 6
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 51
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1788,8 +1790,8 @@ class TestMigration047:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 1
|
assert applied == 5
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 51
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class TestChannelMessagePipeline:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
# Raw packet should be stored
|
# Raw packet should be stored
|
||||||
raw_packets = await RawPacketRepository.get_all_undecrypted()
|
raw_packets = [p async for p in RawPacketRepository.stream_all_undecrypted()]
|
||||||
assert len(raw_packets) >= 1
|
assert len(raw_packets) >= 1
|
||||||
|
|
||||||
# No message broadcast (only raw_packet broadcast)
|
# No message broadcast (only raw_packet broadcast)
|
||||||
@@ -900,7 +900,7 @@ class TestCreateMessageFromDecrypted:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify packet is marked decrypted (has message_id set)
|
# Verify packet is marked decrypted (has message_id set)
|
||||||
undecrypted = await RawPacketRepository.get_all_undecrypted()
|
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
|
||||||
packet_ids = [p[0] for p in undecrypted]
|
packet_ids = [p[0] for p in undecrypted]
|
||||||
assert packet_id not in packet_ids # Should be marked as decrypted
|
assert packet_id not in packet_ids # Should be marked as decrypted
|
||||||
|
|
||||||
@@ -1206,7 +1206,7 @@ class TestCreateDMMessageFromDecrypted:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify packet is marked decrypted
|
# Verify packet is marked decrypted
|
||||||
undecrypted = await RawPacketRepository.get_all_undecrypted()
|
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
|
||||||
packet_ids = [p[0] for p in undecrypted]
|
packet_ids = [p[0] for p in undecrypted]
|
||||||
assert packet_id not in packet_ids
|
assert packet_id not in packet_ids
|
||||||
|
|
||||||
@@ -1314,7 +1314,7 @@ class TestDMDecryptionFunction:
|
|||||||
assert messages[0].outgoing is False
|
assert messages[0].outgoing is False
|
||||||
|
|
||||||
# Verify raw packet is linked
|
# Verify raw packet is linked
|
||||||
undecrypted = await RawPacketRepository.get_all_undecrypted()
|
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
|
||||||
assert packet_id not in [p[0] for p in undecrypted]
|
assert packet_id not in [p[0] for p in undecrypted]
|
||||||
|
|
||||||
|
|
||||||
@@ -2080,7 +2080,7 @@ class TestProcessRawPacketIntegration:
|
|||||||
result = await process_raw_packet(raw, timestamp=7000)
|
result = await process_raw_packet(raw, timestamp=7000)
|
||||||
|
|
||||||
# Verify packet is in undecrypted list
|
# Verify packet is in undecrypted list
|
||||||
undecrypted = await RawPacketRepository.get_all_undecrypted()
|
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
|
||||||
packet_ids = [p[0] for p in undecrypted]
|
packet_ids = [p[0] for p in undecrypted]
|
||||||
assert result["packet_id"] in packet_ids
|
assert result["packet_id"] in packet_ids
|
||||||
|
|
||||||
@@ -2590,7 +2590,7 @@ class TestHistoricalChannelDecryptIntegration:
|
|||||||
assert len(message_broadcasts) == 0
|
assert len(message_broadcasts) == 0
|
||||||
|
|
||||||
# Raw packet is in the undecrypted pool
|
# Raw packet is in the undecrypted pool
|
||||||
undecrypted = await RawPacketRepository.get_all_undecrypted()
|
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
|
||||||
assert len(undecrypted) == 1
|
assert len(undecrypted) == 1
|
||||||
packet_id = undecrypted[0][0]
|
packet_id = undecrypted[0][0]
|
||||||
|
|
||||||
@@ -2615,7 +2615,7 @@ class TestHistoricalChannelDecryptIntegration:
|
|||||||
assert msg.conversation_key == channel_key_hex
|
assert msg.conversation_key == channel_key_hex
|
||||||
|
|
||||||
# --- Verify: raw packet is now marked as decrypted ---
|
# --- Verify: raw packet is now marked as decrypted ---
|
||||||
undecrypted_after = await RawPacketRepository.get_all_undecrypted()
|
undecrypted_after = [p async for p in RawPacketRepository.stream_all_undecrypted()]
|
||||||
remaining_ids = [p[0] for p in undecrypted_after]
|
remaining_ids = [p[0] for p in undecrypted_after]
|
||||||
assert packet_id not in remaining_ids
|
assert packet_id not in remaining_ids
|
||||||
|
|
||||||
@@ -2639,7 +2639,7 @@ class TestHistoricalChannelDecryptIntegration:
|
|||||||
await process_raw_packet(raw_packet, timestamp=1700000000)
|
await process_raw_packet(raw_packet, timestamp=1700000000)
|
||||||
|
|
||||||
# Packet stored undecrypted
|
# Packet stored undecrypted
|
||||||
assert len(await RawPacketRepository.get_all_undecrypted()) == 1
|
assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 1
|
||||||
|
|
||||||
# Run historical decrypt with the wrong key
|
# Run historical decrypt with the wrong key
|
||||||
with patch("app.websocket.ws_manager") as mock_ws:
|
with patch("app.websocket.ws_manager") as mock_ws:
|
||||||
@@ -2653,7 +2653,7 @@ class TestHistoricalChannelDecryptIntegration:
|
|||||||
assert len(messages) == 0
|
assert len(messages) == 0
|
||||||
|
|
||||||
# Packet still undecrypted
|
# Packet still undecrypted
|
||||||
assert len(await RawPacketRepository.get_all_undecrypted()) == 1
|
assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_historical_decrypt_multiple_packets(self, test_db, captured_broadcasts):
|
async def test_historical_decrypt_multiple_packets(self, test_db, captured_broadcasts):
|
||||||
@@ -2680,7 +2680,7 @@ class TestHistoricalChannelDecryptIntegration:
|
|||||||
for pkt in packets:
|
for pkt in packets:
|
||||||
await process_raw_packet(pkt, timestamp=1700000000)
|
await process_raw_packet(pkt, timestamp=1700000000)
|
||||||
|
|
||||||
assert len(await RawPacketRepository.get_all_undecrypted()) == 3
|
assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 3
|
||||||
|
|
||||||
# Add channel, run historical decrypt
|
# Add channel, run historical decrypt
|
||||||
await ChannelRepository.upsert(key=channel_key_hex, name=channel_name, is_hashtag=True)
|
await ChannelRepository.upsert(key=channel_key_hex, name=channel_name, is_hashtag=True)
|
||||||
@@ -2697,4 +2697,4 @@ class TestHistoricalChannelDecryptIntegration:
|
|||||||
assert texts == ["Alice: First message", "Bob: Second message", "Carol: Third message"]
|
assert texts == ["Alice: First message", "Bob: Second message", "Carol: Third message"]
|
||||||
|
|
||||||
# All packets now decrypted
|
# All packets now decrypted
|
||||||
assert len(await RawPacketRepository.get_all_undecrypted()) == 0
|
assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 0
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ class TestDiscoverMesh:
|
|||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=[],
|
return_value=[],
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"app.routers.radio.reconcile_contact_messages",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(0, 0),
|
||||||
|
),
|
||||||
patch("app.routers.radio.broadcast_event"),
|
patch("app.routers.radio.broadcast_event"),
|
||||||
):
|
):
|
||||||
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
||||||
@@ -454,6 +459,11 @@ class TestDiscoverMesh:
|
|||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=[],
|
return_value=[],
|
||||||
) as mock_promote,
|
) as mock_promote,
|
||||||
|
patch(
|
||||||
|
"app.routers.radio.reconcile_contact_messages",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(0, 0),
|
||||||
|
),
|
||||||
patch("app.routers.radio.broadcast_event") as mock_broadcast,
|
patch("app.routers.radio.broadcast_event") as mock_broadcast,
|
||||||
):
|
):
|
||||||
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
||||||
@@ -779,6 +789,11 @@ class TestTracePath:
|
|||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=[],
|
return_value=[],
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"app.routers.radio.reconcile_contact_messages",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(0, 0),
|
||||||
|
),
|
||||||
patch("app.routers.radio.broadcast_event"),
|
patch("app.routers.radio.broadcast_event"),
|
||||||
):
|
):
|
||||||
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
|
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
|
||||||
|
|||||||
@@ -768,305 +768,6 @@ class TestSyncAndOffloadAll:
|
|||||||
assert payload["public_key"] == KEY_A
|
assert payload["public_key"] == KEY_A
|
||||||
|
|
||||||
|
|
||||||
class TestSyncAndOffloadContacts:
|
|
||||||
"""Test sync_and_offload_contacts: pull contacts from radio, save to DB, remove from radio."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_syncs_and_removes_contacts(self, test_db):
|
|
||||||
"""Contacts are upserted to DB and removed from radio."""
|
|
||||||
from app.radio_sync import sync_and_offload_contacts
|
|
||||||
|
|
||||||
contact_payload = {
|
|
||||||
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
|
|
||||||
KEY_B: {"adv_name": "Bob", "type": 1, "flags": 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_get_result = MagicMock()
|
|
||||||
mock_get_result.type = EventType.NEW_CONTACT # Not ERROR
|
|
||||||
mock_get_result.payload = contact_payload
|
|
||||||
|
|
||||||
mock_remove_result = MagicMock()
|
|
||||||
mock_remove_result.type = EventType.OK
|
|
||||||
|
|
||||||
mock_mc = MagicMock()
|
|
||||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
|
||||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
|
|
||||||
|
|
||||||
result = await sync_and_offload_contacts(mock_mc)
|
|
||||||
|
|
||||||
assert result["synced"] == 2
|
|
||||||
assert result["removed"] == 2
|
|
||||||
|
|
||||||
# Verify contacts are in real DB
|
|
||||||
alice = await ContactRepository.get_by_key(KEY_A)
|
|
||||||
bob = await ContactRepository.get_by_key(KEY_B)
|
|
||||||
assert alice is not None
|
|
||||||
assert alice.name == "Alice"
|
|
||||||
assert bob is not None
|
|
||||||
assert bob.name == "Bob"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_claims_prefix_messages_for_each_contact(self, test_db):
|
|
||||||
"""Prefix message claims still complete via scheduled reconciliation tasks."""
|
|
||||||
from app.radio_sync import sync_and_offload_contacts
|
|
||||||
|
|
||||||
# Pre-insert a message with a prefix key that matches KEY_A
|
|
||||||
await MessageRepository.create(
|
|
||||||
msg_type="PRIV",
|
|
||||||
text="Hello from prefix",
|
|
||||||
received_at=1700000000,
|
|
||||||
conversation_key=KEY_A[:12],
|
|
||||||
sender_timestamp=1700000000,
|
|
||||||
)
|
|
||||||
|
|
||||||
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
|
|
||||||
|
|
||||||
mock_get_result = MagicMock()
|
|
||||||
mock_get_result.type = EventType.NEW_CONTACT
|
|
||||||
mock_get_result.payload = contact_payload
|
|
||||||
|
|
||||||
mock_remove_result = MagicMock()
|
|
||||||
mock_remove_result.type = EventType.OK
|
|
||||||
|
|
||||||
mock_mc = MagicMock()
|
|
||||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
|
||||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
|
|
||||||
|
|
||||||
created_tasks: list[asyncio.Task] = []
|
|
||||||
real_create_task = asyncio.create_task
|
|
||||||
|
|
||||||
def _capture_task(coro):
|
|
||||||
task = real_create_task(coro)
|
|
||||||
created_tasks.append(task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
with patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task):
|
|
||||||
await sync_and_offload_contacts(mock_mc)
|
|
||||||
|
|
||||||
await asyncio.gather(*created_tasks)
|
|
||||||
|
|
||||||
# Verify the prefix message was claimed (promoted to full key)
|
|
||||||
messages = await MessageRepository.get_all(conversation_key=KEY_A)
|
|
||||||
assert len(messages) == 1
|
|
||||||
assert messages[0].conversation_key == KEY_A.lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reconciliation_does_not_block_contact_removal(self, test_db):
|
|
||||||
"""Slow reconciliation work is scheduled in background, not awaited inline."""
|
|
||||||
from app.radio_sync import sync_and_offload_contacts
|
|
||||||
|
|
||||||
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
|
|
||||||
|
|
||||||
mock_get_result = MagicMock()
|
|
||||||
mock_get_result.type = EventType.NEW_CONTACT
|
|
||||||
mock_get_result.payload = contact_payload
|
|
||||||
|
|
||||||
mock_remove_result = MagicMock()
|
|
||||||
mock_remove_result.type = EventType.OK
|
|
||||||
|
|
||||||
mock_mc = MagicMock()
|
|
||||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
|
||||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
|
|
||||||
|
|
||||||
reconcile_started = asyncio.Event()
|
|
||||||
reconcile_release = asyncio.Event()
|
|
||||||
created_tasks: list[asyncio.Task] = []
|
|
||||||
real_create_task = asyncio.create_task
|
|
||||||
|
|
||||||
async def _slow_reconcile(*, public_key: str, contact_name: str | None, log):
|
|
||||||
del public_key, contact_name, log
|
|
||||||
reconcile_started.set()
|
|
||||||
await reconcile_release.wait()
|
|
||||||
|
|
||||||
def _capture_task(coro):
|
|
||||||
task = real_create_task(coro)
|
|
||||||
created_tasks.append(task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.radio_sync.reconcile_contact_messages", side_effect=_slow_reconcile),
|
|
||||||
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
|
|
||||||
):
|
|
||||||
result = await sync_and_offload_contacts(mock_mc)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
assert result["synced"] == 1
|
|
||||||
assert result["removed"] == 1
|
|
||||||
assert reconcile_started.is_set() is True
|
|
||||||
assert created_tasks and created_tasks[0].done() is False
|
|
||||||
mock_mc.commands.remove_contact.assert_awaited_once()
|
|
||||||
|
|
||||||
reconcile_release.set()
|
|
||||||
await asyncio.gather(*created_tasks)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_handles_remove_failure_gracefully(self, test_db):
|
|
||||||
"""Failed remove_contact logs warning but continues to next contact."""
|
|
||||||
from app.radio_sync import sync_and_offload_contacts
|
|
||||||
|
|
||||||
contact_payload = {
|
|
||||||
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
|
|
||||||
KEY_B: {"adv_name": "Bob", "type": 1, "flags": 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_get_result = MagicMock()
|
|
||||||
mock_get_result.type = EventType.NEW_CONTACT
|
|
||||||
mock_get_result.payload = contact_payload
|
|
||||||
|
|
||||||
mock_fail_result = MagicMock()
|
|
||||||
mock_fail_result.type = EventType.ERROR
|
|
||||||
mock_fail_result.payload = {"error": "busy"}
|
|
||||||
|
|
||||||
mock_ok_result = MagicMock()
|
|
||||||
mock_ok_result.type = EventType.OK
|
|
||||||
|
|
||||||
mock_mc = MagicMock()
|
|
||||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
|
||||||
# First remove fails, second succeeds
|
|
||||||
mock_mc.commands.remove_contact = AsyncMock(side_effect=[mock_fail_result, mock_ok_result])
|
|
||||||
|
|
||||||
result = await sync_and_offload_contacts(mock_mc)
|
|
||||||
|
|
||||||
# Both contacts synced, but only one removed successfully
|
|
||||||
assert result["synced"] == 2
|
|
||||||
assert result["removed"] == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_handles_remove_exception_gracefully(self, test_db):
|
|
||||||
"""Exception during remove_contact is caught and processing continues."""
|
|
||||||
from app.radio_sync import sync_and_offload_contacts
|
|
||||||
|
|
||||||
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
|
|
||||||
|
|
||||||
mock_get_result = MagicMock()
|
|
||||||
mock_get_result.type = EventType.NEW_CONTACT
|
|
||||||
mock_get_result.payload = contact_payload
|
|
||||||
|
|
||||||
mock_mc = MagicMock()
|
|
||||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
|
||||||
mock_mc.commands.remove_contact = AsyncMock(side_effect=Exception("Timeout"))
|
|
||||||
|
|
||||||
result = await sync_and_offload_contacts(mock_mc)
|
|
||||||
|
|
||||||
assert result["synced"] == 1
|
|
||||||
assert result["removed"] == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_error_when_get_contacts_fails(self):
|
|
||||||
"""Error result from get_contacts returns error dict."""
|
|
||||||
from app.radio_sync import sync_and_offload_contacts
|
|
||||||
|
|
||||||
mock_error_result = MagicMock()
|
|
||||||
mock_error_result.type = EventType.ERROR
|
|
||||||
mock_error_result.payload = {"error": "radio busy"}
|
|
||||||
|
|
||||||
mock_mc = MagicMock()
|
|
||||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_error_result)
|
|
||||||
|
|
||||||
result = await sync_and_offload_contacts(mock_mc)
|
|
||||||
|
|
||||||
assert result["synced"] == 0
|
|
||||||
assert result["removed"] == 0
|
|
||||||
assert "error" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upserts_with_on_radio_false(self, test_db):
|
|
||||||
"""Contacts are upserted with on_radio=False (being removed from radio)."""
|
|
||||||
from app.radio_sync import sync_and_offload_contacts
|
|
||||||
|
|
||||||
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
|
|
||||||
|
|
||||||
mock_get_result = MagicMock()
|
|
||||||
mock_get_result.type = EventType.NEW_CONTACT
|
|
||||||
mock_get_result.payload = contact_payload
|
|
||||||
|
|
||||||
mock_remove_result = MagicMock()
|
|
||||||
mock_remove_result.type = EventType.OK
|
|
||||||
|
|
||||||
mock_mc = MagicMock()
|
|
||||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
|
||||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
|
|
||||||
|
|
||||||
await sync_and_offload_contacts(mock_mc)
|
|
||||||
|
|
||||||
contact = await ContactRepository.get_by_key(KEY_A)
|
|
||||||
assert contact is not None
|
|
||||||
assert contact.on_radio is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_evicts_removed_contacts_from_library_cache(self, test_db):
|
|
||||||
"""Successfully removed contacts are evicted from mc._contacts.
|
|
||||||
|
|
||||||
The MeshCore library's remove_contact() command does not update the
|
|
||||||
library's in-memory _contacts cache. If we don't evict manually,
|
|
||||||
sync_recent_contacts_to_radio() will find stale entries via
|
|
||||||
get_contact_by_key_prefix() and skip re-adding contacts to the radio.
|
|
||||||
"""
|
|
||||||
from app.radio_sync import sync_and_offload_contacts
|
|
||||||
|
|
||||||
contact_payload = {
|
|
||||||
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
|
|
||||||
KEY_B: {"adv_name": "Bob", "type": 1, "flags": 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_get_result = MagicMock()
|
|
||||||
mock_get_result.type = EventType.NEW_CONTACT
|
|
||||||
mock_get_result.payload = contact_payload
|
|
||||||
|
|
||||||
mock_remove_result = MagicMock()
|
|
||||||
mock_remove_result.type = EventType.OK
|
|
||||||
|
|
||||||
mock_mc = MagicMock()
|
|
||||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
|
||||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
|
|
||||||
# Seed the library's in-memory cache with the same contacts —
|
|
||||||
# simulating what happens after get_contacts() populates it.
|
|
||||||
mock_mc._contacts = {
|
|
||||||
KEY_A: {"public_key": KEY_A, "adv_name": "Alice"},
|
|
||||||
KEY_B: {"public_key": KEY_B, "adv_name": "Bob"},
|
|
||||||
}
|
|
||||||
|
|
||||||
await sync_and_offload_contacts(mock_mc)
|
|
||||||
|
|
||||||
# Both contacts should have been evicted from the library cache
|
|
||||||
assert KEY_A not in mock_mc._contacts
|
|
||||||
assert KEY_B not in mock_mc._contacts
|
|
||||||
assert mock_mc._contacts == {}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_failed_remove_does_not_evict_from_library_cache(self, test_db):
|
|
||||||
"""Contacts that fail to remove from radio stay in mc._contacts.
|
|
||||||
|
|
||||||
We only evict from the cache on successful removal — if the radio
|
|
||||||
still has the contact, the cache should reflect that.
|
|
||||||
"""
|
|
||||||
from app.radio_sync import sync_and_offload_contacts
|
|
||||||
|
|
||||||
contact_payload = {
|
|
||||||
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_get_result = MagicMock()
|
|
||||||
mock_get_result.type = EventType.NEW_CONTACT
|
|
||||||
mock_get_result.payload = contact_payload
|
|
||||||
|
|
||||||
mock_fail_result = MagicMock()
|
|
||||||
mock_fail_result.type = EventType.ERROR
|
|
||||||
mock_fail_result.payload = {"error": "busy"}
|
|
||||||
|
|
||||||
mock_mc = MagicMock()
|
|
||||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
|
||||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_fail_result)
|
|
||||||
mock_mc._contacts = {
|
|
||||||
KEY_A: {"public_key": KEY_A, "adv_name": "Alice"},
|
|
||||||
}
|
|
||||||
|
|
||||||
await sync_and_offload_contacts(mock_mc)
|
|
||||||
|
|
||||||
# Contact should still be in the cache since removal failed
|
|
||||||
assert KEY_A in mock_mc._contacts
|
|
||||||
|
|
||||||
|
|
||||||
class TestBackgroundContactReconcile:
|
class TestBackgroundContactReconcile:
|
||||||
"""Test the yielding background contact reconcile loop."""
|
"""Test the yielding background contact reconcile loop."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Tests for repeater telemetry history: repository CRUD and embedded status response."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import CONTACT_TYPE_REPEATER
|
||||||
|
from app.repository import (
|
||||||
|
ContactRepository,
|
||||||
|
RepeaterTelemetryRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
KEY_A = "aa" * 32
|
||||||
|
KEY_B = "bb" * 32
|
||||||
|
|
||||||
|
SAMPLE_STATUS = {
|
||||||
|
"battery_volts": 4.15,
|
||||||
|
"tx_queue_len": 0,
|
||||||
|
"noise_floor_dbm": -100,
|
||||||
|
"last_rssi_dbm": -80,
|
||||||
|
"last_snr_db": 5.0,
|
||||||
|
"packets_received": 100,
|
||||||
|
"packets_sent": 50,
|
||||||
|
"airtime_seconds": 300,
|
||||||
|
"rx_airtime_seconds": 200,
|
||||||
|
"uptime_seconds": 1000,
|
||||||
|
"sent_flood": 10,
|
||||||
|
"sent_direct": 40,
|
||||||
|
"recv_flood": 60,
|
||||||
|
"recv_direct": 40,
|
||||||
|
"flood_dups": 5,
|
||||||
|
"direct_dups": 2,
|
||||||
|
"full_events": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_repeater(public_key: str, name: str = "Repeater"):
|
||||||
|
"""Insert a repeater contact into the test database."""
|
||||||
|
await ContactRepository.upsert(
|
||||||
|
{
|
||||||
|
"public_key": public_key,
|
||||||
|
"name": name,
|
||||||
|
"type": CONTACT_TYPE_REPEATER,
|
||||||
|
"flags": 0,
|
||||||
|
"direct_path": None,
|
||||||
|
"direct_path_len": -1,
|
||||||
|
"direct_path_hash_mode": -1,
|
||||||
|
"last_advert": None,
|
||||||
|
"lat": None,
|
||||||
|
"lon": None,
|
||||||
|
"last_seen": None,
|
||||||
|
"on_radio": False,
|
||||||
|
"last_contacted": None,
|
||||||
|
"first_seen": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def _db(test_db):
|
||||||
|
"""Set up test DB and patch the repeater_telemetry module's db reference."""
|
||||||
|
from app.repository import repeater_telemetry
|
||||||
|
|
||||||
|
original = repeater_telemetry.db
|
||||||
|
repeater_telemetry.db = test_db
|
||||||
|
try:
|
||||||
|
yield test_db
|
||||||
|
finally:
|
||||||
|
repeater_telemetry.db = original
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepeaterTelemetryRepository:
|
||||||
|
"""Tests for RepeaterTelemetryRepository CRUD operations with JSON blob storage."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_and_get_history(self, _db):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
public_key=KEY_A,
|
||||||
|
timestamp=now - 3600,
|
||||||
|
data={**SAMPLE_STATUS, "battery_volts": 4.15},
|
||||||
|
)
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
public_key=KEY_A,
|
||||||
|
timestamp=now,
|
||||||
|
data={**SAMPLE_STATUS, "battery_volts": 4.10},
|
||||||
|
)
|
||||||
|
|
||||||
|
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 7200)
|
||||||
|
assert len(history) == 2
|
||||||
|
assert history[0]["data"]["battery_volts"] == 4.15
|
||||||
|
assert history[1]["data"]["battery_volts"] == 4.10
|
||||||
|
assert history[0]["timestamp"] < history[1]["timestamp"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history_filters_by_time(self, _db):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now - 7200, SAMPLE_STATUS)
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now - 3600, SAMPLE_STATUS)
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
|
||||||
|
|
||||||
|
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 3601)
|
||||||
|
assert len(history) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history_isolates_by_key(self, _db):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
await _insert_repeater(KEY_B)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
KEY_A, now, {**SAMPLE_STATUS, "battery_volts": 4.1}
|
||||||
|
)
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
KEY_B, now, {**SAMPLE_STATUS, "battery_volts": 3.9}
|
||||||
|
)
|
||||||
|
|
||||||
|
history_a = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
|
||||||
|
history_b = await RepeaterTelemetryRepository.get_history(KEY_B, 0)
|
||||||
|
assert len(history_a) == 1
|
||||||
|
assert len(history_b) == 1
|
||||||
|
assert history_a[0]["data"]["battery_volts"] == 4.1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_stored_as_json(self, _db):
|
||||||
|
"""Verify the data column stores valid JSON that round-trips correctly."""
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
|
||||||
|
history = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
|
||||||
|
assert len(history) == 1
|
||||||
|
assert history[0]["data"] == SAMPLE_STATUS
|
||||||
|
|
||||||
|
|
||||||
|
class TestTelemetryHistoryEndpoint:
|
||||||
|
"""Tests for the read-only GET telemetry-history endpoint."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_history_for_repeater(self, _db, client):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
now = int(time.time())
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["data"]["battery_volts"] == 4.15
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_empty_list_when_no_history(self, _db, client):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_non_repeater(self, _db, client):
|
||||||
|
await ContactRepository.upsert(
|
||||||
|
{
|
||||||
|
"public_key": KEY_A,
|
||||||
|
"name": "Node",
|
||||||
|
"type": 0,
|
||||||
|
"flags": 0,
|
||||||
|
"direct_path": None,
|
||||||
|
"direct_path_len": -1,
|
||||||
|
"direct_path_hash_mode": -1,
|
||||||
|
"last_advert": None,
|
||||||
|
"lat": None,
|
||||||
|
"lon": None,
|
||||||
|
"last_seen": None,
|
||||||
|
"on_radio": False,
|
||||||
|
"last_contacted": None,
|
||||||
|
"first_seen": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_404_for_unknown_contact(self, _db, client):
|
||||||
|
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -622,7 +622,6 @@ class TestAppSettingsRepository:
|
|||||||
"max_radio_contacts": 250,
|
"max_radio_contacts": 250,
|
||||||
"favorites": "{not-json",
|
"favorites": "{not-json",
|
||||||
"auto_decrypt_dm_on_advert": 1,
|
"auto_decrypt_dm_on_advert": 1,
|
||||||
"sidebar_sort_order": "invalid",
|
|
||||||
"last_message_times": "{also-not-json",
|
"last_message_times": "{also-not-json",
|
||||||
"preferences_migrated": 0,
|
"preferences_migrated": 0,
|
||||||
"advert_interval": None,
|
"advert_interval": None,
|
||||||
@@ -630,6 +629,7 @@ class TestAppSettingsRepository:
|
|||||||
"flood_scope": "",
|
"flood_scope": "",
|
||||||
"blocked_keys": "[]",
|
"blocked_keys": "[]",
|
||||||
"blocked_names": "[]",
|
"blocked_names": "[]",
|
||||||
|
"discovery_blocked_types": "[]",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||||
@@ -644,7 +644,6 @@ class TestAppSettingsRepository:
|
|||||||
assert settings.max_radio_contacts == 250
|
assert settings.max_radio_contacts == 250
|
||||||
assert settings.favorites == []
|
assert settings.favorites == []
|
||||||
assert settings.last_message_times == {}
|
assert settings.last_message_times == {}
|
||||||
assert settings.sidebar_sort_order == "recent"
|
|
||||||
assert settings.advert_interval == 0
|
assert settings.advert_interval == 0
|
||||||
assert settings.last_advert_time == 0
|
assert settings.last_advert_time == 0
|
||||||
|
|
||||||
@@ -679,7 +678,7 @@ class TestAppSettingsRepository:
|
|||||||
from app.models import AppSettings
|
from app.models import AppSettings
|
||||||
|
|
||||||
current = AppSettings(preferences_migrated=False)
|
current = AppSettings(preferences_migrated=False)
|
||||||
migrated = AppSettings(preferences_migrated=True, sidebar_sort_order="recent")
|
migrated = AppSettings(preferences_migrated=True)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
@@ -703,7 +702,7 @@ class TestAppSettingsRepository:
|
|||||||
|
|
||||||
assert did_migrate is True
|
assert did_migrate is True
|
||||||
assert result.preferences_migrated is True
|
assert result.preferences_migrated is True
|
||||||
assert mock_update.call_args.kwargs["sidebar_sort_order"] == "recent"
|
assert "sidebar_sort_order" not in mock_update.call_args.kwargs
|
||||||
assert mock_update.call_args.kwargs["preferences_migrated"] is True
|
assert mock_update.call_args.kwargs["preferences_migrated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ class TestMigratePreferences:
|
|||||||
|
|
||||||
assert response.migrated is True
|
assert response.migrated is True
|
||||||
assert response.settings.preferences_migrated is True
|
assert response.settings.preferences_migrated is True
|
||||||
assert response.settings.sidebar_sort_order == "alpha"
|
|
||||||
assert len(response.settings.favorites) == 1
|
assert len(response.settings.favorites) == 1
|
||||||
assert response.settings.favorites[0].type == "contact"
|
assert response.settings.favorites[0].type == "contact"
|
||||||
assert response.settings.favorites[0].id == "aa" * 32
|
assert response.settings.favorites[0].id == "aa" * 32
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 1
|
revision = 1
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiomqtt"
|
name = "aiomqtt"
|
||||||
@@ -8,7 +8,6 @@ version = "2.5.0"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "paho-mqtt" },
|
{ name = "paho-mqtt" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/db/b5/798e4855d17f0f3a2e2ed21c07473fcb4bb45993116693d0f68553927e2c/aiomqtt-2.5.0.tar.gz", hash = "sha256:70e181c140a54ae736394efe2b9e865f665551a5417f6957456cc46010487b21", size = 86453 }
|
sdist = { url = "https://files.pythonhosted.org/packages/db/b5/798e4855d17f0f3a2e2ed21c07473fcb4bb45993116693d0f68553927e2c/aiomqtt-2.5.0.tar.gz", hash = "sha256:70e181c140a54ae736394efe2b9e865f665551a5417f6957456cc46010487b21", size = 86453 }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -47,7 +46,6 @@ name = "anyio"
|
|||||||
version = "4.12.0"
|
version = "4.12.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
@@ -74,30 +72,11 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fb/6b/cfa80a13437896eb8f4504ddac6dfa4ef7f1d2b2261057aa4a30003b8de6/apprise-1.9.7-py3-none-any.whl", hash = "sha256:c7640a81a1097685de66e0508e3da89f49235d566cb44bbead1dd98419bf5ee3", size = 1459879 },
|
{ url = "https://files.pythonhosted.org/packages/fb/6b/cfa80a13437896eb8f4504ddac6dfa4ef7f1d2b2261057aa4a30003b8de6/apprise-1.9.7-py3-none-any.whl", hash = "sha256:c7640a81a1097685de66e0508e3da89f49235d566cb44bbead1dd98419bf5ee3", size = 1459879 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-timeout"
|
|
||||||
version = "5.0.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "backports-asyncio-runner"
|
|
||||||
version = "1.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bleak"
|
name = "bleak"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "async-timeout", marker = "python_full_version < '3.11'" },
|
|
||||||
{ name = "dbus-fast", marker = "sys_platform == 'linux'" },
|
{ name = "dbus-fast", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
|
{ name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" },
|
{ name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" },
|
||||||
@@ -164,18 +143,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 },
|
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 },
|
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 },
|
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 },
|
||||||
@@ -243,22 +210,6 @@ version = "3.4.4"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 }
|
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 },
|
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 },
|
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 },
|
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 },
|
||||||
@@ -353,10 +304,6 @@ version = "3.1.2"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/16/a4/e54607cf8b0a696beba591f1a543cff5b6a9e4b4f842fd55f7ba741d678d/dbus_fast-3.1.2.tar.gz", hash = "sha256:6c9e1b45e4b5e7df0c021bf1bf3f27649374e47c3de1afdba6d00a7d7bba4b3a", size = 73191 }
|
sdist = { url = "https://files.pythonhosted.org/packages/16/a4/e54607cf8b0a696beba591f1a543cff5b6a9e4b4f842fd55f7ba741d678d/dbus_fast-3.1.2.tar.gz", hash = "sha256:6c9e1b45e4b5e7df0c021bf1bf3f27649374e47c3de1afdba6d00a7d7bba4b3a", size = 73191 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/81/df380f31ff7646c010c166c160391e86d697b66b2024f56418e2e79bffd6/dbus_fast-3.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a0896821dd8b03f960d1bfabd1fa7f4af580f45ec070c1fe90ad9d093f7e56", size = 826852 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/e3/862bb5a67a6ee83dcd20cc2d916a6aad45df441f57ed2860a894fc69bb21/dbus_fast-3.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abe5e38cd78844a66154bfb2c11e70840849cd4ef8acf63504d3ee7ef14d0d15", size = 871036 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/a3/b8f82873aa0466dbe86e89e8e8fb6f89db5bbd90a31dddfa1f4e109f81ef/dbus_fast-3.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:793e58c123ad513c11a97f1dd423518342b806c4d0d8d7a0763b60a8daeb32d2", size = 832326 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/1d/be600b1eb685b7f73606ae78349a93f154164ba7d61345a6be7997c2cdbe/dbus_fast-3.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a5726eba4ad6a9ed951e6a402e2c69418d4cc82668709183c78a7ca24ad17cd8", size = 877282 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/7c/c401f6f90fe049425f381a5219bb499e2b71ea6862a06f4787c3afe8107a/dbus_fast-3.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33be2457766da461d3c79627aa6b007a65dd9af0e9b305ca43d7a7dd2794824a", size = 825097 },
|
{ url = "https://files.pythonhosted.org/packages/0e/7c/c401f6f90fe049425f381a5219bb499e2b71ea6862a06f4787c3afe8107a/dbus_fast-3.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33be2457766da461d3c79627aa6b007a65dd9af0e9b305ca43d7a7dd2794824a", size = 825097 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/15/f33579339eaf50b64be460b6f34fb567819f7d229d946fa5cc599ce34aae/dbus_fast-3.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15279fd88952442c8b6b0b910b6c5eff74e9380dde74db0841523f3e6206377f", size = 870328 },
|
{ url = "https://files.pythonhosted.org/packages/f1/15/f33579339eaf50b64be460b6f34fb567819f7d229d946fa5cc599ce34aae/dbus_fast-3.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15279fd88952442c8b6b0b910b6c5eff74e9380dde74db0841523f3e6206377f", size = 870328 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/50/06d0061394395784daf578e8ae688b4c5bf7595bab22db88955a6c35e8a0/dbus_fast-3.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fb4db6cc605193576b6825d1827ff6bde9c09c23e385e33b05db74ed8916021f", size = 830406 },
|
{ url = "https://files.pythonhosted.org/packages/16/50/06d0061394395784daf578e8ae688b4c5bf7595bab22db88955a6c35e8a0/dbus_fast-3.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fb4db6cc605193576b6825d1827ff6bde9c09c23e385e33b05db74ed8916021f", size = 830406 },
|
||||||
@@ -380,18 +327,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/89/94/b7ff6279e642b014cd4aef4d914b9fca3917c2c9c35df49db062023cbdfc/dbus_fast-3.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d7cc1315586e4c50875c9a2d56b9ad2e056ec75e2f27c43cd80392f72d0f6e3", size = 1623709 },
|
{ url = "https://files.pythonhosted.org/packages/89/94/b7ff6279e642b014cd4aef4d914b9fca3917c2c9c35df49db062023cbdfc/dbus_fast-3.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d7cc1315586e4c50875c9a2d56b9ad2e056ec75e2f27c43cd80392f72d0f6e3", size = 1623709 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "exceptiongroup"
|
|
||||||
version = "1.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "execnet"
|
name = "execnet"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -444,13 +379,6 @@ version = "0.7.1"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 }
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521 },
|
{ url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375 },
|
{ url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621 },
|
{ url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621 },
|
||||||
@@ -589,7 +517,6 @@ version = "5.5.1"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "prettytable" },
|
{ name = "prettytable" },
|
||||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/44/4c/b4be9024dae3b5b3c0a6c58cc1d4a35fffe51c3adb835350cb7dcd43b5cd/pip_licenses-5.5.1.tar.gz", hash = "sha256:7df370e6e5024a3f7449abf8e4321ef868ba9a795698ad24ab6851f3e7fc65a7", size = 49108 }
|
sdist = { url = "https://files.pythonhosted.org/packages/44/4c/b4be9024dae3b5b3c0a6c58cc1d4a35fffe51c3adb835350cb7dcd43b5cd/pip_licenses-5.5.1.tar.gz", hash = "sha256:7df370e6e5024a3f7449abf8e4321ef868ba9a795698ad24ab6851f3e7fc65a7", size = 49108 }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -663,11 +590,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484 },
|
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636 },
|
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 },
|
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -694,19 +616,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 }
|
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 },
|
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 },
|
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 },
|
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 },
|
||||||
@@ -785,14 +694,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 },
|
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 },
|
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 },
|
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 },
|
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 },
|
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 },
|
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 },
|
||||||
@@ -867,7 +768,6 @@ version = "12.1"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532 }
|
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/bf/3dbb1783388da54e650f8a6b88bde03c101d9ba93dfe8ab1b1873f1cd999/pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7", size = 676748 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263 },
|
{ url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335 },
|
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370 },
|
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370 },
|
||||||
@@ -885,7 +785,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191 }
|
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/aa/2b2d7ec3ac4b112a605e9bd5c5e5e4fd31d60a8a4b610ab19cc4838aa92a/pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48", size = 383825 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812 },
|
{ url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590 },
|
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689 },
|
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689 },
|
||||||
@@ -904,7 +803,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/25/d21d6cb3fd249c2c2aa96ee54279f40876a0c93e7161b3304bf21cbd0bfe/pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0", size = 33157 }
|
sdist = { url = "https://files.pythonhosted.org/packages/4b/25/d21d6cb3fd249c2c2aa96ee54279f40876a0c93e7161b3304bf21cbd0bfe/pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0", size = 33157 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/1b/06914f4eb1bd8ce598fdd210e1a7411556286910fc8d8919ab7dbaebe629/pyobjc_framework_corebluetooth-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:937849f4d40a33afbcc56cbe90c8d1fbf30fb27a962575b9fb7e8e2c61d3c551", size = 13187 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/7a/26ae106beb97e9c4745065edb3ce3c2bdd91d81f5b52b8224f82ce9d5fb9/pyobjc_framework_corebluetooth-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:37e6456c8a076bd5a2bdd781d0324edd5e7397ef9ac9234a97433b522efb13cf", size = 13189 },
|
{ url = "https://files.pythonhosted.org/packages/57/7a/26ae106beb97e9c4745065edb3ce3c2bdd91d81f5b52b8224f82ce9d5fb9/pyobjc_framework_corebluetooth-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:37e6456c8a076bd5a2bdd781d0324edd5e7397ef9ac9234a97433b522efb13cf", size = 13189 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/56/01fef62a479cdd6ff9ee40b6e062a205408ff386ce5ba56d7e14a71fcf73/pyobjc_framework_corebluetooth-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe72c9732ee6c5c793b9543f08c1f5bdd98cd95dfc9d96efd5708ec9d6eeb213", size = 13209 },
|
{ url = "https://files.pythonhosted.org/packages/2a/56/01fef62a479cdd6ff9ee40b6e062a205408ff386ce5ba56d7e14a71fcf73/pyobjc_framework_corebluetooth-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe72c9732ee6c5c793b9543f08c1f5bdd98cd95dfc9d96efd5708ec9d6eeb213", size = 13209 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/6c/831139ebf6a811aed36abfdfad846bc380dcdf4e6fb751a310ce719ddcfd/pyobjc_framework_corebluetooth-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a894f695e6c672f0260327103a31ad8b98f8d4fb9516a0383db79a82a7e58dc", size = 13229 },
|
{ url = "https://files.pythonhosted.org/packages/e0/6c/831139ebf6a811aed36abfdfad846bc380dcdf4e6fb751a310ce719ddcfd/pyobjc_framework_corebluetooth-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a894f695e6c672f0260327103a31ad8b98f8d4fb9516a0383db79a82a7e58dc", size = 13229 },
|
||||||
@@ -923,7 +821,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/26/e8/75b6b9b3c88b37723c237e5a7600384ea2d84874548671139db02e76652b/pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41", size = 38277 }
|
sdist = { url = "https://files.pythonhosted.org/packages/26/e8/75b6b9b3c88b37723c237e5a7600384ea2d84874548671139db02e76652b/pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41", size = 38277 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/76/9936d97586dbae4d7d10f3958d899ee7a763930af69b5ad03d4516178c7c/pyobjc_framework_libdispatch-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:50a81a29506f0e35b4dc313f97a9d469f7b668dae3ba597bb67bbab94de446bd", size = 20471 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/75/c4aeab6ce7268373d4ceabbc5c406c4bbf557038649784384910932985f8/pyobjc_framework_libdispatch-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:954cc2d817b71383bd267cc5cd27d83536c5f879539122353ca59f1c945ac706", size = 20463 },
|
{ url = "https://files.pythonhosted.org/packages/1f/75/c4aeab6ce7268373d4ceabbc5c406c4bbf557038649784384910932985f8/pyobjc_framework_libdispatch-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:954cc2d817b71383bd267cc5cd27d83536c5f879539122353ca59f1c945ac706", size = 20463 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/6f/96e15c7b2f7b51fc53252216cd0bed0c3541bc0f0aeb32756fefd31bed7d/pyobjc_framework_libdispatch-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0e9570d7a9a3136f54b0b834683bf3f206acd5df0e421c30f8fd4f8b9b556789", size = 15650 },
|
{ url = "https://files.pythonhosted.org/packages/83/6f/96e15c7b2f7b51fc53252216cd0bed0c3541bc0f0aeb32756fefd31bed7d/pyobjc_framework_libdispatch-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0e9570d7a9a3136f54b0b834683bf3f206acd5df0e421c30f8fd4f8b9b556789", size = 15650 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/3a/d85a74606c89b6b293782adfb18711026ff79159db20fc543740f2ac0bc7/pyobjc_framework_libdispatch-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:58ffce5e6bcd7456b4311009480b195b9f22107b7682fb0835d4908af5a68ad0", size = 15668 },
|
{ url = "https://files.pythonhosted.org/packages/38/3a/d85a74606c89b6b293782adfb18711026ff79159db20fc543740f2ac0bc7/pyobjc_framework_libdispatch-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:58ffce5e6bcd7456b4311009480b195b9f22107b7682fb0835d4908af5a68ad0", size = 15668 },
|
||||||
@@ -972,12 +869,10 @@ version = "9.0.2"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
||||||
{ name = "iniconfig" },
|
{ name = "iniconfig" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -989,7 +884,6 @@ name = "pytest-asyncio"
|
|||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
@@ -1038,15 +932,6 @@ version = "6.0.3"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
|
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
|
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
|
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
|
||||||
@@ -1098,7 +983,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.6.3"
|
version = "3.7.1"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiomqtt" },
|
{ name = "aiomqtt" },
|
||||||
@@ -1252,55 +1137,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033 },
|
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tomli"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
@@ -1347,7 +1183,6 @@ source = { registry = "https://pypi.org/simple" }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "h11" },
|
{ name = "h11" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761 }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761 }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1371,12 +1206,6 @@ version = "0.22.1"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 }
|
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420 },
|
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677 },
|
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819 },
|
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819 },
|
||||||
@@ -1418,18 +1247,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 }
|
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529 },
|
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384 },
|
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789 },
|
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789 },
|
||||||
@@ -1502,10 +1319,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 },
|
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 },
|
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 },
|
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250 },
|
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117 },
|
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493 },
|
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493 },
|
||||||
@@ -1527,17 +1340,6 @@ version = "15.0.1"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 }
|
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423 },
|
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082 },
|
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330 },
|
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330 },
|
||||||
@@ -1571,12 +1373,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
|
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
|
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
|
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
|
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1589,9 +1385,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721 }
|
sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/28/26d86ca6d2f155f31ca61e069312034a8922a5a89f5d0fc68abb7c04aad1/winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3", size = 210993 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/a4/f096687e0d1877d206bc5d1f5f07ff90e00b0772d69d4559ab2b6b37090b/winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257", size = 242210 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/81/46927ce4d79fc8f40f193f35204bce79eff7c496d888825a7a74d8560b6e/winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae", size = 415833 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/8d/d7ae0e07cd85c7768de76e8578261854f2af72bd3a8a527bb675e8ae0eda/winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022", size = 210798 },
|
{ url = "https://files.pythonhosted.org/packages/90/8d/d7ae0e07cd85c7768de76e8578261854f2af72bd3a8a527bb675e8ae0eda/winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022", size = 210798 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/66/d05f6e6c0517654734e7f87fa1f0fbc965add9f27cc36b524d96331ab3d8/winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c", size = 242032 },
|
{ url = "https://files.pythonhosted.org/packages/ac/66/d05f6e6c0517654734e7f87fa1f0fbc965add9f27cc36b524d96331ab3d8/winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c", size = 242032 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/a5/760c8396110f6d3e4c417752da1a2bf3b89e0998329c2f10afc717ef6291/winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130", size = 415659 },
|
{ url = "https://files.pythonhosted.org/packages/39/a5/760c8396110f6d3e4c417752da1a2bf3b89e0998329c2f10afc717ef6291/winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130", size = 415659 },
|
||||||
@@ -1615,9 +1408,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732 }
|
sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/b7/822da8bc0b6a67cc0c3e460fef793f00c51a6fe59aa54f6bfe416519a9d9/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78", size = 105569 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/46/696893d3bae80751e35fb0fb8fae5e7fc94a5354dfb5e19167d415e27c66/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb", size = 114743 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/6a/a36b28739b73cc2c67050da866b063af135b5f6c071997c85a27adb6815c/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d", size = 105021 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/cf/671bf29337323cc08f9969cb32312f217d2927d29dbf2964f0dbb378cb90/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b", size = 105535 },
|
{ url = "https://files.pythonhosted.org/packages/3b/cf/671bf29337323cc08f9969cb32312f217d2927d29dbf2964f0dbb378cb90/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b", size = 105535 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/d5/5761a8b6dcc56957018970dd443059c8ee8a79de7b07f0b4d143f8e7dc15/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d", size = 114612 },
|
{ url = "https://files.pythonhosted.org/packages/b6/d5/5761a8b6dcc56957018970dd443059c8ee8a79de7b07f0b4d143f8e7dc15/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d", size = 114612 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/0b/7819bb102286752d3572a75d03e6a8000ffe3c6cb7aee3eb136dca383fe2/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80", size = 105017 },
|
{ url = "https://files.pythonhosted.org/packages/24/0b/7819bb102286752d3572a75d03e6a8000ffe3c6cb7aee3eb136dca383fe2/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80", size = 105017 },
|
||||||
@@ -1641,9 +1431,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906 }
|
sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/b9/c2b0d201b8b38895809591d089a5edc37e702a23f3a6bc6e542c5e7d6dbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866", size = 89730 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/f9/f086c3ac17745a71d8384e1831cab0d5a7c737e1fe5cb84d7584f6c14bbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1", size = 95825 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/b5/f7f830b2da1fb7ffcaf25ce2734db0019615111f8f39e7b4d83fea4a0bd0/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f", size = 89402 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/5e/c628719e877a89f00cac7ce53f9666acbc5ed6f074130729d5d6768b63ff/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9", size = 89614 },
|
{ url = "https://files.pythonhosted.org/packages/ad/5e/c628719e877a89f00cac7ce53f9666acbc5ed6f074130729d5d6768b63ff/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9", size = 89614 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/1a/d172d6f1c2fae53535e7f23835025cf39e3002749a0304f18a38e8ed490d/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09", size = 95783 },
|
{ url = "https://files.pythonhosted.org/packages/ac/1a/d172d6f1c2fae53535e7f23835025cf39e3002749a0304f18a38e8ed490d/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09", size = 95783 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/c1/568dfdaea62ca3b13bb70162cb292e5cd0be5bbb98b738961ddcc2edd374/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401", size = 89253 },
|
{ url = "https://files.pythonhosted.org/packages/67/c1/568dfdaea62ca3b13bb70162cb292e5cd0be5bbb98b738961ddcc2edd374/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401", size = 89253 },
|
||||||
@@ -1667,9 +1454,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896 }
|
sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/a3/449ffc2f8e4c3cfbe7f14c1b43bcaa0475fbd2e8e8bf08465399c5ea078c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46", size = 182059 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/d9/6ea88731df569f5c1b086daf4c3496c8d43281588e3a578ea623fef6bc43/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed", size = 187866 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/2c/ace56fd32ad07608462de0ac7df218e0bf810e4cc31f2c0fbd7f5f90ee93/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde", size = 184627 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/5e/349a5d958be8c0570f0a49bbb746088bcfaa81555accb57503ba01185359/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c", size = 182312 },
|
{ url = "https://files.pythonhosted.org/packages/fa/5e/349a5d958be8c0570f0a49bbb746088bcfaa81555accb57503ba01185359/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c", size = 182312 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/db/929ab0085ec89e46bd3a58c74b451dd770c3285dfa0cbd4f4aa4730da004/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415", size = 187768 },
|
{ url = "https://files.pythonhosted.org/packages/90/db/929ab0085ec89e46bd3a58c74b451dd770c3285dfa0cbd4f4aa4730da004/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415", size = 187768 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/53/f316e2224c384178204430439f04f9b72017fe8237e341a9aebb20da8191/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7", size = 184189 },
|
{ url = "https://files.pythonhosted.org/packages/a3/53/f316e2224c384178204430439f04f9b72017fe8237e341a9aebb20da8191/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7", size = 184189 },
|
||||||
@@ -1693,9 +1477,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538 }
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/61/2744d0e0b3fa7807149a1a36dd89abba901d6b24184d9fd5ef3f28467232/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5", size = 130040 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/f9/881b7ee8acdf3c9fe6c79d8ccd90f9246b397fc78420d55014c4ac05b822/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a", size = 142463 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/db/b09dffcf1158b35d81d8d57bf19ad04293870cea5afa77943c87f1110d88/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e", size = 135871 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/92/ca1fd311d96fce15fba25543a2ae3cb829744a8af548a11d74233d0e4f64/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011", size = 129898 },
|
{ url = "https://files.pythonhosted.org/packages/a6/92/ca1fd311d96fce15fba25543a2ae3cb829744a8af548a11d74233d0e4f64/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011", size = 129898 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/fd/5bd5da5d7997725ba3f1995c16aa1c3362937f8ff68ad4cadfd3415eebcb/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd", size = 142361 },
|
{ url = "https://files.pythonhosted.org/packages/03/fd/5bd5da5d7997725ba3f1995c16aa1c3362937f8ff68ad4cadfd3415eebcb/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd", size = 142361 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/be/d423b63e740600e0617ddb85fba3ef99e7bbff02299fe46323bfe624a382/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817", size = 135808 },
|
{ url = "https://files.pythonhosted.org/packages/df/be/d423b63e740600e0617ddb85fba3ef99e7bbff02299fe46323bfe624a382/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817", size = 135808 },
|
||||||
@@ -1719,9 +1500,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908 }
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/1b/0de659ed4bb80aee28753b4431011334205637a2578481a511866a11e0cf/winrt_windows_devices_radios-3.2.1-cp310-cp310-win32.whl", hash = "sha256:f97766fd551d06c102155d51b2922f96663dee045e1f8d57177def0a2149cb78", size = 38643 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/fd/67c6db8a3244ecc95f85970a7b0e749cda28e26563db1274c3db36a8fbe4/winrt_windows_devices_radios-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:104b737fa1279a3b6a88ba3c6236157afc1de03c472657c45e5176ad7a209e23", size = 40295 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/6d/d145c7f90b01c24f4f9885d1f7d430ecaf2a2b42b6bc236701791b0b0a06/winrt_windows_devices_radios-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:55b02877d2de06ca6f0f6140611a9af9d0c65710e28f1afdeaac1040433b1837", size = 37060 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/a0/4a8b51da15de218cec04bcc1cd85b4b93bcfd8ebe50a5f0a7eee28836dc6/winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e", size = 38505 },
|
{ url = "https://files.pythonhosted.org/packages/6b/a0/4a8b51da15de218cec04bcc1cd85b4b93bcfd8ebe50a5f0a7eee28836dc6/winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e", size = 38505 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/49/ba69e3180585dbc6f3336a09fef7cba4558a6a1e7d500500f62c1478418e/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104", size = 40157 },
|
{ url = "https://files.pythonhosted.org/packages/de/49/ba69e3180585dbc6f3336a09fef7cba4558a6a1e7d500500f62c1478418e/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104", size = 40157 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/92/64817f71a20ecf842da36dc3848f42614217688137a69c93fda8a6103155/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a", size = 36976 },
|
{ url = "https://files.pythonhosted.org/packages/9c/92/64817f71a20ecf842da36dc3848f42614217688137a69c93fda8a6103155/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a", size = 36976 },
|
||||||
@@ -1745,9 +1523,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485 }
|
sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/69/d387332c4378b41f87211b7dc40a4cfc6b7047dc227448aaa207624fc911/winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b", size = 111969 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/71/046c1e2424627c3db66d764871186de4d26936e8a138d6bf04dc143e4606/winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39", size = 118695 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/2e/2463bc4ad984836fb3ecf1abac62df67bc5cabab004cad09b828b86ed51b/winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4", size = 109690 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/36/09b9757f7cbf269e67008ea2ad188a44f974c94c9b49ebf0b52d1a8c4069/winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437", size = 111944 },
|
{ url = "https://files.pythonhosted.org/packages/c0/36/09b9757f7cbf269e67008ea2ad188a44f974c94c9b49ebf0b52d1a8c4069/winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437", size = 111944 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/a5/216d66df6bdcee58eb3877fabc1544337e23f850bf9f93838db7f5698371/winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e", size = 118465 },
|
{ url = "https://files.pythonhosted.org/packages/05/a5/216d66df6bdcee58eb3877fabc1544337e23f850bf9f93838db7f5698371/winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e", size = 118465 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/ca/48ca8b5bc5be5c7a5516c9e1d9a21861b4217e1b4ee57923aab6f13fa411/winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671", size = 109609 },
|
{ url = "https://files.pythonhosted.org/packages/be/ca/48ca8b5bc5be5c7a5516c9e1d9a21861b4217e1b4ee57923aab6f13fa411/winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671", size = 109609 },
|
||||||
@@ -1771,9 +1546,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043 }
|
sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/26/ed3d35ea262999d28be957c35a32e93360eac0ef9f14e75d32cd6b5c6a37/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389", size = 59880 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/39/b4a1aeba2d13c1f2ad3d851d5092b8397c05f34fb318d6a7d499f5b5720b/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6", size = 70650 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/74/f8a4a29202da24f2af2c4a8f515b0a44fe46bc4d25b3d54ea2249e980bd3/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6", size = 59216 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/b3/7e4a75c62e86bedf9458b7ec8dfed74cff3236e0b4b2288f95967d5cc4d2/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7", size = 59693 },
|
{ url = "https://files.pythonhosted.org/packages/87/b3/7e4a75c62e86bedf9458b7ec8dfed74cff3236e0b4b2288f95967d5cc4d2/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7", size = 59693 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/58/049db1d95fdfc0c8451dc6db17442ed4e6b2aba361c425c0bb8dc8c98c4a/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89", size = 70828 },
|
{ url = "https://files.pythonhosted.org/packages/32/58/049db1d95fdfc0c8451dc6db17442ed4e6b2aba361c425c0bb8dc8c98c4a/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89", size = 70828 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/6b/a04974f5555c86452e54c19d063d9fd45f0fe9f2a6858e7fe12c639043fb/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523", size = 59051 },
|
{ url = "https://files.pythonhosted.org/packages/5b/6b/a04974f5555c86452e54c19d063d9fd45f0fe9f2a6858e7fe12c639043fb/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523", size = 59051 },
|
||||||
@@ -1797,9 +1569,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335 }
|
sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/4d/a0d806f4664b9bcf525bd31dcdf1f9520cc14f033e897dc7f7dd4ad4eb77/winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003", size = 127791 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/2c/00baa87041a3d92a3cc5230d4033e995a52740e9c08fcd9f7bde93cb979f/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00", size = 132608 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/d0/ed03e864aa8eaaec964d5bbc95baccf738275ae6cc88600db66ecb5adaf4/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6", size = 128495 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/60/a9e0dc03434aa29e6b5c83067e988cd5934adf830cd9f87cbbc06569ca32/winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69", size = 127509 },
|
{ url = "https://files.pythonhosted.org/packages/19/60/a9e0dc03434aa29e6b5c83067e988cd5934adf830cd9f87cbbc06569ca32/winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69", size = 127509 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/98/6c9c21b5e75ff5927a130da9eaf5ab628dfa1f93b64c181f0193706cbd6c/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5", size = 132491 },
|
{ url = "https://files.pythonhosted.org/packages/23/98/6c9c21b5e75ff5927a130da9eaf5ab628dfa1f93b64c181f0193706cbd6c/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5", size = 132491 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/ca/d0a02045d445cbf1029d65f01b487fdded5b333c0367a8bae0565b3def00/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780", size = 128577 },
|
{ url = "https://files.pythonhosted.org/packages/38/ca/d0a02045d445cbf1029d65f01b487fdded5b333c0367a8bae0565b3def00/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780", size = 128577 },
|
||||||
|
|||||||
Reference in New Issue
Block a user