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:
parent
05ee680778
commit
cabddb26e6
16 changed files with 1947 additions and 49 deletions
460
session-prompt.c
Normal file
460
session-prompt.c
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue