mirror of
https://github.com/vacp2p/universal-connectivity.git
synced 2026-01-09 15:18:05 -05:00
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ yarn.lock
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
go-peer/go-peer
|
go-peer/go-peer
|
||||||
**/.idea
|
**/.idea
|
||||||
|
nim-peer/nim_peer
|
||||||
|
nim-peer/local.*
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -26,6 +26,7 @@ Some of the cool and cutting-edge [transport protocols](https://connectivity.lib
|
|||||||
| [`node-js-peer`](./node-js-peer/) | Node.js Chat Peer in TypeScript | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [`node-js-peer`](./node-js-peer/) | Node.js Chat Peer in TypeScript | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [`go-peer`](./go-peer/) | Chat peer implemented in Go | ✅ | ❌ | ✅ | ✅ | ✅ |
|
| [`go-peer`](./go-peer/) | Chat peer implemented in Go | ✅ | ❌ | ✅ | ✅ | ✅ |
|
||||||
| [`rust-peer`](./rust-peer/) | Chat peer implemented in Rust | ❌ | ❌ | ✅ | ✅ | ✅ |
|
| [`rust-peer`](./rust-peer/) | Chat peer implemented in Rust | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| [`nim-peer`](./nim-peer/) | Chat peer implemented in Nim | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
✅ - Protocol supported
|
✅ - Protocol supported
|
||||||
❌ - Protocol not supported
|
❌ - Protocol not supported
|
||||||
@@ -97,3 +98,15 @@ cargo run -- --help
|
|||||||
cd go-peer
|
cd go-peer
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Getting started: Nim
|
||||||
|
```
|
||||||
|
cd nim-peer
|
||||||
|
nimble build
|
||||||
|
|
||||||
|
# Wait for connections in tcp/9093
|
||||||
|
./nim_peer
|
||||||
|
|
||||||
|
# Connect to another node (e.g. in localhost tcp/9092)
|
||||||
|
./nim_peer --connect /ip4/127.0.0.1/tcp/9092/p2p/12D3KooSomePeerId
|
||||||
|
```
|
||||||
|
|||||||
8
nim-peer/config.nims
Normal file
8
nim-peer/config.nims
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# begin Nimble config (version 2)
|
||||||
|
when withDir(thisDir(), system.fileExists("nimble.paths")):
|
||||||
|
include "nimble.paths"
|
||||||
|
--define:
|
||||||
|
"chronicles_sinks=textblocks[dynamic]"
|
||||||
|
--define:
|
||||||
|
"chronicles_log_level=DEBUG"
|
||||||
|
# end Nimble config
|
||||||
13
nim-peer/nim_peer.nimble
Normal file
13
nim-peer/nim_peer.nimble
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Package
|
||||||
|
|
||||||
|
version = "0.1.0"
|
||||||
|
author = "Status Research & Development GmbH"
|
||||||
|
description = "universal-connectivity nim peer"
|
||||||
|
license = "MIT"
|
||||||
|
srcDir = "src"
|
||||||
|
bin = @["nim_peer"]
|
||||||
|
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
|
||||||
|
requires "nim >= 2.2.0", "nimwave", "chronos", "chronicles", "libp2p", "illwill", "cligen", "stew"
|
||||||
33
nim-peer/src/file_exchange.nim
Normal file
33
nim-peer/src/file_exchange.nim
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import os
|
||||||
|
import libp2p, chronos, chronicles, stew/byteutils
|
||||||
|
|
||||||
|
const
|
||||||
|
MaxFileSize: int = 1024 # 1KiB
|
||||||
|
MaxFileIdSize: int = 1024 # 1KiB
|
||||||
|
FileExchangeCodec*: string = "/universal-connectivity-file/1"
|
||||||
|
|
||||||
|
type FileExchange* = ref object of LPProtocol
|
||||||
|
|
||||||
|
proc new*(T: typedesc[FileExchange]): T =
|
||||||
|
proc handle(conn: Connection, proto: string) {.async: (raises: [CancelledError]).} =
|
||||||
|
try:
|
||||||
|
let fileId = string.fromBytes(await conn.readLp(MaxFileIdSize))
|
||||||
|
# filename is /tmp/{fileid}
|
||||||
|
let filename = getTempDir().joinPath(fileId)
|
||||||
|
if filename.fileExists:
|
||||||
|
let fileContent = cast[seq[byte]](readFile(filename))
|
||||||
|
await conn.writeLp(fileContent)
|
||||||
|
except CancelledError as e:
|
||||||
|
raise e
|
||||||
|
except CatchableError as e:
|
||||||
|
error "Exception in handler", error = e.msg
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
return T.new(codecs = @[FileExchangeCodec], handler = handle)
|
||||||
|
|
||||||
|
proc requestFile*(
|
||||||
|
p: FileExchange, conn: Connection, fileId: string
|
||||||
|
): Future[seq[byte]] {.async.} =
|
||||||
|
await conn.writeLp(cast[seq[byte]](fileId))
|
||||||
|
await conn.readLp(MaxFileSize)
|
||||||
241
nim-peer/src/nim_peer.nim
Normal file
241
nim-peer/src/nim_peer.nim
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
{.push raises: [Exception].}
|
||||||
|
|
||||||
|
import tables, deques, strutils, os, streams
|
||||||
|
|
||||||
|
import libp2p, chronos, cligen, chronicles
|
||||||
|
from libp2p/protocols/pubsub/rpc/message import Message
|
||||||
|
|
||||||
|
from illwave as iw import nil, `[]`, `[]=`, `==`, width, height
|
||||||
|
from terminal import nil
|
||||||
|
|
||||||
|
import ./ui/root
|
||||||
|
import ./utils
|
||||||
|
import ./file_exchange
|
||||||
|
|
||||||
|
const
|
||||||
|
KeyFile: string = "local.key"
|
||||||
|
PeerIdFile: string = "local.peerid"
|
||||||
|
MaxKeyLen: int = 4096
|
||||||
|
ListenPort: int = 9093
|
||||||
|
|
||||||
|
proc cleanup() {.noconv: (raises: []).} =
|
||||||
|
try:
|
||||||
|
iw.deinit()
|
||||||
|
except:
|
||||||
|
discard
|
||||||
|
try:
|
||||||
|
terminal.resetAttributes()
|
||||||
|
terminal.showCursor()
|
||||||
|
# Clear screen and move cursor to top-left
|
||||||
|
stdout.write("\e[2J\e[H") # ANSI escape: clear screen & home
|
||||||
|
stdout.flushFile()
|
||||||
|
quit(130) # SIGINT conventional exit code
|
||||||
|
except IOError as exc:
|
||||||
|
echo "Unexpected error: " & exc.msg
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
proc readKeyFile(
|
||||||
|
filename: string
|
||||||
|
): PrivateKey {.raises: [OSError, IOError, ResultError[crypto.CryptoError]].} =
|
||||||
|
let size = getFileSize(filename)
|
||||||
|
|
||||||
|
if size == 0:
|
||||||
|
raise newException(OSError, "Empty key file")
|
||||||
|
|
||||||
|
var buf: seq[byte]
|
||||||
|
buf.setLen(size)
|
||||||
|
|
||||||
|
var fs = openFileStream(filename, fmRead)
|
||||||
|
defer:
|
||||||
|
fs.close()
|
||||||
|
|
||||||
|
discard fs.readData(buf[0].addr, size.int)
|
||||||
|
PrivateKey.init(buf).tryGet()
|
||||||
|
|
||||||
|
proc writeKeyFile(
|
||||||
|
filename: string, key: PrivateKey
|
||||||
|
) {.raises: [OSError, IOError, ResultError[crypto.CryptoError]].} =
|
||||||
|
var fs = openFileStream(filename, fmWrite)
|
||||||
|
defer:
|
||||||
|
fs.close()
|
||||||
|
|
||||||
|
let buf = key.getBytes().tryGet()
|
||||||
|
fs.writeData(buf[0].addr, buf.len)
|
||||||
|
|
||||||
|
proc loadOrCreateKey(rng: var HmacDrbgContext): PrivateKey =
|
||||||
|
if fileExists(KeyFile):
|
||||||
|
try:
|
||||||
|
return readKeyFile(KeyFile)
|
||||||
|
except:
|
||||||
|
discard # overwrite file
|
||||||
|
try:
|
||||||
|
let k = PrivateKey.random(rng).tryGet()
|
||||||
|
writeKeyFile(KeyFile, k)
|
||||||
|
k
|
||||||
|
except:
|
||||||
|
echo "Could not create new key"
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
proc start(
|
||||||
|
addrs: Opt[MultiAddress], headless: bool, room: string, port: int
|
||||||
|
) {.async: (raises: [CancelledError]).} =
|
||||||
|
# Handle Ctrl+C
|
||||||
|
setControlCHook(cleanup)
|
||||||
|
|
||||||
|
var rng = newRng()
|
||||||
|
|
||||||
|
let switch =
|
||||||
|
try:
|
||||||
|
SwitchBuilder
|
||||||
|
.new()
|
||||||
|
.withRng(rng)
|
||||||
|
.withTcpTransport()
|
||||||
|
.withAddresses(@[MultiAddress.init("/ip4/0.0.0.0/tcp/" & $port).tryGet()])
|
||||||
|
.withYamux()
|
||||||
|
.withNoise()
|
||||||
|
.withPrivateKey(loadOrCreateKey(rng[]))
|
||||||
|
.build()
|
||||||
|
except LPError as exc:
|
||||||
|
echo "Could not start switch: " & $exc.msg
|
||||||
|
quit(1)
|
||||||
|
except Exception as exc:
|
||||||
|
echo "Could not start switch: " & $exc.msg
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
writeFile(PeerIdFile, $switch.peerInfo.peerId)
|
||||||
|
except IOError as exc:
|
||||||
|
error "Could not write PeerId to file", description = exc.msg
|
||||||
|
|
||||||
|
let (gossip, fileExchange) =
|
||||||
|
try:
|
||||||
|
(GossipSub.init(switch = switch, triggerSelf = true), FileExchange.new())
|
||||||
|
except InitializationError as exc:
|
||||||
|
echo "Could not initialize gossipsub: " & $exc.msg
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
switch.mount(gossip)
|
||||||
|
switch.mount(fileExchange)
|
||||||
|
await switch.start()
|
||||||
|
except LPError as exc:
|
||||||
|
echo "Could start switch: " & $exc.msg
|
||||||
|
|
||||||
|
info "Started switch", peerId = $switch.peerInfo.peerId
|
||||||
|
|
||||||
|
let
|
||||||
|
recvQ = newAsyncQueue[string]()
|
||||||
|
peerQ = newAsyncQueue[(PeerId, PeerEventKind)]()
|
||||||
|
systemQ = newAsyncQueue[string]()
|
||||||
|
|
||||||
|
# if --connect was specified, connect to peer
|
||||||
|
if addrs.isSome():
|
||||||
|
try:
|
||||||
|
discard await switch.connect(addrs.get())
|
||||||
|
except Exception as exc:
|
||||||
|
error "Connection error", description = exc.msg
|
||||||
|
|
||||||
|
# wait so that gossipsub can form mesh
|
||||||
|
await sleepAsync(3.seconds)
|
||||||
|
|
||||||
|
# topic handlers
|
||||||
|
# chat and file handlers actually need to be validators instead of regular handlers
|
||||||
|
# validators allow us to get information about which peer sent a message
|
||||||
|
let onChatMsg = proc(
|
||||||
|
topic: string, msg: Message
|
||||||
|
): Future[ValidationResult] {.async, gcsafe.} =
|
||||||
|
let strMsg = cast[string](msg.data)
|
||||||
|
await recvQ.put(shortPeerId(msg.fromPeer) & ": " & strMsg)
|
||||||
|
await systemQ.put("Received message")
|
||||||
|
await systemQ.put(" Source: " & $msg.fromPeer)
|
||||||
|
await systemQ.put(" Topic: " & $topic)
|
||||||
|
await systemQ.put(" Seqno: " & $seqnoToUint64(msg.seqno))
|
||||||
|
await systemQ.put(" ") # empty line
|
||||||
|
return ValidationResult.Accept
|
||||||
|
|
||||||
|
# when a new file is announced, download it
|
||||||
|
let onNewFile = proc(
|
||||||
|
topic: string, msg: Message
|
||||||
|
): Future[ValidationResult] {.async, gcsafe.} =
|
||||||
|
let fileId = sanitizeFileId(cast[string](msg.data))
|
||||||
|
# this will only work if we're connected to `fromPeer` (since we don't have kad-dht)
|
||||||
|
let conn = await switch.dial(msg.fromPeer, FileExchangeCodec)
|
||||||
|
let filePath = getTempDir() / fileId
|
||||||
|
let fileContents = await fileExchange.requestFile(conn, fileId)
|
||||||
|
writeFile(filePath, fileContents)
|
||||||
|
await conn.close()
|
||||||
|
# Save file in /tmp/fileId
|
||||||
|
await systemQ.put("Downloaded file to " & filePath)
|
||||||
|
await systemQ.put(" ") # empty line
|
||||||
|
return ValidationResult.Accept
|
||||||
|
|
||||||
|
# when a new peer is announced
|
||||||
|
let onNewPeer = proc(topic: string, data: seq[byte]) {.async, gcsafe.} =
|
||||||
|
let peerId = PeerId.init(data).valueOr:
|
||||||
|
error "Could not parse PeerId from data", data = $data
|
||||||
|
return
|
||||||
|
await peerQ.put((peerId, PeerEventKind.Joined))
|
||||||
|
|
||||||
|
# register validators and handlers
|
||||||
|
|
||||||
|
# receive chat messages
|
||||||
|
gossip.subscribe(room, nil)
|
||||||
|
gossip.addValidator(room, onChatMsg)
|
||||||
|
|
||||||
|
# receive files offerings
|
||||||
|
gossip.subscribe(ChatFileTopic, nil)
|
||||||
|
gossip.addValidator(ChatFileTopic, onNewFile)
|
||||||
|
|
||||||
|
# receive newly connected peers through gossipsub
|
||||||
|
gossip.subscribe(PeerDiscoveryTopic, onNewPeer)
|
||||||
|
|
||||||
|
let onPeerJoined = proc(
|
||||||
|
peer: PeerId, peerEvent: PeerEvent
|
||||||
|
) {.gcsafe, async: (raises: [CancelledError]).} =
|
||||||
|
await peerQ.put((peer, PeerEventKind.Joined))
|
||||||
|
|
||||||
|
let onPeerLeft = proc(
|
||||||
|
peer: PeerId, peerEvent: PeerEvent
|
||||||
|
) {.gcsafe, async: (raises: [CancelledError]).} =
|
||||||
|
await peerQ.put((peer, PeerEventKind.Left))
|
||||||
|
|
||||||
|
# receive newly connected peers through direct connections
|
||||||
|
switch.addPeerEventHandler(onPeerJoined, PeerEventKind.Joined)
|
||||||
|
switch.addPeerEventHandler(onPeerLeft, PeerEventKind.Left)
|
||||||
|
|
||||||
|
# add already connected peers
|
||||||
|
for peerId in switch.peerStore[AddressBook].book.keys:
|
||||||
|
await peerQ.put((peerId, PeerEventKind.Joined))
|
||||||
|
|
||||||
|
if headless:
|
||||||
|
runForever()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await runUI(gossip, room, recvQ, peerQ, systemQ, switch.peerInfo.peerId)
|
||||||
|
except Exception as exc:
|
||||||
|
error "Unexpected error", description = exc.msg
|
||||||
|
finally:
|
||||||
|
if switch != nil:
|
||||||
|
await switch.stop()
|
||||||
|
try:
|
||||||
|
cleanup()
|
||||||
|
except:
|
||||||
|
discard
|
||||||
|
|
||||||
|
proc cli(connect = "", room = ChatTopic, port = ListenPort, headless = false) =
|
||||||
|
var addrs = Opt.none(MultiAddress)
|
||||||
|
if connect.len > 0:
|
||||||
|
addrs = Opt.some(MultiAddress.init(connect).get())
|
||||||
|
try:
|
||||||
|
waitFor start(addrs, headless, room, port)
|
||||||
|
except CancelledError:
|
||||||
|
echo "Operation cancelled"
|
||||||
|
|
||||||
|
when isMainModule:
|
||||||
|
dispatch cli,
|
||||||
|
help = {
|
||||||
|
"connect": "full multiaddress (with /p2p/ peerId) of the node to connect to",
|
||||||
|
"room": "Room name",
|
||||||
|
"port": "TCP listen port",
|
||||||
|
"headless": "No UI, can only receive messages",
|
||||||
|
}
|
||||||
4
nim-peer/src/ui/context.nim
Normal file
4
nim-peer/src/ui/context.nim
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type State* = object
|
||||||
|
inputBuffer*: string
|
||||||
|
|
||||||
|
include nimwave/prelude
|
||||||
196
nim-peer/src/ui/root.nim
Normal file
196
nim-peer/src/ui/root.nim
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import chronos, chronicles, deques, strutils, os
|
||||||
|
from illwave as iw import nil, `[]`, `[]=`, `==`, width, height
|
||||||
|
from nimwave as nw import nil
|
||||||
|
from terminal import nil
|
||||||
|
import libp2p
|
||||||
|
|
||||||
|
import ./scrollingtextbox
|
||||||
|
import ./context
|
||||||
|
import ../utils
|
||||||
|
|
||||||
|
const
|
||||||
|
InputPanelHeight: int = 3
|
||||||
|
ScrollSpeed: int = 2
|
||||||
|
|
||||||
|
type InputPanel = ref object of nw.Node
|
||||||
|
|
||||||
|
method render(node: InputPanel, ctx: var nw.Context[State]) =
|
||||||
|
ctx = nw.slice(ctx, 0, 0, iw.width(ctx.tb), InputPanelHeight)
|
||||||
|
render(
|
||||||
|
nw.Box(
|
||||||
|
border: nw.Border.Single,
|
||||||
|
direction: nw.Direction.Vertical,
|
||||||
|
children: nw.seq("> " & ctx.data.inputBuffer),
|
||||||
|
),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
proc resizePanels(
|
||||||
|
chatPanel: ScrollingTextBox,
|
||||||
|
peersPanel: ScrollingTextBox,
|
||||||
|
systemPanel: ScrollingTextBox,
|
||||||
|
newWidth: int,
|
||||||
|
newHeight: int,
|
||||||
|
) =
|
||||||
|
let
|
||||||
|
peersPanelWidth = (newWidth / 4).int
|
||||||
|
topHeight = (newHeight / 2).int
|
||||||
|
chatPanel.resize(newWidth - peersPanelWidth, topHeight)
|
||||||
|
peersPanel.resize(peersPanelWidth, topHeight)
|
||||||
|
systemPanel.resize(newWidth, newHeight - topHeight - InputPanelHeight)
|
||||||
|
|
||||||
|
proc runUI*(
|
||||||
|
gossip: GossipSub,
|
||||||
|
room: string,
|
||||||
|
recvQ: AsyncQueue[string],
|
||||||
|
peerQ: AsyncQueue[(PeerId, PeerEventKind)],
|
||||||
|
systemQ: AsyncQueue[string],
|
||||||
|
myPeerId: PeerId,
|
||||||
|
) {.async: (raises: [Exception]).} =
|
||||||
|
var
|
||||||
|
ctx = nw.initContext[State]()
|
||||||
|
prevTb: iw.TerminalBuffer
|
||||||
|
mouse: iw.MouseInfo
|
||||||
|
key: iw.Key
|
||||||
|
terminal.enableTrueColors()
|
||||||
|
terminal.hideCursor()
|
||||||
|
try:
|
||||||
|
iw.init()
|
||||||
|
except:
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx.tb = iw.initTerminalBuffer(terminal.terminalWidth(), terminal.terminalHeight())
|
||||||
|
|
||||||
|
# TODO: publish my peerid in peerid topic
|
||||||
|
let
|
||||||
|
peersPanelWidth = (iw.width(ctx.tb) / 4).int
|
||||||
|
topHeight = (iw.height(ctx.tb) / 2).int
|
||||||
|
chatPanel = ScrollingTextBox.new(
|
||||||
|
title = "Chat", width = iw.width(ctx.tb) - peersPanelWidth, height = topHeight
|
||||||
|
)
|
||||||
|
peersPanel = ScrollingTextBox.new(
|
||||||
|
title = "Peers",
|
||||||
|
width = peersPanelWidth,
|
||||||
|
height = topHeight,
|
||||||
|
text = @[shortPeerId(myPeerId) & " (You)"],
|
||||||
|
)
|
||||||
|
systemPanel = ScrollingTextBox.new(
|
||||||
|
title = "System",
|
||||||
|
width = iw.width(ctx.tb),
|
||||||
|
height = iw.height(ctx.tb) - topHeight - InputPanelHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send chronicle logs to systemPanel
|
||||||
|
defaultChroniclesStream.output.writer = proc(
|
||||||
|
logLevel: LogLevel, msg: LogOutputStr
|
||||||
|
) {.gcsafe.} =
|
||||||
|
for line in msg.replace("\t", " ").splitLines():
|
||||||
|
systemPanel.push(line)
|
||||||
|
|
||||||
|
ctx.data.inputBuffer = ""
|
||||||
|
let focusAreas = @[chatPanel, peersPanel, systemPanel]
|
||||||
|
var focusIndex = 0
|
||||||
|
var focusedPanel: ScrollingTextBox
|
||||||
|
|
||||||
|
while true:
|
||||||
|
focusedPanel = focusAreas[focusIndex]
|
||||||
|
focusedPanel.border = nw.Border.Double
|
||||||
|
key = iw.getKey(mouse)
|
||||||
|
if key == iw.Key.Mouse:
|
||||||
|
case mouse.scrollDir
|
||||||
|
of iw.ScrollDirection.sdUp:
|
||||||
|
focusedPanel.scrollUp(ScrollSpeed)
|
||||||
|
of iw.ScrollDirection.sdDown:
|
||||||
|
focusedPanel.scrollDown(ScrollSpeed)
|
||||||
|
else:
|
||||||
|
discard
|
||||||
|
elif key == iw.Key.Tab:
|
||||||
|
# unfocus previous panel
|
||||||
|
focusedPanel.border = nw.Border.Single
|
||||||
|
focusIndex += 1
|
||||||
|
if focusIndex >= focusAreas.len:
|
||||||
|
focusIndex = 0 # wrap around
|
||||||
|
elif key in {iw.Key.Space .. iw.Key.Tilde}:
|
||||||
|
ctx.data.inputBuffer.add(cast[char](key.ord))
|
||||||
|
elif key == iw.Key.Backspace and ctx.data.inputBuffer.len > 0:
|
||||||
|
ctx.data.inputBuffer.setLen(ctx.data.inputBuffer.len - 1)
|
||||||
|
elif key == iw.Key.Enter:
|
||||||
|
# handle /file command to send/publish files
|
||||||
|
if ctx.data.inputBuffer.startsWith("/file"):
|
||||||
|
let parts = ctx.data.inputBuffer.split(" ")
|
||||||
|
if parts.len < 2:
|
||||||
|
systemPanel.push("Invalid /file command, missing file name")
|
||||||
|
else:
|
||||||
|
for path in parts[1 ..^ 1]:
|
||||||
|
if not fileExists(path):
|
||||||
|
systemPanel.push("Unable to find file '" & path & "', skipping")
|
||||||
|
continue
|
||||||
|
let fileId = path.splitFile().name
|
||||||
|
# copy file to /tmp/{filename}
|
||||||
|
copyFile(path, getTempDir().joinPath(fileId))
|
||||||
|
# publish /tmp/{filename}
|
||||||
|
try:
|
||||||
|
discard await gossip.publish(ChatFileTopic, cast[seq[byte]](@(fileId)))
|
||||||
|
systemPanel.push("Offering file " & fileId)
|
||||||
|
except Exception as exc:
|
||||||
|
systemPanel.push("Unable to offer file: " & exc.msg)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
discard await gossip.publish(room, cast[seq[byte]](@(ctx.data.inputBuffer)))
|
||||||
|
chatPanel.push("You: " & ctx.data.inputBuffer) # show message in ui
|
||||||
|
systemPanel.push("Sent chat message")
|
||||||
|
except Exception as exc:
|
||||||
|
systemPanel.push("Unable to send chat message: " & exc.msg)
|
||||||
|
ctx.data.inputBuffer = "" # clear input buffer
|
||||||
|
elif key != iw.Key.None:
|
||||||
|
discard
|
||||||
|
|
||||||
|
# update peer list if there's a new peer from peerQ
|
||||||
|
if not peerQ.empty():
|
||||||
|
let (newPeer, eventKind) = await peerQ.get()
|
||||||
|
|
||||||
|
if eventKind == PeerEventKind.Joined and
|
||||||
|
not peersPanel.text.contains(shortPeerId(newPeer)):
|
||||||
|
systemPanel.push("Adding peer " & shortPeerId(newPeer))
|
||||||
|
peersPanel.push(shortPeerId(newPeer))
|
||||||
|
|
||||||
|
if eventKind == PeerEventKind.Left and
|
||||||
|
peersPanel.text.contains(shortPeerId(newPeer)):
|
||||||
|
systemPanel.push("Removing peer " & shortPeerId(newPeer))
|
||||||
|
peersPanel.remove(shortPeerId(newPeer))
|
||||||
|
|
||||||
|
# update messages if there's a new message from recvQ
|
||||||
|
if not recvQ.empty():
|
||||||
|
let msg = await recvQ.get()
|
||||||
|
chatPanel.push(msg) # show message in ui
|
||||||
|
|
||||||
|
# update messages if there's a new message from recvQ
|
||||||
|
if not systemQ.empty():
|
||||||
|
let msg = await systemQ.get()
|
||||||
|
if msg.len > 0:
|
||||||
|
systemPanel.push(msg) # show message in ui
|
||||||
|
|
||||||
|
renderRoot(
|
||||||
|
nw.Box(
|
||||||
|
direction: nw.Direction.Vertical,
|
||||||
|
children: nw.seq(
|
||||||
|
nw.Box(
|
||||||
|
direction: nw.Direction.Horizontal, children: nw.seq(chatPanel, peersPanel)
|
||||||
|
),
|
||||||
|
systemPanel,
|
||||||
|
InputPanel(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
# render
|
||||||
|
iw.display(ctx.tb, prevTb)
|
||||||
|
prevTb = ctx.tb
|
||||||
|
ctx.tb = iw.initTerminalBuffer(terminal.terminalWidth(), terminal.terminalHeight())
|
||||||
|
if iw.width(prevTb) != iw.width(ctx.tb) or iw.height(prevTb) != iw.height(ctx.tb):
|
||||||
|
resizePanels(
|
||||||
|
chatPanel, peersPanel, systemPanel, iw.width(ctx.tb), iw.height(ctx.tb)
|
||||||
|
)
|
||||||
|
|
||||||
|
await sleepAsync(5.milliseconds)
|
||||||
112
nim-peer/src/ui/scrollingtextbox.nim
Normal file
112
nim-peer/src/ui/scrollingtextbox.nim
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import unicode
|
||||||
|
from nimwave as nw import nil
|
||||||
|
|
||||||
|
import ./context
|
||||||
|
|
||||||
|
type ScrollingTextBox* = ref object of nw.Node
|
||||||
|
title*: string
|
||||||
|
text*: seq[string]
|
||||||
|
width*: int
|
||||||
|
height*: int
|
||||||
|
startingLine: int
|
||||||
|
border*: nw.Border
|
||||||
|
|
||||||
|
proc new*(
|
||||||
|
T: typedesc[ScrollingTextBox],
|
||||||
|
title: string = "",
|
||||||
|
width: int = 3,
|
||||||
|
height: int = 3,
|
||||||
|
text: seq[string] = @[],
|
||||||
|
): T =
|
||||||
|
# width and height cannot be less than 3 (2 for borders + 1 for content)
|
||||||
|
let height = max(height, 3)
|
||||||
|
let width = max(width, 3)
|
||||||
|
# height and width - 2 to account for size of box lines (top and botton)
|
||||||
|
ScrollingTextBox(
|
||||||
|
title: title,
|
||||||
|
width: width - 2,
|
||||||
|
height: height - 2,
|
||||||
|
text: text,
|
||||||
|
startingLine: 0,
|
||||||
|
border: nw.Border.Single,
|
||||||
|
)
|
||||||
|
|
||||||
|
proc resize*(node: ScrollingTextBox, width: int, height: int) =
|
||||||
|
let height = max(height, 3)
|
||||||
|
let width = max(width, 3)
|
||||||
|
node.width = width - 2
|
||||||
|
node.height = height - 2
|
||||||
|
|
||||||
|
proc formatText(node: ScrollingTextBox): seq[string] =
|
||||||
|
result = @[]
|
||||||
|
result.add(node.title.alignLeft(node.width))
|
||||||
|
# empty line after title
|
||||||
|
result.add(" ".alignLeft(node.width))
|
||||||
|
for i in node.startingLine ..< max(node.startingLine + node.height - 2, 0):
|
||||||
|
if i < node.text.len:
|
||||||
|
result.add(node.text[i].alignLeft(node.width))
|
||||||
|
else:
|
||||||
|
result.add(" ".alignLeft(node.width))
|
||||||
|
|
||||||
|
proc scrollUp*(node: ScrollingTextBox, speed: int) =
|
||||||
|
node.startingLine = max(node.startingLine - speed, 0)
|
||||||
|
|
||||||
|
proc scrollDown*(node: ScrollingTextBox, speed: int) =
|
||||||
|
let lastStartingLine = max(0, node.text.len - node.height + 2)
|
||||||
|
node.startingLine = min(node.startingLine + speed, lastStartingLine)
|
||||||
|
|
||||||
|
proc tail(node: ScrollingTextBox) =
|
||||||
|
## focuses window in lowest frame
|
||||||
|
node.startingLine = max(0, node.text.len - node.height + 2)
|
||||||
|
|
||||||
|
proc isAnsiEscapeSequence(s: string, idx: int): bool =
|
||||||
|
## Check if the substring starting at `idx` is an ANSI escape sequence
|
||||||
|
if idx < 0 or idx + 2 >= s.len: # Need at least 3 characters for "\e["
|
||||||
|
return false
|
||||||
|
if s[idx] == '\e' and s[idx + 1] == '[': # Must start with "\e["
|
||||||
|
var i = idx + 2
|
||||||
|
while i < s.len and (s[i] in '0' .. '9' or s[i] == ';' or s[i] == 'm'):
|
||||||
|
i.inc
|
||||||
|
return s[i - 1] == 'm' # Ends with 'm'
|
||||||
|
return false
|
||||||
|
|
||||||
|
proc chunkString(s: string, chunkSize: int): seq[string] =
|
||||||
|
var result: seq[string] = @[]
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while i < s.len:
|
||||||
|
var endIdx = min(i + chunkSize - 1, s.len - 1)
|
||||||
|
|
||||||
|
# Avoid splitting escape sequences
|
||||||
|
while endIdx > i and isAnsiEscapeSequence(s, endIdx):
|
||||||
|
dec endIdx
|
||||||
|
|
||||||
|
result.add(s[i .. endIdx])
|
||||||
|
i = endIdx + 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
proc push*(node: ScrollingTextBox, newLine: string) =
|
||||||
|
if newLine.len == 0 or node.width <= 0:
|
||||||
|
return
|
||||||
|
for chunk in chunkString(newLine, node.width):
|
||||||
|
node.text.add(chunk)
|
||||||
|
node.tail()
|
||||||
|
|
||||||
|
proc remove*(node: ScrollingTextBox, lineToRemove: string) =
|
||||||
|
let idx = node.text.find(lineToRemove)
|
||||||
|
if idx >= 0:
|
||||||
|
node.text.delete(idx)
|
||||||
|
if idx <= node.startingLine:
|
||||||
|
node.scrollUp(1)
|
||||||
|
|
||||||
|
method render(node: ScrollingTextBox, ctx: var nw.Context[State]) =
|
||||||
|
ctx = nw.slice(ctx, 0, 0, node.width + 2, node.height + 2)
|
||||||
|
render(
|
||||||
|
nw.Box(
|
||||||
|
border: node.border,
|
||||||
|
direction: nw.Direction.Vertical,
|
||||||
|
children: nw.seq(node.formatText()),
|
||||||
|
),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
47
nim-peer/src/utils.nim
Normal file
47
nim-peer/src/utils.nim
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import strutils
|
||||||
|
|
||||||
|
import libp2p
|
||||||
|
|
||||||
|
const
|
||||||
|
ChatTopic*: string = "universal-connectivity"
|
||||||
|
ChatFileTopic*: string = "universal-connectivity-file"
|
||||||
|
PeerDiscoveryTopic*: string = "universal-connectivity-browser-peer-discovery"
|
||||||
|
|
||||||
|
const SanitizationRules = [
|
||||||
|
({'\0' .. '\31'}, ' '), # Control chars -> space
|
||||||
|
({'"'}, '\''), # Double quote -> single quote
|
||||||
|
({'/', '\\', ':', '|'}, '-'), # Slash, backslash, colon, pipe -> dash
|
||||||
|
({'*', '?', '<', '>'}, '_'), # Asterisk, question, angle brackets -> underscore
|
||||||
|
]
|
||||||
|
|
||||||
|
proc shortPeerId*(peerId: PeerId): string {.raises: [ValueError].} =
|
||||||
|
let strPeerId = $peerId
|
||||||
|
if strPeerId.len < 7:
|
||||||
|
raise newException(ValueError, "PeerId too short")
|
||||||
|
strPeerId[^7 ..^ 1]
|
||||||
|
|
||||||
|
proc sanitizeFileId*(fileId: string): string =
|
||||||
|
## Sanitize a filename for Windows, macOS, and Linux
|
||||||
|
result = fileId
|
||||||
|
for (chars, replacement) in SanitizationRules:
|
||||||
|
for ch in chars:
|
||||||
|
result = result.multiReplace(($ch, $replacement))
|
||||||
|
result = result.strip()
|
||||||
|
# Avoid reserved Windows filenames (CON, PRN, AUX, NUL, COM1..COM9, LPT1..LPT9)
|
||||||
|
var reserved = @["CON", "PRN", "AUX", "NUL"]
|
||||||
|
for i in 1 .. 9:
|
||||||
|
reserved.add("COM" & $i)
|
||||||
|
reserved.add("LPT" & $i)
|
||||||
|
if result.toUpperAscii() in reserved:
|
||||||
|
result = "_" & result
|
||||||
|
# Avoid empty filenames
|
||||||
|
if result.len == 0:
|
||||||
|
result = "_"
|
||||||
|
|
||||||
|
proc seqnoToUint64*(bytes: seq[byte]): uint64 =
|
||||||
|
if bytes.len != 8:
|
||||||
|
return 0
|
||||||
|
var seqno: uint64 = 0
|
||||||
|
for i in 0 ..< 8:
|
||||||
|
seqno = seqno or (uint64(bytes[i]) shl (8 * (7 - i)))
|
||||||
|
seqno
|
||||||
Reference in New Issue
Block a user