1050 lines
31 KiB
C++
1050 lines
31 KiB
C++
/*
|
|
* main.cpp
|
|
* IRC client for MontaukOS
|
|
* Split-screen terminal UI with ANSI escape codes
|
|
* Copyright (c) 2025-2026 Daniel Hammer
|
|
*/
|
|
|
|
#include <montauk/syscall.h>
|
|
#include <montauk/string.h>
|
|
|
|
using montauk::slen;
|
|
using montauk::streq;
|
|
using montauk::starts_with;
|
|
using montauk::skip_spaces;
|
|
using montauk::strcpy;
|
|
using montauk::strncpy;
|
|
using montauk::memcpy;
|
|
using montauk::memmove;
|
|
|
|
// ---- Minimal snprintf (no libc available) ----
|
|
|
|
using va_list = __builtin_va_list;
|
|
#define va_start __builtin_va_start
|
|
#define va_end __builtin_va_end
|
|
#define va_arg __builtin_va_arg
|
|
|
|
struct PfState {
|
|
char* buf;
|
|
int pos;
|
|
int max;
|
|
};
|
|
|
|
static void pf_putc(PfState* st, char c) {
|
|
if (st->pos < st->max) st->buf[st->pos] = c;
|
|
st->pos++;
|
|
}
|
|
|
|
static void pf_putnum(PfState* st, unsigned long val, int base, int width, char pad, int neg) {
|
|
char tmp[24];
|
|
int i = 0;
|
|
const char* digits = "0123456789abcdef";
|
|
if (val == 0) { tmp[i++] = '0'; }
|
|
else { while (val > 0) { tmp[i++] = digits[val % base]; val /= base; } }
|
|
int total = (neg ? 1 : 0) + i;
|
|
if (neg && pad == '0') pf_putc(st, '-');
|
|
for (int w = total; w < width; w++) pf_putc(st, pad);
|
|
if (neg && pad != '0') pf_putc(st, '-');
|
|
while (i > 0) pf_putc(st, tmp[--i]);
|
|
}
|
|
|
|
static int vsnprintf(char* buf, int size, const char* fmt, va_list ap) {
|
|
PfState st;
|
|
st.buf = buf;
|
|
st.pos = 0;
|
|
st.max = size > 0 ? size - 1 : 0;
|
|
while (*fmt) {
|
|
if (*fmt != '%') { pf_putc(&st, *fmt++); continue; }
|
|
fmt++;
|
|
char pad = ' ';
|
|
if (*fmt == '0') { pad = '0'; fmt++; }
|
|
int width = 0;
|
|
while (*fmt >= '0' && *fmt <= '9') { width = width * 10 + (*fmt - '0'); fmt++; }
|
|
if (*fmt == 'l') fmt++;
|
|
switch (*fmt) {
|
|
case 'd': case 'i': {
|
|
long val = va_arg(ap, int);
|
|
int neg = 0; unsigned long uval;
|
|
if (val < 0) { neg = 1; uval = (unsigned long)(-val); }
|
|
else uval = (unsigned long)val;
|
|
pf_putnum(&st, uval, 10, width, pad, neg);
|
|
break;
|
|
}
|
|
case 'u': { unsigned val = va_arg(ap, unsigned); pf_putnum(&st, val, 10, width, pad, 0); break; }
|
|
case 'x': { unsigned val = va_arg(ap, unsigned); pf_putnum(&st, val, 16, width, pad, 0); break; }
|
|
case 's': {
|
|
const char* s = va_arg(ap, const char*);
|
|
if (!s) s = "(null)";
|
|
int slen = 0; while (s[slen]) slen++;
|
|
for (int w = slen; w < width; w++) pf_putc(&st, ' ');
|
|
for (int j = 0; j < slen; j++) pf_putc(&st, s[j]);
|
|
break;
|
|
}
|
|
case 'c': { char c = (char)va_arg(ap, int); pf_putc(&st, c); break; }
|
|
case '%': pf_putc(&st, '%'); break;
|
|
default: pf_putc(&st, '%'); pf_putc(&st, *fmt); break;
|
|
}
|
|
if (*fmt) fmt++;
|
|
}
|
|
if (size > 0) {
|
|
if (st.pos < size) st.buf[st.pos] = '\0';
|
|
else st.buf[size - 1] = '\0';
|
|
}
|
|
return st.pos;
|
|
}
|
|
|
|
static int snprintf(char* buf, int size, const char* fmt, ...) {
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
int ret = vsnprintf(buf, size, fmt, ap);
|
|
va_end(ap);
|
|
return ret;
|
|
}
|
|
|
|
// Case-insensitive comparison for IRC commands
|
|
static bool streqi(const char* a, const char* b) {
|
|
while (*a && *b) {
|
|
char ca = *a, cb = *b;
|
|
if (ca >= 'A' && ca <= 'Z') ca += 32;
|
|
if (cb >= 'A' && cb <= 'Z') cb += 32;
|
|
if (ca != cb) return false;
|
|
a++; b++;
|
|
}
|
|
return *a == *b;
|
|
}
|
|
|
|
// ---- IP parsing (from shell) ----
|
|
|
|
static bool parse_ip(const char* s, uint32_t* out) {
|
|
uint32_t octets[4];
|
|
int idx = 0;
|
|
uint32_t val = 0;
|
|
bool hasDigit = false;
|
|
for (int i = 0; ; i++) {
|
|
char c = s[i];
|
|
if (c >= '0' && c <= '9') {
|
|
val = val * 10 + (c - '0');
|
|
if (val > 255) return false;
|
|
hasDigit = true;
|
|
} else if (c == '.' || c == '\0') {
|
|
if (!hasDigit || idx >= 4) return false;
|
|
octets[idx++] = val;
|
|
val = 0; hasDigit = false;
|
|
if (c == '\0') break;
|
|
} else return false;
|
|
}
|
|
if (idx != 4) return false;
|
|
*out = octets[0] | (octets[1] << 8) | (octets[2] << 16) | (octets[3] << 24);
|
|
return true;
|
|
}
|
|
|
|
static bool parse_uint16(const char* s, uint16_t* out) {
|
|
uint32_t val = 0;
|
|
if (*s == '\0') return false;
|
|
while (*s) {
|
|
if (*s < '0' || *s > '9') return false;
|
|
val = val * 10 + (*s - '0');
|
|
if (val > 65535) return false;
|
|
s++;
|
|
}
|
|
*out = (uint16_t)val;
|
|
return true;
|
|
}
|
|
|
|
// ---- Data structures ----
|
|
|
|
static constexpr int MAX_NICK_LEN = 32;
|
|
static constexpr int MAX_CHANNEL_LEN = 64;
|
|
static constexpr int MAX_LINE_LEN = 256;
|
|
static constexpr int MAX_DISPLAY_LINES = 512;
|
|
static constexpr int IRC_MAX_MSG = 512;
|
|
|
|
struct IrcState {
|
|
int fd;
|
|
uint32_t serverIp;
|
|
uint16_t serverPort;
|
|
char nick[MAX_NICK_LEN];
|
|
char channel[MAX_CHANNEL_LEN];
|
|
bool registered;
|
|
bool connected;
|
|
bool inChannel;
|
|
int nickRetries;
|
|
};
|
|
|
|
struct RecvBuffer {
|
|
char buf[2048];
|
|
int len;
|
|
};
|
|
|
|
struct DisplayLine {
|
|
char text[MAX_LINE_LEN];
|
|
int len;
|
|
};
|
|
|
|
struct MessageBuffer {
|
|
DisplayLine lines[MAX_DISPLAY_LINES];
|
|
int head;
|
|
int count;
|
|
int scrollOffset;
|
|
};
|
|
|
|
struct InputState {
|
|
char buf[512];
|
|
int pos;
|
|
int len;
|
|
};
|
|
|
|
struct TermState {
|
|
int cols;
|
|
int rows;
|
|
int msgAreaRows;
|
|
};
|
|
|
|
// ---- Terminal helpers ----
|
|
|
|
static int int_to_buf(char* buf, int n) {
|
|
if (n == 0) { buf[0] = '0'; return 1; }
|
|
char tmp[12]; int i = 0;
|
|
while (n > 0) { tmp[i++] = '0' + (n % 10); n /= 10; }
|
|
for (int j = 0; j < i; j++) buf[j] = tmp[i - 1 - j];
|
|
return i;
|
|
}
|
|
|
|
// ---- Screen buffer for flicker-free rendering ----
|
|
|
|
static constexpr int SCREEN_BUF_SIZE = 32768;
|
|
|
|
struct ScreenBuf {
|
|
char buf[SCREEN_BUF_SIZE];
|
|
int pos;
|
|
};
|
|
|
|
static ScreenBuf screen;
|
|
|
|
static void sb_reset() { screen.pos = 0; }
|
|
|
|
static void sb_putc(char c) {
|
|
if (screen.pos < SCREEN_BUF_SIZE - 1) screen.buf[screen.pos++] = c;
|
|
}
|
|
|
|
static void sb_puts(const char* s) {
|
|
while (*s && screen.pos < SCREEN_BUF_SIZE - 1) screen.buf[screen.pos++] = *s++;
|
|
}
|
|
|
|
static void sb_cursor_to(int row, int col) {
|
|
sb_puts("\033[");
|
|
char tmp[12]; int n = int_to_buf(tmp, row); for (int i = 0; i < n; i++) sb_putc(tmp[i]);
|
|
sb_putc(';');
|
|
n = int_to_buf(tmp, col); for (int i = 0; i < n; i++) sb_putc(tmp[i]);
|
|
sb_putc('H');
|
|
}
|
|
|
|
static void sb_flush() {
|
|
screen.buf[screen.pos] = '\0';
|
|
montauk::print(screen.buf);
|
|
}
|
|
|
|
// ---- Globals ----
|
|
|
|
static IrcState irc;
|
|
static RecvBuffer recvBuf;
|
|
static MessageBuffer msgBuf;
|
|
static InputState input;
|
|
static TermState term;
|
|
static bool running;
|
|
static bool dirty;
|
|
|
|
// ---- Message buffer ----
|
|
|
|
static void msg_add(const char* text) {
|
|
int idx = (msgBuf.head + msgBuf.count) % MAX_DISPLAY_LINES;
|
|
if (msgBuf.count >= MAX_DISPLAY_LINES) {
|
|
// Overwrite oldest
|
|
msgBuf.head = (msgBuf.head + 1) % MAX_DISPLAY_LINES;
|
|
} else {
|
|
msgBuf.count++;
|
|
}
|
|
DisplayLine& line = msgBuf.lines[idx];
|
|
int i = 0;
|
|
while (text[i] && i < MAX_LINE_LEN - 1) {
|
|
line.text[i] = text[i];
|
|
i++;
|
|
}
|
|
line.text[i] = '\0';
|
|
line.len = i;
|
|
|
|
// Auto-scroll to bottom when new message arrives
|
|
if (msgBuf.scrollOffset == 0 || msgBuf.count <= term.msgAreaRows) {
|
|
msgBuf.scrollOffset = 0;
|
|
}
|
|
dirty = true;
|
|
}
|
|
|
|
static void msg_add_fmt(const char* fmt, ...) {
|
|
char tmp[MAX_LINE_LEN];
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
vsnprintf(tmp, sizeof(tmp), fmt, ap);
|
|
va_end(ap);
|
|
msg_add(tmp);
|
|
}
|
|
|
|
static void msg_clear() {
|
|
msgBuf.head = 0;
|
|
msgBuf.count = 0;
|
|
msgBuf.scrollOffset = 0;
|
|
}
|
|
|
|
// ---- IRC send helpers ----
|
|
|
|
static void irc_send(const char* fmt, ...) {
|
|
char buf[IRC_MAX_MSG];
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
int len = vsnprintf(buf, sizeof(buf) - 2, fmt, ap);
|
|
va_end(ap);
|
|
// Ensure \r\n termination
|
|
if (len > IRC_MAX_MSG - 3) len = IRC_MAX_MSG - 3;
|
|
buf[len] = '\r';
|
|
buf[len + 1] = '\n';
|
|
buf[len + 2] = '\0';
|
|
montauk::send(irc.fd, buf, len + 2);
|
|
}
|
|
|
|
// ---- Sanitize incoming text (strip control chars) ----
|
|
|
|
static void sanitize(char* dst, const char* src, int maxLen) {
|
|
int j = 0;
|
|
for (int i = 0; src[i] && j < maxLen - 1; i++) {
|
|
unsigned char c = (unsigned char)src[i];
|
|
if (c == 0x1B) {
|
|
// Skip ESC sequences entirely
|
|
continue;
|
|
}
|
|
if (c < 0x20 && c != ' ') continue;
|
|
dst[j++] = (char)c;
|
|
}
|
|
dst[j] = '\0';
|
|
}
|
|
|
|
// ---- IRC prefix parsing ----
|
|
|
|
// Extract nick from ":nick!user@host" prefix
|
|
static void parse_prefix_nick(const char* prefix, char* nick, int maxLen) {
|
|
int i = 0;
|
|
if (prefix[0] == ':') prefix++;
|
|
while (prefix[i] && prefix[i] != '!' && prefix[i] != '@' && prefix[i] != ' ' && i < maxLen - 1) {
|
|
nick[i] = prefix[i];
|
|
i++;
|
|
}
|
|
nick[i] = '\0';
|
|
}
|
|
|
|
// ---- IRC protocol handlers ----
|
|
|
|
static void irc_handle_ping(const char* params) {
|
|
char buf[IRC_MAX_MSG];
|
|
snprintf(buf, sizeof(buf), "PONG %s", params);
|
|
irc_send("%s", buf);
|
|
}
|
|
|
|
static void irc_handle_privmsg(const char* prefix, const char* params) {
|
|
char senderNick[MAX_NICK_LEN];
|
|
parse_prefix_nick(prefix, senderNick, sizeof(senderNick));
|
|
|
|
// Find the message text after " :"
|
|
const char* text = params;
|
|
// Skip target
|
|
while (*text && *text != ' ') text++;
|
|
text = skip_spaces(text);
|
|
if (*text == ':') text++;
|
|
|
|
char clean[MAX_LINE_LEN];
|
|
sanitize(clean, text, sizeof(clean));
|
|
|
|
// Check for CTCP ACTION (\001ACTION ... \001)
|
|
if (starts_with(clean, "\001ACTION ")) {
|
|
const char* action = clean + 8;
|
|
// Strip trailing \001
|
|
char actionBuf[MAX_LINE_LEN];
|
|
strncpy(actionBuf, action, sizeof(actionBuf));
|
|
int alen = slen(actionBuf);
|
|
if (alen > 0 && actionBuf[alen - 1] == '\001') actionBuf[alen - 1] = '\0';
|
|
msg_add_fmt("\033[35m* %s %s\033[0m", senderNick, actionBuf);
|
|
return;
|
|
}
|
|
|
|
// Color own nick green, others cyan
|
|
if (streq(senderNick, irc.nick)) {
|
|
msg_add_fmt("\033[1;32m<%s>\033[0m %s", senderNick, clean);
|
|
} else {
|
|
msg_add_fmt("\033[1;36m<%s>\033[0m %s", senderNick, clean);
|
|
}
|
|
}
|
|
|
|
static void irc_handle_notice(const char* prefix, const char* params) {
|
|
char senderNick[MAX_NICK_LEN];
|
|
if (prefix[0]) {
|
|
parse_prefix_nick(prefix, senderNick, sizeof(senderNick));
|
|
} else {
|
|
strcpy(senderNick, "*");
|
|
}
|
|
|
|
const char* text = params;
|
|
while (*text && *text != ' ') text++;
|
|
text = skip_spaces(text);
|
|
if (*text == ':') text++;
|
|
|
|
char clean[MAX_LINE_LEN];
|
|
sanitize(clean, text, sizeof(clean));
|
|
msg_add_fmt("\033[1m-%s-\033[0m %s", senderNick, clean);
|
|
}
|
|
|
|
static void irc_handle_join(const char* prefix, const char* params) {
|
|
char nick[MAX_NICK_LEN];
|
|
parse_prefix_nick(prefix, nick, sizeof(nick));
|
|
|
|
const char* chan = params;
|
|
if (*chan == ':') chan++;
|
|
|
|
if (streq(nick, irc.nick)) {
|
|
strncpy(irc.channel, chan, sizeof(irc.channel));
|
|
irc.inChannel = true;
|
|
msg_add_fmt("\033[33m* Now talking in %s\033[0m", chan);
|
|
} else {
|
|
msg_add_fmt("\033[33m* %s has joined %s\033[0m", nick, chan);
|
|
}
|
|
}
|
|
|
|
static void irc_handle_part(const char* prefix, const char* params) {
|
|
char nick[MAX_NICK_LEN];
|
|
parse_prefix_nick(prefix, nick, sizeof(nick));
|
|
|
|
const char* chan = params;
|
|
while (*chan && *chan != ' ') chan++; // get channel text
|
|
// Actually parse channel from params start
|
|
chan = params;
|
|
char chanBuf[MAX_CHANNEL_LEN];
|
|
int i = 0;
|
|
while (chan[i] && chan[i] != ' ' && i < MAX_CHANNEL_LEN - 1) {
|
|
chanBuf[i] = chan[i]; i++;
|
|
}
|
|
chanBuf[i] = '\0';
|
|
|
|
if (streq(nick, irc.nick)) {
|
|
irc.inChannel = false;
|
|
irc.channel[0] = '\0';
|
|
msg_add_fmt("\033[33m* You have left %s\033[0m", chanBuf);
|
|
} else {
|
|
msg_add_fmt("\033[33m* %s has left %s\033[0m", nick, chanBuf);
|
|
}
|
|
}
|
|
|
|
static void irc_handle_quit(const char* prefix, const char* params) {
|
|
char nick[MAX_NICK_LEN];
|
|
parse_prefix_nick(prefix, nick, sizeof(nick));
|
|
|
|
const char* reason = params;
|
|
if (*reason == ':') reason++;
|
|
|
|
char clean[MAX_LINE_LEN];
|
|
sanitize(clean, reason, sizeof(clean));
|
|
if (clean[0]) {
|
|
msg_add_fmt("\033[33m* %s has quit (%s)\033[0m", nick, clean);
|
|
} else {
|
|
msg_add_fmt("\033[33m* %s has quit\033[0m", nick);
|
|
}
|
|
}
|
|
|
|
static void irc_handle_nick(const char* prefix, const char* params) {
|
|
char oldNick[MAX_NICK_LEN];
|
|
parse_prefix_nick(prefix, oldNick, sizeof(oldNick));
|
|
|
|
const char* newNick = params;
|
|
if (*newNick == ':') newNick++;
|
|
|
|
if (streq(oldNick, irc.nick)) {
|
|
strncpy(irc.nick, newNick, sizeof(irc.nick));
|
|
msg_add_fmt("\033[33m* You are now known as %s\033[0m", newNick);
|
|
} else {
|
|
msg_add_fmt("\033[33m* %s is now known as %s\033[0m", oldNick, newNick);
|
|
}
|
|
}
|
|
|
|
static void irc_handle_numeric(int num, const char* params) {
|
|
// Extract trailing text (after the last " :")
|
|
const char* text = params;
|
|
const char* lastColon = nullptr;
|
|
for (int i = 0; text[i]; i++) {
|
|
if (text[i] == ':' && (i == 0 || text[i - 1] == ' ')) {
|
|
lastColon = text + i + 1;
|
|
}
|
|
}
|
|
|
|
switch (num) {
|
|
case 1: // RPL_WELCOME
|
|
irc.registered = true;
|
|
if (lastColon) {
|
|
char clean[MAX_LINE_LEN];
|
|
sanitize(clean, lastColon, sizeof(clean));
|
|
msg_add_fmt("\033[1m*** %s\033[0m", clean);
|
|
}
|
|
// Auto-join channel if specified
|
|
if (irc.channel[0]) {
|
|
irc_send("JOIN %s", irc.channel);
|
|
}
|
|
break;
|
|
|
|
case 433: // ERR_NICKNAMEINUSE
|
|
if (irc.nickRetries < 3) {
|
|
int nlen = slen(irc.nick);
|
|
if (nlen < MAX_NICK_LEN - 2) {
|
|
irc.nick[nlen] = '_';
|
|
irc.nick[nlen + 1] = '\0';
|
|
}
|
|
irc.nickRetries++;
|
|
irc_send("NICK %s", irc.nick);
|
|
msg_add_fmt("\033[33m* Nick in use, trying %s\033[0m", irc.nick);
|
|
} else {
|
|
msg_add("\033[31m*** Could not find available nickname\033[0m");
|
|
}
|
|
break;
|
|
|
|
case 332: // RPL_TOPIC
|
|
case 353: // RPL_NAMREPLY
|
|
case 366: // RPL_ENDOFNAMES
|
|
case 372: // RPL_MOTD
|
|
case 375: // RPL_MOTDSTART
|
|
case 376: // RPL_ENDOFMOTD
|
|
default:
|
|
if (lastColon) {
|
|
char clean[MAX_LINE_LEN];
|
|
sanitize(clean, lastColon, sizeof(clean));
|
|
msg_add_fmt("\033[1m*** %s\033[0m", clean);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---- IRC line parser ----
|
|
|
|
static void irc_process_line(char* line) {
|
|
// Strip trailing \r\n
|
|
int len = slen(line);
|
|
while (len > 0 && (line[len - 1] == '\r' || line[len - 1] == '\n')) {
|
|
line[--len] = '\0';
|
|
}
|
|
if (len == 0) return;
|
|
|
|
char prefix[256] = "";
|
|
char* cmd = line;
|
|
|
|
// Parse optional prefix
|
|
if (line[0] == ':') {
|
|
cmd = line + 1;
|
|
int i = 0;
|
|
while (cmd[i] && cmd[i] != ' ') i++;
|
|
if (i > 0) {
|
|
memcpy(prefix, cmd, i < 255 ? i : 255);
|
|
prefix[i < 255 ? i : 255] = '\0';
|
|
}
|
|
cmd += i;
|
|
while (*cmd == ' ') cmd++;
|
|
}
|
|
|
|
// Extract command word
|
|
char command[32] = "";
|
|
{
|
|
int i = 0;
|
|
while (cmd[i] && cmd[i] != ' ' && i < 31) {
|
|
command[i] = cmd[i]; i++;
|
|
}
|
|
command[i] = '\0';
|
|
cmd += i;
|
|
while (*cmd == ' ') cmd++;
|
|
}
|
|
|
|
// cmd now points to params
|
|
|
|
// Handle PING first (highest priority)
|
|
if (streqi(command, "PING")) {
|
|
irc_handle_ping(cmd);
|
|
return;
|
|
}
|
|
|
|
// Check for numeric command
|
|
bool isNumeric = true;
|
|
for (int i = 0; command[i]; i++) {
|
|
if (command[i] < '0' || command[i] > '9') { isNumeric = false; break; }
|
|
}
|
|
|
|
if (isNumeric && command[0]) {
|
|
int num = 0;
|
|
for (int i = 0; command[i]; i++) num = num * 10 + (command[i] - '0');
|
|
irc_handle_numeric(num, cmd);
|
|
return;
|
|
}
|
|
|
|
if (streqi(command, "PRIVMSG")) irc_handle_privmsg(prefix, cmd);
|
|
else if (streqi(command, "NOTICE")) irc_handle_notice(prefix, cmd);
|
|
else if (streqi(command, "JOIN")) irc_handle_join(prefix, cmd);
|
|
else if (streqi(command, "PART")) irc_handle_part(prefix, cmd);
|
|
else if (streqi(command, "QUIT")) irc_handle_quit(prefix, cmd);
|
|
else if (streqi(command, "NICK")) irc_handle_nick(prefix, cmd);
|
|
else if (streqi(command, "PONG")) { /* Ignore PONG replies */ }
|
|
else if (streqi(command, "ERROR")) {
|
|
char clean[MAX_LINE_LEN];
|
|
sanitize(clean, cmd, sizeof(clean));
|
|
msg_add_fmt("\033[31m*** Error: %s\033[0m", clean);
|
|
}
|
|
}
|
|
|
|
// ---- TCP recv with fragment assembly ----
|
|
|
|
static void recv_process() {
|
|
char tmp[512];
|
|
int r = montauk::recv(irc.fd, tmp, sizeof(tmp));
|
|
if (r < 0) {
|
|
irc.connected = false;
|
|
msg_add("\033[31m*** Connection lost\033[0m");
|
|
return;
|
|
}
|
|
if (r == 0) return;
|
|
|
|
// Append to recv buffer
|
|
int space = (int)sizeof(recvBuf.buf) - recvBuf.len;
|
|
if (r > space) r = space;
|
|
memcpy(recvBuf.buf + recvBuf.len, tmp, r);
|
|
recvBuf.len += r;
|
|
|
|
// Scan for complete lines (\r\n)
|
|
int start = 0;
|
|
for (int i = 0; i < recvBuf.len - 1; i++) {
|
|
if (recvBuf.buf[i] == '\r' && recvBuf.buf[i + 1] == '\n') {
|
|
// Extract line [start..i)
|
|
int lineLen = i - start;
|
|
char lineStr[IRC_MAX_MSG];
|
|
if (lineLen > IRC_MAX_MSG - 1) lineLen = IRC_MAX_MSG - 1;
|
|
memcpy(lineStr, recvBuf.buf + start, lineLen);
|
|
lineStr[lineLen] = '\0';
|
|
irc_process_line(lineStr);
|
|
start = i + 2;
|
|
i++; // skip \n
|
|
}
|
|
}
|
|
|
|
// Move unprocessed remainder to front
|
|
if (start > 0) {
|
|
int remaining = recvBuf.len - start;
|
|
if (remaining > 0) {
|
|
memmove(recvBuf.buf, recvBuf.buf + start, remaining);
|
|
}
|
|
recvBuf.len = remaining;
|
|
}
|
|
|
|
// Prevent overflow if no \r\n found in a full buffer
|
|
if (recvBuf.len >= (int)sizeof(recvBuf.buf) - 1) {
|
|
recvBuf.len = 0;
|
|
}
|
|
}
|
|
|
|
// ---- UI rendering (buffered, single flush) ----
|
|
|
|
static void ui_render() {
|
|
sb_reset();
|
|
|
|
// Hide cursor during redraw
|
|
sb_puts("\033[?25l");
|
|
|
|
// Status bar (row 1)
|
|
sb_cursor_to(1, 1);
|
|
sb_puts("\033[7m\033[2K");
|
|
char status[256];
|
|
if (irc.connected) {
|
|
if (irc.inChannel) {
|
|
snprintf(status, sizeof(status), " IRC | %s | %s | Connected ", irc.nick, irc.channel);
|
|
} else {
|
|
snprintf(status, sizeof(status), " IRC | %s | (no channel) | Connected ", irc.nick);
|
|
}
|
|
} else {
|
|
snprintf(status, sizeof(status), " IRC | %s | Disconnected ", irc.nick);
|
|
}
|
|
sb_puts(status);
|
|
int statusLen = slen(status);
|
|
for (int i = statusLen; i < term.cols; i++) sb_putc(' ');
|
|
sb_puts("\033[0m");
|
|
|
|
// Message area (rows 2..N-2)
|
|
int startLine;
|
|
if (msgBuf.count <= term.msgAreaRows) {
|
|
startLine = 0;
|
|
} else {
|
|
startLine = msgBuf.count - term.msgAreaRows - msgBuf.scrollOffset;
|
|
if (startLine < 0) startLine = 0;
|
|
}
|
|
for (int r = 0; r < term.msgAreaRows; r++) {
|
|
sb_cursor_to(r + 2, 1);
|
|
sb_puts("\033[2K");
|
|
int msgIdx = startLine + r;
|
|
if (msgIdx < msgBuf.count) {
|
|
int realIdx = (msgBuf.head + msgIdx) % MAX_DISPLAY_LINES;
|
|
sb_puts(msgBuf.lines[realIdx].text);
|
|
}
|
|
}
|
|
|
|
// Separator (row N-1)
|
|
sb_cursor_to(term.rows - 1, 1);
|
|
sb_puts("\033[2K\033[90m");
|
|
for (int i = 0; i < term.cols; i++) sb_putc('-');
|
|
sb_puts("\033[0m");
|
|
|
|
// Input line (row N)
|
|
sb_cursor_to(term.rows, 1);
|
|
sb_puts("\033[2K\033[1m>\033[0m ");
|
|
for (int i = 0; i < input.len; i++) sb_putc(input.buf[i]);
|
|
|
|
// Position cursor at input insertion point
|
|
sb_cursor_to(term.rows, input.pos + 3);
|
|
|
|
// Single flush — entire screen in one print call
|
|
sb_flush();
|
|
}
|
|
|
|
// ---- User command processing ----
|
|
|
|
static void handle_user_input() {
|
|
input.buf[input.len] = '\0';
|
|
const char* text = input.buf;
|
|
|
|
if (text[0] != '/') {
|
|
// Plain message to current channel
|
|
if (!irc.inChannel) {
|
|
msg_add("\033[31m*** Not in a channel. Use /join #channel\033[0m");
|
|
return;
|
|
}
|
|
irc_send("PRIVMSG %s :%s", irc.channel, text);
|
|
// Echo own message
|
|
msg_add_fmt("\033[1;32m<%s>\033[0m %s", irc.nick, text);
|
|
return;
|
|
}
|
|
|
|
// Parse command
|
|
const char* cmd = text + 1;
|
|
|
|
if (starts_with(cmd, "join ") || starts_with(cmd, "JOIN ")) {
|
|
const char* chan = skip_spaces(cmd + 5);
|
|
if (*chan == '\0') {
|
|
msg_add("\033[31m*** Usage: /join #channel\033[0m");
|
|
return;
|
|
}
|
|
strncpy(irc.channel, chan, sizeof(irc.channel));
|
|
irc_send("JOIN %s", chan);
|
|
}
|
|
else if (starts_with(cmd, "part") && (cmd[4] == '\0' || cmd[4] == ' ')) {
|
|
if (!irc.inChannel) {
|
|
msg_add("\033[31m*** Not in a channel\033[0m");
|
|
return;
|
|
}
|
|
const char* reason = cmd[4] ? skip_spaces(cmd + 5) : "";
|
|
if (*reason) {
|
|
irc_send("PART %s :%s", irc.channel, reason);
|
|
} else {
|
|
irc_send("PART %s", irc.channel);
|
|
}
|
|
}
|
|
else if (starts_with(cmd, "msg ") || starts_with(cmd, "MSG ")) {
|
|
const char* rest = skip_spaces(cmd + 4);
|
|
char target[MAX_NICK_LEN];
|
|
int i = 0;
|
|
while (rest[i] && rest[i] != ' ' && i < MAX_NICK_LEN - 1) {
|
|
target[i] = rest[i]; i++;
|
|
}
|
|
target[i] = '\0';
|
|
const char* msg = skip_spaces(rest + i);
|
|
if (!target[0] || !*msg) {
|
|
msg_add("\033[31m*** Usage: /msg nick message\033[0m");
|
|
return;
|
|
}
|
|
irc_send("PRIVMSG %s :%s", target, msg);
|
|
msg_add_fmt("\033[1;35m-> %s:\033[0m %s", target, msg);
|
|
}
|
|
else if (starts_with(cmd, "nick ") || starts_with(cmd, "NICK ")) {
|
|
const char* newNick = skip_spaces(cmd + 5);
|
|
if (!*newNick) {
|
|
msg_add("\033[31m*** Usage: /nick newnick\033[0m");
|
|
return;
|
|
}
|
|
irc_send("NICK %s", newNick);
|
|
strncpy(irc.nick, newNick, sizeof(irc.nick));
|
|
}
|
|
else if (starts_with(cmd, "quit") && (cmd[4] == '\0' || cmd[4] == ' ')) {
|
|
const char* reason = cmd[4] ? skip_spaces(cmd + 5) : "";
|
|
if (*reason) {
|
|
irc_send("QUIT :%s", reason);
|
|
} else {
|
|
irc_send("QUIT :Leaving");
|
|
}
|
|
irc.connected = false;
|
|
running = false;
|
|
}
|
|
else if (starts_with(cmd, "me ") || starts_with(cmd, "ME ")) {
|
|
if (!irc.inChannel) {
|
|
msg_add("\033[31m*** Not in a channel\033[0m");
|
|
return;
|
|
}
|
|
const char* action = skip_spaces(cmd + 3);
|
|
irc_send("PRIVMSG %s :\001ACTION %s\001", irc.channel, action);
|
|
msg_add_fmt("\033[35m* %s %s\033[0m", irc.nick, action);
|
|
}
|
|
else if (starts_with(cmd, "raw ") || starts_with(cmd, "RAW ")) {
|
|
const char* raw = cmd + 4;
|
|
irc_send("%s", raw);
|
|
msg_add_fmt("\033[90m>> %s\033[0m", raw);
|
|
}
|
|
else if (streqi(cmd, "help") || starts_with(cmd, "help ")) {
|
|
msg_add("\033[1m--- Help ---\033[0m");
|
|
msg_add(" /join #channel - Join a channel");
|
|
msg_add(" /part [reason] - Leave current channel");
|
|
msg_add(" /msg nick text - Send private message");
|
|
msg_add(" /nick newnick - Change nickname");
|
|
msg_add(" /quit [reason] - Disconnect and exit");
|
|
msg_add(" /me action - Send action");
|
|
msg_add(" /raw text - Send raw IRC line");
|
|
msg_add(" /clear - Clear message area");
|
|
msg_add(" /help - Show this help");
|
|
msg_add(" Ctrl+Q - Quit");
|
|
msg_add(" PgUp/PgDn - Scroll messages");
|
|
}
|
|
else if (streqi(cmd, "clear")) {
|
|
msg_clear();
|
|
}
|
|
else {
|
|
char cmdWord[32];
|
|
int i = 0;
|
|
while (cmd[i] && cmd[i] != ' ' && i < 31) { cmdWord[i] = cmd[i]; i++; }
|
|
cmdWord[i] = '\0';
|
|
msg_add_fmt("\033[31m*** Unknown command: /%s (try /help)\033[0m", cmdWord);
|
|
}
|
|
}
|
|
|
|
// ---- Entry point ----
|
|
|
|
extern "C" void _start() {
|
|
// Parse arguments: <server_ip> <port> <nickname> [#channel]
|
|
char argbuf[256];
|
|
montauk::getargs(argbuf, sizeof(argbuf));
|
|
const char* arg = skip_spaces(argbuf);
|
|
|
|
if (*arg == '\0') {
|
|
montauk::print("Usage: irc <server> <port> <nickname> [#channel]\n");
|
|
montauk::print("Example: irc irc.libera.chat 6667 MontaukUser #general\n");
|
|
return;
|
|
}
|
|
|
|
// Parse host (IP or hostname)
|
|
char hostStr[128];
|
|
int i = 0;
|
|
while (arg[i] && arg[i] != ' ' && i < 127) { hostStr[i] = arg[i]; i++; }
|
|
hostStr[i] = '\0';
|
|
arg = skip_spaces(arg + i);
|
|
|
|
if (!parse_ip(hostStr, &irc.serverIp)) {
|
|
irc.serverIp = montauk::resolve(hostStr);
|
|
if (irc.serverIp == 0) {
|
|
montauk::print("Could not resolve: ");
|
|
montauk::print(hostStr);
|
|
montauk::putchar('\n');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Parse port
|
|
char portStr[16];
|
|
i = 0;
|
|
while (arg[i] && arg[i] != ' ' && i < 15) { portStr[i] = arg[i]; i++; }
|
|
portStr[i] = '\0';
|
|
arg = skip_spaces(arg + i);
|
|
|
|
if (!parse_uint16(portStr, &irc.serverPort)) {
|
|
montauk::print("Invalid port: ");
|
|
montauk::print(portStr);
|
|
montauk::putchar('\n');
|
|
return;
|
|
}
|
|
|
|
// Parse nickname
|
|
i = 0;
|
|
while (arg[i] && arg[i] != ' ' && i < MAX_NICK_LEN - 1) { irc.nick[i] = arg[i]; i++; }
|
|
irc.nick[i] = '\0';
|
|
arg = skip_spaces(arg + i);
|
|
|
|
if (!irc.nick[0]) {
|
|
montauk::print("Missing nickname\n");
|
|
return;
|
|
}
|
|
|
|
// Parse optional channel
|
|
i = 0;
|
|
while (arg[i] && arg[i] != ' ' && i < MAX_CHANNEL_LEN - 1) { irc.channel[i] = arg[i]; i++; }
|
|
irc.channel[i] = '\0';
|
|
|
|
// Initialize state
|
|
irc.fd = -1;
|
|
irc.registered = false;
|
|
irc.connected = false;
|
|
irc.inChannel = false;
|
|
irc.nickRetries = 0;
|
|
recvBuf.len = 0;
|
|
msgBuf.head = 0;
|
|
msgBuf.count = 0;
|
|
msgBuf.scrollOffset = 0;
|
|
input.pos = 0;
|
|
input.len = 0;
|
|
running = true;
|
|
|
|
// Get terminal size
|
|
montauk::termsize(&term.cols, &term.rows);
|
|
term.msgAreaRows = term.rows - 3;
|
|
if (term.msgAreaRows < 1) term.msgAreaRows = 1;
|
|
|
|
// Enter alternate screen buffer, hide cursor
|
|
montauk::print("\033[?1049h");
|
|
montauk::print("\033[?25l");
|
|
|
|
// Initial draw
|
|
msg_add("\033[1m*** MontaukOS IRC Client\033[0m");
|
|
msg_add_fmt("*** Connecting to %s:%d as %s...", hostStr, (int)irc.serverPort, irc.nick);
|
|
ui_render();
|
|
|
|
// Create socket and connect
|
|
irc.fd = montauk::socket(montauk::abi::SOCK_TCP);
|
|
if (irc.fd < 0) {
|
|
msg_add("\033[31m*** Failed to create socket\033[0m");
|
|
ui_render();
|
|
// Wait for keypress then exit
|
|
while (!montauk::is_key_available()) montauk::yield();
|
|
goto cleanup;
|
|
}
|
|
|
|
if (montauk::connect(irc.fd, irc.serverIp, irc.serverPort) < 0) {
|
|
msg_add("\033[31m*** Connection failed\033[0m");
|
|
ui_render();
|
|
montauk::closesocket(irc.fd);
|
|
irc.fd = -1;
|
|
while (!montauk::is_key_available()) montauk::yield();
|
|
goto cleanup;
|
|
}
|
|
|
|
irc.connected = true;
|
|
msg_add("\033[32m*** Connected!\033[0m");
|
|
|
|
// Send IRC registration
|
|
irc_send("NICK %s", irc.nick);
|
|
irc_send("USER %s 0 * :%s", irc.nick, irc.nick);
|
|
|
|
ui_render();
|
|
|
|
// ---- Main loop ----
|
|
while (running && irc.connected) {
|
|
dirty = false;
|
|
|
|
// Process incoming data
|
|
int prevCount = msgBuf.count;
|
|
recv_process();
|
|
if (msgBuf.count != prevCount) dirty = true;
|
|
|
|
// Poll keyboard
|
|
if (montauk::is_key_available()) {
|
|
montauk::abi::KeyEvent ev;
|
|
montauk::getkey(&ev);
|
|
|
|
if (!ev.pressed) {
|
|
if (dirty) ui_render();
|
|
continue;
|
|
}
|
|
|
|
dirty = true;
|
|
|
|
// Ctrl+Q to quit
|
|
if (ev.ctrl && (ev.ascii == 'q' || ev.ascii == 'Q')) {
|
|
if (irc.connected) {
|
|
irc_send("QUIT :Leaving");
|
|
}
|
|
running = false;
|
|
continue;
|
|
}
|
|
|
|
// Handle special keys by scancode
|
|
switch (ev.scancode) {
|
|
case 0x49: // Page Up
|
|
if (msgBuf.scrollOffset < msgBuf.count - term.msgAreaRows) {
|
|
msgBuf.scrollOffset += term.msgAreaRows / 2;
|
|
int maxScroll = msgBuf.count - term.msgAreaRows;
|
|
if (maxScroll < 0) maxScroll = 0;
|
|
if (msgBuf.scrollOffset > maxScroll) msgBuf.scrollOffset = maxScroll;
|
|
}
|
|
break;
|
|
case 0x51: // Page Down
|
|
msgBuf.scrollOffset -= term.msgAreaRows / 2;
|
|
if (msgBuf.scrollOffset < 0) msgBuf.scrollOffset = 0;
|
|
break;
|
|
case 0x47: // Home (scroll to top)
|
|
if (msgBuf.count > term.msgAreaRows) {
|
|
msgBuf.scrollOffset = msgBuf.count - term.msgAreaRows;
|
|
}
|
|
break;
|
|
case 0x4F: // End (scroll to bottom)
|
|
msgBuf.scrollOffset = 0;
|
|
break;
|
|
default:
|
|
if (ev.ascii == '\n') {
|
|
if (input.len > 0) {
|
|
input.buf[input.len] = '\0';
|
|
handle_user_input();
|
|
input.pos = 0;
|
|
input.len = 0;
|
|
}
|
|
} else if (ev.ascii == '\b') {
|
|
if (input.pos > 0) {
|
|
for (int j = input.pos - 1; j < input.len - 1; j++) {
|
|
input.buf[j] = input.buf[j + 1];
|
|
}
|
|
input.pos--;
|
|
input.len--;
|
|
}
|
|
} else if (ev.ascii >= ' ' && ev.ascii <= '~') {
|
|
if (input.len < 510) {
|
|
for (int j = input.len; j > input.pos; j--) {
|
|
input.buf[j] = input.buf[j - 1];
|
|
}
|
|
input.buf[input.pos] = ev.ascii;
|
|
input.pos++;
|
|
input.len++;
|
|
}
|
|
} else {
|
|
dirty = false; // Unhandled key, no redraw needed
|
|
}
|
|
break;
|
|
}
|
|
} else {
|
|
if (!dirty) {
|
|
montauk::wait_handle(irc.fd,
|
|
montauk::abi::IPC_SIGNAL_READABLE | montauk::abi::IPC_SIGNAL_PEER_CLOSED,
|
|
10);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (dirty) ui_render();
|
|
}
|
|
|
|
if (irc.fd >= 0) {
|
|
montauk::closesocket(irc.fd);
|
|
}
|
|
|
|
cleanup:
|
|
// Restore terminal
|
|
montauk::print("\033[?25h");
|
|
montauk::print("\033[?1049l");
|
|
}
|