/* * TermTCP CLI - Terminal TCP Client * * Layout: * [status bar ] * [output window - session data, scrolls] * [--- Monitor ---------------------------] <- toggleable * [monitor window - BPQ RF frames, scrolls] * [╔═ Input ══════════════════════════════╗] * [║ > text wraps here ║] * [╚══════════════════════════════════════╝] * * BPQ protocol: 0xFF = monitor frame start, 0xFE = end. * Auto-login: sends username\rpassword\rBPQTermTCP\r on connect. * Menu: Ctrl+A */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifndef PATH_MAX #define PATH_MAX 4096 #endif #define VERSION "0.0.60" #define MAX_HOSTS 4 #define INPUT_MAX 512 #define CONF_FILE ".termtcprc" #define INPUT_INNER 2 /* content rows inside input box */ #define INPUT_OUTER (INPUT_INNER + 2) /* total rows incl. border */ #define SCRL_MAX 500 /* scrollback ring buffer depth */ #define SCRL_W 512 /* max bytes per stored line */ #define HIST_MAX 100 /* input history ring depth */ /* ── Color pairs ─────────────────────────────────────────── */ #define CP_DEFAULT 1 #define CP_RED 2 #define CP_GREEN 3 #define CP_YELLOW 4 #define CP_CYAN 5 #define CP_MAGENTA 6 #define CP_STATUS 7 /* white on blue - status bar */ #define CP_INPUT 8 /* black on white - input area */ #define CP_SEP 9 /* cyan on black - separators */ #define CP_FKEY 10 /* blue on white - fkey chip label */ /* ── Host config ─────────────────────────────────────────── */ typedef struct { char host[100]; char port[10]; char username[80]; char password[80]; char name[50]; } HostEntry; typedef struct { char data[SCRL_W]; int len; int base_attr; } ScrlLine; /* ── Session ────────────────────────────────────────────────── * All per-connection state in one struct so the program can run * multiple simultaneous sessions (one per tab in the GUI, or one * in the CLI). S always points to the currently active session. */ #define MAX_SESSIONS 4 #define SESSION_LABEL 32 typedef struct { int active; /* slot is in use */ int host_idx; /* index into hosts[] */ char label[SESSION_LABEL]; /* display name / tab title */ /* Connection */ int sock; int connected; time_t connect_time; /* Logging */ int logging; FILE *log_file; /* BPQ monitor frame accumulator */ int in_mon_frame; char mon_fbuf[4096]; int mon_flen; /* Port tracking */ int port_seen; char ports_line_buf[256]; int ports_line_len; int ports_parse_mode; int ports_loaded; /* Output scrollback */ ScrlLine out_sbuf[SCRL_MAX]; int out_sbuf_head, out_sbuf_count; int out_scroll; char out_part[SCRL_W]; int out_part_len, out_part_base, out_part_cur; /* Monitor scrollback */ ScrlLine mon_sbuf[SCRL_MAX]; int mon_sbuf_head, mon_sbuf_count; int mon_scroll; char mon_part[SCRL_W]; int mon_part_len, mon_part_base, mon_part_cur; /* Input */ char input_buf[INPUT_MAX]; int input_len; /* Input history */ char hist_buf[HIST_MAX][INPUT_MAX]; int hist_head, hist_count, hist_pos; char hist_save[INPUT_MAX]; } Session; static Session sessions[MAX_SESSIONS]; static Session *S; /* active (displayed) session */ HostEntry hosts[MAX_HOSTS]; int num_hosts = 0; /* ── Signal handling ─────────────────────────────────────── * * SIGINT (Ctrl+C) is trapped rather than left at its default * (immediate process kill) so the program can ask for confirmation * instead of dropping a live session/unsent form with no warning. * The handler only sets a flag - all actual UI/exit work happens * in interactive_loop(), since signal handlers must stay minimal * and ncurses calls are not safe to make from inside one. */ static volatile sig_atomic_t sigint_pending = 0; static void on_sigint(int signum) { (void)signum; sigint_pending = 1; } /* set while receiving data for a background session — suppresses live screen writes */ static int suppress_draw = 0; /* ── BPQ / monitor state (shared across all sessions) ─────── */ int mon_visible = 1; int mon_tx = 1; int mon_com = 1; int mon_nodes = 0; int mon_colour = 1; int mon_ui_only = 1; int port_mask = 0xFFFF; char port_names[32][32]; /* port 1-32 names, loaded from config */ /* ── ncurses windows ─────────────────────────────────────── */ WINDOW *status_win = NULL; WINDOW *out_win = NULL; WINDOW *sep_win = NULL; /* separator between monitor and output panes */ WINDOW *mon_win = NULL; WINDOW *sep2_win = NULL; /* separator above input area */ WINDOW *input_win = NULL; WINDOW *fkey_win = NULL; /* function key hint bar at bottom */ /* ── ICS-213 General Message ─────────────────────────────── */ #define ICS_SBLINE_MAX 120 /* "SB @" line */ #define ICS_FIELD_MAX 80 /* To / From / Subject single-line fields */ #define ICS_DT_MAX 40 /* Date/Time field */ #define ICS_MSG_ROWS 12 /* visible rows in the message textarea */ #define ICS_MSG_COLS 80 /* wrap width sent to the BBS (also box width) */ #define ICS_MSG_LINES 60 /* max stored logical lines in the message body */ #define ICS_MSG_LINELEN 200 /* max chars per logical (unwrapped) line typed */ typedef struct { char sb_line[ICS_SBLINE_MAX]; /* BBS command: "SB PKTNET@USA" — sent verbatim */ char subject[ICS_FIELD_MAX]; /* BBS title prompt + ICS field 4 (shared) */ char incident[ICS_FIELD_MAX]; /* ICS field 1 — Incident Name (optional) */ char to[ICS_FIELD_MAX]; /* ICS field 2 — To (Name and Position) */ char from[ICS_FIELD_MAX]; /* ICS field 3 — From (Name and Position) */ char date[ICS_FIELD_MAX]; /* ICS field 5 — Date (UTC) */ char time_z[ICS_FIELD_MAX]; /* ICS field 6 — Time UTC, Z appended on send */ char msg_lines[ICS_MSG_LINES][ICS_MSG_LINELEN]; /* ICS field 7 — Message body */ int msg_nlines; char appr_name[ICS_FIELD_MAX]; /* ICS field 8 — Approved by: Name */ char appr_sig[ICS_FIELD_MAX]; /* ICS field 8 — Approved by: Signature */ char appr_pos[ICS_FIELD_MAX]; /* ICS field 8 — Approved by: Position/Title */ } IcsMessage; /* ── PKTNET Packet Check-In form (https://vden.org/pktnet/check_in.html) ─── */ #define CHKIN_FIELD_MAX 80 #define CHKIN_COMMENT_MAX 500 /* form's own stated limit */ static const char *chkin_type_opts[] = { "EXERCISE", "REAL EVENT" }; static const int chkin_type_n = 2; static const char *chkin_band_opts[] = { "AXIP", "HF", "VHF", "UHF", "SHF" }; static const int chkin_band_n = 5; static const char *chkin_session_opts[] = { "AXIP", "AX25 Packet", "Pactor", "Robust Packet", "Ardop", "VARA HF", "VARA FM", "Mesh" }; static const int chkin_session_n = 8; typedef struct { char sb_line[ICS_SBLINE_MAX]; /* defaults to "SB PKTNET@USA" */ char subject[CHKIN_FIELD_MAX]; char agency[CHKIN_FIELD_MAX]; /* optional agency/group name */ char date_time[ICS_DT_MAX]; /* 1a */ char to[CHKIN_FIELD_MAX]; /* 1b - fixed "PKTNET@USA" but editable */ char from[CHKIN_FIELD_MAX]; /* 1c */ char contact_name[CHKIN_FIELD_MAX]; /* 1d */ char operators[CHKIN_FIELD_MAX]; /* 1e */ int type_idx; /* 2a - index into chkin_type_opts */ int band_idx; /* 2c - index into chkin_band_opts */ int session_idx; /* 2d - index into chkin_session_opts */ char location[CHKIN_FIELD_MAX]; /* 3a */ char grid[CHKIN_FIELD_MAX]; /* 3b */ char comment_lines[ICS_MSG_LINES][ICS_MSG_LINELEN]; /* 4 - textarea-edited */ int comment_nlines; } PktnetCheckin; /* ── Forward declarations ────────────────────────────────── */ void draw_status(void); void create_windows(void); void append_mon(const char *text, int len, int attr); void append_out(const char *text, int len, int attr); void draw_input(void); void send_trace_options(void); void tcp_disconnect(void); static int session_count(void); static int session_index(void); static void session_switch(int n); static void session_next(void); static void session_prev(void); static void session_new(void); static void session_close(void); static void do_connect(int idx); /* ═══════════════════════════════════════════════════════════ * Config * ═══════════════════════════════════════════════════════════ */ /* Config file is ~/.BPQTermTCP.ini — same format as the GTK GUI uses. * Sections: [Session 1] for monitor/display settings, [Host1]-[Host4] for hosts. * Simple key=value parser; we only write what we read to avoid clobbering * unknown keys the GUI may have written. */ static char cfg_path[PATH_MAX]; static void cfg_path_init(void) { if (cfg_path[0]) return; char *home = getenv("HOME"); if (!home) home = "."; snprintf(cfg_path, PATH_MAX, "%s/.BPQTermTCP.ini", home); } /* Trim trailing whitespace/newline in place */ static void trim_nl(char *s) { int n = (int)strlen(s); while (n > 0 && (s[n-1] == '\n' || s[n-1] == '\r' || s[n-1] == ' ')) s[--n] = '\0'; } void load_config(void) { cfg_path_init(); FILE *fp = fopen(cfg_path, "r"); if (!fp) { /* Try legacy ~/.termtcprc for migration */ char legacy[PATH_MAX]; char *home = getenv("HOME"); if (!home) home = "."; snprintf(legacy, PATH_MAX, "%s/.termtcprc", home); fp = fopen(legacy, "r"); if (!fp) return; /* Parse old simple format */ char line[512]; num_hosts = 0; while (fgets(line, sizeof(line), fp) && num_hosts < MAX_HOSTS) { if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') continue; HostEntry *h = &hosts[num_hosts]; int n = sscanf(line, "%99s %9s %79s %79s %49[^\n]", h->host, h->port, h->username, h->password, h->name); if (n < 2) continue; if (n == 3) { strncpy(h->name, h->username, 49); h->username[0] = h->password[0] = '\0'; } if (n == 4) { strncpy(h->name, h->password, 49); h->password[0] = '\0'; } num_hosts++; } fclose(fp); return; } char line[512]; char section[64] = ""; int host_idx = -1; num_hosts = 0; while (fgets(line, sizeof(line), fp)) { trim_nl(line); if (line[0] == '#' || line[0] == ';' || line[0] == '\0') continue; /* Section header */ if (line[0] == '[') { char *e = strchr(line, ']'); if (e) { *e = '\0'; strncpy(section, line+1, 63); section[63] = '\0'; } host_idx = -1; if (strncmp(section, "Host", 4) == 0) { int n = atoi(section + 4); if (n >= 1 && n <= MAX_HOSTS) { host_idx = n - 1; if (host_idx >= num_hosts) { /* ensure slot exists */ while (num_hosts <= host_idx) { memset(&hosts[num_hosts], 0, sizeof(HostEntry)); num_hosts++; } } } } continue; } /* key=value */ char *eq = strchr(line, '='); if (!eq) continue; *eq = '\0'; char *key = line; char *val = eq + 1; if (strcmp(section, "Session 1") == 0) { if (strcmp(key,"MTX") == 0) mon_tx = atoi(val); else if (strcmp(key,"MCOM") == 0) mon_com = atoi(val); else if (strcmp(key,"MonNODES") == 0) mon_nodes = atoi(val); else if (strcmp(key,"MONColour") == 0) mon_colour = atoi(val); else if (strcmp(key,"PortMask") == 0) port_mask = atoi(val); else if (strcmp(key,"MUIONLY") == 0) mon_ui_only = atoi(val); else if (strcmp(key,"MonPorts") == 0) { /* informational */ } /* Port names: PortName1=VHF etc. */ else if (strncmp(key,"PortName",8) == 0) { int p = atoi(key + 8); if (p >= 1 && p <= 32) strncpy(port_names[p-1], val, 31); } } else if (host_idx >= 0 && host_idx < MAX_HOSTS) { HostEntry *h = &hosts[host_idx]; if (strcmp(key,"Host") == 0) strncpy(h->host, val, 99); else if (strcmp(key,"Port") == 0) strncpy(h->port, val, 9); else if (strcmp(key,"UserName") == 0) strncpy(h->username, val, 79); else if (strcmp(key,"Password") == 0) strncpy(h->password, val, 79); else if (strcmp(key,"Path") == 0) strncpy(h->name, val, 49); } } fclose(fp); /* Remove trailing empty hosts */ while (num_hosts > 0 && hosts[num_hosts-1].host[0] == '\0') num_hosts--; } void save_config(void) { cfg_path_init(); /* Read existing file so we preserve any keys we don't know about */ FILE *fp = fopen(cfg_path, "w"); if (!fp) return; fprintf(fp, "[Session 1]\n"); fprintf(fp, "MTX=%d\n", mon_tx); fprintf(fp, "MCOM=%d\n", mon_com); fprintf(fp, "MonNODES=%d\n", mon_nodes); fprintf(fp, "MONColour=%d\n", mon_colour); fprintf(fp, "PortMask=%d\n", port_mask); fprintf(fp, "MUIONLY=%d\n", mon_ui_only); fprintf(fp, "MonPorts=%d\n", num_hosts > 0 ? 8 : 1); /* Save non-default port names */ for (int i = 0; i < 32; i++) { char def[32]; snprintf(def, 32, "Port %d", i+1); if (strcmp(port_names[i], def) != 0) fprintf(fp, "PortName%d=%s\n", i+1, port_names[i]); } for (int i = 0; i < num_hosts; i++) { fprintf(fp, "\n[Host%d]\n", i+1); fprintf(fp, "Host=%s\n", hosts[i].host); fprintf(fp, "Port=%s\n", hosts[i].port); fprintf(fp, "UserName=%s\n", hosts[i].username); fprintf(fp, "Password=%s\n", hosts[i].password); fprintf(fp, "Path=%s\n", hosts[i].name); } fclose(fp); } /* ═══════════════════════════════════════════════════════════ * Logging * ═══════════════════════════════════════════════════════════ */ void open_log(const char *prefix) { time_t now = time(NULL); struct tm *t = localtime(&now); char filename[PATH_MAX]; snprintf(filename, PATH_MAX, "%s_%04d%02d%02d_%02d%02d%02d.log", prefix, t->tm_year+1900, t->tm_mon+1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec); S->log_file = fopen(filename, "w"); if (S->log_file) S->logging = 1; } void close_log(void) { if (S->log_file) { fclose(S->log_file); S->log_file = NULL; S->logging = 0; } } /* ═══════════════════════════════════════════════════════════ * Network * ═══════════════════════════════════════════════════════════ */ int tcp_connect(const char *host, const char *port) { struct addrinfo hints, *res = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; if (getaddrinfo(host, port, &hints, &res) != 0 || !res) return -1; int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol); if (fd < 0) { freeaddrinfo(res); return -1; } int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); connect(fd, res->ai_addr, res->ai_addrlen); freeaddrinfo(res); fd_set wfds; struct timeval tv = {5, 0}; FD_ZERO(&wfds); FD_SET(fd, &wfds); if (select(fd+1, NULL, &wfds, NULL, &tv) <= 0) { close(fd); return -1; } int err = 0; socklen_t elen = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &elen); if (err) { close(fd); return -1; } flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); return fd; } int tcp_send(const char *data, int len) { if (S->sock < 0 || len <= 0) return -1; if (S->logging && S->log_file) { fwrite(data, 1, len, S->log_file); fflush(S->log_file); } return send(S->sock, data, len, 0); } void tcp_disconnect(void) { if (S->sock >= 0) { close(S->sock); S->sock = -1; } S->connected = 0; S->connect_time = 0; S->in_mon_frame = 0; S->mon_flen = 0; S->ports_parse_mode = 0; S->ports_line_len = 0; S->ports_loaded = 0; S->port_seen = 0; for (int i = 0; i < 32; i++) snprintf(port_names[i], 32, "Port %d", i + 1); draw_status(); } /* ═══════════════════════════════════════════════════════════ * BPQ protocol * ═══════════════════════════════════════════════════════════ */ void send_trace_options(void) { if (S->sock < 0) return; char buf[80]; int len = snprintf(buf, sizeof(buf), "\\\\\\\\%x %x %x %x %x %x\r", port_mask, mon_tx, mon_com, mon_nodes, mon_colour, mon_ui_only); send(S->sock, buf, len, 0); } /* Same as send_trace_options but appends P8=1, which tells BPQ to call * SendPortsForMonitor() — returns port list as 0xFF 0xFF N|num desc|... */ static void send_trace_options_with_ports(void) { if (S->sock < 0) return; char buf[80]; int len = snprintf(buf, sizeof(buf), "\\\\\\\\%x %x %x %x %x %x 0 1\r", port_mask, mon_tx, mon_com, mon_nodes, mon_colour, mon_ui_only); send(S->sock, buf, len, 0); } /* Parse the 0xFF 0xFF port-info packet from SendPortsForMonitor. * Format (after both 0xFF bytes): "N|portnum desc|portnum desc|..." * N = total entries; portnum 0 = Mail Monitor (skipped). */ static void parse_portinfo(const char *data, int len) { const char *p = data, *e = data + len; int nports = 0; while (p < e && *p >= '0' && *p <= '9') nports = nports * 10 + (*p++ - '0'); if (p >= e || *p != '|') return; p++; for (int i = 0; i < nports && p < e; i++) { int n = 0; while (p < e && *p >= '0' && *p <= '9') n = n * 10 + (*p++ - '0'); if (p >= e || *p != ' ') break; p++; const char *desc = p; while (p < e && *p != '|') p++; int dlen = (int)(p - desc); if (n >= 1 && n <= 32 && dlen > 0) { int copy = dlen < 31 ? dlen : 31; memcpy(port_names[n - 1], desc, copy); port_names[n - 1][copy] = '\0'; S->port_seen |= (1u << (n - 1)); } if (p < e && *p == '|') p++; } S->ports_loaded = 1; } static int bpq_color_attr(unsigned char color_byte) { /* Map BPQ Colours[] index (color_byte - 10) to nearest terminal color. * Colours[]: 1-4 = dark blues, 5-20 = greens, 21-60 = reds, 61-100 = brighter reds */ int idx = (int)color_byte - 10; if (idx < 0) return COLOR_PAIR(CP_DEFAULT); if (idx < 4) return COLOR_PAIR(CP_CYAN); /* dark blues → cyan */ if (idx < 20) return COLOR_PAIR(CP_GREEN); /* greens */ if (idx < 40) return COLOR_PAIR(CP_RED); /* dark reds */ if (idx < 60) return COLOR_PAIR(CP_RED) | A_BOLD; /* medium reds */ if (idx < 80) return COLOR_PAIR(CP_YELLOW); /* bright reds → yellow so visible */ return COLOR_PAIR(CP_MAGENTA); } /* Scan monitor frame text for "Port=N" and record N in S->port_seen bitmask. * BPQ monitor frames use "Port=N" format (e.g. "KB8PMY>APRS Port=2 "). * Port names come from the PORTS session command, not from monitor frames. */ static void detect_port_number(const char *text, int len) { /* Skip optional BPQ color prefix (0x1B + color_byte) */ const char *p = text; if (len >= 2 && (unsigned char)p[0] == 0x1B) { p += 2; len -= 2; } for (int i = 0; i + 5 <= len; i++) { if ((p[i]=='P'||p[i]=='p') && (p[i+1]=='o'||p[i+1]=='O') && (p[i+2]=='r'||p[i+2]=='R') && (p[i+3]=='t'||p[i+3]=='T') && p[i+4]=='=') { int n = 0, j = i + 5; while (j < len && p[j] >= '0' && p[j] <= '9') n = n * 10 + (p[j++] - '0'); if (n >= 1 && n <= 32) S->port_seen |= (1 << (n-1)); } } } /* Parse one complete session output line for the PORTS command response. * Expects: "} Ports" → start of list * " N name..." → port entry (leading 2 spaces + number + name) */ static void parse_session_line(const char *line, int len) { /* Trim trailing whitespace */ while (len > 0 && (unsigned char)line[len-1] <= ' ') len--; if (len == 0) return; if (!S->ports_parse_mode) { /* Detect "} Ports" header (case-insensitive) */ for (int i = 0; i + 6 < len; i++) { if (line[i] == '}' && line[i+1] == ' ' && (line[i+2]=='P'||line[i+2]=='p') && (line[i+3]=='o'||line[i+3]=='O') && (line[i+4]=='r'||line[i+4]=='R') && (line[i+5]=='t'||line[i+5]=='T') && (line[i+6]=='s'||line[i+6]=='S')) { S->ports_parse_mode = 1; return; } } return; } /* In ports mode: parse " N name" lines */ if (len >= 4 && line[0]==' ' && line[1]==' ' && line[2]>='1' && line[2]<='9') { int n = 0, j = 2; while (j < len && line[j] >= '0' && line[j] <= '9') n = n * 10 + (line[j++] - '0'); if (j < len && line[j] == ' ') j++; /* Name: from j to len, trim trailing spaces */ int ne = len; while (ne > j && (unsigned char)line[ne-1] <= ' ') ne--; int nlen = ne - j; if (n >= 1 && n <= 32 && nlen > 0) { int copy = nlen < 31 ? nlen : 31; memcpy(port_names[n-1], line + j, copy); port_names[n-1][copy] = '\0'; S->port_seen |= (1 << (n-1)); } } else { if (S->ports_parse_mode) S->ports_loaded = 1; /* list ended cleanly */ S->ports_parse_mode = 0; } } /* Feed one session output byte through the line accumulator. */ static void feed_session_byte(unsigned char c) { if (c == '\r' || c == '\n') { if (S->ports_line_len > 0) { parse_session_line(S->ports_line_buf, S->ports_line_len); S->ports_line_len = 0; } } else if (c >= 32 && c < 128 && S->ports_line_len < 255) { S->ports_line_buf[S->ports_line_len++] = (char)c; } } void bpq_process(const char *buf, int len) { const char *p = buf; const char *end = buf + len; while (p < end) { if (!S->in_mon_frame) { const char *ff = memchr(p, 0xFF, end - p); if (!ff) { /* All remaining bytes are session output */ for (const char *q = p; q < end; q++) feed_session_byte((unsigned char)*q); append_out(p, (int)(end - p), COLOR_PAIR(CP_DEFAULT)); break; } if (ff > p) { /* Session output before the next monitor frame */ for (const char *q = p; q < ff; q++) feed_session_byte((unsigned char)*q); append_out(p, (int)(ff - p), COLOR_PAIR(CP_DEFAULT)); } /* 0xFF 0xFF = SendPortsForMonitor response; 0xFF 0x1B = monitor frame */ p = ff + 1; if (p < end && (unsigned char)*p == 0xFF) { /* Port info packet — parse in place, consume to end of buffer */ parse_portinfo(p + 1, (int)(end - p - 1)); p = end; } else { S->in_mon_frame = 1; S->mon_flen = 0; } } else { const char *fe = memchr(p, 0xFE, end - p); int seg_len = fe ? (int)(fe - p) : (int)(end - p); if (seg_len > 0) { detect_port_number(p, seg_len); /* Accumulate into the per-frame buffer */ int room = (int)sizeof(S->mon_fbuf) - 1 - S->mon_flen; if (room > 0) { int add = seg_len < room ? seg_len : room; memcpy(S->mon_fbuf + S->mon_flen, p, add); S->mon_flen += add; } } if (fe) { /* Frame complete: apply client-side NODES filter then display */ if (S->mon_flen > 0) { S->mon_fbuf[S->mon_flen] = '\0'; /* Detect NODES broadcast by AX.25 destination ">NODES" in header */ int is_nodes = 0; for (int i = 0; i <= S->mon_flen - 6 && !is_nodes; i++) if (memcmp(S->mon_fbuf + i, ">NODES", 6) == 0) is_nodes = 1; if (mon_nodes || !is_nodes) append_mon(S->mon_fbuf, S->mon_flen, COLOR_PAIR(CP_GREEN)); } S->mon_flen = 0; S->in_mon_frame = 0; p = fe + 1; } else { break; } } } if (S->logging && S->log_file) { fwrite(buf, 1, len, S->log_file); fflush(S->log_file); } } /* ═══════════════════════════════════════════════════════════ * ncurses UI * ═══════════════════════════════════════════════════════════ */ void init_colors(void) { if (!has_colors()) return; start_color(); use_default_colors(); init_pair(CP_DEFAULT, COLOR_WHITE, COLOR_BLACK); init_pair(CP_RED, COLOR_RED, COLOR_BLACK); init_pair(CP_GREEN, COLOR_GREEN, COLOR_BLACK); init_pair(CP_YELLOW, COLOR_YELLOW, COLOR_BLACK); init_pair(CP_CYAN, COLOR_CYAN, COLOR_BLACK); init_pair(CP_MAGENTA, COLOR_MAGENTA, COLOR_BLACK); init_pair(CP_STATUS, COLOR_WHITE, COLOR_BLUE); init_pair(CP_INPUT, COLOR_BLACK, COLOR_WHITE); init_pair(CP_SEP, COLOR_CYAN, COLOR_BLACK); init_pair(CP_FKEY, COLOR_BLUE, COLOR_WHITE); } /* Draw a box using Unicode cchar_t — bypasses ACS so it works on any UTF-8 terminal */ static void draw_box(WINDOW *w) { cchar_t vt, hz, ul, ur, ll, lr; setcchar(&vt, L"│", 0, 0, NULL); /* │ */ setcchar(&hz, L"─", 0, 0, NULL); /* ─ */ setcchar(&ul, L"┌", 0, 0, NULL); /* ┌ */ setcchar(&ur, L"┐", 0, 0, NULL); /* ┐ */ setcchar(&ll, L"└", 0, 0, NULL); /* └ */ setcchar(&lr, L"┘", 0, 0, NULL); /* ┘ */ wborder_set(w, &vt, &vt, &hz, &hz, &ul, &ur, &ll, &lr); } /* Fill a 1-row window with plain '-' dashes and an optional centred label. * No ACS — works on any terminal. */ static void fill_sep(WINDOW *w, const char *label) { if (!w) return; werase(w); wbkgd(w, COLOR_PAIR(CP_SEP)); wattron(w, COLOR_PAIR(CP_SEP)); cchar_t hz; setcchar(&hz, L"─", 0, 0, NULL); whline_set(w, &hz, COLS); if (label && COLS > 4) { int llen = (int)strlen(label); int col = (COLS - llen) / 2; if (col < 0) col = 0; wattron(w, A_BOLD); mvwaddstr(w, 0, col, label); wattroff(w, A_BOLD); } wattroff(w, COLOR_PAIR(CP_SEP)); wrefresh(w); } void create_windows(void) { if (status_win) { delwin(status_win); status_win = NULL; } if (out_win) { delwin(out_win); out_win = NULL; } if (sep_win) { delwin(sep_win); sep_win = NULL; } if (mon_win) { delwin(mon_win); mon_win = NULL; } if (sep2_win) { delwin(sep2_win); sep2_win = NULL; } if (input_win) { delwin(input_win); input_win = NULL; } if (fkey_win) { delwin(fkey_win); fkey_win = NULL; } /* overhead = status(1) + sep2(1) + input(INPUT_OUTER) + fkey(1) * + sep(1) if monitor visible */ int overhead = 1 + 1 + INPUT_OUTER + 1 + (mon_visible ? 1 : 0); int content_rows = LINES - overhead; if (content_rows < 4) content_rows = 4; int row = 0; /* Status bar */ status_win = newwin(1, COLS, row, 0); wbkgd(status_win, COLOR_PAIR(CP_STATUS)); leaveok(status_win, TRUE); row++; if (mon_visible) { int mon_h = content_rows / 2; int out_h = content_rows - mon_h; mon_win = newwin(mon_h, COLS, row, 0); wbkgd(mon_win, COLOR_PAIR(CP_DEFAULT)); scrollok(mon_win, TRUE); leaveok(mon_win, TRUE); row += mon_h; sep_win = newwin(1, COLS, row, 0); leaveok(sep_win, TRUE); fill_sep(sep_win, " Output "); row++; out_win = newwin(out_h, COLS, row, 0); wbkgd(out_win, COLOR_PAIR(CP_DEFAULT)); scrollok(out_win, TRUE); leaveok(out_win, TRUE); row += out_h; } else { out_win = newwin(content_rows, COLS, row, 0); wbkgd(out_win, COLOR_PAIR(CP_DEFAULT)); scrollok(out_win, TRUE); leaveok(out_win, TRUE); row += content_rows; } /* Separator above input */ sep2_win = newwin(1, COLS, row, 0); leaveok(sep2_win, TRUE); fill_sep(sep2_win, " Input "); row++; /* Input area — no border, just open lines */ input_win = newwin(INPUT_OUTER, COLS, row, 0); wbkgd(input_win, COLOR_PAIR(CP_INPUT)); leaveok(input_win, FALSE); keypad(input_win, TRUE); nodelay(input_win, TRUE); row += INPUT_OUTER; /* Function key hint bar */ fkey_win = newwin(1, COLS, row, 0); wbkgd(fkey_win, COLOR_PAIR(CP_STATUS)); leaveok(fkey_win, TRUE); } void draw_status(void) { if (!status_win) return; werase(status_win); wbkgd(status_win, COLOR_PAIR(CP_STATUS)); wattron(status_win, COLOR_PAIR(CP_STATUS)); /* Left: program name */ mvwprintw(status_win, 0, 1, "TermTCP %s", VERSION); /* Center: session indicator + connection info */ char center[80]; int nsess = session_count(); char sess_tag[16] = ""; if (nsess > 1) snprintf(sess_tag, sizeof(sess_tag), "[%d/%d] ", session_index() + 1, nsess); if (S->connected && S->host_idx >= 0) { int elapsed = (int)(time(NULL) - S->connect_time); snprintf(center, sizeof(center), "%s%s [%02d:%02d]", sess_tag, hosts[S->host_idx].name, elapsed / 60, elapsed % 60); } else { snprintf(center, sizeof(center), "%sNot Connected", sess_tag); } int cx = (COLS - (int)strlen(center)) / 2; if (cx > 0) mvwprintw(status_win, 0, cx, "%s", center); /* Right: UTC time + log indicator */ time_t now = time(NULL); struct tm *t = gmtime(&now); char right[32]; snprintf(right, sizeof(right), "%s%02d:%02d UTC ", S->logging ? "LOG " : "", t->tm_hour, t->tm_min); int rx = COLS - (int)strlen(right); if (rx > 0) mvwprintw(status_win, 0, rx, "%s", right); wattroff(status_win, COLOR_PAIR(CP_STATUS)); wrefresh(status_win); } static void draw_fkeys(void) { if (!fkey_win) return; werase(fkey_win); static const struct { const char *key; const char *desc; } fkeys[] = { { "F1", "Help" }, { "F3", "Connect" }, { "F4", "Disc" }, { "F5", "Forms" }, { "F6", "Log" }, { "F7", "New Ses" }, { "F8", "Prev" }, { "F9", "Next" }, { "F10", "Quit" }, }; static const int nfkeys = 9; int col = 0; for (int i = 0; i < nfkeys && col < COLS - 2; i++) { wattron(fkey_win, COLOR_PAIR(CP_FKEY) | A_BOLD); mvwprintw(fkey_win, 0, col, "%s", fkeys[i].key); col += (int)strlen(fkeys[i].key); wattroff(fkey_win, COLOR_PAIR(CP_FKEY) | A_BOLD); wattron(fkey_win, COLOR_PAIR(CP_STATUS)); mvwprintw(fkey_win, 0, col, " %-*s", (int)strlen(fkeys[i].desc) + 1, fkeys[i].desc); col += (int)strlen(fkeys[i].desc) + 2; wattroff(fkey_win, COLOR_PAIR(CP_STATUS)); } /* Fill remainder of bar */ wattron(fkey_win, COLOR_PAIR(CP_STATUS)); for (int c = col; c < COLS; c++) mvwaddch(fkey_win, 0, c, ' '); wattroff(fkey_win, COLOR_PAIR(CP_STATUS)); wrefresh(fkey_win); } /* Render stored lines from a ring buffer into an ncurses window. * scroll_off=0 shows the most recent lines (live); >0 scrolls back. */ static void scrl_draw_window(WINDOW *win, ScrlLine *buf, int count, int head, int scroll_off) { if (!win) return; scrollok(win, FALSE); /* disable scroll during batch redraw — prevents losing lines */ werase(win); if (count == 0) { scrollok(win, TRUE); wrefresh(win); return; } int win_h, win_w; getmaxyx(win, win_h, win_w); (void)win_w; int last = count - 1 - scroll_off; if (last < 0) { wrefresh(win); return; } int first = last - win_h + 1; if (first < 0) first = 0; for (int i = first; i <= last; i++) { int slot = (head - count + i + SCRL_MAX) % SCRL_MAX; const ScrlLine *sl = &buf[slot]; const char *p = sl->data; const char *end = p + sl->len; int cur = sl->base_attr; wattron(win, cur); while (p < end) { unsigned char c = (unsigned char)*p; if (c == 0x1B && (p + 1) < end) { wattroff(win, cur); cur = bpq_color_attr((unsigned char)p[1]); wattron(win, cur); p += 2; } else if (c >= 32 && c < 127) { waddch(win, c); p++; } else { p++; } } wattroff(win, cur); if (i < last) waddch(win, '\n'); } /* Move cursor to start of next blank row so live appends begin on a fresh line */ int drawn = last - first + 1; if (drawn < win_h) wmove(win, drawn, 0); scrollok(win, TRUE); /* re-enable for live appends */ wrefresh(win); } void append_mon(const char *text, int len, int attr) { if (!mon_visible || len <= 0) return; if (suppress_draw) return; /* background session — discard RF monitor backlog */ const char *p = text; const char *end = text + len; int live = (mon_win != NULL); S->mon_part_cur = attr; if (S->mon_part_len == 0) S->mon_part_base = attr; if (live && S->mon_scroll == 0) wattron(mon_win, S->mon_part_cur); while (p < end) { unsigned char c = (unsigned char)*p; if (c == 0x1B && (p + 1) < end) { int na = bpq_color_attr((unsigned char)p[1]); if (S->mon_part_len + 2 < SCRL_W) { S->mon_part[S->mon_part_len++] = (char)0x1B; S->mon_part[S->mon_part_len++] = p[1]; } if (live && S->mon_scroll == 0) { wattroff(mon_win, S->mon_part_cur); wattron(mon_win, na); } S->mon_part_cur = na; p += 2; } else if (c == '\r' || c == '\n') { ScrlLine *sl = &S->mon_sbuf[S->mon_sbuf_head]; memcpy(sl->data, S->mon_part, S->mon_part_len); sl->len = S->mon_part_len; sl->base_attr = S->mon_part_base; S->mon_sbuf_head = (S->mon_sbuf_head + 1) % SCRL_MAX; if (S->mon_sbuf_count < SCRL_MAX) S->mon_sbuf_count++; S->mon_part_len = 0; S->mon_part_base = S->mon_part_cur; if (live && S->mon_scroll == 0) waddch(mon_win, '\n'); p++; } else if (c >= 32 && c < 127) { if (S->mon_part_len < SCRL_W - 1) S->mon_part[S->mon_part_len++] = (char)c; if (live && S->mon_scroll == 0) waddch(mon_win, c); p++; } else { p++; } } if (live && S->mon_scroll == 0) { wattroff(mon_win, S->mon_part_cur); wrefresh(mon_win); if (input_win) wrefresh(input_win); } } void append_out(const char *text, int len, int attr) { if (len <= 0) return; const char *p = text; const char *end = text + len; int live = (out_win != NULL && !suppress_draw); S->out_part_cur = attr; if (S->out_part_len == 0) S->out_part_base = attr; if (live && S->out_scroll == 0) wattron(out_win, S->out_part_cur); while (p < end) { unsigned char c = (unsigned char)*p; if (c == 0x1B && (p + 1) < end) { int na = bpq_color_attr((unsigned char)p[1]); if (S->out_part_len + 2 < SCRL_W) { S->out_part[S->out_part_len++] = (char)0x1B; S->out_part[S->out_part_len++] = p[1]; } if (live && S->out_scroll == 0) { wattroff(out_win, S->out_part_cur); wattron(out_win, na); } S->out_part_cur = na; p += 2; } else if (c == '\r' || c == '\n') { ScrlLine *sl = &S->out_sbuf[S->out_sbuf_head]; memcpy(sl->data, S->out_part, S->out_part_len); sl->len = S->out_part_len; sl->base_attr = S->out_part_base; S->out_sbuf_head = (S->out_sbuf_head + 1) % SCRL_MAX; if (S->out_sbuf_count < SCRL_MAX) S->out_sbuf_count++; S->out_part_len = 0; S->out_part_base = S->out_part_cur; if (live && S->out_scroll == 0) waddch(out_win, '\n'); p++; } else if (c >= 32 && c < 127) { if (S->out_part_len < SCRL_W - 1) S->out_part[S->out_part_len++] = (char)c; if (live && S->out_scroll == 0) waddch(out_win, c); p++; } else { p++; } } if (live && S->out_scroll == 0) { wattroff(out_win, S->out_part_cur); wrefresh(out_win); if (input_win) wrefresh(input_win); } } void draw_input(void) { if (!input_win) return; /* Refresh separator above */ if (sep2_win) fill_sep(sep2_win, " Input "); werase(input_win); wattron(input_win, COLOR_PAIR(CP_INPUT)); /* Prompt on first row */ mvwaddstr(input_win, 0, 0, "> "); /* Buffer content — wraps at COLS, '> ' prefix is 2 chars */ for (int i = 0; i < S->input_len; i++) { int pos = 2 + i; int r = pos / COLS; int c = pos % COLS; if (r >= INPUT_OUTER) break; mvwaddch(input_win, r, c, (unsigned char)S->input_buf[i]); } /* Cursor position */ int cpos = 2 + S->input_len; int cr = cpos / COLS; int cc = cpos % COLS; if (cr < INPUT_OUTER) wmove(input_win, cr, cc); wattroff(input_win, COLOR_PAIR(CP_INPUT)); wrefresh(input_win); } /* ═══════════════════════════════════════════════════════════ * In-program config editor (Ctrl+A -> O) * ═══════════════════════════════════════════════════════════ */ static void scrl_redraw_out(void) { scrl_draw_window(out_win, S->out_sbuf, S->out_sbuf_count, S->out_sbuf_head, S->out_scroll); } static void scrl_redraw_mon(void) { if (!mon_visible) return; scrl_draw_window(mon_win, S->mon_sbuf, S->mon_sbuf_count, S->mon_sbuf_head, S->mon_scroll); } static void update_sep_labels(void) { if (sep_win) { if (S->out_scroll > 0) fill_sep(sep_win, " Output [scrolled - PgDn=live] "); else fill_sep(sep_win, " Output "); } } static void refresh_all(void) { draw_status(); update_sep_labels(); if (sep2_win) fill_sep(sep2_win, " Input "); scrl_redraw_out(); scrl_redraw_mon(); draw_input(); draw_fkeys(); } /* Full background repaint using redrawwin() — safe alternative to * clearok(curscr) which resets ACS mode and breaks box() borders. */ static void force_repaint(void) { if (status_win) redrawwin(status_win); if (sep_win) redrawwin(sep_win); if (out_win) redrawwin(out_win); if (mon_win) redrawwin(mon_win); if (sep2_win) redrawwin(sep2_win); if (input_win) redrawwin(input_win); if (fkey_win) redrawwin(fkey_win); refresh_all(); } static void config_menu(void) { int mh = 18, mw = 64; int my = (LINES - mh) / 2; int mx = (COLS - mw) / 2; if (my < 0) my = 0; if (mx < 0) mx = 0; WINDOW *w = newwin(mh, mw, my, mx); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); keypad(w, TRUE); nodelay(w, FALSE); const char *labels[] = { "Host:", "Port:", "User:", "Pass:", "Name:" }; const int field_y[] = { 4, 6, 8, 10, 12 }; const int max_len[] = { 99, 9, 79, 79, 49 }; char fields[5][100]; int cur_host = (S->host_idx >= 0 && S->host_idx < num_hosts) ? S->host_idx : 0; int cur_field = 0; if (num_hosts == 0) { memset(&hosts[0], 0, sizeof(HostEntry)); num_hosts = 1; } #define CF_COMMIT() do { \ strncpy(hosts[cur_host].host, fields[0], 99); hosts[cur_host].host[99] =0; \ strncpy(hosts[cur_host].port, fields[1], 9); hosts[cur_host].port[9] =0; \ strncpy(hosts[cur_host].username, fields[2], 79); hosts[cur_host].username[79]=0; \ strncpy(hosts[cur_host].password, fields[3], 79); hosts[cur_host].password[79]=0; \ strncpy(hosts[cur_host].name, fields[4], 49); hosts[cur_host].name[49] =0; \ } while(0) int running = 1; while (running) { if (cur_host >= num_hosts) cur_host = num_hosts - 1; if (cur_host < 0) cur_host = 0; strncpy(fields[0], hosts[cur_host].host, 99); fields[0][99]=0; strncpy(fields[1], hosts[cur_host].port, 9); fields[1][9] =0; strncpy(fields[2], hosts[cur_host].username, 79); fields[2][79]=0; strncpy(fields[3], hosts[cur_host].password, 79); fields[3][79]=0; strncpy(fields[4], hosts[cur_host].name, 49); fields[4][49]=0; werase(w); draw_box(w); mvwprintw(w, 0, (mw-14)/2, " Config Hosts "); mvwprintw(w, 2, 2, "Host %d of %d PgUp/PgDn=switch N=new D=delete", cur_host+1, num_hosts); for (int i = 0; i < 5; i++) { mvwprintw(w, field_y[i], 2, "%-6s", labels[i]); if (i == cur_field) wattron(w, A_REVERSE); mvwprintw(w, field_y[i], 9, "%-52s", fields[i]); if (i == cur_field) wattroff(w, A_REVERSE); } mvwprintw(w, mh-4, 2, "Tab / Up / Down = move between fields"); mvwprintw(w, mh-3, 2, "Enter / F1 = save & close Esc / Q = cancel"); mvwprintw(w, mh-2, 2, "Backspace = delete char"); int flen = (int)strlen(fields[cur_field]); wmove(w, field_y[cur_field], 9 + flen); wrefresh(w); int ch = wgetch(w); switch (ch) { case KEY_PPAGE: CF_COMMIT(); if (cur_host > 0) { cur_host--; cur_field = 0; } break; case KEY_NPAGE: CF_COMMIT(); if (cur_host < num_hosts-1) { cur_host++; cur_field = 0; } break; case 'N': case 'n': if (num_hosts < MAX_HOSTS) { CF_COMMIT(); memset(&hosts[num_hosts], 0, sizeof(HostEntry)); num_hosts++; cur_host = num_hosts-1; cur_field = 0; } break; case 'D': case 'd': if (num_hosts > 1) { for (int i = cur_host; i < num_hosts-1; i++) hosts[i] = hosts[i+1]; num_hosts--; if (cur_host >= num_hosts) cur_host = num_hosts-1; } break; case '\t': case KEY_DOWN: CF_COMMIT(); cur_field = (cur_field + 1) % 5; break; case KEY_UP: CF_COMMIT(); cur_field = (cur_field + 4) % 5; break; case '\r': case '\n': case KEY_ENTER: case KEY_F(1): CF_COMMIT(); save_config(); running = 0; break; case 27: case 'Q': case 'q': running = 0; break; case KEY_BACKSPACE: case 127: case '\b': { int fl = (int)strlen(fields[cur_field]); if (fl > 0) { fields[cur_field][fl-1] = '\0'; CF_COMMIT(); } break; } default: if (ch >= 32 && ch < 127) { int fl = (int)strlen(fields[cur_field]); if (fl < max_len[cur_field]-1) { fields[cur_field][fl] = (char)ch; fields[cur_field][fl+1] = '\0'; CF_COMMIT(); } } break; } } #undef CF_COMMIT delwin(w); force_repaint(); } /* ═══════════════════════════════════════════════════════════ * Multi-line textarea editor (used by the General Message form) * * Operates on an IcsMessage's msg_lines[]/msg_nlines directly. * Enter splits/creates a new logical line, arrows move the cursor, * Backspace joins with the previous line at column 0. * w must already be sized at least ICS_MSG_ROWS+2 x ICS_MSG_COLS+2 * (the box border eats one row/col on each side). * Returns when the caller's exit key (Tab/Esc handled by caller via * the return value) is pressed; ch_out receives that key. * ═══════════════════════════════════════════════════════════ */ typedef struct { char (*lines)[ICS_MSG_LINELEN]; int *nlines; int cur_line; int cur_col; int top_line; /* first visible line (vertical scroll) */ } TextArea; static void ta_clamp(TextArea *ta, int rows) { if (*ta->nlines < 1) *ta->nlines = 1; if (ta->cur_line >= *ta->nlines) ta->cur_line = *ta->nlines - 1; if (ta->cur_line < 0) ta->cur_line = 0; int llen = (int)strlen(ta->lines[ta->cur_line]); if (ta->cur_col > llen) ta->cur_col = llen; if (ta->cur_col < 0) ta->cur_col = 0; if (ta->cur_line < ta->top_line) ta->top_line = ta->cur_line; if (ta->cur_line >= ta->top_line + rows) ta->top_line = ta->cur_line - rows + 1; if (ta->top_line < 0) ta->top_line = 0; } /* Draws the textarea inside window w at the given inner origin/size * (caller already drew the surrounding box/labels). */ static void ta_draw(WINDOW *w, TextArea *ta, int oy, int ox, int rows, int cols) { for (int r = 0; r < rows; r++) { int line_idx = ta->top_line + r; mvwprintw(w, oy + r, ox, "%-*s", cols, ""); /* clear row */ if (line_idx < *ta->nlines) { const char *s = ta->lines[line_idx]; int slen = (int)strlen(s); int show = slen < cols ? slen : cols; mvwaddnstr(w, oy + r, ox, s, show); } } int crow = oy + (ta->cur_line - ta->top_line); int ccol = ox + ta->cur_col; wmove(w, crow, ccol); } /* Handles one keystroke. Returns 0 to keep editing, or the raw key * code that the caller should treat as an "exit/switch focus" key * (currently: '\t' and 27/Esc; caller decides what those mean). * cols = visible display width of the textarea; used for auto word-wrap. */ static int ta_handle_key(TextArea *ta, int ch, int rows, int cols) { switch (ch) { case '\t': case 27: return ch; case KEY_UP: ta->cur_line--; break; case KEY_DOWN: ta->cur_line++; break; case KEY_LEFT: if (ta->cur_col > 0) ta->cur_col--; else if (ta->cur_line > 0) { ta->cur_line--; ta->cur_col = (int)strlen(ta->lines[ta->cur_line]); } break; case KEY_RIGHT: { int llen = (int)strlen(ta->lines[ta->cur_line]); if (ta->cur_col < llen) ta->cur_col++; else if (ta->cur_line < *ta->nlines - 1) { ta->cur_line++; ta->cur_col = 0; } break; } case KEY_HOME: ta->cur_col = 0; break; case KEY_END: ta->cur_col = (int)strlen(ta->lines[ta->cur_line]); break; case '\r': case '\n': case KEY_ENTER: { if (*ta->nlines >= ICS_MSG_LINES) break; /* full */ /* Split current line at cursor into a new line below */ char *cur = ta->lines[ta->cur_line]; char tail[ICS_MSG_LINELEN]; strncpy(tail, cur + ta->cur_col, ICS_MSG_LINELEN - 1); tail[ICS_MSG_LINELEN - 1] = '\0'; cur[ta->cur_col] = '\0'; for (int i = *ta->nlines; i > ta->cur_line + 1; i--) memcpy(ta->lines[i], ta->lines[i-1], ICS_MSG_LINELEN); strncpy(ta->lines[ta->cur_line + 1], tail, ICS_MSG_LINELEN - 1); ta->lines[ta->cur_line + 1][ICS_MSG_LINELEN - 1] = '\0'; (*ta->nlines)++; ta->cur_line++; ta->cur_col = 0; break; } case KEY_BACKSPACE: case 127: case '\b': if (ta->cur_col > 0) { char *cur = ta->lines[ta->cur_line]; int len = (int)strlen(cur); memmove(cur + ta->cur_col - 1, cur + ta->cur_col, len - ta->cur_col + 1); ta->cur_col--; } else if (ta->cur_line > 0) { /* Join with previous line */ char *prev = ta->lines[ta->cur_line - 1]; char *cur = ta->lines[ta->cur_line]; int plen = (int)strlen(prev); int clen = (int)strlen(cur); if (plen + clen < ICS_MSG_LINELEN - 1) { memcpy(prev + plen, cur, clen + 1); for (int i = ta->cur_line; i < *ta->nlines - 1; i++) memcpy(ta->lines[i], ta->lines[i+1], ICS_MSG_LINELEN); (*ta->nlines)--; ta->cur_line--; ta->cur_col = plen; } } break; case KEY_DC: { /* Delete key: remove char at cursor */ char *cur = ta->lines[ta->cur_line]; int len = (int)strlen(cur); if (ta->cur_col < len) { memmove(cur + ta->cur_col, cur + ta->cur_col + 1, len - ta->cur_col); } else if (ta->cur_line < *ta->nlines - 1) { char *next = ta->lines[ta->cur_line + 1]; int nlen = (int)strlen(next); if (len + nlen < ICS_MSG_LINELEN - 1) { memcpy(cur + len, next, nlen + 1); for (int i = ta->cur_line + 1; i < *ta->nlines - 1; i++) memcpy(ta->lines[i], ta->lines[i+1], ICS_MSG_LINELEN); (*ta->nlines)--; } } break; } default: if (ch >= 32 && ch < 256) { char *cur = ta->lines[ta->cur_line]; int len = (int)strlen(cur); /* Auto word-wrap when the visible line width is reached */ if (len >= cols - 1 && *ta->nlines < ICS_MSG_LINES) { int brk = len - 1; while (brk > 0 && cur[brk] != ' ') brk--; if (brk == 0) brk = len; /* no space: hard break at end */ int skip = (brk < len && cur[brk] == ' ') ? 1 : 0; char tail[ICS_MSG_LINELEN]; strncpy(tail, cur + brk + skip, ICS_MSG_LINELEN - 1); tail[ICS_MSG_LINELEN - 1] = '\0'; cur[brk] = '\0'; for (int i = *ta->nlines; i > ta->cur_line + 1; i--) memcpy(ta->lines[i], ta->lines[i-1], ICS_MSG_LINELEN); strncpy(ta->lines[ta->cur_line + 1], tail, ICS_MSG_LINELEN - 1); ta->lines[ta->cur_line + 1][ICS_MSG_LINELEN - 1] = '\0'; (*ta->nlines)++; ta->cur_line++; ta->cur_col = (int)strlen(ta->lines[ta->cur_line]); cur = ta->lines[ta->cur_line]; } len = (int)strlen(cur); if (len < ICS_MSG_LINELEN - 1 && ta->cur_col <= len) { memmove(cur + ta->cur_col + 1, cur + ta->cur_col, len - ta->cur_col + 1); cur[ta->cur_col] = (char)ch; ta->cur_col++; } } break; } ta_clamp(ta, rows); return 0; } /* ═══════════════════════════════════════════════════════════ * Monitor options (Ctrl+A -> M) * ═══════════════════════════════════════════════════════════ */ /* Returns 'P' to switch to port page, 0 to close */ static int mon_options_page(void) { static const char *labels[] = { "Monitor TX", "Monitor Supervisor", "Monitor Nodes", "Monitor UI Only", "Enable Colour", "Show Monitor Pane", }; int *vals[] = { &mon_tx, &mon_com, &mon_nodes, &mon_ui_only, &mon_colour, &mon_visible }; const int N = 6; int mh = N + 5, mw = 46; int my = (LINES - mh) / 2; if (my < 0) my = 0; int mx = (COLS - mw) / 2; if (mx < 0) mx = 0; force_repaint(); WINDOW *w = newwin(mh, mw, my, mx); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); keypad(w, TRUE); nodelay(w, FALSE); int cur = 0; for (;;) { werase(w); draw_box(w); mvwprintw(w, 0, (mw - 18) / 2, " Monitor Options "); for (int i = 0; i < N; i++) { if (i == cur) wattron(w, A_REVERSE); mvwprintw(w, 2 + i, 3, "[%c] %s", *vals[i] ? 'X' : ' ', labels[i]); if (i == cur) wattroff(w, A_REVERSE); } mvwprintw(w, mh - 3, 3, "Up/Down=move Space=toggle"); mvwprintw(w, mh - 2, 3, "Tab=Port Select Esc/Q=close"); wrefresh(w); int ch = wgetch(w); switch (ch) { case KEY_UP: cur = (cur - 1 + N) % N; break; case KEY_DOWN: cur = (cur + 1) % N; break; case '\t': delwin(w); return 'P'; case ' ': case '\r': case '\n': case KEY_ENTER: if (cur == 5) { mon_visible = !mon_visible; save_config(); delwin(w); create_windows(); force_repaint(); return 0; } *vals[cur] = !(*vals[cur]); send_trace_options(); save_config(); break; case 27: case 'Q': case 'q': delwin(w); force_repaint(); return 0; } } } /* Returns 'O' to switch to options page, 0 to close */ static int mon_ports_page(void) { int port_list[32], n_ports = 0; for (int b = 0; b < 32; b++) if (S->port_seen & (1 << b)) port_list[n_ports++] = b + 1; if (n_ports == 0) for (int b = 0; b < 8; b++) port_list[n_ports++] = b + 1; int mh = n_ports + 5; if (mh < 10) mh = 10; int mw = 54; int my = (LINES - mh) / 2; if (my < 0) my = 0; int mx = (COLS - mw) / 2; if (mx < 0) mx = 0; force_repaint(); WINDOW *w = newwin(mh, mw, my, mx); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); keypad(w, TRUE); nodelay(w, FALSE); int cur = 0; for (;;) { werase(w); draw_box(w); mvwprintw(w, 0, (mw - 16) / 2, " Port Selection "); if (S->port_seen == 0) mvwprintw(w, 2, 3, "(connect to auto-detect ports)"); for (int i = 0; i < n_ports; i++) { int pn = port_list[i]; if (i == cur) wattron(w, A_REVERSE); mvwprintw(w, 2 + i, 3, "[%c] %2d %-32s", (port_mask >> (pn - 1)) & 1 ? 'X' : ' ', pn, port_names[pn - 1]); if (i == cur) wattroff(w, A_REVERSE); } mvwprintw(w, mh - 3, 3, "Up/Down=move Space=toggle"); mvwprintw(w, mh - 2, 3, "Tab=Monitor Opts Esc/Q=close"); wrefresh(w); int ch = wgetch(w); switch (ch) { case KEY_UP: cur = (cur - 1 + n_ports) % n_ports; break; case KEY_DOWN: cur = (cur + 1) % n_ports; break; case '\t': delwin(w); return 'O'; case ' ': case '\r': case '\n': case KEY_ENTER: port_mask ^= (1 << (port_list[cur] - 1)); send_trace_options(); save_config(); break; case 27: case 'Q': case 'q': delwin(w); force_repaint(); return 0; } } } static void monitor_options_menu(void) { int next = 'O'; while (next) { if (next == 'O') next = mon_options_page(); else next = mon_ports_page(); } } /* ═══════════════════════════════════════════════════════════ * Help overlay * ═══════════════════════════════════════════════════════════ */ static void show_help(void) { int mh = 19, mw = 52; WINDOW *w = newwin(mh, mw, (LINES-mh)/2, (COLS-mw)/2); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); draw_box(w); mvwprintw(w, 0, (mw-8)/2, " Help "); mvwprintw(w, 2, 3, "Ctrl+A Open menu"); mvwprintw(w, 3, 3, "Ctrl+A C Connect"); mvwprintw(w, 4, 3, "Ctrl+A D Disconnect"); mvwprintw(w, 5, 3, "Ctrl+A O Config / host editor"); mvwprintw(w, 6, 3, "Ctrl+A M Monitor options"); mvwprintw(w, 7, 3, "Ctrl+A L Toggle log"); mvwprintw(w, 8, 3, "Ctrl+A F Forms & Check-Ins"); mvwprintw(w, 9, 3, "Ctrl+A H This help"); mvwprintw(w, 10, 3, "Ctrl+A Q Quit"); mvwprintw(w, 12, 3, "Enter Send line to server"); mvwprintw(w, 13, 3, "Up / Down Input history"); mvwprintw(w, 14, 3, "PgUp / PgDn Scroll output window"); mvwprintw(w, 15, 3, "Home / End Scroll monitor window"); mvwprintw(w, 16, 3, "Backspace Delete char"); mvwprintw(w, mh-2, 3, "Press any key to close"); wrefresh(w); nodelay(w, FALSE); wgetch(w); delwin(w); force_repaint(); } /* ═══════════════════════════════════════════════════════════ * ICS-213 General Message (Ctrl+A -> G) * * F2 always sends over a dedicated background connection (see * "Background send connection" below) rather than the main * session's socket, so a send never disturbs whatever is happening * in the live monitor/output windows. * ═══════════════════════════════════════════════════════════ */ #define BG_PROMPT_TIMEOUT_MS 10000 /* max ms to wait for BBS to stop talking */ #define BG_QUIET_MS 300 /* silence this long after data = BBS at prompt */ /* ── Background send connection ────────────────────────────── * F2 always opens its OWN socket for sending, completely separate * from the main interactive session's global `S->sock`/`S->connected`. * This means a send never disturbs whatever you're doing in the * main window - no shared connection state, no risk of interleaving * a send with whatever you're typing or watching scroll by. * * Deliberately does NOT call bpq_process(): that function's monitor * frame parser keeps state in globals (S->in_mon_frame, S->mon_flen, * S->ports_parse_mode, etc.) shared with the main session. Running two * sockets' data through one shared parser would corrupt both. The * background connection instead does a much simpler raw drain: BPQ * monitor frames are bounded by 0xFF/0xFE bytes, so they're just * stripped out wholesale rather than decoded, since the background * send has no monitor window of its own to display them in anyway. * * All progress/results are echoed into the same out_win as the main * session, prefixed "[BG]" so it's visually distinct from anything * happening on the live connection. * ──────────────────────────────────────────────────────────── */ /* Strips BPQ monitor-frame markers (0xFF ... 0xFE) from raw bytes * before treating the rest as plain session text, since the * background connection has no monitor window to decode them into. * Writes the surviving plain-text bytes into out (caller-sized) and * returns how many bytes were written. */ static int bg_strip_monitor_frames(const char *buf, int len, char *out, int outcap) { int outlen = 0; int in_frame = 0; for (int i = 0; i < len && outlen < outcap; i++) { unsigned char c = (unsigned char)buf[i]; if (!in_frame) { if (c == 0xFF) { in_frame = 1; continue; } out[outlen++] = (char)c; } else { if (c == 0xFE) in_frame = 0; /* else: still inside a monitor frame, discard the byte */ } } return outlen; } /* Wait for the BBS to finish talking and reach a prompt. * Strategy: keep reading until BG_QUIET_MS of silence after the last * received byte — that silence means the BBS is waiting for input. * Hard timeout at BG_PROMPT_TIMEOUT_MS in case the server never responds. * Returns 1 if a BBS error keyword was seen, 0 otherwise. */ static int bg_wait_for_prompt(int fd) { char rbuf[2048], stripped[2048], lc[2048]; struct timeval start, now, last_rx; int got_data = 0; gettimeofday(&start, NULL); for (;;) { gettimeofday(&now, NULL); long total = (now.tv_sec - start.tv_sec) * 1000L + (now.tv_usec - start.tv_usec) / 1000L; if (total >= BG_PROMPT_TIMEOUT_MS) return 0; /* Once data has arrived, return as soon as it's been quiet long enough */ if (got_data) { long quiet = (now.tv_sec - last_rx.tv_sec) * 1000L + (now.tv_usec - last_rx.tv_usec) / 1000L; if (quiet >= BG_QUIET_MS) return 0; } fd_set rfds; FD_ZERO(&rfds); FD_SET(fd, &rfds); struct timeval tv = { 0, 100000L }; /* poll every 100ms */ int r = select(fd + 1, &rfds, NULL, NULL, &tv); if (r > 0 && FD_ISSET(fd, &rfds)) { int n = recv(fd, rbuf, sizeof(rbuf) - 1, 0); if (n <= 0) return 0; gettimeofday(&last_rx, NULL); got_data = 1; int slen = bg_strip_monitor_frames(rbuf, n, stripped, sizeof(stripped) - 1); if (slen > 0) { stripped[slen] = '\0'; char echo[2200]; int elen = snprintf(echo, sizeof(echo), "[BG] %s", stripped); if (elen > 0) append_out(echo, elen, COLOR_PAIR(CP_MAGENTA)); /* Lowercase copy for keyword scan so we don't clobber the echo */ memcpy(lc, stripped, (size_t)(slen + 1)); for (int i = 0; i < slen; i++) if (lc[i] >= 'A' && lc[i] <= 'Z') lc[i] = (char)(lc[i] - 'A' + 'a'); if (strstr(lc, "invalid") || strstr(lc, "unknown command") || strstr(lc, "not allowed") || strstr(lc, "rejected")) { return 1; } } } } } static void bg_send_line(int fd, const char *text, int *aborted) { if (*aborted || fd < 0) return; int len = (int)strlen(text); send(fd, text, len, 0); send(fd, "\r", 1, 0); char echo[ICS_MSG_LINELEN + 16]; int elen = snprintf(echo, sizeof(echo), "[BG] > %s\n", text); append_out(echo, elen, COLOR_PAIR(CP_MAGENTA) | A_BOLD); /* Wait for BBS to go quiet (prompt ready) before caller sends next line */ if (bg_wait_for_prompt(fd)) *aborted = 1; } static void bg_send_block(int fd, char lines[][ICS_MSG_LINELEN], int n, int *aborted) { if (*aborted || fd < 0 || n <= 0) return; for (int i = 0; i < n; i++) { int len = (int)strlen(lines[i]); send(fd, lines[i], len, 0); send(fd, "\r", 1, 0); char echo[ICS_MSG_LINELEN + 16]; int elen = snprintf(echo, sizeof(echo), "[BG] > %s\n", lines[i]); append_out(echo, elen, COLOR_PAIR(CP_MAGENTA) | A_BOLD); } if (bg_wait_for_prompt(fd)) *aborted = 1; } /* Opens a new socket to hosts[idx], logs in if credentials are saved, * and drains the login banner briefly. Returns the S->connected fd, or * -1 on failure (with a message already echoed to out_win). This is * intentionally separate from do_connect()/S->sock/S->connected - it never * touches the main session's connection state. */ static int bg_connect_and_login(int idx) { if (idx < 0 || idx >= num_hosts) return -1; char msg[160]; snprintf(msg, sizeof(msg), "[BG] Connecting to %s:%s...\n", hosts[idx].host, hosts[idx].port); append_out(msg, (int)strlen(msg), COLOR_PAIR(CP_MAGENTA)); int fd = tcp_connect(hosts[idx].host, hosts[idx].port); if (fd < 0) { append_out("[BG] Connection failed.\n", 24, COLOR_PAIR(CP_RED)); return -1; } if (hosts[idx].username[0]) { char login[256]; int llen = snprintf(login, sizeof(login), "%s\r%s\rBPQTermTCP\r", hosts[idx].username, hosts[idx].password); send(fd, login, llen, 0); } /* Wait for login banner to finish — node is at a prompt when it goes quiet */ bg_wait_for_prompt(fd); /* Enter BBS — BPQ drops you at the node prompt after login */ send(fd, "bbs\r", 4, 0); append_out("[BG] > bbs\n", 11, COLOR_PAIR(CP_MAGENTA) | A_BOLD); /* Wait for BBS greeting to finish before sending the message */ bg_wait_for_prompt(fd); return fd; } /* ═══════════════════════════════════════════════════════════ * Host picker (shared by Connect and the background send flow) * Returns the chosen host index, or -1 if cancelled / no hosts. * Picks automatically with no popup if exactly one host exists. * ═══════════════════════════════════════════════════════════ */ static int pick_host(const char *title) { if (num_hosts <= 0) return -1; if (num_hosts == 1) return 0; int pw = 20; for (int i = 0; i < num_hosts; i++) { int len = snprintf(NULL, 0, "%d - %s (%s:%s)", i, hosts[i].name, hosts[i].host, hosts[i].port) + 6; if (len > pw) pw = len; } int hint_w = snprintf(NULL, 0, "0-%d = pick Esc = cancel", num_hosts-1) + 6; if (hint_w > pw) pw = hint_w; if (pw > COLS) pw = COLS; int ph = num_hosts + 5; WINDOW *hw = newwin(ph, pw, (LINES-ph)/2, (COLS-pw)/2); wbkgd(hw, COLOR_PAIR(CP_DEFAULT)); draw_box(hw); int tlen = (int)strlen(title) + 2; mvwprintw(hw, 0, (pw - tlen) / 2 > 0 ? (pw - tlen) / 2 : 0, " %s ", title); for (int i = 0; i < num_hosts; i++) mvwprintw(hw, 2+i, 3, "%d - %s (%s:%s)", i, hosts[i].name, hosts[i].host, hosts[i].port); mvwprintw(hw, ph-2, 3, "0-%d = pick Esc = cancel", num_hosts-1); wrefresh(hw); nodelay(hw, FALSE); keypad(hw, TRUE); int hch = wgetch(hw); delwin(hw); force_repaint(); if (hch >= '0' && hch < '0' + num_hosts) return hch - '0'; return -1; } /* Word-wraps src into dst_lines (each up to width chars), up to max_lines. * Returns the number of wrapped lines produced. A blank src line produces * one blank output line, preserving paragraph breaks the user typed. */ static int ics_wrap_line(const char *src, char dst_lines[][ICS_MSG_LINELEN], int max_lines, int width) { int n = 0; int slen = (int)strlen(src); if (slen == 0) { if (n < max_lines) dst_lines[n++][0] = '\0'; return n; } int pos = 0; while (pos < slen && n < max_lines) { int remaining = slen - pos; int take = remaining < width ? remaining : width; if (take < remaining) { /* try to break on the last space within the window */ int brk = take; while (brk > 0 && src[pos + brk] != ' ') brk--; if (brk > 0) take = brk; } memcpy(dst_lines[n], src + pos, take); dst_lines[n][take] = '\0'; n++; pos += take; while (pos < slen && src[pos] == ' ') pos++; /* skip the break space */ } return n; } /* Builds the full SB exchange and sends it over its OWN background * connection (see "Background send connection" above) - the main * session's connection, if any, is never touched. Echoes progress/ * result into out_win, prefixed "[BG]". Returns 1 on apparent * success, 0 if cancelled/failed (no host picked, connect failed, * or a BBS error keyword was detected). */ static int ics_send_message(const IcsMessage *msg) { int idx = pick_host("Send via host"); if (idx < 0) { append_out("Send cancelled - no host selected.\n", 37, COLOR_PAIR(CP_YELLOW)); return 0; } int fd = bg_connect_and_login(idx); if (fd < 0) return 0; append_out("[BG] --- Sending ICS-213 General Message ---\n", 47, COLOR_PAIR(CP_MAGENTA) | A_BOLD); int aborted = 0; /* Step 1: SB line (addressee/distribution) */ bg_send_line(fd, msg->sb_line, &aborted); /* Step 2: Subject, in reply to the BBS's subject prompt */ if (!aborted) bg_send_line(fd, msg->subject, &aborted); /* Step 3: Body — ICS-213 numbered format, assembled then sent as one block. */ if (!aborted) { /* Build raw (pre-wrap) lines */ char raw[100][ICS_MSG_LINELEN]; int rn = 0; #define RAW(fmt, ...) \ if (rn < 100) snprintf(raw[rn++], ICS_MSG_LINELEN, fmt, ##__VA_ARGS__) RAW("GENERAL MESSAGE (ICS 213)"); RAW(""); RAW("1. Incident Name (Optional): %s", msg->incident); RAW(""); RAW("2. To (Name and Position): %s", msg->to); RAW(""); RAW("3. From (Name and Position): %s", msg->from); RAW(""); RAW("4. Subject: %s", msg->subject); RAW(""); RAW("5. Date: %s", msg->date); RAW(""); RAW("6. Time: %sZ", msg->time_z); RAW(""); /* Field 7: first message line shares the "7. Message:" prefix */ if (rn < 100) { const char *first = (msg->msg_nlines > 0) ? msg->msg_lines[0] : ""; snprintf(raw[rn++], ICS_MSG_LINELEN, "7. Message: %s", first); } for (int i = 1; i < msg->msg_nlines && rn < 100; i++) { strncpy(raw[rn], msg->msg_lines[i], ICS_MSG_LINELEN - 1); raw[rn++][ICS_MSG_LINELEN - 1] = '\0'; } RAW(""); RAW("8. Approved by: Name: %s Signature: %s Position/Title: %s", msg->appr_name, msg->appr_sig, msg->appr_pos); RAW(""); RAW("9. Reply:"); RAW(""); RAW("10. Replied by: Name: Signature: Position/Title:"); #undef RAW /* Word-wrap each raw line and collect into the send buffer */ char body[ICS_MSG_LINES + 60][ICS_MSG_LINELEN]; int bn = 0; for (int i = 0; i < rn; i++) { char wrapped[ICS_MSG_LINES][ICS_MSG_LINELEN]; int nw = ics_wrap_line(raw[i], wrapped, ICS_MSG_LINES, ICS_MSG_COLS); for (int j = 0; j < nw && bn < (int)(sizeof(body)/sizeof(body[0])); j++) { strncpy(body[bn], wrapped[j], ICS_MSG_LINELEN - 1); body[bn][ICS_MSG_LINELEN - 1] = '\0'; bn++; } } bg_send_block(fd, body, bn, &aborted); } /* Step 4: terminator */ if (!aborted) bg_send_line(fd, "/EX", &aborted); close(fd); if (aborted) { append_out("[BG] --- BBS reported an error - message send aborted ---\n", 60, COLOR_PAIR(CP_RED) | A_BOLD); return 0; } append_out("[BG] --- General Message sent ---\n", 35, COLOR_PAIR(CP_GREEN) | A_BOLD); return 1; } /* ── Form UI ──────────────────────────────────────────────── */ /* Single-line field editor shared by several form rows. Returns the * key that ended editing (Tab/Up/Down/Esc/Enter) so the caller can * move focus. Operates in-place on buf (NUL-terminated, max maxlen-1). */ static int ics_edit_field(WINDOW *w, int y, int x, int width, char *buf, int maxlen) { int len = (int)strlen(buf); int col = len; for (;;) { mvwprintw(w, y, x, "%-*s", width, ""); mvwaddnstr(w, y, x, buf, (int)strlen(buf)); wmove(w, y, x + col); wrefresh(w); int ch = wgetch(w); switch (ch) { case '\t': case KEY_DOWN: case KEY_UP: case 27: case '\r': case '\n': case KEY_ENTER: case KEY_F(2): return ch; case KEY_LEFT: if (col > 0) col--; break; case KEY_RIGHT: if (col < len) col++; break; case KEY_HOME: col = 0; break; case KEY_END: col = len; break; case KEY_BACKSPACE: case 127: case '\b': if (col > 0) { memmove(buf + col - 1, buf + col, len - col + 1); col--; len--; } break; case KEY_DC: if (col < len) { memmove(buf + col, buf + col + 1, len - col); len--; } break; default: if (ch >= 32 && ch < 256 && len < maxlen - 1) { memmove(buf + col + 1, buf + col, len - col + 1); buf[col] = (char)ch; col++; len++; } break; } } } static void ics_default_datetime(char *out, int outlen) { time_t now = time(NULL); struct tm *t = localtime(&now); snprintf(out, outlen, "%02d/%02d/%04d %02d:%02d", t->tm_mon + 1, t->tm_mday, t->tm_year + 1900, t->tm_hour, t->tm_min); } static void ics_default_date(char *out, int outlen) { time_t now = time(NULL); struct tm *t = gmtime(&now); snprintf(out, outlen, "%04d-%02d-%02d", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday); } static void ics_default_time(char *out, int outlen) { time_t now = time(NULL); struct tm *t = gmtime(&now); snprintf(out, outlen, "%02d:%02d", t->tm_hour, t->tm_min); } enum { GM_SB = 0, GM_INCIDENT, GM_TO, GM_FROM, GM_SUBJECT, GM_DATE, GM_TIME, GM_MSG, GM_APPR_NAME, GM_APPR_SIG, GM_APPR_POS, GM_COUNT }; static void general_message_form(void) { static IcsMessage msg; static int initialized = 0; if (!initialized) { memset(&msg, 0, sizeof(msg)); strncpy(msg.sb_line, "SB PKTNET@USA", ICS_SBLINE_MAX - 1); ics_default_date(msg.date, ICS_FIELD_MAX); ics_default_time(msg.time_z, ICS_FIELD_MAX); msg.msg_nlines = 1; initialized = 1; } else { ics_default_date(msg.date, ICS_FIELD_MAX); ics_default_time(msg.time_z, ICS_FIELD_MAX); } int mh = LINES - 2; if (mh > 30) mh = 30; if (mh < 20) mh = 20; int mw = COLS - 4; if (mw > ICS_MSG_COLS + 6) mw = ICS_MSG_COLS + 6; if (mw < 60) mw = 60; int my = (LINES - mh) / 2; if (my < 0) my = 0; int mx = (COLS - mw) / 2; if (mx < 0) mx = 0; WINDOW *w = newwin(mh, mw, my, mx); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); keypad(w, TRUE); nodelay(w, FALSE); curs_set(1); /* Row layout: * 0 border+title * 1 blank * 2 SB Line * 3 1. Incident * 4 2. To * 5 3. From * 6 4. Subject (= BBS title + ICS field 4) * 7 5. Date * 8 6. Time * 9 -------- 7. Message -------- (top border of textarea) * 10..9+msg_rows textarea * 10+msg_rows --------------- (bottom border) * 11+msg_rows 8. Name * 12+msg_rows Sig * 13+msg_rows Position * 14+msg_rows hint * 15+msg_rows border (= mh-1) */ const int label_x = 2; const int field_x = 16; const int field_w = mw - field_x - 3; const int y_sb = 2; const int y_incident = 3; const int y_to = 4; const int y_from = 5; const int y_subject = 6; const int y_date = 7; const int y_time = 8; const int y_msgtop = 9; /* top '-' border doubles as "7. Message" label */ int msg_rows = mh - 16; if (msg_rows > ICS_MSG_ROWS) msg_rows = ICS_MSG_ROWS; if (msg_rows < 4) msg_rows = 4; int y_msgbot = y_msgtop + msg_rows + 1; int y_apprname = y_msgbot + 1; int y_apprsig = y_msgbot + 2; int y_apprpos = y_msgbot + 3; int y_hint = y_msgbot + 4; int ta_cols = (ICS_MSG_COLS < field_w) ? ICS_MSG_COLS : field_w; TextArea ta; ta.lines = msg.msg_lines; ta.nlines = &msg.msg_nlines; ta.cur_line = 0; ta.cur_col = 0; ta.top_line = 0; int focus = GM_SB; int running = 1; int send_failed = 0; while (running) { werase(w); draw_box(w); mvwprintw(w, 0, (mw - 27) / 2, " General Message (ICS-213) "); /* Labels */ mvwprintw(w, y_sb, label_x, "SB Line:"); mvwprintw(w, y_incident, label_x, "1. Incident:"); mvwprintw(w, y_to, label_x, "2. To:"); mvwprintw(w, y_from, label_x, "3. From:"); mvwprintw(w, y_subject, label_x, "4. Subject:"); mvwprintw(w, y_date, label_x, "5. Date:"); mvwprintw(w, y_time, label_x, "6. Time:"); mvwprintw(w, y_apprname, label_x, "8. Name:"); mvwprintw(w, y_apprsig, label_x, " Sig:"); mvwprintw(w, y_apprpos, label_x, " Position:"); /* Field values */ mvwprintw(w, y_sb, field_x, "%-*s", field_w, msg.sb_line); mvwprintw(w, y_subject, field_x, "%-*s", field_w, msg.subject); mvwprintw(w, y_incident, field_x, "%-*s", field_w, msg.incident); mvwprintw(w, y_to, field_x, "%-*s", field_w, msg.to); mvwprintw(w, y_from, field_x, "%-*s", field_w, msg.from); mvwprintw(w, y_date, field_x, "%-*s", field_w, msg.date); mvwprintw(w, y_time, field_x, "%-*s", field_w, msg.time_z); /* Show static "Z" suffix after the time value */ mvwprintw(w, y_time, field_x + (int)strlen(msg.time_z), "Z"); mvwprintw(w, y_apprname, field_x, "%-*s", field_w, msg.appr_name); mvwprintw(w, y_apprsig, field_x, "%-*s", field_w, msg.appr_sig); mvwprintw(w, y_apprpos, field_x, "%-*s", field_w, msg.appr_pos); /* Message section borders with "7. Message" label centred in the top bar */ for (int c = label_x; c < mw - label_x && c < mw - 1; c++) { mvwaddch(w, y_msgtop, c, '-'); mvwaddch(w, y_msgbot, c, '-'); } mvwprintw(w, y_msgtop, (mw - 13) / 2, " 7. Message "); ta_draw(w, &ta, y_msgtop + 1, label_x + 1, msg_rows, ta_cols); if (send_failed) mvwprintw(w, y_hint, label_x, "%-*s", mw - label_x - 2, "Send failed - see output. F2=retry Esc=Close"); else mvwprintw(w, y_hint, label_x, "%-*s", mw - label_x - 2, "Tab/Up/Down=move F2=Send Esc=Close (Time=UTC, Z auto)"); int ch; switch (focus) { case GM_SB: ch = ics_edit_field(w, y_sb, field_x, field_w, msg.sb_line, ICS_SBLINE_MAX); break; case GM_SUBJECT: ch = ics_edit_field(w, y_subject, field_x, field_w, msg.subject, ICS_FIELD_MAX); break; case GM_INCIDENT: ch = ics_edit_field(w, y_incident, field_x, field_w, msg.incident, ICS_FIELD_MAX); break; case GM_TO: ch = ics_edit_field(w, y_to, field_x, field_w, msg.to, ICS_FIELD_MAX); break; case GM_FROM: ch = ics_edit_field(w, y_from, field_x, field_w, msg.from, ICS_FIELD_MAX); break; case GM_DATE: ch = ics_edit_field(w, y_date, field_x, field_w, msg.date, ICS_FIELD_MAX); break; case GM_TIME: ch = ics_edit_field(w, y_time, field_x, field_w, msg.time_z, ICS_FIELD_MAX); break; case GM_APPR_NAME: ch = ics_edit_field(w, y_apprname, field_x, field_w, msg.appr_name, ICS_FIELD_MAX); break; case GM_APPR_SIG: ch = ics_edit_field(w, y_apprsig, field_x, field_w, msg.appr_sig, ICS_FIELD_MAX); break; case GM_APPR_POS: ch = ics_edit_field(w, y_apprpos, field_x, field_w, msg.appr_pos, ICS_FIELD_MAX); break; case GM_MSG: default: { wmove(w, y_msgtop + 1 + (ta.cur_line - ta.top_line), label_x + 1 + ta.cur_col); wrefresh(w); int raw = wgetch(w); if (raw == KEY_F(2)) { ch = KEY_F(2); break; } int r = ta_handle_key(&ta, raw, msg_rows, ta_cols); if (r == '\t' || r == 27) ch = r; else continue; break; } } send_failed = 0; if (ch == KEY_F(2)) { if (ics_send_message(&msg)) { running = 0; break; } redrawwin(w); wrefresh(w); send_failed = 1; continue; } if (ch == 27) { running = 0; break; } if (ch == '\t' || ch == KEY_DOWN || ch == '\r' || ch == '\n' || ch == KEY_ENTER) { focus++; if (focus >= GM_COUNT) focus = GM_SB; } else if (ch == KEY_UP) { focus--; if (focus < GM_SB) focus = GM_APPR_POS; } } delwin(w); curs_set(1); force_repaint(); } /* ═══════════════════════════════════════════════════════════ * PKTNET Packet Check-In (Ctrl+A -> P) * Mirrors https://vden.org/pktnet/check_in.html * ═══════════════════════════════════════════════════════════ */ /* Small modal picker: shows a bordered list of options, highlights * *cur_idx, lets Up/Down move, Enter/Space confirms, Esc cancels * (leaving *cur_idx unchanged). Mirrors the existing mon_options_page * style of list UI already used elsewhere in this program. */ static void chkin_pick(const char *title, const char **opts, int n, int *cur_idx) { int mh = n + 4; if (mh < 6) mh = 6; int mw = 30; for (int i = 0; i < n; i++) { int l = (int)strlen(opts[i]) + 6; if (l > mw) mw = l; } int my = (LINES - mh) / 2; if (my < 0) my = 0; int mx = (COLS - mw) / 2; if (mx < 0) mx = 0; WINDOW *w = newwin(mh, mw, my, mx); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); keypad(w, TRUE); nodelay(w, FALSE); int sel = (*cur_idx >= 0 && *cur_idx < n) ? *cur_idx : 0; for (;;) { werase(w); draw_box(w); int tlen = (int)strlen(title) + 2; mvwprintw(w, 0, (mw - tlen) / 2 > 0 ? (mw - tlen) / 2 : 0, " %s ", title); for (int i = 0; i < n; i++) { if (i == sel) wattron(w, A_REVERSE); mvwprintw(w, 2 + i, 3, "%-*s", mw - 5, opts[i]); if (i == sel) wattroff(w, A_REVERSE); } mvwprintw(w, mh - 1, 2, "Enter=pick Esc=cancel"); wrefresh(w); int ch = wgetch(w); switch (ch) { case KEY_UP: sel = (sel - 1 + n) % n; break; case KEY_DOWN: sel = (sel + 1) % n; break; case ' ': case '\r': case '\n': case KEY_ENTER: *cur_idx = sel; delwin(w); force_repaint(); return; case 27: case 'Q': case 'q': delwin(w); force_repaint(); return; } } } /* Sends the check-in over its OWN background connection (same as * ics_send_message) - the main session's connection is never touched. */ static int chkin_send(const PktnetCheckin *f) { int idx = pick_host("Send via host"); if (idx < 0) { append_out("Send cancelled - no host selected.\n", 37, COLOR_PAIR(CP_YELLOW)); return 0; } int fd = bg_connect_and_login(idx); if (fd < 0) return 0; append_out("[BG] --- Sending PKTNET Packet Check-In ---\n", 46, COLOR_PAIR(CP_MAGENTA) | A_BOLD); int aborted = 0; bg_send_line(fd, f->sb_line, &aborted); if (!aborted) bg_send_line(fd, f->subject, &aborted); if (!aborted) { char raw[40][ICS_MSG_LINELEN]; int n = 0; strncpy(raw[n], "PACKET CHECK-IN", ICS_MSG_LINELEN - 1); raw[n][ICS_MSG_LINELEN-1]=0; n++; if (f->agency[0]) { strncpy(raw[n], f->agency, ICS_MSG_LINELEN - 1); raw[n][ICS_MSG_LINELEN-1]=0; n++; } raw[n++][0] = '\0'; strncpy(raw[n], "1. STATION", ICS_MSG_LINELEN - 1); raw[n][ICS_MSG_LINELEN-1]=0; n++; raw[n++][0] = '\0'; snprintf(raw[n++], ICS_MSG_LINELEN, "a. Date/Time: %s", f->date_time); raw[n++][0] = '\0'; snprintf(raw[n++], ICS_MSG_LINELEN, "b. To: %s", f->to); raw[n++][0] = '\0'; snprintf(raw[n++], ICS_MSG_LINELEN, "c. From: %s", f->from); snprintf(raw[n++], ICS_MSG_LINELEN, "d. Station Contact Name: %s", f->contact_name); snprintf(raw[n++], ICS_MSG_LINELEN, "e. Initial Operator(s): %s", f->operators); raw[n++][0] = '\0'; strncpy(raw[n], "2. SESSION", ICS_MSG_LINELEN - 1); raw[n][ICS_MSG_LINELEN-1]=0; n++; raw[n++][0] = '\0'; snprintf(raw[n++], ICS_MSG_LINELEN, "a. Type: %s", chkin_type_opts[f->type_idx]); snprintf(raw[n++], ICS_MSG_LINELEN, "b. Service: AMATEUR"); snprintf(raw[n++], ICS_MSG_LINELEN, "c. Band: %s", chkin_band_opts[f->band_idx]); raw[n++][0] = '\0'; snprintf(raw[n++], ICS_MSG_LINELEN, "d. Session: %s", chkin_session_opts[f->session_idx]); raw[n++][0] = '\0'; raw[n++][0] = '\0'; strncpy(raw[n], "3. LOCATION", ICS_MSG_LINELEN - 1); raw[n][ICS_MSG_LINELEN-1]=0; n++; raw[n++][0] = '\0'; snprintf(raw[n++], ICS_MSG_LINELEN, "a. Location: %s", f->location); raw[n++][0] = '\0'; snprintf(raw[n++], ICS_MSG_LINELEN, "b. GRID SQUARE: %s", f->grid); raw[n++][0] = '\0'; raw[n++][0] = '\0'; /* "4. COMMENTS:" label, then the comment text on its own line(s) so a * full-length first comment line can never be truncated by the label. */ strncpy(raw[n], "4. COMMENTS:", ICS_MSG_LINELEN - 1); raw[n][ICS_MSG_LINELEN-1]=0; n++; for (int i = 0; i < f->comment_nlines && n < 40; i++) { strncpy(raw[n], f->comment_lines[i], ICS_MSG_LINELEN - 1); raw[n][ICS_MSG_LINELEN-1] = 0; n++; } /* Word-wrap everything into one flat block, then send it all at * once with no per-line pacing. */ char body[ICS_MSG_LINES][ICS_MSG_LINELEN]; int bn = 0; for (int i = 0; i < n && bn < ICS_MSG_LINES; i++) { char wrapped[ICS_MSG_LINES][ICS_MSG_LINELEN]; int nwrapped = ics_wrap_line(raw[i], wrapped, ICS_MSG_LINES, ICS_MSG_COLS); for (int j = 0; j < nwrapped && bn < ICS_MSG_LINES; j++) { strncpy(body[bn], wrapped[j], ICS_MSG_LINELEN - 1); body[bn][ICS_MSG_LINELEN - 1] = '\0'; bn++; } } bg_send_block(fd, body, bn, &aborted); } if (!aborted) bg_send_line(fd, "/EX", &aborted); close(fd); if (aborted) { append_out("[BG] --- BBS reported an error - check-in send aborted ---\n", 61, COLOR_PAIR(CP_RED) | A_BOLD); return 0; } append_out("[BG] --- Packet Check-In sent ---\n", 35, COLOR_PAIR(CP_GREEN) | A_BOLD); return 1; } enum { CK_SB = 0, CK_SUBJECT, CK_AGENCY, CK_DATETIME, CK_TO, CK_FROM, CK_CONTACT, CK_OPERATORS, CK_TYPE, CK_BAND, CK_SESSION, CK_LOCATION, CK_GRID, CK_COMMENTS, CK_COUNT }; static void pktnet_checkin_form(void) { static PktnetCheckin f; static int initialized = 0; if (!initialized) { memset(&f, 0, sizeof(f)); strncpy(f.sb_line, "SB PKTNET@USA", ICS_SBLINE_MAX - 1); strncpy(f.to, "PKTNET@USA", CHKIN_FIELD_MAX - 1); if (S->host_idx >= 0 && hosts[S->host_idx].username[0]) strncpy(f.from, hosts[S->host_idx].username, CHKIN_FIELD_MAX - 1); f.type_idx = 0; /* EXERCISE */ f.band_idx = 2; /* VHF */ f.session_idx = 1; /* AX25 Packet */ f.comment_nlines = 1; initialized = 1; } ics_default_datetime(f.date_time, ICS_DT_MAX); int mh = LINES - 2; if (mh > 30) mh = 30; int mw = COLS - 4; if (mw > ICS_MSG_COLS + 6) mw = ICS_MSG_COLS + 6; if (mh < 26) mh = 26; if (mw < 60) mw = 60; int my = (LINES - mh) / 2; if (my < 0) my = 0; int mx = (COLS - mw) / 2; if (mx < 0) mx = 0; WINDOW *w = newwin(mh, mw, my, mx); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); keypad(w, TRUE); nodelay(w, FALSE); curs_set(1); const int label_x = 2; const int field_x = 18; const int field_w = mw - field_x - 3; /* Row layout */ const int y_sb = 2; const int y_subject = 3; const int y_agency = 4; const int y_datetime = 6; const int y_to = 7; const int y_from = 8; const int y_contact = 9; const int y_operators= 10; const int y_type = 12; const int y_band = 13; const int y_session = 14; const int y_location = 16; const int y_grid = 17; const int y_commentlbl = 19; const int y_commentbox = 20; int comment_rows = mh - y_commentbox - 4; if (comment_rows > 6) comment_rows = 6; if (comment_rows < 3) comment_rows = 3; int ck_ta_cols = (ICS_MSG_COLS < field_w) ? ICS_MSG_COLS : field_w; TextArea ta; ta.lines = f.comment_lines; ta.nlines = &f.comment_nlines; ta.cur_line = 0; ta.cur_col = 0; ta.top_line = 0; int focus = CK_SB; int running = 1; int just_sent = 0; while (running) { werase(w); draw_box(w); mvwprintw(w, 0, (mw - 22) / 2, " PKTNET Packet Check-In "); mvwprintw(w, y_sb, label_x, "SB Line:"); mvwprintw(w, y_subject, label_x, "Subject:"); mvwprintw(w, y_agency, label_x, "Agency/Group:"); mvwprintw(w, y_datetime, label_x, "1a Date/Time:"); mvwprintw(w, y_to, label_x, "1b To:"); mvwprintw(w, y_from, label_x, "1c From:"); mvwprintw(w, y_contact, label_x, "1d Contact:"); mvwprintw(w, y_operators, label_x, "1e Operators:"); mvwprintw(w, y_type, label_x, "2a Type:"); mvwprintw(w, y_band, label_x, "2c Band:"); mvwprintw(w, y_session, label_x, "2d Session:"); mvwprintw(w, y_location, label_x, "3a Location:"); mvwprintw(w, y_grid, label_x, "3b Grid Sq:"); mvwprintw(w, y_commentlbl, label_x, "4. Comments:"); mvwprintw(w, y_sb, field_x, "%-*s", field_w, f.sb_line); mvwprintw(w, y_subject, field_x, "%-*s", field_w, f.subject); mvwprintw(w, y_agency, field_x, "%-*s", field_w, f.agency); mvwprintw(w, y_datetime, field_x, "%-*s", field_w, f.date_time); mvwprintw(w, y_to, field_x, "%-*s", field_w, f.to); mvwprintw(w, y_from, field_x, "%-*s", field_w, f.from); mvwprintw(w, y_contact, field_x, "%-*s", field_w, f.contact_name); mvwprintw(w, y_operators, field_x, "%-*s", field_w, f.operators); if (focus == CK_TYPE) wattron(w, A_REVERSE); mvwprintw(w, y_type, field_x, "%-20s", chkin_type_opts[f.type_idx]); if (focus == CK_TYPE) wattroff(w, A_REVERSE); mvwprintw(w, y_type, field_x + 22, "(Service: AMATEUR)"); if (focus == CK_BAND) wattron(w, A_REVERSE); mvwprintw(w, y_band, field_x, "%-20s", chkin_band_opts[f.band_idx]); if (focus == CK_BAND) wattroff(w, A_REVERSE); if (focus == CK_SESSION) wattron(w, A_REVERSE); mvwprintw(w, y_session, field_x, "%-20s", chkin_session_opts[f.session_idx]); if (focus == CK_SESSION) wattroff(w, A_REVERSE); mvwprintw(w, y_location, field_x, "%-*s", field_w, f.location); mvwprintw(w, y_grid, field_x, "%-*s", field_w, f.grid); for (int c = label_x; c < label_x + ICS_MSG_COLS + 2 && c < mw - 1; c++) { mvwaddch(w, y_commentbox, c, '-'); mvwaddch(w, y_commentbox + comment_rows + 1, c, '-'); } ta_draw(w, &ta, y_commentbox + 1, label_x + 1, comment_rows, (ICS_MSG_COLS < field_w) ? ICS_MSG_COLS : field_w); mvwprintw(w, mh - 3, label_x, "Tab/Up/Down move fields Enter/Space picks on Type/Band/Session"); mvwprintw(w, mh - 2, label_x, "F2=Send Esc=Cancel/Close"); if (just_sent) mvwprintw(w, mh - 4, label_x, "Sent. Edit and F2 to send again, or Esc to close."); wrefresh(w); int ch; switch (focus) { case CK_SB: ch = ics_edit_field(w, y_sb, field_x, field_w, f.sb_line, ICS_SBLINE_MAX); break; case CK_SUBJECT: ch = ics_edit_field(w, y_subject, field_x, field_w, f.subject, CHKIN_FIELD_MAX); break; case CK_AGENCY: ch = ics_edit_field(w, y_agency, field_x, field_w, f.agency, CHKIN_FIELD_MAX); break; case CK_DATETIME: ch = ics_edit_field(w, y_datetime, field_x, field_w, f.date_time, ICS_DT_MAX); break; case CK_TO: ch = ics_edit_field(w, y_to, field_x, field_w, f.to, CHKIN_FIELD_MAX); break; case CK_FROM: ch = ics_edit_field(w, y_from, field_x, field_w, f.from, CHKIN_FIELD_MAX); break; case CK_CONTACT: ch = ics_edit_field(w, y_contact, field_x, field_w, f.contact_name, CHKIN_FIELD_MAX); break; case CK_OPERATORS: ch = ics_edit_field(w, y_operators,field_x, field_w, f.operators, CHKIN_FIELD_MAX); break; case CK_LOCATION: ch = ics_edit_field(w, y_location, field_x, field_w, f.location, CHKIN_FIELD_MAX); break; case CK_GRID: ch = ics_edit_field(w, y_grid, field_x, field_w, f.grid, CHKIN_FIELD_MAX); break; case CK_TYPE: wmove(w, y_type, field_x); ch = wgetch(w); if (ch == ' ' || ch == '\r' || ch == '\n' || ch == KEY_ENTER) chkin_pick("Type", chkin_type_opts, chkin_type_n, &f.type_idx); break; case CK_BAND: wmove(w, y_band, field_x); ch = wgetch(w); if (ch == ' ' || ch == '\r' || ch == '\n' || ch == KEY_ENTER) chkin_pick("Band", chkin_band_opts, chkin_band_n, &f.band_idx); break; case CK_SESSION: wmove(w, y_session, field_x); ch = wgetch(w); if (ch == ' ' || ch == '\r' || ch == '\n' || ch == KEY_ENTER) chkin_pick("Session", chkin_session_opts, chkin_session_n, &f.session_idx); break; case CK_COMMENTS: default: { wmove(w, y_commentbox + 1 + (ta.cur_line - ta.top_line), label_x + 1 + ta.cur_col); wrefresh(w); int raw = wgetch(w); if (raw == KEY_F(2)) { ch = KEY_F(2); break; } int r = ta_handle_key(&ta, raw, comment_rows, ck_ta_cols); if (r == '\t' || r == 27) ch = r; else continue; break; } } just_sent = 0; if (ch == KEY_F(2)) { /* Send */ if (chkin_send(&f)) { running = 0; /* success: behave like Esc was pressed */ break; } /* See general_message_form()'s F2 handler for why this is here. */ redrawwin(w); wrefresh(w); just_sent = 1; /* failed/aborted: stay open, show status */ continue; } if (ch == 27) { /* Esc closes the form */ running = 0; break; } if (ch == '\t' || ch == KEY_DOWN || ch == '\r' || ch == '\n' || ch == KEY_ENTER) { focus++; if (focus > CK_COMMENTS) focus = CK_SB; } else if (ch == KEY_UP) { focus--; if (focus < CK_SB) focus = CK_COMMENTS; } } delwin(w); curs_set(1); force_repaint(); } /* ═══════════════════════════════════════════════════════════ * Session management * ═══════════════════════════════════════════════════════════ */ static int session_count(void) { int n = 0; for (int i = 0; i < MAX_SESSIONS; i++) if (sessions[i].active) n++; return n; } static int session_index(void) /* index of S in sessions[] */ { return (int)(S - sessions); } static void session_switch(int n) { if (n < 0 || n >= MAX_SESSIONS || !sessions[n].active) return; S = &sessions[n]; refresh_all(); draw_fkeys(); } static void session_next(void) { int cur = session_index(); for (int i = 1; i < MAX_SESSIONS; i++) { int n = (cur + i) % MAX_SESSIONS; if (sessions[n].active) { session_switch(n); return; } } } static void session_prev(void) { int cur = session_index(); for (int i = MAX_SESSIONS - 1; i > 0; i--) { int n = (cur + i) % MAX_SESSIONS; if (sessions[n].active) { session_switch(n); return; } } } static void session_new(void) { int slot = -1; for (int i = 0; i < MAX_SESSIONS; i++) if (!sessions[i].active) { slot = i; break; } if (slot < 0) { append_out("Max sessions reached (4).\n", 26, COLOR_PAIR(CP_YELLOW)); return; } int idx = pick_host("New Session — Select Host"); if (idx < 0) return; memset(&sessions[slot], 0, sizeof(Session)); sessions[slot].active = 1; sessions[slot].sock = -1; sessions[slot].host_idx = -1; sessions[slot].hist_pos = -1; snprintf(sessions[slot].label, SESSION_LABEL, "Session %d", slot + 1); S = &sessions[slot]; refresh_all(); draw_fkeys(); do_connect(idx); } static void session_close(void) { if (session_count() <= 1) { if (S->connected) tcp_disconnect(); if (S->logging) close_log(); endwin(); exit(0); } int idx = session_index(); if (S->connected) tcp_disconnect(); if (S->logging) close_log(); session_next(); /* S now points to another active session */ sessions[idx].active = 0; draw_status(); } /* ═══════════════════════════════════════════════════════════ * Connect helper * ═══════════════════════════════════════════════════════════ */ static void do_connect(int idx) { if (idx < 0 || idx >= num_hosts) return; if (S->connected) tcp_disconnect(); char msg[160]; snprintf(msg, sizeof(msg), "Connecting to %s:%s...\n", hosts[idx].host, hosts[idx].port); append_out(msg, (int)strlen(msg), COLOR_PAIR(CP_YELLOW)); S->sock = tcp_connect(hosts[idx].host, hosts[idx].port); if (S->sock < 0) { append_out("Connection failed.\n", 19, COLOR_PAIR(CP_RED)); return; } S->connected = 1; S->host_idx = idx; S->connect_time = time(NULL); snprintf(msg, sizeof(msg), "Connected to %s\n", hosts[idx].name); append_out(msg, (int)strlen(msg), COLOR_PAIR(CP_GREEN) | A_BOLD); if (hosts[idx].username[0]) { char login[256]; int llen = snprintf(login, sizeof(login), "%s\r%s\rBPQTermTCP\r", hosts[idx].username, hosts[idx].password); tcp_send(login, llen); char info[120]; int ilen = snprintf(info, sizeof(info), "[Auto-login: %s]\n", hosts[idx].username); append_out(info, ilen, COLOR_PAIR(CP_YELLOW)); } else { append_out("[No credentials - manual login required]\n", 41, COLOR_PAIR(CP_YELLOW)); } /* P8=1 triggers BPQ's SendPortsForMonitor → arrives as 0xFF 0xFF packet */ send_trace_options_with_ports(); /* Drain incoming data for up to 2 seconds waiting for PORTS response. * A single recv() often gets only the login banner; loop until we have * the full port list (S->ports_loaded) or the deadline passes. */ { char rbuf[4096]; struct timeval t0, now; gettimeofday(&t0, NULL); for (;;) { gettimeofday(&now, NULL); long elapsed = (now.tv_sec - t0.tv_sec) * 1000000L + (now.tv_usec - t0.tv_usec); if (elapsed >= 2000000L) break; if (S->ports_loaded) break; long remain = 2000000L - elapsed; fd_set rfds; struct timeval tv = { remain / 1000000L, remain % 1000000L }; FD_ZERO(&rfds); FD_SET(S->sock, &rfds); if (select(S->sock + 1, &rfds, NULL, NULL, &tv) <= 0) break; int n = recv(S->sock, rbuf, sizeof(rbuf) - 1, 0); if (n <= 0) break; bpq_process(rbuf, n); } } /* Safety-net resend: the very first send_trace_options_with_ports() * call above can race with BPQ still processing login - if BPQ wasn't * ready to parse it yet, the port mask/monitor flags silently fall * back to BPQ's own defaults with no error on either side (seen as * "port shows enabled here but no traffic flows until re-toggled"). * Re-sending once the initial drain has settled costs one extra * trace command and catches that race without needing to detect it. */ if (S->connected && S->sock >= 0) { send_trace_options_with_ports(); char rbuf[4096]; struct timeval tv2 = { 1, 0 }; fd_set rfds2; FD_ZERO(&rfds2); FD_SET(S->sock, &rfds2); if (select(S->sock + 1, &rfds2, NULL, NULL, &tv2) > 0) { int n = recv(S->sock, rbuf, sizeof(rbuf) - 1, 0); if (n > 0) bpq_process(rbuf, n); } } draw_status(); } /* ═══════════════════════════════════════════════════════════ * Forms submenu (Ctrl+A -> F) * ═══════════════════════════════════════════════════════════ */ static void forms_menu(void) { int mh = 8, mw = 40; int my = (LINES - mh) / 2; if (my < 0) my = 0; int mx = (COLS - mw) / 2; if (mx < 0) mx = 0; WINDOW *w = newwin(mh, mw, my, mx); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); draw_box(w); mvwprintw(w, 0, (mw - 9) / 2, " Forms "); mvwprintw(w, 2, 4, "G - General Message (ICS-213)"); mvwprintw(w, 3, 4, "P - PKTNET Packet Check-In"); mvwprintw(w, mh - 2, 4, "Esc = close"); wrefresh(w); nodelay(w, FALSE); int ch = wgetch(w); delwin(w); force_repaint(); switch (ch | 0x20) { case 'g': general_message_form(); break; case 'p': pktnet_checkin_form(); break; } } /* ═══════════════════════════════════════════════════════════ * Ctrl+A main menu * ═══════════════════════════════════════════════════════════ */ static void ctrl_a_menu(void) { int mh = 15, mw = 42; int my = (LINES-mh)/2; if (my<0) my=0; int mx = (COLS-mw)/2; if (mx<0) mx=0; WINDOW *w = newwin(mh, mw, my, mx); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); draw_box(w); mvwprintw(w, 0, (mw-14)/2, " TermTCP Menu "); mvwprintw(w, 2, 4, "C - Connect"); mvwprintw(w, 3, 4, "D - Disconnect"); mvwprintw(w, 4, 4, "N - New Session"); mvwprintw(w, 5, 4, "X - Close Session"); mvwprintw(w, 6, 4, "1-4 Switch Session"); mvwprintw(w, 7, 4, "O - Config / Host Editor"); mvwprintw(w, 8, 4, "M - Monitor Options"); mvwprintw(w, 9, 4, "L - Toggle Log"); mvwprintw(w, 10, 4, "F - Forms & Check-Ins"); mvwprintw(w, 11, 4, "H - Help"); mvwprintw(w, 12, 4, "Q - Quit"); mvwprintw(w, mh-2, 4, "Esc = close"); wrefresh(w); nodelay(w, FALSE); int ch = wgetch(w); delwin(w); force_repaint(); switch (ch | 0x20) { case 'c': { int idx = pick_host("Select Host"); if (idx >= 0) do_connect(idx); break; } break; case 'd': if (S->connected) { tcp_disconnect(); append_out("*** Disconnected\n", 17, COLOR_PAIR(CP_RED)); } break; case 'o': config_menu(); break; case 'm': monitor_options_menu(); break; case 'l': if (S->logging) { close_log(); append_out("[Logging OFF]\n", 14, COLOR_PAIR(CP_YELLOW)); } else if (S->connected && S->host_idx >= 0) { char prefix[120]; snprintf(prefix, sizeof(prefix), "%s_%s", hosts[S->host_idx].host, hosts[S->host_idx].port); open_log(prefix); append_out("[Logging ON]\n", 13, COLOR_PAIR(CP_YELLOW)); } else { append_out("Connect first to start S->logging.\n", 32, COLOR_PAIR(CP_YELLOW)); } draw_status(); break; case 'f': forms_menu(); break; case 'h': show_help(); break; case 'n': session_new(); break; case 'x': session_close(); break; case '1': case '2': case '3': case '4': session_switch(ch - '1'); break; case 'q': if (S->connected) tcp_disconnect(); if (S->logging) close_log(); endwin(); exit(0); } draw_input(); } /* ═══════════════════════════════════════════════════════════ * Key handler * ═══════════════════════════════════════════════════════════ */ static void handle_key(int ch) { switch (ch) { case 1: /* Ctrl+A */ ctrl_a_menu(); return; case KEY_F(1): show_help(); return; case KEY_F(3): { int idx = pick_host("Select Host"); if (idx >= 0) do_connect(idx); return; } case KEY_F(4): if (S->connected) { tcp_disconnect(); append_out("*** Disconnected\n", 17, COLOR_PAIR(CP_RED)); draw_status(); } return; case KEY_F(5): forms_menu(); return; case KEY_F(6): if (S->logging) { close_log(); append_out("[Logging OFF]\n", 14, COLOR_PAIR(CP_YELLOW)); } else if (S->connected && S->host_idx >= 0) { char prefix[120]; snprintf(prefix, sizeof(prefix), "%s_%s", hosts[S->host_idx].host, hosts[S->host_idx].port); open_log(prefix); append_out("[Logging ON]\n", 13, COLOR_PAIR(CP_YELLOW)); } else { append_out("Connect first to start S->logging.\n", 32, COLOR_PAIR(CP_YELLOW)); } draw_status(); return; case KEY_F(7): session_new(); return; case KEY_F(8): session_prev(); return; case KEY_F(9): session_next(); return; case KEY_F(10): if (S->connected) tcp_disconnect(); if (S->logging) close_log(); endwin(); exit(0); case KEY_RESIZE: endwin(); refresh(); clear(); create_windows(); refresh_all(); return; case KEY_PPAGE: /* PgUp — scroll output back */ if (out_win) { int page = getmaxy(out_win); S->out_scroll += page; int max_scroll = S->out_sbuf_count > 0 ? S->out_sbuf_count - 1 : 0; if (S->out_scroll > max_scroll) S->out_scroll = max_scroll; scrl_redraw_out(); update_sep_labels(); if (input_win) wrefresh(input_win); } return; case KEY_NPAGE: /* PgDn — scroll output forward / return to live */ if (out_win && S->out_scroll > 0) { int page = getmaxy(out_win); S->out_scroll -= page; if (S->out_scroll < 0) S->out_scroll = 0; scrl_redraw_out(); update_sep_labels(); if (input_win) wrefresh(input_win); } return; case KEY_HOME: /* Home — scroll monitor back */ if (mon_win && mon_visible) { int page = getmaxy(mon_win); S->mon_scroll += page; int max_scroll = S->mon_sbuf_count > 0 ? S->mon_sbuf_count - 1 : 0; if (S->mon_scroll > max_scroll) S->mon_scroll = max_scroll; scrl_redraw_mon(); if (input_win) wrefresh(input_win); } return; case KEY_END: /* End — scroll monitor forward / return to live */ if (mon_win && mon_visible && S->mon_scroll > 0) { int page = getmaxy(mon_win); S->mon_scroll -= page; if (S->mon_scroll < 0) S->mon_scroll = 0; scrl_redraw_mon(); if (input_win) wrefresh(input_win); } return; case '\r': case '\n': case KEY_ENTER: if (S->input_len == 0) return; S->input_buf[S->input_len] = '\0'; /* Save to history */ strncpy(S->hist_buf[S->hist_head], S->input_buf, INPUT_MAX - 1); S->hist_buf[S->hist_head][INPUT_MAX - 1] = '\0'; S->hist_head = (S->hist_head + 1) % HIST_MAX; if (S->hist_count < HIST_MAX) S->hist_count++; S->hist_pos = -1; if (S->connected) { tcp_send(S->input_buf, S->input_len); tcp_send("\r", 1); char echo[INPUT_MAX + 4]; int elen = snprintf(echo, sizeof(echo), "> %s\n", S->input_buf); append_out(echo, elen, COLOR_PAIR(CP_CYAN) | A_BOLD); } else { append_out("Not S->connected -- Ctrl+A to open menu.\n", 38, COLOR_PAIR(CP_YELLOW)); } S->input_len = 0; break; case KEY_UP: /* history: go back */ if (S->hist_count == 0) break; if (S->hist_pos == -1) { strncpy(S->hist_save, S->input_buf, INPUT_MAX - 1); S->hist_save[INPUT_MAX - 1] = '\0'; S->hist_pos = 0; } else if (S->hist_pos < S->hist_count - 1) { S->hist_pos++; } { int idx = (S->hist_head - 1 - S->hist_pos + HIST_MAX * 2) % HIST_MAX; strncpy(S->input_buf, S->hist_buf[idx], INPUT_MAX - 1); S->input_buf[INPUT_MAX - 1] = '\0'; S->input_len = (int)strlen(S->input_buf); } break; case KEY_DOWN: /* history: go forward */ if (S->hist_pos < 0) break; S->hist_pos--; if (S->hist_pos < 0) { S->input_buf[0] = '\0'; /* always end with blank line */ } else { int idx = (S->hist_head - 1 - S->hist_pos + HIST_MAX * 2) % HIST_MAX; strncpy(S->input_buf, S->hist_buf[idx], INPUT_MAX - 1); S->input_buf[INPUT_MAX - 1] = '\0'; } S->input_len = (int)strlen(S->input_buf); break; case KEY_BACKSPACE: case 127: case '\b': if (S->input_len > 0) S->input_len--; break; default: if (ch >= 32 && ch < 256 && S->input_len < INPUT_MAX - 1) S->input_buf[S->input_len++] = (char)ch; break; } draw_input(); } /* ═══════════════════════════════════════════════════════════ * Main event loop * ═══════════════════════════════════════════════════════════ */ /* Shown when Ctrl+C (SIGINT) is caught. Returns 1 if the user * confirms quitting, 0 to resume exactly where they were. */ static int confirm_quit_popup(void) { int mh = 7, mw = 40; int my = (LINES - mh) / 2; if (my < 0) my = 0; int mx = (COLS - mw) / 2; if (mx < 0) mx = 0; WINDOW *w = newwin(mh, mw, my, mx); wbkgd(w, COLOR_PAIR(CP_DEFAULT)); draw_box(w); mvwprintw(w, 0, (mw - 8) / 2, " Quit? "); mvwprintw(w, 2, 3, "Ctrl+C pressed."); if (S->connected) mvwprintw(w, 3, 3, "This will close the current session."); mvwprintw(w, mh - 2, 3, "Y = quit N / Esc = resume"); wrefresh(w); nodelay(w, FALSE); keypad(w, TRUE); int ch = wgetch(w); delwin(w); force_repaint(); return (ch == 'y' || ch == 'Y'); } void interactive_loop(void) { fd_set readfds; struct timeval tv; char recv_buf[1024]; draw_input(); while (1) { if (sigint_pending) { sigint_pending = 0; if (confirm_quit_popup()) { if (S->connected) tcp_disconnect(); if (S->logging) close_log(); endwin(); exit(0); } draw_input(); } draw_status(); /* Watch stdin + every connected session's socket */ FD_ZERO(&readfds); FD_SET(STDIN_FILENO, &readfds); int maxfd = STDIN_FILENO; for (int i = 0; i < MAX_SESSIONS; i++) { if (sessions[i].active && sessions[i].connected && sessions[i].sock >= 0) { FD_SET(sessions[i].sock, &readfds); if (sessions[i].sock > maxfd) maxfd = sessions[i].sock; } } tv.tv_sec = 1; tv.tv_usec = 0; int ready = select(maxfd + 1, &readfds, NULL, NULL, &tv); if (ready < 0) continue; /* Network data — process all active sessions */ Session *active = S; for (int i = 0; i < MAX_SESSIONS; i++) { if (!sessions[i].active || !sessions[i].connected || sessions[i].sock < 0) continue; if (!FD_ISSET(sessions[i].sock, &readfds)) continue; int is_bg = (&sessions[i] != active); S = &sessions[i]; if (is_bg) suppress_draw = 1; int n = recv(S->sock, recv_buf, sizeof(recv_buf)-1, 0); if (n > 0) { bpq_process(recv_buf, n); if (!is_bg) draw_input(); } else if (n == 0 || (n < 0 && errno != EAGAIN)) { append_out("\n*** Server closed connection ***\n", 33, COLOR_PAIR(CP_RED)); tcp_disconnect(); } suppress_draw = 0; S = active; } /* Keyboard */ if (FD_ISSET(STDIN_FILENO, &readfds)) { int k; while ((k = wgetch(input_win)) != ERR) handle_key(k); } } } /* ═══════════════════════════════════════════════════════════ * main() * ═══════════════════════════════════════════════════════════ */ int main(int argc, char *argv[]) { int auto_connect = -1; for (int i = 1; i < argc; i++) { if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--connect") == 0) && i+1 < argc) auto_connect = atoi(argv[++i]); else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { printf("Usage: termtcp [-c N]\n -c N auto-connect to host N\n"); return 0; } } /* Initialise session 0 — the one and only session in CLI mode */ memset(sessions, 0, sizeof(sessions)); sessions[0].active = 1; sessions[0].sock = -1; sessions[0].host_idx = -1; sessions[0].hist_pos = -1; snprintf(sessions[0].label, SESSION_LABEL, "Session 1"); S = &sessions[0]; /* Set default port names before load_config so INI PortName keys override them */ for (int i = 0; i < 32; i++) snprintf(port_names[i], 32, "Port %d", i + 1); load_config(); setlocale(LC_ALL, ""); /* enable UTF-8 so ncursesw renders box chars correctly */ initscr(); cbreak(); noecho(); set_escdelay(25); /* fast Escape key response */ init_colors(); curs_set(1); create_windows(); draw_status(); { /* Trap Ctrl+C so it asks for confirmation instead of killing the * program outright. SA_RESTART deliberately omitted: select() in * interactive_loop() should return EINTR so the pending flag is * noticed on the very next loop iteration rather than after the * current 1-second poll silently restarts. */ struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = on_sigint; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGINT, &sa, NULL); } { const char *banner = "TermTCP v" VERSION " - Ctrl+A opens the menu\n"; append_out(banner, (int)strlen(banner), COLOR_PAIR(CP_CYAN) | A_BOLD); } if (num_hosts > 0) { char msg[128]; snprintf(msg, sizeof(msg), "Loaded %d host(s). Ctrl+A then C to connect.\n", num_hosts); append_out(msg, (int)strlen(msg), COLOR_PAIR(CP_DEFAULT)); } else { append_out("No hosts configured. Ctrl+A then O to edit config.\n", 51, COLOR_PAIR(CP_YELLOW)); } if (auto_connect >= 0) do_connect(auto_connect); interactive_loop(); if (S->connected) tcp_disconnect(); if (S->logging) close_log(); endwin(); return 0; }