add session save and session load functionality

New [key-bindings]:
- session-save: captures cwd and foreground process argv to ~/.local/share/foot/state/{name}.json
- session-save-secure: prompts for a password, encrypts the scrollback with argon2id + XChaCha20-Poly1305 (libsodium) and writes it to {name}.scrollback.enc(stores up to 1Mb scrollback buffer).
- session-load: a minimal fuzzy picker that displays saved sessions (both secure and vanilla), UI piggybacks on search bar subsurface. use arrows to navigate and delete to delete a previously saved session.
This commit is contained in:
entailz 2026-05-21 14:08:33 -07:00
parent 05ee680778
commit cabddb26e6
16 changed files with 1947 additions and 49 deletions

View file

@ -130,6 +130,11 @@ static const char *const binding_action_map[] = {
[BIND_ACTION_TAB_9] = "tab-9", [BIND_ACTION_TAB_9] = "tab-9",
[BIND_ACTION_TAB_OVERVIEW] = "tab-overview", [BIND_ACTION_TAB_OVERVIEW] = "tab-overview",
/* Session actions */
[BIND_ACTION_SESSION_SAVE] = "session-save",
[BIND_ACTION_SESSION_LOAD] = "session-load",
[BIND_ACTION_SESSION_SAVE_SECURE] = "session-save-secure",
/* Mouse-specific actions */ /* Mouse-specific actions */
[BIND_ACTION_SCROLLBACK_UP_MOUSE] = "scrollback-up-mouse", [BIND_ACTION_SCROLLBACK_UP_MOUSE] = "scrollback-up-mouse",
[BIND_ACTION_SCROLLBACK_DOWN_MOUSE] = "scrollback-down-mouse", [BIND_ACTION_SCROLLBACK_DOWN_MOUSE] = "scrollback-down-mouse",

5
csi.c
View file

@ -1583,7 +1583,10 @@ void csi_dispatch(struct terminal *term, uint8_t final) {
/* 0 - icon + title, 1 - icon, 2 - title */ /* 0 - icon + title, 1 - icon, 2 - title */
unsigned what = vt_param_get(term, 1, 0); unsigned what = vt_param_get(term, 1, 0);
if (what == 0 || what == 2) { if (what == 0 || what == 2) {
tll_push_back(term->window_title_stack, xstrdup(term->window_title)); tll_push_back(term->window_title_stack,
xstrdup(term->window_title != NULL
? term->window_title
: ""));
} }
break; break;
} }

View file

@ -251,6 +251,9 @@ height=26
# tab-next=Control+Tab # tab-next=Control+Tab
# tab-prev=Control+Shift+Tab # tab-prev=Control+Shift+Tab
# tab-overview=Control+Shift+space # tab-overview=Control+Shift+space
# session-save=Control+Shift+s
# session-load=Control+Shift+l
# session-save-secure=Control+Shift+Alt+s
[search-bindings] [search-bindings]
# cancel=Control+g Control+c Escape # cancel=Control+g Control+c Escape

12
input.c
View file

@ -491,6 +491,18 @@ static bool execute_binding(struct seat *seat, struct terminal *term,
tab_overview_toggle(term->window); tab_overview_toggle(term->window);
return true; return true;
case BIND_ACTION_SESSION_SAVE:
search_begin_session(term, SEARCH_MODE_SESSION_SAVE);
return true;
case BIND_ACTION_SESSION_LOAD:
search_begin_session(term, SEARCH_MODE_SESSION_LOAD);
return true;
case BIND_ACTION_SESSION_SAVE_SECURE:
search_begin_session(term, SEARCH_MODE_SESSION_SAVE_SECURE_NAME);
return true;
case BIND_ACTION_REGEX_LAUNCH: case BIND_ACTION_REGEX_LAUNCH:
case BIND_ACTION_REGEX_COPY: case BIND_ACTION_REGEX_COPY:
if (binding->aux->type != BINDING_AUX_REGEX) if (binding->aux->type != BINDING_AUX_REGEX)

View file

@ -65,6 +65,11 @@ enum bind_action_normal {
BIND_ACTION_TAB_9, BIND_ACTION_TAB_9,
BIND_ACTION_TAB_OVERVIEW, BIND_ACTION_TAB_OVERVIEW,
/* Session actions */
BIND_ACTION_SESSION_SAVE,
BIND_ACTION_SESSION_LOAD,
BIND_ACTION_SESSION_SAVE_SECURE,
/* Mouse specific actions - i.e. they require a mouse coordinate */ /* Mouse specific actions - i.e. they require a mouse coordinate */
BIND_ACTION_SCROLLBACK_UP_MOUSE, BIND_ACTION_SCROLLBACK_UP_MOUSE,
BIND_ACTION_SCROLLBACK_DOWN_MOUSE, BIND_ACTION_SCROLLBACK_DOWN_MOUSE,
@ -77,7 +82,7 @@ enum bind_action_normal {
BIND_ACTION_SELECT_QUOTE, BIND_ACTION_SELECT_QUOTE,
BIND_ACTION_SELECT_ROW, BIND_ACTION_SELECT_ROW,
BIND_ACTION_KEY_COUNT = BIND_ACTION_TAB_OVERVIEW + 1, BIND_ACTION_KEY_COUNT = BIND_ACTION_SESSION_SAVE_SECURE + 1,
BIND_ACTION_COUNT = BIND_ACTION_SELECT_ROW + 1, BIND_ACTION_COUNT = BIND_ACTION_SELECT_ROW + 1,
}; };

View file

@ -162,6 +162,7 @@ endif
tllist = dependency('tllist', version: '>=1.1.0', fallback: 'tllist') tllist = dependency('tllist', version: '>=1.1.0', fallback: 'tllist')
fcft = dependency('fcft', version: ['>=3.3.1', '<4.0.0'], fallback: 'fcft') fcft = dependency('fcft', version: ['>=3.3.1', '<4.0.0'], fallback: 'fcft')
libsodium = dependency('libsodium')
wayland_protocols_datadir = wayland_protocols.get_variable('pkgdatadir') wayland_protocols_datadir = wayland_protocols.get_variable('pkgdatadir')
@ -330,6 +331,8 @@ executable(
'reaper.c', 'reaper.h', 'reaper.c', 'reaper.h',
'render.c', 'render.h', 'render.c', 'render.h',
'search.c', 'search.h', 'search.c', 'search.h',
'session.c', 'session.h', 'session-prompt.c',
'session-crypto.c', 'session-crypto.h',
'server.c', 'server.h', 'client-protocol.h', 'server.c', 'server.h', 'client-protocol.h',
'shm.c', 'shm.h', 'shm.c', 'shm.h',
'slave.c', 'slave.h', 'slave.c', 'slave.h',
@ -342,7 +345,7 @@ executable(
'xkbcommon-vmod.h', 'xkbcommon-vmod.h',
srgb_funcs, wl_proto_src + wl_proto_headers, version, srgb_funcs, wl_proto_src + wl_proto_headers, version,
dependencies: [math, threads, libepoll, pixman, wayland_client, wayland_cursor, xkb, fontconfig, utf8proc, dependencies: [math, threads, libepoll, pixman, wayland_client, wayland_cursor, xkb, fontconfig, utf8proc,
tllist, fcft], tllist, fcft, libsodium],
link_with: pgolib, link_with: pgolib,
install: true) install: true)

229
render.c
View file

@ -3635,6 +3635,21 @@ static void render_search_box(struct terminal *term) {
const size_t text_len = term->search.len; const size_t text_len = term->search.len;
#endif #endif
/* Password modes: replace every codepoint with a mask glyph for display. */
char32_t *masked_text = NULL;
char32_t *ime_alloc = NULL;
#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED
ime_alloc = text; /* remember the IME-allocated buffer for cleanup */
#endif
if (term->search.mode == SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD ||
term->search.mode == SEARCH_MODE_SESSION_LOAD_PASSWORD) {
masked_text = xmalloc((text_len + 1) * sizeof(char32_t));
for (size_t i = 0; i < text_len; i++)
masked_text[i] = U'';
masked_text[text_len] = U'\0';
text = masked_text;
}
/* Calculate the width of each character */ /* Calculate the width of each character */
int widths[text_len + 1]; int widths[text_len + 1];
for (size_t i = 0; i < text_len; i++) for (size_t i = 0; i < text_len; i++)
@ -3642,7 +3657,16 @@ static void render_search_box(struct terminal *term) {
widths[text_len] = 0; widths[text_len] = 0;
const size_t total_cells = c32swidth(text, text_len); const size_t total_cells = c32swidth(text, text_len);
const size_t wanted_visible_cells = max(20, total_cells); size_t wanted_visible_cells = max(20, total_cells);
if (term->search.mode == SEARCH_MODE_SESSION_LOAD) {
/* Make room for the longest session name */
for (size_t i = 0; i < term->session_picker.all_count; i++) {
size_t n = strlen(term->session_picker.all[i]);
if (n > wanted_visible_cells)
wanted_visible_cells = n;
}
wanted_visible_cells = max(wanted_visible_cells, 30);
}
/* /*
* Build status string: " Aa Wb Re 3/17 ↻" * Build status string: " Aa Wb Re 3/17 ↻"
@ -3655,36 +3679,70 @@ static void render_search_box(struct terminal *term) {
char32_t status[64]; char32_t status[64];
size_t st_len = 0; size_t st_len = 0;
if (term->search.case_mode == SEARCH_CASE_SENSITIVE) { if (term->search.mode != SEARCH_MODE_NORMAL) {
status[st_len++] = U'A'; char label_buf[128];
status[st_len++] = U'a'; const char *label;
status[st_len++] = U' '; switch (term->search.mode) {
} else if (term->search.case_mode == SEARCH_CASE_INSENSITIVE) { case SEARCH_MODE_SESSION_OVERWRITE_CONFIRM:
status[st_len++] = U'a'; snprintf(label_buf, sizeof(label_buf), "Overwrite '%s'? (y/n) ",
status[st_len++] = U'a'; term->session_pending_name != NULL
status[st_len++] = U' '; ? term->session_pending_name
} : "");
if (term->search.whole_word) { label = label_buf;
status[st_len++] = U'W'; break;
status[st_len++] = U'b'; case SEARCH_MODE_SESSION_SAVE:
status[st_len++] = U' '; label = "Save State As: ";
} break;
if (term->search.regex) { case SEARCH_MODE_SESSION_SAVE_SECURE_NAME:
status[st_len++] = U'.'; label = "Secure Save As: ";
status[st_len++] = U'*'; break;
status[st_len++] = U' '; case SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD:
} label = "Password (new): ";
if (term->search.wrapped) { break;
status[st_len++] = U'~'; case SEARCH_MODE_SESSION_LOAD:
status[st_len++] = U' '; label = "Load State: ";
} break;
if (term->search.len > 0 && term->search.total_count > 0) { case SEARCH_MODE_SESSION_LOAD_PASSWORD:
char tmp[32]; label = "Decrypt Password: ";
snprintf(tmp, sizeof(tmp), "%zu/%zu", term->search.current_idx, break;
term->search.total_count); default:
for (size_t i = 0; tmp[i] != '\0' && st_len < ALEN(status) - 1; i++) label = "";
status[st_len++] = (char32_t)tmp[i]; break;
status[st_len++] = U' '; }
for (const char *p = label; *p != '\0' && st_len < ALEN(status) - 1; p++)
status[st_len++] = (char32_t)(unsigned char)*p;
} else {
if (term->search.case_mode == SEARCH_CASE_SENSITIVE) {
status[st_len++] = U'A';
status[st_len++] = U'a';
status[st_len++] = U' ';
} else if (term->search.case_mode == SEARCH_CASE_INSENSITIVE) {
status[st_len++] = U'a';
status[st_len++] = U'a';
status[st_len++] = U' ';
}
if (term->search.whole_word) {
status[st_len++] = U'W';
status[st_len++] = U'b';
status[st_len++] = U' ';
}
if (term->search.regex) {
status[st_len++] = U'.';
status[st_len++] = U'*';
status[st_len++] = U' ';
}
if (term->search.wrapped) {
status[st_len++] = U'~';
status[st_len++] = U' ';
}
if (term->search.len > 0 && term->search.total_count > 0) {
char tmp[32];
snprintf(tmp, sizeof(tmp), "%zu/%zu", term->search.current_idx,
term->search.total_count);
for (size_t i = 0; tmp[i] != '\0' && st_len < ALEN(status) - 1; i++)
status[st_len++] = (char32_t)tmp[i];
status[st_len++] = U' ';
}
} }
status[st_len] = U'\0'; status[st_len] = U'\0';
const size_t status_cells = c32swidth(status, st_len); const size_t status_cells = c32swidth(status, st_len);
@ -3704,8 +3762,20 @@ static void render_search_box(struct terminal *term) {
const size_t want_box_w = chrome_w + wanted_visible_cells * term->cell_width; const size_t want_box_w = chrome_w + wanted_visible_cells * term->cell_width;
size_t width = min(want_box_w, max_box_w); size_t width = min(want_box_w, max_box_w);
size_t height =
min(term->height - 2 * outer_margin, margin + term->cell_height + margin); /* Session-load picker: grow box to fit a few rows of session names */
size_t list_rows = 0;
if (term->search.mode == SEARCH_MODE_SESSION_LOAD) {
const size_t max_visible = 10;
list_rows = min(max_visible, term->session_picker.filtered_count);
/* Reserve at least one row so empty state is visible */
if (list_rows == 0)
list_rows = 1;
}
size_t height = min(
term->height - 2 * outer_margin,
margin + (1 + list_rows) * term->cell_height + margin);
width = roundf(scale * ceilf(width / scale)); width = roundf(scale * ceilf(width / scale));
height = roundf(scale * ceilf(height / scale)); height = roundf(scale * ceilf(height / scale));
@ -3732,12 +3802,14 @@ static void render_search_box(struct terminal *term) {
uint32_t bg_hex = term->colors.bg; uint32_t bg_hex = term->colors.bg;
uint32_t fg_hex = term->colors.fg; uint32_t fg_hex = term->colors.fg;
if (term->search.len > 0 && term->search.match_len == 0) { if (term->search.mode == SEARCH_MODE_NORMAL) {
bg_hex = term->colors.table[1]; if (term->search.len > 0 && term->search.match_len == 0) {
fg_hex = term->colors.table[0]; bg_hex = term->colors.table[1];
} else if (term->search.wrapped) { fg_hex = term->colors.table[0];
bg_hex = term->colors.table[3]; } else if (term->search.wrapped) {
fg_hex = term->colors.table[0]; bg_hex = term->colors.table[3];
fg_hex = term->colors.table[0];
}
} }
const pixman_color_t color = color_hex_to_pixman(bg_hex, gamma_correct); const pixman_color_t color = color_hex_to_pixman(bg_hex, gamma_correct);
@ -3973,6 +4045,80 @@ static void render_search_box(struct terminal *term) {
} }
} }
/* Session-load list */
if (term->search.mode == SEARCH_MODE_SESSION_LOAD) {
const pixman_color_t sel_bg = color_hex_to_pixman(
term->colors.table[4], gamma_correct);
const size_t name_x = margin;
const size_t name_w = width > 2 * margin ? width - 2 * margin : 0;
if (term->session_picker.filtered_count == 0) {
const char32_t *msg = U"(no sessions)";
int rx = (int)name_x;
const int ry = (int)margin + (int)term->cell_height;
for (size_t i = 0; msg[i] != U'\0'; i++) {
const struct fcft_glyph *g =
fcft_rasterize_char_utf32(font, msg[i], term->font_subpixel);
int gw = max(1, c32width(msg[i]));
if (g != NULL && !g->is_color_glyph) {
pixman_image_t *src = pixman_image_create_solid_fill(&fg);
pixman_image_composite32(PIXMAN_OP_OVER, src, g->pix, buf->pix[0], 0,
0, 0, 0, rx + x_ofs + g->x,
ry + term->font_baseline - g->y, g->width,
g->height);
pixman_image_unref(src);
}
rx += gw * (int)term->cell_width;
}
} else {
const size_t shown = min(list_rows, term->session_picker.filtered_count);
/* Scroll the list so the selection is visible */
size_t first = 0;
if (term->session_picker.sel >= shown)
first = term->session_picker.sel - shown + 1;
for (size_t r = 0; r < shown; r++) {
size_t fi = first + r;
if (fi >= term->session_picker.filtered_count)
break;
const char *name =
term->session_picker.all[term->session_picker.filtered[fi]];
const int ry = (int)margin + (int)(1 + r) * (int)term->cell_height;
const bool selected = fi == term->session_picker.sel;
if (selected) {
pixman_image_fill_rectangles(
PIXMAN_OP_SRC, buf->pix[0], &sel_bg, 1,
&(pixman_rectangle16_t){(int16_t)name_x, (int16_t)ry,
(uint16_t)name_w,
(uint16_t)term->cell_height});
}
const pixman_color_t row_fg = selected
? color_hex_to_pixman(term->colors.bg, gamma_correct)
: fg;
int rx = (int)name_x;
for (const char *p = name; *p != '\0'; p++) {
char32_t ch = (char32_t)(unsigned char)*p;
const struct fcft_glyph *g =
fcft_rasterize_char_utf32(font, ch, term->font_subpixel);
if (g != NULL && !g->is_color_glyph) {
pixman_image_t *src = pixman_image_create_solid_fill(&row_fg);
pixman_image_composite32(PIXMAN_OP_OVER, src, g->pix, buf->pix[0],
0, 0, 0, 0, rx + x_ofs + g->x,
ry + term->font_baseline - g->y,
g->width, g->height);
pixman_image_unref(src);
}
rx += (int)term->cell_width;
if (rx > (int)(name_x + name_w))
break;
}
}
}
}
quirk_weston_subsurface_desync_on(term->window->search.sub); quirk_weston_subsurface_desync_on(term->window->search.sub);
/* TODO: this is only necessary on a window resize */ /* TODO: this is only necessary on a window resize */
@ -3998,9 +4144,8 @@ static void render_search_box(struct terminal *term) {
wl_surface_commit(term->window->search.surface.surf); wl_surface_commit(term->window->search.surface.surf);
quirk_weston_subsurface_desync_off(term->window->search.sub); quirk_weston_subsurface_desync_off(term->window->search.sub);
#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED free(ime_alloc);
free(text); free(masked_text);
#endif
#undef WINDOW_X #undef WINDOW_X
#undef WINDOW_Y #undef WINDOW_Y
} }

View file

@ -20,6 +20,7 @@
#include "quirks.h" #include "quirks.h"
#include "render.h" #include "render.h"
#include "selection.h" #include "selection.h"
#include "session.h"
#include "shm.h" #include "shm.h"
#include "unicode-mode.h" #include "unicode-mode.h"
#include "util.h" #include "util.h"
@ -155,6 +156,9 @@ static void search_cancel_keep_selection(struct terminal *term) {
term->search.wrapped = false; term->search.wrapped = false;
term->search.history_pos = NULL; term->search.history_pos = NULL;
term->is_searching = false; term->is_searching = false;
if (term->search.mode == SEARCH_MODE_SESSION_LOAD)
session_picker_free(term);
term->search.mode = SEARCH_MODE_NORMAL;
term->render.search_glyph_offset = 0; term->render.search_glyph_offset = 0;
/* Reset IME state */ /* Reset IME state */
@ -193,11 +197,20 @@ void search_begin(struct terminal *term) {
term->search.sz = 64; term->search.sz = 64;
term->search.buf = xmalloc(term->search.sz * sizeof(term->search.buf[0])); term->search.buf = xmalloc(term->search.sz * sizeof(term->search.buf[0]));
term->search.buf[0] = U'\0'; term->search.buf[0] = U'\0';
term->search.mode = SEARCH_MODE_NORMAL;
term_xcursor_update(term); term_xcursor_update(term);
render_refresh_search(term); render_refresh_search(term);
} }
void search_begin_session(struct terminal *term, enum search_mode mode) {
search_begin(term);
term->search.mode = mode;
if (mode == SEARCH_MODE_SESSION_LOAD)
session_picker_init(term);
render_refresh_search(term);
}
void search_cancel(struct terminal *term) { void search_cancel(struct terminal *term) {
if (!term->is_searching) if (!term->is_searching)
return; return;
@ -1539,6 +1552,10 @@ static bool execute_binding(struct seat *seat, struct terminal *term,
return true; return true;
case BIND_ACTION_SEARCH_COMMIT: case BIND_ACTION_SEARCH_COMMIT:
if (term->search.mode != SEARCH_MODE_NORMAL) {
session_prompt_commit(term);
return true;
}
selection_finalize(seat, term, serial); selection_finalize(seat, term, serial);
search_cancel_keep_selection(term); search_cancel_keep_selection(term);
return true; return true;
@ -1881,6 +1898,48 @@ void search_input(struct seat *seat, struct terminal *term,
LOG_DBG("search: input: sym=%d/0x%x, mods=0x%08x, consumed=0x%08x", sym, sym, LOG_DBG("search: input: sym=%d/0x%x, mods=0x%08x, consumed=0x%08x", sym, sym,
mods, consumed); mods, consumed);
/* Overwrite-confirm: only y/Y proceeds; anything else cancels. */
if (term->search.mode == SEARCH_MODE_SESSION_OVERWRITE_CONFIRM) {
if (sym == XKB_KEY_y || sym == XKB_KEY_Y || sym == XKB_KEY_Return ||
sym == XKB_KEY_KP_Enter) {
session_prompt_confirm_overwrite(term);
} else {
session_prompt_cancel(term);
}
return;
}
/* In session-load mode, hijack a few keys for picker navigation. We do this
* before binding dispatch so configured search-bindings (e.g. history-prev
* on Up) don't fire. */
if (term->search.mode == SEARCH_MODE_SESSION_LOAD) {
bool handled = true;
switch (sym) {
case XKB_KEY_Up:
session_picker_move(term, -1);
break;
case XKB_KEY_Down:
session_picker_move(term, +1);
break;
case XKB_KEY_Page_Up:
session_picker_move(term, -5);
break;
case XKB_KEY_Page_Down:
session_picker_move(term, +5);
break;
case XKB_KEY_Delete:
session_picker_delete_selected(term);
break;
default:
handled = false;
break;
}
if (handled) {
render_refresh_search(term);
return;
}
}
enum xkb_compose_status compose_status = enum xkb_compose_status compose_status =
seat->kbd.xkb_compose_state != NULL seat->kbd.xkb_compose_state != NULL
? xkb_compose_state_get_status(seat->kbd.xkb_compose_state) ? xkb_compose_state_get_status(seat->kbd.xkb_compose_state)
@ -1969,8 +2028,10 @@ void search_input(struct seat *seat, struct terminal *term,
update_search: update_search:
LOG_DBG("search: buffer: %ls", (const wchar_t *)term->search.buf); LOG_DBG("search: buffer: %ls", (const wchar_t *)term->search.buf);
if (update_search_result) if (update_search_result && term->search.mode == SEARCH_MODE_NORMAL)
search_find_next(term, search_direction); search_find_next(term, search_direction);
if (term->search.mode == SEARCH_MODE_SESSION_LOAD)
session_picker_refilter(term);
if (redraw) if (redraw)
render_refresh_search(term); render_refresh_search(term);
} }

View file

@ -6,6 +6,7 @@
#include "terminal.h" #include "terminal.h"
void search_begin(struct terminal *term); void search_begin(struct terminal *term);
void search_begin_session(struct terminal *term, enum search_mode mode);
void search_cancel(struct terminal *term); void search_cancel(struct terminal *term);
void search_input( void search_input(
struct seat *seat, struct terminal *term, struct seat *seat, struct terminal *term,

160
session-crypto.c Normal file
View file

@ -0,0 +1,160 @@
#include "session-crypto.h"
#include <stdlib.h>
#include <string.h>
#include <sodium.h>
#define LOG_MODULE "session-crypto"
#define LOG_ENABLE_DBG 0
#include "log.h"
#include "xmalloc.h"
#define MAGIC "FOOT-ENC1\0"
#define MAGIC_LEN 10
static bool sodium_ready = false;
bool
session_crypto_init(void)
{
if (sodium_ready)
return true;
if (sodium_init() < 0) {
LOG_ERR("libsodium init failed");
return false;
}
sodium_ready = true;
return true;
}
static bool
derive_key(const char *password, const unsigned char *salt,
unsigned char key[crypto_aead_xchacha20poly1305_ietf_KEYBYTES])
{
return crypto_pwhash(
key, crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
password, strlen(password), salt,
crypto_pwhash_OPSLIMIT_INTERACTIVE,
crypto_pwhash_MEMLIMIT_INTERACTIVE,
crypto_pwhash_ALG_ARGON2ID13) == 0;
}
bool
session_crypto_encrypt(const char *password,
const unsigned char *plaintext, size_t plaintext_len,
unsigned char **out, size_t *out_len)
{
if (!session_crypto_init())
return false;
*out = NULL;
*out_len = 0;
const size_t salt_len = crypto_pwhash_SALTBYTES;
const size_t nonce_len = crypto_aead_xchacha20poly1305_ietf_NPUBBYTES;
const size_t tag_len = crypto_aead_xchacha20poly1305_ietf_ABYTES;
const size_t total = MAGIC_LEN + salt_len + nonce_len + plaintext_len + tag_len;
unsigned char *buf = xmalloc(total);
memcpy(buf, MAGIC, MAGIC_LEN);
unsigned char *salt = buf + MAGIC_LEN;
unsigned char *nonce = salt + salt_len;
unsigned char *cipher = nonce + nonce_len;
randombytes_buf(salt, salt_len);
randombytes_buf(nonce, nonce_len);
unsigned char key[crypto_aead_xchacha20poly1305_ietf_KEYBYTES];
if (!derive_key(password, salt, key)) {
LOG_ERR("crypto_pwhash failed (out of memory?)");
sodium_memzero(key, sizeof(key));
free(buf);
return false;
}
/* Bind the file header into the AEAD so any tampering breaks decryption. */
unsigned long long clen = 0;
int rc = crypto_aead_xchacha20poly1305_ietf_encrypt(
cipher, &clen,
plaintext, plaintext_len,
buf, MAGIC_LEN + salt_len + nonce_len, /* AAD = full header */
NULL,
nonce,
key);
sodium_memzero(key, sizeof(key));
if (rc != 0) {
LOG_ERR("xchacha20poly1305 encrypt failed");
free(buf);
return false;
}
/* Sanity */
if (clen != plaintext_len + tag_len) {
free(buf);
return false;
}
*out = buf;
*out_len = total;
return true;
}
bool
session_crypto_decrypt(const char *password,
const unsigned char *blob, size_t blob_len,
unsigned char **out, size_t *out_len)
{
if (!session_crypto_init())
return false;
*out = NULL;
*out_len = 0;
const size_t salt_len = crypto_pwhash_SALTBYTES;
const size_t nonce_len = crypto_aead_xchacha20poly1305_ietf_NPUBBYTES;
const size_t tag_len = crypto_aead_xchacha20poly1305_ietf_ABYTES;
const size_t header_len = MAGIC_LEN + salt_len + nonce_len;
if (blob_len < header_len + tag_len) {
LOG_ERR("encrypted blob too small (%zu bytes)", blob_len);
return false;
}
if (memcmp(blob, MAGIC, MAGIC_LEN) != 0) {
LOG_ERR("bad magic in encrypted scrollback");
return false;
}
const unsigned char *salt = blob + MAGIC_LEN;
const unsigned char *nonce = salt + salt_len;
const unsigned char *cipher = nonce + nonce_len;
const size_t cipher_len = blob_len - header_len;
unsigned char key[crypto_aead_xchacha20poly1305_ietf_KEYBYTES];
if (!derive_key(password, salt, key)) {
LOG_ERR("crypto_pwhash failed during decrypt");
sodium_memzero(key, sizeof(key));
return false;
}
unsigned char *plain = xmalloc(cipher_len); /* upper bound */
unsigned long long plen = 0;
int rc = crypto_aead_xchacha20poly1305_ietf_decrypt(
plain, &plen,
NULL,
cipher, cipher_len,
blob, header_len, /* AAD must match what was used in encrypt */
nonce,
key);
sodium_memzero(key, sizeof(key));
if (rc != 0) {
LOG_ERR("decryption failed (wrong password or corrupted file)");
sodium_memzero(plain, cipher_len);
free(plain);
return false;
}
*out = plain;
*out_len = (size_t)plen;
return true;
}

39
session-crypto.h Normal file
View file

@ -0,0 +1,39 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
/*
* Encrypt and decrypt session scrollback with a password.
*
* File format (binary):
* [magic : 10 bytes "FOOT-ENC1\0"]
* [salt : crypto_pwhash_SALTBYTES (16)]
* [nonce : crypto_aead_xchacha20poly1305_ietf_NPUBBYTES (24)]
* [ciphertext : plaintext_len + crypto_aead_xchacha20poly1305_ietf_ABYTES (16)]
*
* KDF: argon2id at INTERACTIVE ops/mem limits (100ms on modern hardware).
* AEAD: XChaCha20-Poly1305 (authenticates the magic+salt+nonce as AAD).
*/
bool session_crypto_init(void);
/*
* Encrypts plaintext with the given password. Allocates and returns a buffer
* in *out (caller frees); *out_len receives its size. Returns true on success.
*/
bool session_crypto_encrypt(
const char *password,
const unsigned char *plaintext, size_t plaintext_len,
unsigned char **out, size_t *out_len);
/*
* Decrypts an encrypted blob (as produced by session_crypto_encrypt) with
* the given password. Allocates *out (caller frees) on success. Returns
* false on bad magic, truncated file, or authentication failure (wrong
* password / corrupted file).
*/
bool session_crypto_decrypt(
const char *password,
const unsigned char *blob, size_t blob_len,
unsigned char **out, size_t *out_len);

460
session-prompt.c Normal file
View file

@ -0,0 +1,460 @@
#include "session.h"
#include <stdlib.h>
#include <string.h>
#include <sodium.h>
#define LOG_MODULE "session-prompt"
#define LOG_ENABLE_DBG 0
#include "char32.h"
#include "extract.h"
#include "grid.h"
#include "log.h"
#include "render.h"
#include "search.h"
#include "session-crypto.h"
#include "terminal.h"
#include "vt.h"
#include "xmalloc.h"
#define SCROLLBACK_MAX_BYTES (1u << 20) /* 1 MB */
static char *
buf_to_utf8(const struct terminal *term)
{
if (term->search.len == 0)
return NULL;
/* Make a NUL-terminated char32_t copy for ac32tombs */
char32_t *tmp = calloc(term->search.len + 1, sizeof(char32_t));
if (tmp == NULL)
return NULL;
memcpy(tmp, term->search.buf, term->search.len * sizeof(char32_t));
tmp[term->search.len] = U'\0';
char *utf8 = ac32tombs(tmp);
free(tmp);
return utf8;
}
void
session_picker_init(struct terminal *term)
{
session_picker_free(term);
term->session_picker.all = session_list(&term->session_picker.all_count);
term->session_picker.filtered_cap = term->session_picker.all_count;
term->session_picker.filtered = term->session_picker.filtered_cap > 0
? xmalloc(sizeof(size_t) * term->session_picker.filtered_cap)
: NULL;
session_picker_refilter(term);
}
void
session_picker_free(struct terminal *term)
{
session_free_names(term->session_picker.all,
term->session_picker.all_count);
free(term->session_picker.filtered);
term->session_picker.all = NULL;
term->session_picker.all_count = 0;
term->session_picker.filtered = NULL;
term->session_picker.filtered_count = 0;
term->session_picker.filtered_cap = 0;
term->session_picker.sel = 0;
}
void
session_picker_refilter(struct terminal *term)
{
char *filter = buf_to_utf8(term);
term->session_picker.filtered_count = 0;
for (size_t i = 0; i < term->session_picker.all_count; i++) {
const char *name = term->session_picker.all[i];
if (filter == NULL || strstr(name, filter) != NULL)
term->session_picker.filtered[term->session_picker.filtered_count++] = i;
}
free(filter);
if (term->session_picker.sel >= term->session_picker.filtered_count)
term->session_picker.sel = term->session_picker.filtered_count > 0
? term->session_picker.filtered_count - 1
: 0;
}
void
session_picker_move(struct terminal *term, int delta)
{
if (term->session_picker.filtered_count == 0)
return;
long n = (long)term->session_picker.filtered_count;
long cur = (long)term->session_picker.sel + delta;
/* Wrap */
cur = ((cur % n) + n) % n;
term->session_picker.sel = (size_t)cur;
}
bool
session_picker_delete_selected(struct terminal *term)
{
if (term->session_picker.filtered_count == 0)
return false;
size_t idx = term->session_picker.filtered[term->session_picker.sel];
const char *name = term->session_picker.all[idx];
if (!session_delete(name))
return false;
/* Rebuild the list */
session_picker_init(term);
return true;
}
static void
dismiss(struct terminal *term)
{
session_picker_free(term);
if (term->session_pending_name != NULL)
sodium_memzero(term->session_pending_name,
strlen(term->session_pending_name));
free(term->session_pending_name);
term->session_pending_name = NULL;
term->session_pending_secure = false;
if (term->session_pending_load_state != NULL) {
struct session_state *st = term->session_pending_load_state;
session_state_free(st);
free(st);
term->session_pending_load_state = NULL;
}
/* Zero any residual password bytes in the input buffer */
if (term->search.buf != NULL && term->search.len > 0)
sodium_memzero(term->search.buf,
term->search.len * sizeof(term->search.buf[0]));
term->search.mode = SEARCH_MODE_NORMAL;
/* search_cancel clears the search bar; reuse it. We deliberately do not
* leave selection state behind (no commit semantics for sessions). */
search_cancel(term);
}
static char *
extract_scrollback_text(struct terminal *term, size_t *out_len)
{
*out_len = 0;
struct grid *grid = &term->normal;
if (grid->rows == NULL)
return NULL;
const int total = grid->num_rows;
const int visible = term->rows;
const int scrollback = total > visible ? total - visible : 0;
struct extraction_context *ctx = extract_begin(SELECTION_NONE, true);
if (ctx == NULL)
return NULL;
for (int row_no = -scrollback; row_no < visible; row_no++) {
int idx = ((grid->offset + row_no) % total + total) % total;
const struct row *row = grid->rows[idx];
if (row == NULL)
continue;
for (int col = 0; col < grid->num_cols; col++) {
if (!extract_one(term, row, &row->cells[col], col, ctx)) {
char *junk = NULL;
size_t jl = 0;
extract_finish(ctx, &junk, &jl);
free(junk);
return NULL;
}
}
}
char *text = NULL;
size_t len = 0;
if (!extract_finish(ctx, &text, &len))
return NULL;
/* Trim leading newlines (uninitialized scrollback) */
size_t start = 0;
while (start < len && (text[start] == '\n' || text[start] == ' '))
start++;
if (start > 0) {
memmove(text, text + start, len - start + 1);
len -= start;
}
/* Cap at SCROLLBACK_MAX_BYTES, keeping the newest content. */
if (len > SCROLLBACK_MAX_BYTES) {
size_t drop = len - SCROLLBACK_MAX_BYTES;
const char *nl = memchr(text + drop, '\n', len - drop);
if (nl != NULL)
drop = (size_t)(nl + 1 - text);
memmove(text, text + drop, len - drop + 1);
len -= drop;
}
*out_len = len;
return text;
}
static void
do_save(struct terminal *term, const char *name)
{
struct session_state st = {0};
if (!session_capture(term->ptmx, term->cwd, &st)) {
LOG_ERR("session_save: could not capture state");
session_state_free(&st);
return;
}
if (!session_save(name, &st))
LOG_ERR("session_save: write failed for '%s'", name);
session_state_free(&st);
/* Plain save deliberately drops any stale encrypted scrollback so the
* session reflects the current save. */
session_delete_enc_scrollback(name);
session_delete_scrollback(name);
}
static void
do_save_secure(struct terminal *term, const char *name, const char *password)
{
struct session_state st = {0};
if (!session_capture(term->ptmx, term->cwd, &st)) {
LOG_ERR("session_save_secure: could not capture state");
session_state_free(&st);
return;
}
bool ok = session_save(name, &st);
session_state_free(&st);
if (!ok) {
LOG_ERR("session_save_secure: JSON write failed for '%s'", name);
return;
}
/* Capture scrollback as plain text, encrypt, write sidecar. */
size_t plain_len = 0;
char *plain = extract_scrollback_text(term, &plain_len);
if (plain == NULL || plain_len == 0) {
/* No scrollback to encrypt — clear any stale ciphertext. */
free(plain);
session_delete_enc_scrollback(name);
session_delete_scrollback(name);
return;
}
unsigned char *blob = NULL;
size_t blob_len = 0;
bool enc_ok = session_crypto_encrypt(
password, (const unsigned char *)plain, plain_len, &blob, &blob_len);
sodium_memzero(plain, plain_len);
free(plain);
if (!enc_ok) {
LOG_ERR("session_save_secure: encryption failed");
return;
}
if (!session_write_enc_blob(name, blob, blob_len))
LOG_ERR("session_save_secure: sidecar write failed");
/* Remove any legacy plain sidecar so we never load it accidentally. */
session_delete_scrollback(name);
sodium_memzero(blob, blob_len);
free(blob);
}
/*
* Replay a saved scrollback into a freshly created terminal. Walks the bytes
* once to convert bare LF into CRLF (so each saved line lands at column 0),
* then runs them through vt_from_slave. Called from the LOAD path right after
* the new tab is spawned, before any output from the new slave has arrived.
*/
static void
replay_scrollback(struct terminal *new_term, const char *text, size_t len)
{
if (text == NULL || len == 0 || new_term == NULL)
return;
char *buf = xmalloc(len * 2);
size_t out = 0;
for (size_t i = 0; i < len; i++) {
char c = text[i];
if (c == '\n')
buf[out++] = '\r';
buf[out++] = c;
}
if (out > 0)
buf[out++] = '\n';
/* Visually separate replayed content from the upcoming shell prompt */
/* (single trailing newline is enough; the shell will emit its own CRLF) */
vt_from_slave(new_term, (const uint8_t *)buf, out);
free(buf);
}
void
session_prompt_confirm_overwrite(struct terminal *term)
{
if (term->session_pending_name == NULL) {
dismiss(term);
return;
}
if (term->session_pending_secure) {
/* Don't save yet — transition to password prompt. */
term->search.mode = SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD;
term->search.len = 0;
term->search.cursor = 0;
if (term->search.buf != NULL)
term->search.buf[0] = U'\0';
render_refresh_search(term);
return;
}
do_save(term, term->session_pending_name);
dismiss(term);
}
void
session_prompt_cancel(struct terminal *term)
{
dismiss(term);
}
static void
spawn_and_replay(struct terminal *term, struct session_state *st,
const char *name, const char *password)
{
struct terminal *nt = term_tab_new_with_cwd(
term, st->cwd, st->argc, st->argv, NULL,
term->shutdown.cb, term->shutdown.cb_data);
if (nt == NULL) {
LOG_ERR("session_load: could not spawn new tab");
return;
}
/* Encrypted sidecar takes priority if present. */
if (password != NULL && session_has_enc_scrollback(name)) {
unsigned char *blob = NULL;
size_t blob_len = 0;
if (session_read_enc_blob(name, &blob, &blob_len)) {
unsigned char *plain = NULL;
size_t plain_len = 0;
if (session_crypto_decrypt(password, blob, blob_len,
&plain, &plain_len)) {
replay_scrollback(nt, (const char *)plain, plain_len);
sodium_memzero(plain, plain_len);
free(plain);
} else {
LOG_ERR("session_load: decryption failed for '%s'", name);
}
free(blob);
}
} else {
/* Plain sidecar (legacy or never-encrypted). */
char *sb_text = NULL;
size_t sb_len = 0;
if (session_load_scrollback(name, &sb_text, &sb_len))
replay_scrollback(nt, sb_text, sb_len);
free(sb_text);
}
}
void
session_prompt_commit(struct terminal *term)
{
enum search_mode mode = term->search.mode;
if (mode == SEARCH_MODE_SESSION_SAVE ||
mode == SEARCH_MODE_SESSION_SAVE_SECURE_NAME) {
const bool secure = (mode == SEARCH_MODE_SESSION_SAVE_SECURE_NAME);
char *name = buf_to_utf8(term);
if (name == NULL || !session_name_is_valid(name)) {
LOG_ERR("session name must be non-empty [A-Za-z0-9._-]");
free(name);
dismiss(term);
return;
}
if (session_exists(name)) {
free(term->session_pending_name);
term->session_pending_name = name;
term->session_pending_secure = secure;
term->search.mode = SEARCH_MODE_SESSION_OVERWRITE_CONFIRM;
render_refresh_search(term);
return;
}
if (secure) {
/* No overwrite needed, go straight to password prompt. */
free(term->session_pending_name);
term->session_pending_name = name;
term->session_pending_secure = true;
term->search.mode = SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD;
term->search.len = 0;
term->search.cursor = 0;
if (term->search.buf != NULL)
term->search.buf[0] = U'\0';
render_refresh_search(term);
return;
}
do_save(term, name);
free(name);
} else if (mode == SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD) {
char *password = buf_to_utf8(term);
if (password == NULL || password[0] == '\0') {
LOG_ERR("session_save_secure: empty password");
free(password);
dismiss(term);
return;
}
const char *name = term->session_pending_name;
if (name == NULL) {
sodium_memzero(password, strlen(password));
free(password);
dismiss(term);
return;
}
do_save_secure(term, name, password);
sodium_memzero(password, strlen(password));
free(password);
} else if (mode == SEARCH_MODE_SESSION_LOAD) {
if (term->session_picker.filtered_count == 0) {
LOG_ERR("session_load: no matching session");
dismiss(term);
return;
}
size_t idx = term->session_picker.filtered[term->session_picker.sel];
const char *picked = term->session_picker.all[idx];
struct session_state *st = xmalloc(sizeof(*st));
memset(st, 0, sizeof(*st));
if (!session_load(picked, st)) {
LOG_ERR("session_load: could not read '%s'", picked);
free(st);
dismiss(term);
return;
}
if (session_has_enc_scrollback(picked)) {
/* Stash state, transition to password mode. */
free(term->session_pending_name);
term->session_pending_name = xstrdup(picked);
term->session_pending_load_state = st;
term->search.mode = SEARCH_MODE_SESSION_LOAD_PASSWORD;
term->search.len = 0;
term->search.cursor = 0;
if (term->search.buf != NULL)
term->search.buf[0] = U'\0';
session_picker_free(term);
render_refresh_search(term);
return;
}
/* No encrypted scrollback — spawn now. */
spawn_and_replay(term, st, picked, NULL);
session_state_free(st);
free(st);
} else if (mode == SEARCH_MODE_SESSION_LOAD_PASSWORD) {
char *password = buf_to_utf8(term);
struct session_state *st = term->session_pending_load_state;
const char *name = term->session_pending_name;
if (st != NULL && name != NULL && password != NULL) {
spawn_and_replay(term, st, name, password);
}
if (st != NULL) {
session_state_free(st);
free(st);
term->session_pending_load_state = NULL;
}
if (password != NULL) {
sodium_memzero(password, strlen(password));
free(password);
}
}
dismiss(term);
}

854
session.c Normal file
View file

@ -0,0 +1,854 @@
#include "session.h"
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <termios.h>
#define LOG_MODULE "session"
#define LOG_ENABLE_DBG 0
#include "log.h"
#include "xmalloc.h"
#define JSON_SUFFIX ".json"
#define JSON_SUFFIX_LEN 5
#define SCROLLBACK_SUFFIX ".scrollback.txt"
#define SCROLLBACK_ENC_SUFFIX ".scrollback.enc"
#define SCROLLBACK_MAX_BYTES (1u << 20) /* 1 MB cap */
static int
state_dir(char *buf, size_t buf_sz)
{
const char *xdg = getenv("XDG_DATA_HOME");
int n;
if (xdg != NULL && xdg[0] == '/')
n = snprintf(buf, buf_sz, "%s/foot/state", xdg);
else {
const char *home = getenv("HOME");
if (home == NULL)
return -1;
n = snprintf(buf, buf_sz, "%s/.local/share/foot/state", home);
}
if (n < 0 || (size_t)n >= buf_sz)
return -1;
return n;
}
static bool
mkdir_p(const char *path)
{
char tmp[4096];
size_t len = strlen(path);
if (len >= sizeof(tmp))
return false;
memcpy(tmp, path, len + 1);
for (size_t i = 1; i < len; i++) {
if (tmp[i] == '/') {
tmp[i] = '\0';
if (mkdir(tmp, 0700) < 0 && errno != EEXIST) {
LOG_ERRNO("mkdir(%s)", tmp);
return false;
}
tmp[i] = '/';
}
}
if (mkdir(tmp, 0700) < 0 && errno != EEXIST) {
LOG_ERRNO("mkdir(%s)", tmp);
return false;
}
return true;
}
static int
session_path(const char *name, char *buf, size_t buf_sz)
{
char dir[1024];
if (state_dir(dir, sizeof(dir)) < 0)
return -1;
int n = snprintf(buf, buf_sz, "%s/%s.json", dir, name);
if (n < 0 || (size_t)n >= buf_sz)
return -1;
return n;
}
bool
session_name_is_valid(const char *name)
{
if (name == NULL || name[0] == '\0')
return false;
for (const char *p = name; *p != '\0'; p++) {
char c = *p;
bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
if (!ok)
return false;
}
return true;
}
void
session_state_free(struct session_state *st)
{
if (st == NULL)
return;
free(st->cwd);
if (st->argv != NULL) {
for (int i = 0; i < st->argc; i++)
free(st->argv[i]);
free(st->argv);
}
st->cwd = NULL;
st->argv = NULL;
st->argc = 0;
}
#if defined(__linux__)
static char *
read_link(const char *path)
{
char *buf = NULL;
size_t cap = 256;
for (;;) {
buf = xrealloc(buf, cap);
ssize_t n = readlink(path, buf, cap - 1);
if (n < 0) {
free(buf);
return NULL;
}
if ((size_t)n < cap - 1) {
buf[n] = '\0';
return buf;
}
cap *= 2;
if (cap > 1 << 16) {
free(buf);
return NULL;
}
}
}
static bool
read_cmdline(pid_t pid, int *out_argc, char ***out_argv)
{
char path[64];
snprintf(path, sizeof(path), "/proc/%d/cmdline", (int)pid);
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0)
return false;
char *buf = NULL;
size_t len = 0;
size_t cap = 0;
char chunk[1024];
for (;;) {
ssize_t n = read(fd, chunk, sizeof(chunk));
if (n < 0) {
if (errno == EINTR)
continue;
close(fd);
free(buf);
return false;
}
if (n == 0)
break;
if (len + (size_t)n + 1 > cap) {
cap = (cap == 0 ? 1024 : cap * 2);
while (len + (size_t)n + 1 > cap)
cap *= 2;
buf = xrealloc(buf, cap);
}
memcpy(buf + len, chunk, n);
len += n;
}
close(fd);
if (buf == NULL || len == 0) {
free(buf);
return false;
}
/* /proc/<pid>/cmdline is NUL-separated; ensure trailing NUL */
if (buf[len - 1] != '\0') {
buf = xrealloc(buf, len + 1);
buf[len] = '\0';
len++;
}
/* Count args */
int argc = 0;
for (size_t i = 0; i < len; i++)
if (buf[i] == '\0' && (i == 0 || buf[i - 1] != '\0'))
argc++;
if (argc <= 0) {
free(buf);
return false;
}
char **argv = xmalloc(sizeof(char *) * (argc + 1));
int idx = 0;
size_t start = 0;
for (size_t i = 0; i < len && idx < argc; i++) {
if (buf[i] == '\0') {
argv[idx++] = xstrdup(buf + start);
start = i + 1;
}
}
argv[argc] = NULL;
free(buf);
*out_argc = argc;
*out_argv = argv;
return true;
}
#endif
bool
session_capture(int ptmx, const char *fallback_cwd, struct session_state *out)
{
memset(out, 0, sizeof(*out));
#if defined(__linux__)
pid_t fg = tcgetpgrp(ptmx);
if (fg > 0) {
char path[64];
snprintf(path, sizeof(path), "/proc/%d/cwd", (int)fg);
char *cwd = read_link(path);
if (cwd != NULL)
out->cwd = cwd;
int argc = 0;
char **argv = NULL;
if (read_cmdline(fg, &argc, &argv)) {
/* If the foreground process is a shell (login or otherwise), don't
* try to resurrect it via argv let the user's normal shell
* launch take over. We only restore non-shell processes. */
const char *base = argv[0];
const char *slash = strrchr(base, '/');
if (slash != NULL)
base = slash + 1;
const char *shells[] = {
"bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh",
"-bash", "-zsh", "-fish", "-sh", "-dash"
};
bool is_shell = false;
for (size_t i = 0; i < sizeof(shells) / sizeof(shells[0]); i++) {
if (strcmp(base, shells[i]) == 0) {
is_shell = true;
break;
}
}
if (is_shell) {
for (int i = 0; i < argc; i++)
free(argv[i]);
free(argv);
} else {
out->argc = argc;
out->argv = argv;
}
}
}
#endif
if (out->cwd == NULL && fallback_cwd != NULL)
out->cwd = xstrdup(fallback_cwd);
return out->cwd != NULL;
}
/* ------------- JSON encoding/decoding (minimal, hand-rolled) ------------- */
static void
json_escape(FILE *fp, const char *s)
{
fputc('"', fp);
for (const char *p = s; *p != '\0'; p++) {
unsigned char c = (unsigned char)*p;
switch (c) {
case '"': fputs("\\\"", fp); break;
case '\\': fputs("\\\\", fp); break;
case '\b': fputs("\\b", fp); break;
case '\f': fputs("\\f", fp); break;
case '\n': fputs("\\n", fp); break;
case '\r': fputs("\\r", fp); break;
case '\t': fputs("\\t", fp); break;
default:
if (c < 0x20)
fprintf(fp, "\\u%04x", c);
else
fputc(c, fp);
}
}
fputc('"', fp);
}
bool
session_save(const char *name, const struct session_state *st)
{
if (!session_name_is_valid(name)) {
LOG_ERR("invalid session name: %s", name == NULL ? "(null)" : name);
return false;
}
if (st == NULL || st->cwd == NULL) {
LOG_ERR("session_save: missing cwd");
return false;
}
char dir[1024];
if (state_dir(dir, sizeof(dir)) < 0) {
LOG_ERR("could not resolve state directory");
return false;
}
if (!mkdir_p(dir))
return false;
char path[1280];
if (session_path(name, path, sizeof(path)) < 0)
return false;
char tmp_path[1408];
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path);
FILE *fp = fopen(tmp_path, "we");
if (fp == NULL) {
LOG_ERRNO("fopen(%s)", tmp_path);
return false;
}
fputs("{\n \"cwd\": ", fp);
json_escape(fp, st->cwd);
if (st->argc > 0 && st->argv != NULL) {
fputs(",\n \"argv\": [", fp);
for (int i = 0; i < st->argc; i++) {
if (i > 0)
fputs(", ", fp);
json_escape(fp, st->argv[i]);
}
fputc(']', fp);
}
fputs("\n}\n", fp);
if (fflush(fp) != 0 || fsync(fileno(fp)) != 0) {
LOG_ERRNO("fsync(%s)", tmp_path);
fclose(fp);
unlink(tmp_path);
return false;
}
fclose(fp);
if (rename(tmp_path, path) < 0) {
LOG_ERRNO("rename(%s -> %s)", tmp_path, path);
unlink(tmp_path);
return false;
}
LOG_INFO("session saved: %s", path);
return true;
}
/* ------------- JSON parsing (just enough for our schema) ------------- */
struct parser {
const char *p;
const char *end;
};
static void
skip_ws(struct parser *pa)
{
while (pa->p < pa->end) {
char c = *pa->p;
if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
pa->p++;
else
break;
}
}
static bool
parse_string(struct parser *pa, char **out)
{
skip_ws(pa);
if (pa->p >= pa->end || *pa->p != '"')
return false;
pa->p++;
size_t cap = 64;
size_t len = 0;
char *buf = xmalloc(cap);
while (pa->p < pa->end && *pa->p != '"') {
char c = *pa->p++;
char decoded;
if (c == '\\') {
if (pa->p >= pa->end) { free(buf); return false; }
char esc = *pa->p++;
switch (esc) {
case '"': decoded = '"'; break;
case '\\': decoded = '\\'; break;
case '/': decoded = '/'; break;
case 'b': decoded = '\b'; break;
case 'f': decoded = '\f'; break;
case 'n': decoded = '\n'; break;
case 'r': decoded = '\r'; break;
case 't': decoded = '\t'; break;
case 'u': {
if (pa->end - pa->p < 4) { free(buf); return false; }
unsigned val = 0;
for (int i = 0; i < 4; i++) {
char h = pa->p[i];
val <<= 4;
if (h >= '0' && h <= '9') val |= h - '0';
else if (h >= 'a' && h <= 'f') val |= h - 'a' + 10;
else if (h >= 'A' && h <= 'F') val |= h - 'A' + 10;
else { free(buf); return false; }
}
pa->p += 4;
if (val < 0x80) {
decoded = (char)val;
} else {
/* UTF-8 encode codepoint (no surrogate-pair handling) */
char enc[4];
int n;
if (val < 0x800) {
enc[0] = (char)(0xc0 | (val >> 6));
enc[1] = (char)(0x80 | (val & 0x3f));
n = 2;
} else {
enc[0] = (char)(0xe0 | (val >> 12));
enc[1] = (char)(0x80 | ((val >> 6) & 0x3f));
enc[2] = (char)(0x80 | (val & 0x3f));
n = 3;
}
if (len + (size_t)n + 1 > cap) {
while (len + (size_t)n + 1 > cap) cap *= 2;
buf = xrealloc(buf, cap);
}
memcpy(buf + len, enc, n);
len += n;
continue;
}
break;
}
default: free(buf); return false;
}
} else {
decoded = c;
}
if (len + 2 > cap) {
cap *= 2;
buf = xrealloc(buf, cap);
}
buf[len++] = decoded;
}
if (pa->p >= pa->end) { free(buf); return false; }
pa->p++; /* consume closing quote */
buf[len] = '\0';
*out = buf;
return true;
}
static bool
expect_char(struct parser *pa, char c)
{
skip_ws(pa);
if (pa->p >= pa->end || *pa->p != c)
return false;
pa->p++;
return true;
}
bool
session_load(const char *name, struct session_state *out)
{
memset(out, 0, sizeof(*out));
if (!session_name_is_valid(name)) {
LOG_ERR("invalid session name: %s", name == NULL ? "(null)" : name);
return false;
}
char path[1280];
if (session_path(name, path, sizeof(path)) < 0)
return false;
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) {
LOG_ERRNO("open(%s)", path);
return false;
}
struct stat sb;
if (fstat(fd, &sb) < 0 || sb.st_size <= 0 || sb.st_size > (off_t)(1 << 20)) {
LOG_ERR("session file %s has unexpected size", path);
close(fd);
return false;
}
char *data = xmalloc(sb.st_size + 1);
ssize_t got = 0;
while (got < sb.st_size) {
ssize_t n = read(fd, data + got, sb.st_size - got);
if (n < 0) {
if (errno == EINTR) continue;
free(data);
close(fd);
return false;
}
if (n == 0) break;
got += n;
}
close(fd);
data[got] = '\0';
struct parser pa = { .p = data, .end = data + got };
bool ok = false;
if (!expect_char(&pa, '{')) goto done;
for (;;) {
skip_ws(&pa);
if (pa.p < pa.end && *pa.p == '}') { pa.p++; break; }
char *key = NULL;
if (!parse_string(&pa, &key)) goto done;
if (!expect_char(&pa, ':')) { free(key); goto done; }
if (strcmp(key, "cwd") == 0) {
free(key);
if (!parse_string(&pa, &out->cwd)) goto done;
} else if (strcmp(key, "argv") == 0) {
free(key);
if (!expect_char(&pa, '[')) goto done;
int cap = 4;
out->argv = xmalloc(sizeof(char *) * cap);
out->argc = 0;
for (;;) {
skip_ws(&pa);
if (pa.p < pa.end && *pa.p == ']') { pa.p++; break; }
if (out->argc + 1 >= cap) {
cap *= 2;
out->argv = xrealloc(out->argv, sizeof(char *) * cap);
}
if (!parse_string(&pa, &out->argv[out->argc])) goto done;
out->argc++;
skip_ws(&pa);
if (pa.p < pa.end && *pa.p == ',') { pa.p++; continue; }
}
out->argv[out->argc] = NULL;
} else {
free(key);
/* unknown key: skip a string value */
char *junk = NULL;
if (!parse_string(&pa, &junk)) goto done;
free(junk);
}
skip_ws(&pa);
if (pa.p < pa.end && *pa.p == ',') { pa.p++; continue; }
}
ok = out->cwd != NULL;
done:
free(data);
if (!ok)
session_state_free(out);
return ok;
}
/* ------------- listing ------------- */
static int
cmp_names(const void *a, const void *b)
{
return strcmp(*(const char *const *)a, *(const char *const *)b);
}
char **
session_list(size_t *out_count)
{
*out_count = 0;
char dir[1024];
if (state_dir(dir, sizeof(dir)) < 0)
return NULL;
DIR *d = opendir(dir);
if (d == NULL)
return NULL;
size_t cap = 8;
size_t count = 0;
char **names = xmalloc(sizeof(char *) * cap);
struct dirent *de;
while ((de = readdir(d)) != NULL) {
const char *n = de->d_name;
size_t nlen = strlen(n);
if (nlen <= JSON_SUFFIX_LEN)
continue;
if (strcmp(n + nlen - JSON_SUFFIX_LEN, JSON_SUFFIX) != 0)
continue;
char *stripped = xmalloc(nlen - JSON_SUFFIX_LEN + 1);
memcpy(stripped, n, nlen - JSON_SUFFIX_LEN);
stripped[nlen - JSON_SUFFIX_LEN] = '\0';
if (!session_name_is_valid(stripped)) {
free(stripped);
continue;
}
if (count >= cap) {
cap *= 2;
names = xrealloc(names, sizeof(char *) * cap);
}
names[count++] = stripped;
}
closedir(d);
qsort(names, count, sizeof(char *), cmp_names);
*out_count = count;
return names;
}
bool
session_exists(const char *name)
{
if (!session_name_is_valid(name))
return false;
char path[1280];
if (session_path(name, path, sizeof(path)) < 0)
return false;
return access(path, F_OK) == 0;
}
bool
session_delete(const char *name)
{
if (!session_name_is_valid(name))
return false;
char path[1280];
if (session_path(name, path, sizeof(path)) < 0)
return false;
if (unlink(path) < 0) {
LOG_ERRNO("unlink(%s)", path);
return false;
}
session_delete_scrollback(name);
session_delete_enc_scrollback(name);
LOG_INFO("session deleted: %s", path);
return true;
}
static int
scrollback_path(const char *name, char *buf, size_t buf_sz)
{
char dir[1024];
if (state_dir(dir, sizeof(dir)) < 0)
return -1;
int n = snprintf(buf, buf_sz, "%s/%s%s", dir, name, SCROLLBACK_SUFFIX);
if (n < 0 || (size_t)n >= buf_sz)
return -1;
return n;
}
bool
session_load_scrollback(const char *name, char **out_text, size_t *out_len)
{
*out_text = NULL;
*out_len = 0;
if (!session_name_is_valid(name))
return false;
char path[1280];
if (scrollback_path(name, path, sizeof(path)) < 0)
return false;
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0)
return false;
struct stat sb;
if (fstat(fd, &sb) < 0 || sb.st_size < 0 ||
(size_t)sb.st_size > SCROLLBACK_MAX_BYTES * 4 /* generous on load */) {
close(fd);
return false;
}
if (sb.st_size == 0) {
close(fd);
return false;
}
char *data = xmalloc(sb.st_size + 1);
ssize_t got = 0;
while (got < sb.st_size) {
ssize_t n = read(fd, data + got, sb.st_size - got);
if (n < 0) {
if (errno == EINTR) continue;
free(data);
close(fd);
return false;
}
if (n == 0) break;
got += n;
}
close(fd);
data[got] = '\0';
*out_text = data;
*out_len = (size_t)got;
return true;
}
void
session_delete_scrollback(const char *name)
{
if (!session_name_is_valid(name))
return;
char path[1280];
if (scrollback_path(name, path, sizeof(path)) < 0)
return;
unlink(path);
}
static int
enc_path(const char *name, char *buf, size_t buf_sz)
{
char dir[1024];
if (state_dir(dir, sizeof(dir)) < 0)
return -1;
int n = snprintf(buf, buf_sz, "%s/%s%s", dir, name, SCROLLBACK_ENC_SUFFIX);
if (n < 0 || (size_t)n >= buf_sz)
return -1;
return n;
}
bool
session_has_enc_scrollback(const char *name)
{
if (!session_name_is_valid(name))
return false;
char path[1280];
if (enc_path(name, path, sizeof(path)) < 0)
return false;
return access(path, F_OK) == 0;
}
void
session_delete_enc_scrollback(const char *name)
{
if (!session_name_is_valid(name))
return;
char path[1280];
if (enc_path(name, path, sizeof(path)) < 0)
return;
unlink(path);
}
bool
session_write_enc_blob(const char *name, const unsigned char *blob, size_t len)
{
if (!session_name_is_valid(name))
return false;
char dir[1024];
if (state_dir(dir, sizeof(dir)) < 0)
return false;
if (!mkdir_p(dir))
return false;
char path[1280];
if (enc_path(name, path, sizeof(path)) < 0)
return false;
char tmp_path[1408];
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path);
/* 0600 — encrypted but still reduces accidental exposure. */
int fd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (fd < 0) {
LOG_ERRNO("open(%s)", tmp_path);
return false;
}
size_t written = 0;
while (written < len) {
ssize_t n = write(fd, blob + written, len - written);
if (n < 0) {
if (errno == EINTR) continue;
LOG_ERRNO("write(%s)", tmp_path);
close(fd);
unlink(tmp_path);
return false;
}
written += n;
}
if (fsync(fd) < 0) {
LOG_ERRNO("fsync(%s)", tmp_path);
close(fd);
unlink(tmp_path);
return false;
}
close(fd);
if (rename(tmp_path, path) < 0) {
LOG_ERRNO("rename(%s -> %s)", tmp_path, path);
unlink(tmp_path);
return false;
}
return true;
}
bool
session_read_enc_blob(const char *name, unsigned char **out, size_t *out_len)
{
*out = NULL;
*out_len = 0;
if (!session_name_is_valid(name))
return false;
char path[1280];
if (enc_path(name, path, sizeof(path)) < 0)
return false;
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0)
return false;
struct stat sb;
if (fstat(fd, &sb) < 0 || sb.st_size <= 0 ||
(size_t)sb.st_size > SCROLLBACK_MAX_BYTES * 4) {
close(fd);
return false;
}
unsigned char *buf = xmalloc(sb.st_size);
ssize_t got = 0;
while (got < sb.st_size) {
ssize_t n = read(fd, buf + got, sb.st_size - got);
if (n < 0) {
if (errno == EINTR) continue;
free(buf);
close(fd);
return false;
}
if (n == 0) break;
got += n;
}
close(fd);
if (got != sb.st_size) {
free(buf);
return false;
}
*out = buf;
*out_len = (size_t)got;
return true;
}
void
session_free_names(char **names, size_t count)
{
if (names == NULL)
return;
for (size_t i = 0; i < count; i++)
free(names[i]);
free(names);
}

92
session.h Normal file
View file

@ -0,0 +1,92 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
/*
* Persistent terminal session state.
*
* Sessions are stored as JSON files under $XDG_DATA_HOME/foot/state/<name>.json
* (defaulting to ~/.local/share/foot/state). Each file records the cwd of the
* foreground process on the pty plus its argv, so a session can be resurrected
* in a new tab.
*/
struct session_state {
char *cwd;
int argc;
char **argv; /* NULL-terminated for convenience; argc does not count the terminator */
};
void session_state_free(struct session_state *st);
/*
* Inspect the pty for its foreground process. On Linux, walks /proc to grab
* cmdline + cwd. Falls back to fallback_cwd and a NULL argv on other platforms
* or when introspection fails (the caller will then save cwd only and let the
* shell start fresh on resume). Returns true if at least cwd is populated.
*/
bool session_capture(int ptmx, const char *fallback_cwd,
struct session_state *out);
/* Persist to <state-dir>/<name>.json. Creates the directory if missing. */
bool session_save(const char *name, const struct session_state *st);
/* Read <state-dir>/<name>.json. */
bool session_load(const char *name, struct session_state *out);
/*
* List existing sessions. Returns a malloc'd array of malloc'd names
* (no .json suffix), sorted. Caller frees with session_free_names().
*/
char **session_list(size_t *out_count);
void session_free_names(char **names, size_t count);
/* Returns true if name is non-empty and contains only [A-Za-z0-9._-]. */
bool session_name_is_valid(const char *name);
/* Remove <state-dir>/<name>.json. Returns true on success. */
bool session_delete(const char *name);
/* True if <state-dir>/<name>.json exists. */
bool session_exists(const char *name);
struct terminal;
/*
* Read <state-dir>/<name>.scrollback.txt into out_text and out_len. Returns
* false (with out_text set to NULL) if the file is missing or fails sanity
* checks. Caller frees out_text.
*/
bool session_load_scrollback(const char *name, char **out_text, size_t *out_len);
/* Best-effort delete of the scrollback sidecar (used when removing a session). */
void session_delete_scrollback(const char *name);
/* Encrypted-sidecar I/O. <state-dir>/<name>.scrollback.enc */
bool session_write_enc_blob(const char *name,
const unsigned char *blob, size_t len);
bool session_read_enc_blob(const char *name,
unsigned char **out, size_t *out_len);
bool session_has_enc_scrollback(const char *name);
void session_delete_enc_scrollback(const char *name);
struct terminal;
/*
* Called from search.c when the user presses Enter while the search bar is
* in a session prompt mode. Reads the typed name from term->search.buf and
* performs the requested save or load, then dismisses the prompt.
*/
void session_prompt_commit(struct terminal *term);
void session_prompt_cancel(struct terminal *term);
/* Finalize a save after the user has confirmed overwrite (y). */
void session_prompt_confirm_overwrite(struct terminal *term);
/* Picker (session-load) helpers. */
void session_picker_init(struct terminal *term);
void session_picker_free(struct terminal *term);
void session_picker_refilter(struct terminal *term);
void session_picker_move(struct terminal *term, int delta); /* +1/-1 etc */
bool session_picker_delete_selected(struct terminal *term);

View file

@ -1511,6 +1511,17 @@ struct terminal *term_tab_new(struct terminal *primary, int argc,
char *const *argv, const char *const *envp, char *const *argv, const char *const *envp,
void (*shutdown_cb)(void *data, int exit_code), void (*shutdown_cb)(void *data, int exit_code),
void *shutdown_data) { void *shutdown_data) {
return term_tab_new_with_cwd(primary, NULL, argc, argv, envp, shutdown_cb,
shutdown_data);
}
struct terminal *term_tab_new_with_cwd(struct terminal *primary,
const char *override_cwd, int argc,
char *const *argv,
const char *const *envp,
void (*shutdown_cb)(void *data,
int exit_code),
void *shutdown_data) {
struct wl_window *win = primary->window; struct wl_window *win = primary->window;
if (win->tab_count >= TAB_MAX) { if (win->tab_count >= TAB_MAX) {
@ -1725,9 +1736,12 @@ struct terminal *term_tab_new(struct terminal *primary, int argc,
.cb_data = shutdown_data, .cb_data = shutdown_data,
}, },
.foot_exe = xstrdup(primary->foot_exe), .foot_exe = xstrdup(primary->foot_exe),
.cwd = xstrdup(conf->tabs.inherit_cwd .cwd = xstrdup(override_cwd != NULL
? win->term->cwd ? override_cwd
: (getenv("HOME") != NULL ? getenv("HOME") : "/")), : (conf->tabs.inherit_cwd
? win->term->cwd
: (getenv("HOME") != NULL ? getenv("HOME")
: "/"))),
.grapheme_shaping = conf->tweak.grapheme_shaping, .grapheme_shaping = conf->tweak.grapheme_shaping,
#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED #if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED
.ime_enabled = true, .ime_enabled = true,
@ -1764,6 +1778,10 @@ struct terminal *term_tab_new(struct terminal *primary, int argc,
add_utmp_record(conf, reaper, ptmx); add_utmp_record(conf, reaper, ptmx);
/* Initialize window title to the configured default; otherwise a freshly
* spawned child sending CSI 22 t (push title) sees a NULL title. */
term_set_window_title(term, conf->title);
if ((term->slave = slave_spawn( if ((term->slave = slave_spawn(
term->ptmx, argc, term->cwd, argv, envp, &conf->env_vars, conf->term, term->ptmx, argc, term->cwd, argv, envp, &conf->env_vars, conf->term,
conf->shell, conf->login_shell, &conf->notifications)) == -1) { conf->shell, conf->login_shell, &conf->notifications)) == -1) {

View file

@ -344,6 +344,15 @@ enum selection_direction {SELECTION_UNDIR, SELECTION_LEFT, SELECTION_RIGHT};
enum selection_scroll_direction {SELECTION_SCROLL_NOT, SELECTION_SCROLL_UP, SELECTION_SCROLL_DOWN}; enum selection_scroll_direction {SELECTION_SCROLL_NOT, SELECTION_SCROLL_UP, SELECTION_SCROLL_DOWN};
enum search_direction { SEARCH_BACKWARD_SAME_POSITION, SEARCH_BACKWARD, SEARCH_FORWARD }; enum search_direction { SEARCH_BACKWARD_SAME_POSITION, SEARCH_BACKWARD, SEARCH_FORWARD };
enum search_case_mode { SEARCH_CASE_SMART, SEARCH_CASE_SENSITIVE, SEARCH_CASE_INSENSITIVE }; enum search_case_mode { SEARCH_CASE_SMART, SEARCH_CASE_SENSITIVE, SEARCH_CASE_INSENSITIVE };
enum search_mode {
SEARCH_MODE_NORMAL,
SEARCH_MODE_SESSION_SAVE,
SEARCH_MODE_SESSION_LOAD,
SEARCH_MODE_SESSION_OVERWRITE_CONFIRM,
SEARCH_MODE_SESSION_SAVE_SECURE_NAME,
SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD,
SEARCH_MODE_SESSION_LOAD_PASSWORD,
};
struct ptmx_buffer { struct ptmx_buffer {
void *data; void *data;
@ -626,6 +635,7 @@ struct terminal {
size_t len; size_t len;
size_t sz; size_t sz;
size_t cursor; size_t cursor;
enum search_mode mode;
int original_view; int original_view;
bool view_followed_offset; bool view_followed_offset;
@ -865,6 +875,24 @@ struct terminal {
char *foot_exe; char *foot_exe;
char *cwd; char *cwd;
/* Active only while term->search.mode == SEARCH_MODE_SESSION_LOAD */
struct {
char **all; /* All session names from disk */
size_t all_count;
size_t *filtered; /* indices into all[] matching current input */
size_t filtered_count;
size_t filtered_cap;
size_t sel; /* index within filtered[] */
} session_picker;
/* Name held across a multi-step session flow (overwrite confirm, secure
* save name->password, load password). */
char *session_pending_name;
bool session_pending_secure; /* set when overwrite-confirm precedes a secure save */
/* For LOAD_PASSWORD: heap-allocated session_state pulled from JSON. The
* new tab isn't spawned until decryption succeeds, so we hold it here. */
void *session_pending_load_state;
bool grapheme_shaping; bool grapheme_shaping;
bool size_notifications; bool size_notifications;
}; };
@ -882,6 +910,15 @@ struct terminal *term_tab_new(
int argc, char *const *argv, const char *const *envp, int argc, char *const *argv, const char *const *envp,
void (*shutdown_cb)(void *data, int exit_code), void *shutdown_data); void (*shutdown_cb)(void *data, int exit_code), void *shutdown_data);
/*
* Like term_tab_new but with an explicit override for the new tab's cwd.
* If override_cwd is NULL behaves identically to term_tab_new.
*/
struct terminal *term_tab_new_with_cwd(
struct terminal *primary, const char *override_cwd,
int argc, char *const *argv, const char *const *envp,
void (*shutdown_cb)(void *data, int exit_code), void *shutdown_data);
void term_tab_switch(struct wl_window *win, size_t idx); void term_tab_switch(struct wl_window *win, size_t idx);
void term_tab_close(struct terminal *term); void term_tab_close(struct terminal *term);
size_t term_tab_bar_hit_test(const struct terminal *term, int x, int y); size_t term_tab_bar_hit_test(const struct terminal *term, int x, int y);