Compare commits
15 Commits
single-nod
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d825a07f8 | ||
|
|
c5fec61123 | ||
|
|
9437959568 | ||
|
|
d1846b102d | ||
|
|
5cd28bd1d9 | ||
|
|
d48595e082 | ||
|
|
1f3a1e5b3f | ||
|
|
c2bcfbf646 | ||
|
|
28d57924ee | ||
|
|
f83681188c | ||
|
|
3acb0efc62 | ||
|
|
1961b4c9e2 | ||
|
|
de47c7c228 | ||
|
|
4f610a329a | ||
|
|
019092ed7d |
4
.gitignore
vendored
@@ -10,6 +10,10 @@ wheels/
|
||||
frontend/node_modules/
|
||||
frontend/test-results/
|
||||
|
||||
# Frontend build output (built from source by end users)
|
||||
frontend/dist/
|
||||
frontend/package-lock.json
|
||||
|
||||
# reference librarys
|
||||
references/
|
||||
|
||||
|
||||
@@ -152,10 +152,10 @@ Terminal 2: `cd frontend && npm run dev`
|
||||
|
||||
### Production
|
||||
|
||||
In production, the FastAPI backend serves the compiled frontend:
|
||||
In production, the FastAPI backend serves the compiled frontend. You must build the frontend first:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
|
||||
34
CHANGELOG.md
@@ -1,3 +1,37 @@
|
||||
## [1.8.0] - 2026-02-07
|
||||
|
||||
Feature: Single hop ping
|
||||
Feature: PWA viewport fixes(thanks @rgregg)
|
||||
Feature (?): No frontend distribution; build it yourself ;P
|
||||
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: Better guarding around reconnection
|
||||
Bugfix: Duplicate websocket connection fixes
|
||||
Bugfix: Settings tab error cleanliness on tab swap
|
||||
Bugfix: Fix path traversal vuln
|
||||
UI: Swap visualizer legend ordering (yay prettier)
|
||||
Misc: Perf and locking improvements
|
||||
Misc: Always flood advertisements
|
||||
Misc: Better packet dupe handling
|
||||
Misc: Dead code cleanup, test improvements
|
||||
|
||||
## [1.8.0] - 2026-02-07
|
||||
|
||||
Feature: Single hop ping
|
||||
Feature: PWA viewport fixes(thanks @rgregg)
|
||||
Feature (?): No frontend distribution; build it yourself ;P
|
||||
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: Better guarding around reconnection
|
||||
Bugfix: Duplicate websocket connection fixes
|
||||
Bugfix: Settings tab error cleanliness on tab swap
|
||||
Bugfix: Fix path traversal vuln
|
||||
UI: Swap visualizer legend ordering (yay prettier)
|
||||
Misc: Perf and locking improvements
|
||||
Misc: Always flood advertisements
|
||||
Misc: Better packet dupe handling
|
||||
Misc: Dead code cleanup, test improvements
|
||||
|
||||
## [1.7.1] - 2026-02-03
|
||||
|
||||
Feature: Clickable hyperlinks
|
||||
|
||||
12
README.md
@@ -8,7 +8,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Attach your
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Brute force hashtag room names for GroupTexts you don't have keys for yet
|
||||
|
||||
**Warning:** This app has no authentication. Run it on a private network only -- do not expose to the internet unless you want strangers sending traffic as you.
|
||||
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI.
|
||||
|
||||

|
||||
|
||||
@@ -57,13 +57,17 @@ usbipd bind --busid 3-8
|
||||
|
||||
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
|
||||
|
||||
The frontend is pre-built -- just run the backend:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
|
||||
cd Remote-Terminal-for-MeshCore
|
||||
|
||||
# Install backend dependencies
|
||||
uv sync
|
||||
|
||||
# Build frontend
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
|
||||
# Run server
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
@@ -193,7 +197,7 @@ cd /opt/remoteterm
|
||||
sudo -u remoteterm uv venv
|
||||
sudo -u remoteterm uv sync
|
||||
|
||||
# Build frontend (optional -- already built in repo and served by backend)
|
||||
# Build frontend (required for the backend to serve the web UI)
|
||||
cd /opt/remoteterm/frontend
|
||||
sudo -u remoteterm npm install
|
||||
sudo -u remoteterm npm run build
|
||||
|
||||
@@ -517,7 +517,8 @@ All endpoints are prefixed with `/api`.
|
||||
- `WS /api/ws` - Real-time updates (health, contacts, channels, messages, raw packets)
|
||||
|
||||
### Static Files (Production)
|
||||
In production, the backend also serves the frontend:
|
||||
In production, the backend serves the frontend if `frontend/dist` exists. Users must build the
|
||||
frontend first (`cd frontend && npm install && npm run build`):
|
||||
- `/` - Serves `frontend/dist/index.html`
|
||||
- `/assets/*` - Serves compiled JS/CSS from `frontend/dist/assets/`
|
||||
- `/*` - Falls back to `index.html` for SPA routing
|
||||
|
||||
@@ -354,7 +354,11 @@ async def run_historical_dm_decryption(
|
||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
# Don't pass our_public_key - we want to decrypt both incoming AND outgoing messages.
|
||||
# Note: passing our_public_key=None means outgoing DMs won't be matched
|
||||
# by try_decrypt_dm (the inbound check requires src_hash == their_first_byte,
|
||||
# which fails for our outgoing packets). This is acceptable because outgoing
|
||||
# DMs are stored directly by the send endpoint. Historical decryption only
|
||||
# recovers incoming messages.
|
||||
result = try_decrypt_dm(
|
||||
packet_data,
|
||||
private_key_bytes,
|
||||
|
||||
@@ -183,6 +183,11 @@ class ContactRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all contacts as read at the given timestamp."""
|
||||
await db.conn.execute("UPDATE contacts SET last_read_at = ?", (timestamp,))
|
||||
|
||||
@staticmethod
|
||||
async def get_by_pubkey_first_byte(hex_byte: str) -> list[Contact]:
|
||||
"""Get contacts whose public key starts with the given hex byte (2 chars)."""
|
||||
@@ -269,6 +274,11 @@ class ChannelRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
await db.conn.execute("UPDATE channels SET last_read_at = ?", (timestamp,))
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
@staticmethod
|
||||
|
||||
@@ -134,29 +134,25 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
|
||||
|
||||
@router.post("/advertise")
|
||||
async def send_advertisement(flood: bool = True) -> dict:
|
||||
"""Send a radio advertisement to announce presence on the mesh.
|
||||
async def send_advertisement() -> dict:
|
||||
"""Send a flood advertisement to announce presence on the mesh.
|
||||
|
||||
Manual advertisement requests always send immediately, updating the
|
||||
last_advert_time which affects when the next periodic/startup advert
|
||||
can occur.
|
||||
|
||||
Args:
|
||||
flood: Whether to flood the advertisement (default True).
|
||||
|
||||
Returns:
|
||||
status: "ok" if sent successfully
|
||||
"""
|
||||
require_connected()
|
||||
|
||||
# Manual requests always send (force=True), but still update last_advert_time
|
||||
logger.info("Sending advertisement (flood=%s)", flood)
|
||||
logger.info("Sending flood advertisement")
|
||||
success = await do_send_advertisement(force=True)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to send advertisement")
|
||||
|
||||
return {"status": "ok", "flood": flood}
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/reboot")
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Query
|
||||
|
||||
from app.database import db
|
||||
from app.models import UnreadCounts
|
||||
from app.repository import MessageRepository
|
||||
from app.repository import ChannelRepository, ContactRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/read-state", tags=["read-state"])
|
||||
@@ -35,9 +35,8 @@ async def mark_all_read() -> dict:
|
||||
"""
|
||||
now = int(time.time())
|
||||
|
||||
# Update all contacts and channels in one transaction
|
||||
await db.conn.execute("UPDATE contacts SET last_read_at = ?", (now,))
|
||||
await db.conn.execute("UPDATE channels SET last_read_at = ?", (now,))
|
||||
await ContactRepository.mark_all_read(now)
|
||||
await ChannelRepository.mark_all_read(now)
|
||||
await db.conn.commit()
|
||||
|
||||
logger.info("Marked all contacts and channels as read at %d", now)
|
||||
|
||||
@@ -148,7 +148,7 @@ await api.getHealth();
|
||||
// Radio
|
||||
await api.getRadioConfig();
|
||||
await api.updateRadioConfig({ name: 'MyRadio' });
|
||||
await api.sendAdvertisement(true);
|
||||
await api.sendAdvertisement();
|
||||
|
||||
// Contacts/Channels
|
||||
await api.getContacts();
|
||||
|
||||
BIN
frontend/dist/apple-touch-icon.png
vendored
|
Before Width: | Height: | Size: 16 KiB |
15
frontend/dist/assets/BotCodeEditor-CmNe1fPT.js
vendored
572
frontend/dist/assets/index-AkYO4-QT.js
vendored
1
frontend/dist/assets/index-AkYO4-QT.js.map
vendored
1
frontend/dist/assets/index-DJA5wYVF.css
vendored
2
frontend/dist/assets/wordlist-BtmChKSf.js
vendored
BIN
frontend/dist/favicon-96x96.png
vendored
|
Before Width: | Height: | Size: 9.5 KiB |
BIN
frontend/dist/favicon.ico
vendored
|
Before Width: | Height: | Size: 15 KiB |
3
frontend/dist/favicon.svg
vendored
|
Before Width: | Height: | Size: 128 KiB |
BIN
frontend/dist/glyph.png
vendored
|
Before Width: | Height: | Size: 96 KiB |
8
frontend/dist/glyph.svg
vendored
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="700pt" height="700pt" version="1.1" viewBox="0 0 700 700" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m405.89 352.77c0 30.797-25.02 55.762-55.887 55.762s-55.887-24.965-55.887-55.762c0-30.797 25.02-55.762 55.887-55.762s55.887 24.965 55.887 55.762z"/>
|
||||
<path d="m333.07 352.77h33.871v297.37h-33.871z"/>
|
||||
<path d="m412.71 495.07-13.648-30.926c44.27-19.438 72.879-63.152 72.879-111.37 0-67.082-54.699-121.65-121.94-121.65-67.242 0-121.94 54.57-121.94 121.65 0 48.215 28.609 91.93 72.879 111.37l-13.648 30.926c-56.547-24.844-93.094-80.695-93.094-142.3 0-85.715 69.887-155.44 155.8-155.44 85.918 0 155.8 69.727 155.8 155.44-0.003906 61.594-36.551 117.46-93.094 142.3z"/>
|
||||
<path d="m410.17 581.6-8.5742-32.691c89.277-23.309 151.63-103.96 151.63-196.15 0-111.8-91.168-202.75-203.22-202.75-112.06 0.003907-203.23 90.961-203.23 202.77 0 92.184 62.348 172.83 151.63 196.15l-8.5742 32.691c-104.18-27.195-176.93-121.3-176.93-228.83 0-130.43 106.36-236.54 237.1-236.54 130.73 0 237.1 106.12 237.1 236.54-0.003906 107.52-72.754 201.62-176.93 228.82z"/>
|
||||
<path d="m409.05 661.5-6.3125-33.199c132.34-25.047 228.39-140.93 228.39-275.53 0-154.66-126.12-280.48-281.13-280.48-155 0-281.13 125.82-281.13 280.48 0 134.6 96.055 250.48 228.39 275.53l-6.3164 33.199c-148.3-28.07-255.95-157.91-255.95-308.73 0-173.29 141.32-314.27 315-314.27s315 140.98 315 314.27c0 150.81-107.65 280.66-255.95 308.73z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
frontend/dist/glyph.xcf
vendored
22
frontend/dist/index.html
vendored
@@ -1,22 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MCTerm" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<script type="module" crossorigin src="/assets/index-AkYO4-QT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJA5wYVF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
21
frontend/dist/site.webmanifest
vendored
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "RemoteTerm for MeshCore",
|
||||
"short_name": "RemoteTerm",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
frontend/dist/web-app-manifest-192x192.png
vendored
|
Before Width: | Height: | Size: 15 KiB |
BIN
frontend/dist/web-app-manifest-512x512.png
vendored
|
Before Width: | Height: | Size: 46 KiB |
5534
frontend/package-lock.json
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "1.7.1",
|
||||
"version": "1.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -543,7 +543,7 @@ export function App() {
|
||||
// Send flood advertisement handler
|
||||
const handleAdvertise = useCallback(async () => {
|
||||
try {
|
||||
await api.sendAdvertisement(true);
|
||||
await api.sendAdvertisement();
|
||||
toast.success('Advertisement sent');
|
||||
} catch (err) {
|
||||
console.error('Failed to send advertisement:', err);
|
||||
@@ -758,7 +758,7 @@ export function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh">
|
||||
<div className="flex flex-col h-full">
|
||||
<StatusBar
|
||||
health={health}
|
||||
config={config}
|
||||
|
||||
@@ -76,8 +76,8 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ private_key: privateKey }),
|
||||
}),
|
||||
sendAdvertisement: (flood = true) =>
|
||||
fetchJson<{ status: string; flood: boolean }>(`/radio/advertise?flood=${flood}`, {
|
||||
sendAdvertisement: () =>
|
||||
fetchJson<{ status: string }>('/radio/advertise', {
|
||||
method: 'POST',
|
||||
}),
|
||||
rebootRadio: () =>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useCallback, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
@@ -231,6 +239,14 @@ export function MessageList({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sort messages by received_at ascending (oldest first)
|
||||
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
|
||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||
const sortedMessages = useMemo(
|
||||
() => [...messages].sort((a, b) => a.received_at - b.received_at),
|
||||
[messages]
|
||||
);
|
||||
|
||||
// Look up contact by public key
|
||||
const getContact = (conversationKey: string | null): Contact | null => {
|
||||
if (!conversationKey) return null;
|
||||
@@ -293,11 +309,6 @@ export function MessageList({
|
||||
);
|
||||
}
|
||||
|
||||
// Sort messages by received_at ascending (oldest first)
|
||||
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
|
||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||
const sortedMessages = [...messages].sort((a, b) => a.received_at - b.received_at);
|
||||
|
||||
// Helper to get a unique sender key for grouping messages
|
||||
const getSenderKey = (msg: Message, sender: string | null): string => {
|
||||
if (msg.outgoing) return '__outgoing__';
|
||||
|
||||
@@ -1521,15 +1521,6 @@ export function PacketVisualizer({
|
||||
{!hideUI && (
|
||||
<div className="absolute bottom-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-muted-foreground font-medium mb-1">Nodes</div>
|
||||
{LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span className={item.size}>{item.emoji}</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-muted-foreground font-medium mb-1">Packets</div>
|
||||
{PACKET_LEGEND_ITEMS.map((item) => (
|
||||
@@ -1544,6 +1535,15 @@ export function PacketVisualizer({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-muted-foreground font-medium mb-1">Nodes</div>
|
||||
{LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span className={item.size}>{item.emoji}</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -485,7 +485,10 @@ export function SettingsModal({
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as SettingsTab)}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v as SettingsTab);
|
||||
setError('');
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
|
||||
@@ -12,10 +12,37 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* iOS PWA safe-area support */
|
||||
:root {
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-bottom-capped: min(var(--safe-area-bottom), 12px);
|
||||
}
|
||||
|
||||
@supports (padding: constant(safe-area-inset-top)) {
|
||||
:root {
|
||||
--safe-area-top: constant(safe-area-inset-top);
|
||||
--safe-area-right: constant(safe-area-inset-right);
|
||||
--safe-area-bottom: constant(safe-area-inset-bottom);
|
||||
--safe-area-left: constant(safe-area-inset-left);
|
||||
--safe-area-bottom-capped: min(var(--safe-area-bottom), 12px);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
/* Prevent overscroll/bounce on mobile */
|
||||
overscroll-behavior: none;
|
||||
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom-capped)
|
||||
var(--safe-area-left);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Fallback for browsers without dvh support */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "1.7.1"
|
||||
version = "1.8.0"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||