mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 18:01:22 +02:00
Complete room -> channel rename
This commit is contained in:
@@ -7,7 +7,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
|||||||
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
||||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||||
* Access your radio remotely over your network or VPN
|
* Access your radio remotely over your network or VPN
|
||||||
* Search for hashtag room names for channels you don't have keys for yet
|
* Search for hashtag channel names for channels you don't have keys for yet
|
||||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
|||||||
|
|
||||||
## HTTPS
|
## HTTPS
|
||||||
|
|
||||||
WebGPU room-finding requires a secure context when you are not on `localhost`.
|
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||||
|
|
||||||
Generate a local cert and start the backend with TLS:
|
Generate a local cert and start the backend with TLS:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ app/
|
|||||||
- Packet `path_len` values are hop counts, not byte counts.
|
- Packet `path_len` values are hop counts, not byte counts.
|
||||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||||
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
||||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
|
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same channel reuse the loaded slot; new channels fill free slots up to the discovered channel capacity, then evict the least recently used cached channel.
|
||||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||||
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
||||||
|
|||||||
+1
-1
@@ -266,7 +266,7 @@ class ContactNameHistory(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ContactActiveRoom(BaseModel):
|
class ContactActiveRoom(BaseModel):
|
||||||
"""A channel/room where a contact has been active."""
|
"""A channel where a contact has been active."""
|
||||||
|
|
||||||
channel_key: str
|
channel_key: str
|
||||||
channel_name: str
|
channel_name: str
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
|||||||
requested_name = request.name
|
requested_name = request.name
|
||||||
is_hashtag = requested_name.startswith("#")
|
is_hashtag = requested_name.startswith("#")
|
||||||
|
|
||||||
# Reserve the canonical Public room so it cannot drift to another 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.
|
# and the well-known Public key cannot be renamed to something else.
|
||||||
if is_public_channel_name(requested_name):
|
if is_public_channel_name(requested_name):
|
||||||
if request.key:
|
if request.key:
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export function ChannelFloodScopeOverrideModal({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Regional Override</DialogTitle>
|
<DialogTitle>Regional Override</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Room-level regional routing temporarily changes the radio flood scope before send and
|
Channel-level regional routing temporarily changes the radio flood scope before send and
|
||||||
restores it after. This can noticeably slow room sends.
|
restores it after. This can noticeably slow channel sends.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,9 @@ export function ChatHeader({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(conversation.id);
|
navigator.clipboard.writeText(conversation.id);
|
||||||
toast.success(
|
toast.success(
|
||||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
conversation.type === 'channel'
|
||||||
|
? 'Channel key copied!'
|
||||||
|
: 'Contact key copied!'
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
|
|||||||
@@ -242,8 +242,8 @@ export function ContactInfoPane({
|
|||||||
|
|
||||||
<ActivityChartsSection analytics={analytics} />
|
<ActivityChartsSection analytics={analytics} />
|
||||||
|
|
||||||
<MostActiveRoomsSection
|
<MostActiveChannelsSection
|
||||||
rooms={analytics?.most_active_rooms ?? []}
|
channels={analytics?.most_active_rooms ?? []}
|
||||||
onNavigateToChannel={onNavigateToChannel}
|
onNavigateToChannel={onNavigateToChannel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -515,8 +515,8 @@ export function ContactInfoPane({
|
|||||||
|
|
||||||
<ActivityChartsSection analytics={analytics} />
|
<ActivityChartsSection analytics={analytics} />
|
||||||
|
|
||||||
<MostActiveRoomsSection
|
<MostActiveChannelsSection
|
||||||
rooms={analytics?.most_active_rooms ?? []}
|
channels={analytics?.most_active_rooms ?? []}
|
||||||
onNavigateToChannel={onNavigateToChannel}
|
onNavigateToChannel={onNavigateToChannel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,23 +588,23 @@ function MessageStatsSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MostActiveRoomsSection({
|
function MostActiveChannelsSection({
|
||||||
rooms,
|
channels,
|
||||||
onNavigateToChannel,
|
onNavigateToChannel,
|
||||||
}: {
|
}: {
|
||||||
rooms: ContactActiveRoom[];
|
channels: ContactActiveRoom[];
|
||||||
onNavigateToChannel?: (channelKey: string) => void;
|
onNavigateToChannel?: (channelKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (rooms.length === 0) {
|
if (channels.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-5 py-3 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
<SectionLabel>Most Active Channels</SectionLabel>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{rooms.map((room) => (
|
{channels.map((channel) => (
|
||||||
<div key={room.channel_key} className="flex justify-between items-center text-sm">
|
<div key={channel.channel_key} className="flex justify-between items-center text-sm">
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
onNavigateToChannel
|
onNavigateToChannel
|
||||||
@@ -614,15 +614,15 @@ function MostActiveRoomsSection({
|
|||||||
role={onNavigateToChannel ? 'button' : undefined}
|
role={onNavigateToChannel ? 'button' : undefined}
|
||||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
onClick={() => onNavigateToChannel?.(channel.channel_key)}
|
||||||
>
|
>
|
||||||
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key)
|
{channel.channel_name.startsWith('#') || isPublicChannelKey(channel.channel_key)
|
||||||
? room.channel_name
|
? channel.channel_name
|
||||||
: `#${room.channel_name}`}
|
: `#${channel.channel_name}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||||
{room.message_count.toLocaleString()} msg
|
{channel.message_count.toLocaleString()} msg
|
||||||
{room.message_count !== 1 ? 's' : ''}
|
{channel.message_count !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { toast } from './ui/sonner';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
||||||
|
|
||||||
interface CrackedRoom {
|
interface CrackedChannel {
|
||||||
roomName: string;
|
channelName: string;
|
||||||
key: string;
|
key: string;
|
||||||
packetId: number;
|
packetId: number;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -45,7 +45,7 @@ export function CrackerPanel({
|
|||||||
const [twoWordMode, setTwoWordMode] = useState(false);
|
const [twoWordMode, setTwoWordMode] = useState(false);
|
||||||
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
||||||
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
||||||
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
|
const [crackedChannels, setCrackedChannels] = useState<CrackedChannel[]>([]);
|
||||||
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
||||||
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
||||||
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
|
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
|
||||||
@@ -325,14 +325,14 @@ export function CrackerPanel({
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
const newRoom: CrackedRoom = {
|
const newCracked: CrackedChannel = {
|
||||||
roomName: result.roomName,
|
channelName: result.roomName,
|
||||||
key: result.key,
|
key: result.key,
|
||||||
packetId: nextId!,
|
packetId: nextId!,
|
||||||
message: result.decryptedMessage || '',
|
message: result.decryptedMessage || '',
|
||||||
crackedAt: Date.now(),
|
crackedAt: Date.now(),
|
||||||
};
|
};
|
||||||
setCrackedRooms((prev) => [...prev, newRoom]);
|
setCrackedChannels((prev) => [...prev, newCracked]);
|
||||||
|
|
||||||
// Auto-add channel if not already exists
|
// Auto-add channel if not already exists
|
||||||
const keyUpper = result.key.toUpperCase();
|
const keyUpper = result.key.toUpperCase();
|
||||||
@@ -580,20 +580,20 @@ export function CrackerPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cracked rooms list */}
|
{/* Cracked channels list */}
|
||||||
{crackedRooms.length > 0 && (
|
{crackedChannels.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
|
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{crackedRooms.map((room, i) => (
|
{crackedChannels.map((channel, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
|
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
|
||||||
>
|
>
|
||||||
<span className="text-success font-medium">#{room.roomName}</span>
|
<span className="text-success font-medium">#{channel.channelName}</span>
|
||||||
<span className="text-muted-foreground ml-2 text-xs">
|
<span className="text-muted-foreground ml-2 text-xs">
|
||||||
"{room.message.slice(0, 50)}
|
"{channel.message.slice(0, 50)}
|
||||||
{room.message.length > 50 ? '...' : ''}"
|
{channel.message.length > 50 ? '...' : ''}"
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -604,8 +604,8 @@ export function CrackerPanel({
|
|||||||
<hr className="border-border" />
|
<hr className="border-border" />
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
|
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
|
||||||
force payloads as they arrive, testing room names up to the specified length to discover
|
force payloads as they arrive, testing channel names up to the specified length to discover
|
||||||
active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no
|
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||||
way of knowing but try as if they are).
|
way of knowing but try as if they are).
|
||||||
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
||||||
pick up messages it couldn't crack, attempting them at one longer length.
|
pick up messages it couldn't crack, attempting them at one longer length.
|
||||||
@@ -613,8 +613,8 @@ export function CrackerPanel({
|
|||||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||||
dictionary pass; this can substantially increase search time and also result in
|
dictionary pass; this can substantially increase search time and also result in
|
||||||
false-positives.
|
false-positives.
|
||||||
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
|
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
|
||||||
if any historically captured packets will decrypt with that key.
|
see if any historically captured packets will decrypt with that key.
|
||||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||||
may allow accelerated cracking and/or system instability.
|
may allow accelerated cracking and/or system instability.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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-room' | 'hashtag';
|
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
||||||
|
|
||||||
interface NewMessageModalProps {
|
interface NewMessageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -37,7 +37,7 @@ export function NewMessageModal({
|
|||||||
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 [roomKey, setRoomKey] = useState('');
|
const [channelKey, setChannelKey] = 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('');
|
||||||
@@ -47,7 +47,7 @@ export function NewMessageModal({
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('');
|
setName('');
|
||||||
setContactKey('');
|
setContactKey('');
|
||||||
setRoomKey('');
|
setChannelKey('');
|
||||||
setTryHistorical(false);
|
setTryHistorical(false);
|
||||||
setPermitCapitals(false);
|
setPermitCapitals(false);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -65,12 +65,12 @@ export function NewMessageModal({
|
|||||||
}
|
}
|
||||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
// 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-room') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !roomKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
setError('Room name and key are required');
|
setError('Channel name and key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
|
await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
|
||||||
} else if (tab === 'hashtag') {
|
} else if (tab === 'hashtag') {
|
||||||
const channelName = name.trim();
|
const channelName = name.trim();
|
||||||
const validationError = validateHashtagName(channelName);
|
const validationError = validateHashtagName(channelName);
|
||||||
@@ -147,7 +147,7 @@ export function NewMessageModal({
|
|||||||
<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-room' && 'Create a private room 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'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -162,7 +162,7 @@ export function NewMessageModal({
|
|||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||||
<TabsTrigger value="new-room">Private Channel</TabsTrigger>
|
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -187,23 +187,23 @@ export function NewMessageModal({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="new-room" className="mt-4 space-y-4">
|
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="room-name">Room Name</Label>
|
<Label htmlFor="channel-name">Channel Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="room-name"
|
id="channel-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Room name"
|
placeholder="Channel name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="room-key">Room Key</Label>
|
<Label htmlFor="channel-key">Channel Key</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="room-key"
|
id="channel-key"
|
||||||
value={roomKey}
|
value={channelKey}
|
||||||
onChange={(e) => setRoomKey(e.target.value)}
|
onChange={(e) => setChannelKey(e.target.value)}
|
||||||
placeholder="Pre-shared key (hex)"
|
placeholder="Pre-shared key (hex)"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -217,7 +217,7 @@ export function NewMessageModal({
|
|||||||
const hex = Array.from(bytes)
|
const hex = Array.from(bytes)
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
setRoomKey(hex);
|
setChannelKey(hex);
|
||||||
}}
|
}}
|
||||||
title="Generate random key"
|
title="Generate random key"
|
||||||
aria-label="Generate random key"
|
aria-label="Generate random key"
|
||||||
@@ -251,7 +251,7 @@ export function NewMessageModal({
|
|||||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||||
className="w-4 h-4 rounded border-input accent-primary"
|
className="w-4 h-4 rounded border-input accent-primary"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Permit capitals in room 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="text-xs text-muted-foreground pl-7">
|
||||||
Not recommended; most companions normalize to lowercase
|
Not recommended; most companions normalize to lowercase
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResol
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveGroupTextRoomName(
|
function resolveGroupTextChannelName(
|
||||||
payload: {
|
payload: {
|
||||||
channelHash?: string;
|
channelHash?: string;
|
||||||
cipherMac?: string;
|
cipherMac?: string;
|
||||||
@@ -211,15 +211,15 @@ function getPacketContext(
|
|||||||
groupTextCandidates: GroupTextResolutionCandidate[]
|
groupTextCandidates: GroupTextResolutionCandidate[]
|
||||||
) {
|
) {
|
||||||
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
||||||
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
|
const fallbackChannel = packet.decrypted_info?.channel_name ?? null;
|
||||||
|
|
||||||
if (!inspection.decoded?.payload.decoded) {
|
if (!inspection.decoded?.payload.decoded) {
|
||||||
if (!fallbackSender && !fallbackRoom) {
|
if (!fallbackSender && !fallbackChannel) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: fallbackRoom ? 'Room' : 'Context',
|
title: fallbackChannel ? 'Channel' : 'Context',
|
||||||
primary: fallbackRoom ?? 'Sender metadata available',
|
primary: fallbackChannel ?? 'Sender metadata available',
|
||||||
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -231,11 +231,12 @@ function getPacketContext(
|
|||||||
ciphertext?: string;
|
ciphertext?: string;
|
||||||
decrypted?: { sender?: string; message?: string };
|
decrypted?: { sender?: string; message?: string };
|
||||||
};
|
};
|
||||||
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
|
const channelName =
|
||||||
|
fallbackChannel ?? resolveGroupTextChannelName(payload, groupTextCandidates);
|
||||||
return {
|
return {
|
||||||
title: roomName ? 'Room' : 'Channel',
|
title: 'Channel',
|
||||||
primary:
|
primary:
|
||||||
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
channelName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
||||||
secondary: payload.decrypted?.sender
|
secondary: payload.decrypted?.sender
|
||||||
? `Sender: ${payload.decrypted.sender}`
|
? `Sender: ${payload.decrypted.sender}`
|
||||||
: fallbackSender
|
: fallbackSender
|
||||||
|
|||||||
@@ -748,7 +748,7 @@ export function Sidebar({
|
|||||||
icon: <LockOpen className="h-4 w-4" />,
|
icon: <LockOpen className="h-4 w-4" />,
|
||||||
label: (
|
label: (
|
||||||
<>
|
<>
|
||||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-1 text-[11px]',
|
'ml-1 text-[11px]',
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ describe('ContactInfoPane', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads name-only channel stats and most active rooms', async () => {
|
it('loads name-only channel stats and most active channels', async () => {
|
||||||
getContactAnalytics.mockResolvedValue(
|
getContactAnalytics.mockResolvedValue(
|
||||||
createAnalytics(null, {
|
createAnalytics(null, {
|
||||||
lookup_type: 'name',
|
lookup_type: 'name',
|
||||||
@@ -188,7 +188,7 @@ describe('ContactInfoPane', () => {
|
|||||||
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
|
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
|
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
|
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
|
expect(screen.getByText('Most Active Channels')).toBeInTheDocument();
|
||||||
expect(screen.getByText('#ops')).toBeInTheDocument();
|
expect(screen.getByText('#ops')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/Name-only analytics include channel messages only/i)
|
screen.getByText(/Name-only analytics include channel messages only/i)
|
||||||
|
|||||||
@@ -105,13 +105,13 @@ describe('NewMessageModal form reset', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('new-room tab', () => {
|
describe('new-channel 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();
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Private Channel');
|
await switchToTab(user, 'Private Channel');
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
@@ -128,7 +128,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Private Channel');
|
await switchToTab(user, 'Private Channel');
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('tab switching resets form', () => {
|
describe('tab switching resets form', () => {
|
||||||
it('clears contact fields when switching to room tab', async () => {
|
it('clears contact fields when switching to channel tab', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Contact');
|
await switchToTab(user, 'Contact');
|
||||||
@@ -153,18 +153,18 @@ describe('NewMessageModal form reset', () => {
|
|||||||
// Switch to Private Channel tab — fields should reset
|
// Switch to Private Channel tab — fields should reset
|
||||||
await switchToTab(user, 'Private Channel');
|
await switchToTab(user, 'Private Channel');
|
||||||
|
|
||||||
expect((screen.getByPlaceholderText('Room name') as HTMLInputElement).value).toBe('');
|
expect((screen.getByPlaceholderText('Channel name') as HTMLInputElement).value).toBe('');
|
||||||
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
|
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears room fields when switching to hashtag tab', async () => {
|
it('clears channel fields when switching to hashtag tab', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderModal();
|
renderModal();
|
||||||
await switchToTab(user, 'Private Channel');
|
await switchToTab(user, 'Private Channel');
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText('Room name'), 'SecretRoom');
|
await user.type(screen.getByPlaceholderText('Channel name'), 'SecretRoom');
|
||||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
|
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
|
||||||
|
|
||||||
await switchToTab(user, 'Hashtag Channel');
|
await switchToTab(user, 'Hashtag Channel');
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ describe('RawPacketFeedView', () => {
|
|||||||
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
|
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
|
it('opens a packet detail modal from the raw feed and decrypts channel messages when a key is loaded', () => {
|
||||||
renderView({
|
renderView({
|
||||||
packets: [
|
packets: [
|
||||||
{
|
{
|
||||||
@@ -392,7 +392,7 @@ describe('RawPacketFeedView', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
|
it('does not guess a channel name when multiple loaded channels collide on the group hash', () => {
|
||||||
renderView({
|
renderView({
|
||||||
packets: [
|
packets: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export function describeCiphertextStructure(
|
|||||||
case PayloadType.GroupText:
|
case PayloadType.GroupText:
|
||||||
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||||
• Timestamp (4 bytes) - send time as unix timestamp
|
• Timestamp (4 bytes) - send time as unix timestamp
|
||||||
• Flags (1 byte) - room-message flags byte
|
• Flags (1 byte) - channel-message flags byte
|
||||||
• Message (remaining bytes) - UTF-8 room message text`;
|
• Message (remaining bytes) - UTF-8 channel message text`;
|
||||||
case PayloadType.TextMessage:
|
case PayloadType.TextMessage:
|
||||||
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||||
• Timestamp (4 bytes) - send time as unix timestamp
|
• Timestamp (4 bytes) - send time as unix timestamp
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { createChannel, getChannels, getMessages } from '../helpers/api';
|
|||||||
* Timeout is 3 minutes to allow for intermittent traffic.
|
* Timeout is 3 minutes to allow for intermittent traffic.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ROOMS = [
|
const CHANNELS = [
|
||||||
'#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette',
|
'#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette',
|
||||||
'#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill',
|
'#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill',
|
||||||
'#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic',
|
'#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic',
|
||||||
@@ -39,14 +39,14 @@ test.describe('Incoming mesh messages', () => {
|
|||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
// Ensure all rooms exist — create any that are missing
|
// Ensure all channels exist — create any that are missing
|
||||||
const existing = await getChannels();
|
const existing = await getChannels();
|
||||||
const existingNames = new Set(existing.map((c) => c.name));
|
const existingNames = new Set(existing.map((c) => c.name));
|
||||||
|
|
||||||
for (const room of ROOMS) {
|
for (const channel of CHANNELS) {
|
||||||
if (!existingNames.has(room)) {
|
if (!existingNames.has(channel)) {
|
||||||
try {
|
try {
|
||||||
await createChannel(room);
|
await createChannel(channel);
|
||||||
} catch {
|
} catch {
|
||||||
// May already exist from a concurrent creation, ignore
|
// May already exist from a concurrent creation, ignore
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ test.describe('Incoming mesh messages', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => {
|
test('receive an incoming message in any channel', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||||
// Nudge echo bot on #flightless — may generate an incoming packet quickly
|
// Nudge echo bot on #flightless — may generate an incoming packet quickly
|
||||||
await nudgeEchoBot();
|
await nudgeEchoBot();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user