1
0
Fork 0
forked from entailz/toes

Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
1962186ce1 Merge pull request 'add libsodium to flake' (#4) from atagen/toes:flake into master
Reviewed-on: entailz/toes#4
2026-05-24 03:32:14 -04:00
3f137482ce add libsodium to flake 2026-05-24 17:28:59 +10:00
1566f14a08 Merge pull request 'flake' (#3) from atagen/toes:flake into master
Reviewed-on: entailz/toes#3
2026-05-23 03:14:27 -04:00
entailz
cabddb26e6 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.
2026-05-21 14:08:33 -07:00
4826aa5e80 add flake 2026-05-21 22:45:50 +10:00
6ba4c5ff7a Merge branch 'master' into master 2026-05-21 22:43:25 +10:00
entailz
05ee680778 Introduce config options for overview border: overview-active-border and overview-select-border 2026-05-18 11:54:37 -07:00
3a4814e1fa Merge pull request 'add gradient tab rendering mode' (#1) from atagen/toes:gradient-tabs into master
Reviewed-on: entailz/toes#1
2026-05-17 23:35:31 -04:00
19 changed files with 22982 additions and 21328 deletions

7409
config.c

File diff suppressed because it is too large Load diff

View file

@ -495,6 +495,8 @@ struct config {
uint32_t active_bg;
uint32_t active_fg;
uint32_t unread_fg;
uint32_t overview_active_border; /* ring around the currently-active tab in the overview */
uint32_t overview_select_border; /* ring around keyboard/hover-selected tab in the overview */
} colors;
} tabs;

4382
csi.c

File diff suppressed because it is too large Load diff

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779259093,
"narHash": "sha256-7DKWmH23hL2eYdkxCKeqj2i+yljTKuU+3Nk1UPHOnxc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d99b013d5d1931ad77fe3912ed218170dec5d9a4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

39
flake.nix Normal file
View file

@ -0,0 +1,39 @@
{
description = "toes terminal emulator";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs =
{ nixpkgs, ... }:
let
systems = [
"aarch64-linux"
"x86_64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
packages = forAllSystems (
system:
let
pkgs = import nixpkgs { inherit system; };
toes = pkgs.foot.overrideAttrs (prev: {
pname = "toes";
version = "1.26.1";
src = ./.;
buildInputs = (prev.buildInputs or [ ]) ++ [
pkgs.libsodium
];
});
in
{
inherit toes;
default = toes;
}
);
};
}

View file

@ -193,6 +193,8 @@ height=26
# inherit-cwd=no (new tabs open in the active tab's cwd; requires OSC 7 shell support)
# unread-indicator=● (string drawn before label when tab has unseen output; empty disables)
# unread-color=fabd2f (color of the unread-indicator)
# overview-active-border=fabd2f (ring around the currently-active tab in tab overview)
# overview-select-border=ffffff (ring around the keyboard/hover-selected tab in tab overview)
[csd]
# preferred=server
@ -249,6 +251,9 @@ height=26
# tab-next=Control+Tab
# tab-prev=Control+Shift+Tab
# tab-overview=Control+Shift+space
# session-save=Control+Shift+s
# session-load=Control+Shift+l
# session-save-secure=Control+Shift+Alt+s
[search-bindings]
# cancel=Control+g Control+c Escape

6850
input.c

File diff suppressed because it is too large Load diff

View file

@ -65,6 +65,11 @@ enum bind_action_normal {
BIND_ACTION_TAB_9,
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 */
BIND_ACTION_SCROLLBACK_UP_MOUSE,
BIND_ACTION_SCROLLBACK_DOWN_MOUSE,
@ -77,7 +82,7 @@ enum bind_action_normal {
BIND_ACTION_SELECT_QUOTE,
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,
};

View file

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

11564
render.c

File diff suppressed because it is too large Load diff

3470
search.c

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@
#include "terminal.h"
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_input(
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);

8907
terminal.c

File diff suppressed because it is too large Load diff

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 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_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 {
void *data;
@ -626,6 +635,7 @@ struct terminal {
size_t len;
size_t sz;
size_t cursor;
enum search_mode mode;
int original_view;
bool view_followed_offset;
@ -865,6 +875,24 @@ struct terminal {
char *foot_exe;
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 size_notifications;
};
@ -882,6 +910,15 @@ struct terminal *term_tab_new(
int argc, char *const *argv, const char *const *envp,
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_close(struct terminal *term);
size_t term_tab_bar_hit_test(const struct terminal *term, int x, int y);