diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39a8ce9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +termtcp +termtcp_cli.o +termtcp.o +*.log diff --git a/termtcp_cli.c b/termtcp_cli.c index 4b125bb..f30680e 100644 --- a/termtcp_cli.c +++ b/termtcp_cli.c @@ -37,7 +37,7 @@ #define PATH_MAX 4096 #endif -#define VERSION "0.0.52" +#define VERSION "0.0.53" #define MAX_HOSTS 4 #define INPUT_MAX 512 #define CONF_FILE ".termtcprc" @@ -78,6 +78,21 @@ int sock = -1; int connected = 0; time_t connect_time = 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; +} + /* ── Logging ─────────────────────────────────────────────── */ int logging = 0; FILE *log_file = NULL; @@ -135,6 +150,64 @@ WINDOW *input_win = NULL; char input_buf[INPUT_MAX]; int input_len = 0; +/* ── 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); @@ -1100,6 +1173,194 @@ static void config_menu(void) 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) * ═══════════════════════════════════════════════════════════ */ @@ -1152,6 +1413,7 @@ static int mon_options_page(void) case ' ': case '\r': case '\n': case KEY_ENTER: if (cur == 5) { mon_visible = !mon_visible; + save_config(); delwin(w); create_windows(); force_repaint(); @@ -1159,6 +1421,7 @@ static int mon_options_page(void) } *vals[cur] = !(*vals[cur]); send_trace_options(); + save_config(); break; case 27: case 'Q': case 'q': delwin(w); @@ -1218,6 +1481,7 @@ static int mon_ports_page(void) 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); @@ -1254,13 +1518,14 @@ static void show_help(void) 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 H This help"); - mvwprintw(w, 9, 3, "Ctrl+A Q Quit"); - mvwprintw(w, 11, 3, "Enter Send line to server"); - mvwprintw(w, 12, 3, "Up / Down Input history"); - mvwprintw(w, 13, 3, "PgUp / PgDn Scroll output window"); - mvwprintw(w, 14, 3, "Home / End Scroll monitor window"); - mvwprintw(w, 15, 3, "Backspace Delete char"); + 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); @@ -1270,6 +1535,1005 @@ static void show_help(void) 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 `sock`/`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 (in_mon_frame, mon_flen, + * 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 connected fd, or + * -1 on failure (with a message already echoed to out_win). This is + * intentionally separate from do_connect()/sock/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 (current_host >= 0 && hosts[current_host].username[0]) + strncpy(f.from, hosts[current_host].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(); +} + /* ═══════════════════════════════════════════════════════════ * Connect helper * ═══════════════════════════════════════════════════════════ */ @@ -1338,9 +2602,62 @@ static void do_connect(int idx) } } + /* 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 (connected && sock >= 0) { + send_trace_options_with_ports(); + char rbuf[4096]; + struct timeval tv2 = { 1, 0 }; + fd_set rfds2; + FD_ZERO(&rfds2); FD_SET(sock, &rfds2); + if (select(sock + 1, &rfds2, NULL, NULL, &tv2) > 0) { + int n = recv(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 * ═══════════════════════════════════════════════════════════ */ @@ -1355,13 +2672,14 @@ static void ctrl_a_menu(void) 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, "O - Config / Host Editor"); - mvwprintw(w, 5, 4, "M - Monitor Options"); - mvwprintw(w, 6, 4, "L - Toggle Log"); - mvwprintw(w, 7, 4, "H - Help"); - mvwprintw(w, 8, 4, "Q - Quit"); + mvwprintw(w, 2, 4, "C - Connect"); + mvwprintw(w, 3, 4, "D - Disconnect"); + mvwprintw(w, 4, 4, "O - Config / Host Editor"); + mvwprintw(w, 5, 4, "M - Monitor Options"); + mvwprintw(w, 6, 4, "L - Toggle Log"); + mvwprintw(w, 7, 4, "F - Forms & Check-Ins"); + mvwprintw(w, 8, 4, "H - Help"); + mvwprintw(w, 9, 4, "Q - Quit"); mvwprintw(w, mh-2, 4, "Esc = close"); wrefresh(w); @@ -1371,37 +2689,11 @@ static void ctrl_a_menu(void) force_repaint(); switch (ch | 0x20) { - case 'c': - if (num_hosts == 1) { - do_connect(0); - } else if (num_hosts > 1) { - /* Auto-size width to fit the longest entry */ - 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); - mvwprintw(hw, 0, (pw-14)/2, " Select Host "); - 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); - int hch = wgetch(hw); - delwin(hw); - force_repaint(); - if (hch >= '0' && hch < '0' + num_hosts) - do_connect(hch - '0'); - } + case 'c': { + int idx = pick_host("Select Host"); + if (idx >= 0) do_connect(idx); + break; + } break; case 'd': @@ -1435,6 +2727,10 @@ static void ctrl_a_menu(void) draw_status(); break; + case 'f': + forms_menu(); + break; + case 'h': show_help(); break; @@ -1579,6 +2875,32 @@ static void handle_key(int ch) * 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 (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; @@ -1588,6 +2910,17 @@ void interactive_loop(void) draw_input(); while (1) { + if (sigint_pending) { + sigint_pending = 0; + if (confirm_quit_popup()) { + if (connected) tcp_disconnect(); + if (logging) close_log(); + endwin(); + exit(0); + } + draw_input(); + } + draw_status(); FD_ZERO(&readfds); @@ -1656,6 +2989,20 @@ int main(int argc, char *argv[]) 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);