esc

Externally Scriptable Editor

git clone git://mccd.space/esc

fuse_ipc.c (21367B)

      1 #define FUSE_USE_VERSION 26
      2 #include <fuse.h>
      3 
      4 #include "editor.h"
      5 #include "fuse_ipc.h"
      6 #include "strbuf.h"
      7 #include <SDL3/SDL.h>
      8 #include <errno.h>
      9 #include <stdio.h>
     10 #include <stdlib.h>
     11 #include <string.h>
     12 #include <sys/stat.h>
     13 #include <sys/types.h>
     14 #include <unistd.h>
     15 
     16 /* Per-open write accumulator (stored in fi->fh cast to/from uintptr_t) */
     17 typedef struct {
     18 	char *data;
     19 	size_t len;
     20 	size_t cap;
     21 } WriteBuffer;
     22 
     23 /*
     24  * In-memory temp file slot.  Used for the create→write→rename workflow
     25  * that tools like `sed -i` perform.  Slots are identified by FUSE path;
     26  * an empty path[0] means the slot is free.
     27  */
     28 #define MAX_TEMP_FILES 8
     29 typedef struct {
     30 	char path[256];
     31 	char *data;
     32 	size_t len;
     33 } TempFile;
     34 
     35 /* FUSE private data passed as private_data to fuse_new */
     36 typedef struct {
     37 	Editor *ed;
     38 	TempFile temps[MAX_TEMP_FILES];
     39 } FuseCtx;
     40 
     41 struct FuseIPC {
     42 	struct fuse *fuse;
     43 	struct fuse_chan *chan;
     44 	SDL_Thread *thread;
     45 	char mountpoint[256];
     46 	FuseCtx *ctx;
     47 };
     48 
     49 static FuseCtx *get_ctx(void) {
     50 	return (FuseCtx *)fuse_get_context()->private_data;
     51 }
     52 
     53 /* ---- temp-file helpers ------------------------------------------------ */
     54 
     55 static TempFile *find_temp(FuseCtx *ctx, const char *path) {
     56 	for (int i = 0; i < MAX_TEMP_FILES; i++) {
     57 		if (ctx->temps[i].path[0] &&
     58 		    strcmp(ctx->temps[i].path, path) == 0)
     59 			return &ctx->temps[i];
     60 	}
     61 	return NULL;
     62 }
     63 
     64 static TempFile *alloc_temp(FuseCtx *ctx, const char *path) {
     65 	for (int i = 0; i < MAX_TEMP_FILES; i++) {
     66 		if (!ctx->temps[i].path[0]) {
     67 			strncpy(ctx->temps[i].path, path,
     68 				sizeof(ctx->temps[i].path) - 1);
     69 			ctx->temps[i].data = NULL;
     70 			ctx->temps[i].len = 0;
     71 			return &ctx->temps[i];
     72 		}
     73 	}
     74 	return NULL;
     75 }
     76 
     77 static void free_temp(TempFile *tf) {
     78 	free(tf->data);
     79 	tf->data = NULL;
     80 	tf->len = 0;
     81 	tf->path[0] = '\0';
     82 }
     83 
     84 /* Push a wakeup event so SDL_WaitEvent unblocks and the frame is redrawn. */
     85 static void wake_render(void) {
     86 	SDL_Event ev;
     87 	memset(&ev, 0, sizeof(ev));
     88 	ev.type = SDL_EVENT_USER;
     89 	SDL_PushEvent(&ev);
     90 }
     91 
     92 /* ---- path helpers ---------------------------------------------------- */
     93 
     94 /*
     95  * Parse /buffer/N/sub paths.
     96  * Returns slot index (>=0) and sets *sub to the sub-file name.
     97  * Returns -2 for /buffer itself.
     98  * Returns -3 for /buffer/N (slot directory).
     99  * Returns -1 if not a /buffer/... path.
    100  */
    101 static int parse_buffer_path(const char *path, const char **sub) {
    102 	if (strcmp(path, "/buffer") == 0) {
    103 		if (sub) *sub = NULL;
    104 		return -2;
    105 	}
    106 	if (strncmp(path, "/buffer/", 8) != 0)
    107 		return -1;
    108 	const char *rest = path + 8;
    109 	/* rest should be N or N/sub */
    110 	char *slash = strchr(rest, '/');
    111 	int idx = atoi(rest);
    112 	if (idx < 0)
    113 		return -1;
    114 	/* Validate that 'rest' is really a number */
    115 	const char *p = rest;
    116 	if (*p == '-') p++;
    117 	if (*p < '0' || *p > '9')
    118 		return -1;
    119 	if (!slash) {
    120 		if (sub) *sub = NULL;
    121 		return -3; /* slot directory */
    122 	}
    123 	if (sub) *sub = slash + 1;
    124 	return idx;
    125 }
    126 
    127 static int is_dir(const char *path) {
    128 	if (strcmp(path, "/") == 0 || strcmp(path, "/buffer") == 0)
    129 		return 1;
    130 	const char *sub = NULL;
    131 	int r = parse_buffer_path(path, &sub);
    132 	return r == -3; /* /buffer/N with no sub-file */
    133 }
    134 
    135 static int is_symlink(const char *path) { return strcmp(path, "/cwd") == 0; }
    136 
    137 static int is_file(const char *path) {
    138 	if (strcmp(path, "/cursor") == 0)
    139 		return 1;
    140 	if (strcmp(path, "/buffer/count") == 0)
    141 		return 1;
    142 	const char *sub = NULL;
    143 	int idx = parse_buffer_path(path, &sub);
    144 	if (idx < 0 || !sub)
    145 		return 0;
    146 	return strcmp(sub, "body") == 0 || strcmp(sub, "path") == 0 ||
    147 	       strcmp(sub, "ranges") == 0 || strcmp(sub, "range") == 0;
    148 }
    149 
    150 static int is_writable(const char *path) {
    151 	if (strcmp(path, "/cursor") == 0)
    152 		return 1;
    153 	const char *sub = NULL;
    154 	int idx = parse_buffer_path(path, &sub);
    155 	if (idx < 0 || !sub)
    156 		return 0;
    157 	return strcmp(sub, "body") == 0 || strcmp(sub, "path") == 0 ||
    158 	       strcmp(sub, "range") == 0;
    159 }
    160 
    161 /* ---- ranges serialisation -------------------------------------------- */
    162 
    163 /*
    164  * Build a malloc'd text representation of ed->ranges filtered to
    165  * [slot_start, slot_end).  Pass slot_start=0, slot_end=INT_MAX for all.
    166  * Offsets in output are relative to slot_start.
    167  * Caller must hold editor lock.
    168  */
    169 static char *build_ranges_filtered(Editor *ed, int slot_start, int slot_end,
    170 				   size_t *out_len) {
    171 	size_t total = 0;
    172 	char tmp[128];
    173 
    174 	for (size_t i = 0; i < ed->ranges_count; i++) {
    175 		EditorRange *r = &ed->ranges[i];
    176 		if (r->end_byte <= slot_start || r->start_byte >= slot_end)
    177 			continue;
    178 		int s = r->start_byte - slot_start;
    179 		int e = r->end_byte - slot_start;
    180 		if (r->type == RANGE_FORMAT)
    181 			total += snprintf(tmp, sizeof(tmp),
    182 					  "format %d %d %d %d\n", s, e,
    183 					  r->data.format.bold ? 1 : 0,
    184 					  r->data.format.italic ? 1 : 0);
    185 		else
    186 			total += snprintf(tmp, sizeof(tmp),
    187 					  "replacement %d %d %d\n", s, e,
    188 					  r->data.replacement.visual_cols);
    189 	}
    190 
    191 	char *buf = malloc(total + 1);
    192 	if (!buf)
    193 		return NULL;
    194 
    195 	size_t pos = 0;
    196 	for (size_t i = 0; i < ed->ranges_count; i++) {
    197 		EditorRange *r = &ed->ranges[i];
    198 		if (r->end_byte <= slot_start || r->start_byte >= slot_end)
    199 			continue;
    200 		int s = r->start_byte - slot_start;
    201 		int e = r->end_byte - slot_start;
    202 		if (r->type == RANGE_FORMAT)
    203 			pos += sprintf(buf + pos, "format %d %d %d %d\n", s, e,
    204 				       r->data.format.bold ? 1 : 0,
    205 				       r->data.format.italic ? 1 : 0);
    206 		else
    207 			pos += sprintf(buf + pos, "replacement %d %d %d\n",
    208 				       s, e,
    209 				       r->data.replacement.visual_cols);
    210 	}
    211 	buf[pos] = '\0';
    212 	*out_len = pos;
    213 	return buf;
    214 }
    215 
    216 static char *build_ranges(Editor *ed, size_t *out_len) {
    217 	return build_ranges_filtered(ed, 0, (int)ed->text.len + 1, out_len);
    218 }
    219 
    220 /* ---- FUSE callbacks -------------------------------------------------- */
    221 
    222 static int op_getattr(const char *path, struct stat *st) {
    223 	memset(st, 0, sizeof(*st));
    224 	FuseCtx *ctx = get_ctx();
    225 	Editor *ed = ctx->ed;
    226 
    227 	if (is_dir(path)) {
    228 		st->st_mode = S_IFDIR | 0755;
    229 		st->st_nlink = 2;
    230 		return 0;
    231 	}
    232 
    233 	if (is_symlink(path)) {
    234 		char tmp[64];
    235 		st->st_mode = S_IFLNK | 0777;
    236 		st->st_nlink = 1;
    237 		st->st_size =
    238 		    snprintf(tmp, sizeof(tmp), "/proc/%d/cwd", (int)getpid());
    239 		return 0;
    240 	}
    241 
    242 	if (is_file(path)) {
    243 		st->st_mode = S_IFREG | 0644;
    244 		st->st_nlink = 1;
    245 
    246 		editor_lock(ed);
    247 
    248 		if (strcmp(path, "/cursor") == 0) {
    249 			char tmp[64];
    250 			st->st_size = snprintf(tmp, sizeof(tmp), "%d %d\n",
    251 					       ed->cursor_idx,
    252 					       ed->selection_anchor);
    253 		} else if (strcmp(path, "/buffer/count") == 0) {
    254 			char tmp[32];
    255 			st->st_size =
    256 			    snprintf(tmp, sizeof(tmp), "%d\n", ed->files_count);
    257 		} else {
    258 			const char *sub = NULL;
    259 			int idx = parse_buffer_path(path, &sub);
    260 			if (idx >= 0 && idx < ed->files_count && sub) {
    261 				FileSlot *s = &ed->files[idx];
    262 				if (strcmp(sub, "body") == 0) {
    263 					st->st_size =
    264 					    (off_t)(s->buf_end - s->buf_start);
    265 				} else if (strcmp(sub, "path") == 0) {
    266 					st->st_size = s->path
    267 					                  ? (off_t)strlen(s->path)
    268 					                  : 0;
    269 				} else if (strcmp(sub, "ranges") == 0) {
    270 					size_t sz;
    271 					char *tmp = build_ranges_filtered(
    272 					    ed, s->buf_start, s->buf_end, &sz);
    273 					st->st_size = (off_t)sz;
    274 					free(tmp);
    275 				} else if (strcmp(sub, "range") == 0) {
    276 					if (!s->has_range) {
    277 						editor_unlock(ed);
    278 						return -ENOENT;
    279 					}
    280 					char tmp[64];
    281 					st->st_size = snprintf(
    282 					    tmp, sizeof(tmp), "%d %d\n",
    283 					    s->range_start, s->range_end);
    284 				}
    285 			}
    286 		}
    287 
    288 		editor_unlock(ed);
    289 		return 0;
    290 	}
    291 
    292 	/* In-flight or committed temp files */
    293 	TempFile *tf = find_temp(ctx, path);
    294 	if (tf) {
    295 		st->st_mode = S_IFREG | 0644;
    296 		st->st_nlink = 1;
    297 		st->st_size = (off_t)tf->len;
    298 		return 0;
    299 	}
    300 
    301 	return -ENOENT;
    302 }
    303 
    304 static int op_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
    305 		      off_t offset, struct fuse_file_info *fi) {
    306 	(void)offset;
    307 	(void)fi;
    308 	FuseCtx *ctx = get_ctx();
    309 	Editor *ed = ctx->ed;
    310 
    311 	filler(buf, ".", NULL, 0);
    312 	filler(buf, "..", NULL, 0);
    313 
    314 	if (strcmp(path, "/") == 0) {
    315 		filler(buf, "cursor", NULL, 0);
    316 		filler(buf, "cwd", NULL, 0);
    317 		filler(buf, "buffer", NULL, 0);
    318 		return 0;
    319 	}
    320 	if (strcmp(path, "/buffer") == 0) {
    321 		filler(buf, "count", NULL, 0);
    322 		editor_lock(ed);
    323 		int n = ed->files_count;
    324 		editor_unlock(ed);
    325 		char tmp[32];
    326 		for (int i = 0; i < n; i++) {
    327 			snprintf(tmp, sizeof(tmp), "%d", i);
    328 			filler(buf, tmp, NULL, 0);
    329 		}
    330 		return 0;
    331 	}
    332 	/* /buffer/N */
    333 	const char *sub = NULL;
    334 	int idx = parse_buffer_path(path, &sub);
    335 	if (idx == -3) {
    336 		filler(buf, "body", NULL, 0);
    337 		filler(buf, "path", NULL, 0);
    338 		filler(buf, "ranges", NULL, 0);
    339 		/* range only shown when has_range */
    340 		editor_lock(ed);
    341 		int slot_idx = -parse_buffer_path(path, NULL) - 3;
    342 		/* re-parse to get numeric index */
    343 		const char *rest = path + 8;
    344 		int sidx = atoi(rest);
    345 		bool hr = (sidx >= 0 && sidx < ed->files_count &&
    346 			   ed->files[sidx].has_range);
    347 		editor_unlock(ed);
    348 		if (hr)
    349 			filler(buf, "range", NULL, 0);
    350 		(void)slot_idx;
    351 		return 0;
    352 	}
    353 	return -ENOENT;
    354 }
    355 
    356 static int op_readlink(const char *path, char *buf, size_t size) {
    357 	if (!is_symlink(path))
    358 		return -ENOENT;
    359 	snprintf(buf, size, "/proc/%d/cwd", (int)getpid());
    360 	return 0;
    361 }
    362 
    363 static int op_open(const char *path, struct fuse_file_info *fi) {
    364 	fi->fh = 0;
    365 
    366 	if (!is_file(path)) {
    367 		/* Allow opening an existing temp file for reading */
    368 		FuseCtx *ctx = get_ctx();
    369 		if (find_temp(ctx, path))
    370 			return 0;
    371 		return -ENOENT;
    372 	}
    373 
    374 	if (!is_writable(path)) {
    375 		if ((fi->flags & O_ACCMODE) != O_RDONLY)
    376 			return -EACCES;
    377 		return 0;
    378 	}
    379 
    380 	/* Read-only open on a writable file: no write buffer needed */
    381 	if ((fi->flags & O_ACCMODE) == O_RDONLY)
    382 		return 0;
    383 
    384 	WriteBuffer *wb = calloc(1, sizeof(WriteBuffer));
    385 	if (!wb)
    386 		return -ENOMEM;
    387 	fi->fh = (uint64_t)(uintptr_t)wb;
    388 	return 0;
    389 }
    390 
    391 /*
    392  * op_create — called when a new file is created (e.g. sed -i's temp file).
    393  * Allow creation inside any /buffer/N/ prefix.
    394  */
    395 static int op_create(const char *path, mode_t mode, struct fuse_file_info *fi) {
    396 	(void)mode;
    397 	FuseCtx *ctx = get_ctx();
    398 
    399 	if (is_file(path) || is_dir(path))
    400 		return -EEXIST;
    401 
    402 	/* Must be under /buffer/N/ */
    403 	const char *sub = NULL;
    404 	int idx = parse_buffer_path(path, &sub);
    405 	if (idx < 0 || !sub)
    406 		return -EACCES;
    407 
    408 	TempFile *tf = find_temp(ctx, path);
    409 	if (!tf) {
    410 		tf = alloc_temp(ctx, path);
    411 		if (!tf)
    412 			return -ENOSPC;
    413 	}
    414 
    415 	WriteBuffer *wb = calloc(1, sizeof(WriteBuffer));
    416 	if (!wb)
    417 		return -ENOMEM;
    418 
    419 	fi->fh = (uint64_t)(uintptr_t)wb;
    420 	return 0;
    421 }
    422 
    423 static int op_read(const char *path, char *buf, size_t size, off_t offset,
    424 		   struct fuse_file_info *fi) {
    425 	(void)fi;
    426 	FuseCtx *ctx = get_ctx();
    427 	Editor *ed = ctx->ed;
    428 
    429 	if (strcmp(path, "/cursor") == 0) {
    430 		editor_lock(ed);
    431 		char tmp[64];
    432 		int n = snprintf(tmp, sizeof(tmp), "%d %d\n", ed->cursor_idx,
    433 				 ed->selection_anchor);
    434 		editor_unlock(ed);
    435 
    436 		if (offset >= (off_t)n)
    437 			return 0;
    438 		size_t to_copy = (size_t)(n - offset);
    439 		if (to_copy > size)
    440 			to_copy = size;
    441 		memcpy(buf, tmp + offset, to_copy);
    442 		return (int)to_copy;
    443 	}
    444 
    445 	if (strcmp(path, "/buffer/count") == 0) {
    446 		editor_lock(ed);
    447 		char tmp[32];
    448 		int n = snprintf(tmp, sizeof(tmp), "%d\n", ed->files_count);
    449 		editor_unlock(ed);
    450 		if (offset >= (off_t)n)
    451 			return 0;
    452 		size_t to_copy = (size_t)(n - offset);
    453 		if (to_copy > size) to_copy = size;
    454 		memcpy(buf, tmp + offset, to_copy);
    455 		return (int)to_copy;
    456 	}
    457 
    458 	/* /buffer/N/... */
    459 	const char *sub = NULL;
    460 	int idx = parse_buffer_path(path, &sub);
    461 	if (idx >= 0 && sub) {
    462 		editor_lock(ed);
    463 		if (idx >= ed->files_count) {
    464 			editor_unlock(ed);
    465 			return -ENOENT;
    466 		}
    467 		FileSlot *s = &ed->files[idx];
    468 
    469 		if (strcmp(sub, "body") == 0) {
    470 			int slot_len = s->buf_end - s->buf_start;
    471 			if (offset >= (off_t)slot_len) {
    472 				editor_unlock(ed);
    473 				return 0;
    474 			}
    475 			size_t to_copy = (size_t)(slot_len - offset);
    476 			if (to_copy > size) to_copy = size;
    477 			memcpy(buf, ed->text.data + s->buf_start + offset, to_copy);
    478 			editor_unlock(ed);
    479 			return (int)to_copy;
    480 		}
    481 
    482 		if (strcmp(sub, "path") == 0) {
    483 			size_t flen = s->path ? strlen(s->path) : 0;
    484 			if (offset >= (off_t)flen) {
    485 				editor_unlock(ed);
    486 				return 0;
    487 			}
    488 			size_t to_copy = flen - (size_t)offset;
    489 			if (to_copy > size) to_copy = size;
    490 			memcpy(buf, s->path + offset, to_copy);
    491 			editor_unlock(ed);
    492 			return (int)to_copy;
    493 		}
    494 
    495 		if (strcmp(sub, "ranges") == 0) {
    496 			size_t rlen;
    497 			char *rbuf = build_ranges_filtered(ed, s->buf_start,
    498 							   s->buf_end, &rlen);
    499 			editor_unlock(ed);
    500 			if (!rbuf) return -ENOMEM;
    501 			if (offset >= (off_t)rlen) { free(rbuf); return 0; }
    502 			size_t to_copy = rlen - (size_t)offset;
    503 			if (to_copy > size) to_copy = size;
    504 			memcpy(buf, rbuf + offset, to_copy);
    505 			free(rbuf);
    506 			return (int)to_copy;
    507 		}
    508 
    509 		if (strcmp(sub, "range") == 0) {
    510 			if (!s->has_range) {
    511 				editor_unlock(ed);
    512 				return -ENOENT;
    513 			}
    514 			char tmp[64];
    515 			int n = snprintf(tmp, sizeof(tmp), "%d %d\n",
    516 					 s->range_start, s->range_end);
    517 			editor_unlock(ed);
    518 			if (offset >= (off_t)n) return 0;
    519 			size_t to_copy = (size_t)(n - offset);
    520 			if (to_copy > size) to_copy = size;
    521 			memcpy(buf, tmp + offset, to_copy);
    522 			return (int)to_copy;
    523 		}
    524 
    525 		editor_unlock(ed);
    526 		return -ENOENT;
    527 	}
    528 
    529 	/* temp files */
    530 	TempFile *tf = find_temp(ctx, path);
    531 	if (tf) {
    532 		if (offset >= (off_t)tf->len) return 0;
    533 		size_t to_copy = tf->len - (size_t)offset;
    534 		if (to_copy > size) to_copy = size;
    535 		memcpy(buf, tf->data + offset, to_copy);
    536 		return (int)to_copy;
    537 	}
    538 
    539 	return -ENOENT;
    540 }
    541 
    542 static int op_write(const char *path, const char *buf, size_t size,
    543 		    off_t offset, struct fuse_file_info *fi) {
    544 	(void)path;
    545 	if (!fi->fh)
    546 		return -EACCES;
    547 
    548 	WriteBuffer *wb = (WriteBuffer *)(uintptr_t)fi->fh;
    549 	size_t end = (size_t)offset + size;
    550 
    551 	if (end > wb->cap) {
    552 		size_t new_cap = end < 128 ? 128 : end * 2;
    553 		char *nd = realloc(wb->data, new_cap);
    554 		if (!nd)
    555 			return -ENOMEM;
    556 		wb->data = nd;
    557 		wb->cap = new_cap;
    558 	}
    559 
    560 	memcpy(wb->data + offset, buf, size);
    561 	if (end > wb->len)
    562 		wb->len = end;
    563 	return (int)size;
    564 }
    565 
    566 static int op_truncate(const char *path, off_t size) {
    567 	(void)size;
    568 	/* Accept truncate on writable files; we reset on open anyway */
    569 	if (is_writable(path))
    570 		return 0;
    571 	/* Also accept on temp files */
    572 	FuseCtx *ctx = get_ctx();
    573 	if (find_temp(ctx, path))
    574 		return 0;
    575 	return -EACCES;
    576 }
    577 
    578 static int op_ftruncate(const char *path, off_t size,
    579 			struct fuse_file_info *fi) {
    580 	(void)path;
    581 	if (!fi->fh)
    582 		return 0;
    583 	WriteBuffer *wb = (WriteBuffer *)(uintptr_t)fi->fh;
    584 	if ((size_t)size < wb->len)
    585 		wb->len = (size_t)size;
    586 	return 0;
    587 }
    588 
    589 /* Apply content to the editor body (slot 0, legacy) and wake render loop. */
    590 static void apply_body(Editor *ed, const char *data, size_t len) {
    591 	editor_replace_body(ed, data, len);
    592 	wake_render();
    593 }
    594 
    595 /* Apply content to a specific slot by index. Creates slot if needed. */
    596 static void apply_slot_body(Editor *ed, int idx, const char *data, size_t len) {
    597 	editor_lock(ed);
    598 	if (idx == ed->files_count) {
    599 		/* Create a new slot */
    600 		editor_unlock(ed);
    601 		editor_add_file_slot(ed, NULL, data, (int)len);
    602 	} else if (idx < ed->files_count) {
    603 		/* Replace existing slot content */
    604 		FileSlot *s = &ed->files[idx];
    605 		int old_len = s->buf_end - s->buf_start;
    606 		/* Delete old content */
    607 		strbuf_delete(&ed->text, s->buf_start, old_len);
    608 		/* Adjust all slot boundaries as if a delete happened */
    609 		for (int i = 0; i < ed->files_count; i++) {
    610 			if (i == idx) continue;
    611 			if (ed->files[i].buf_start >= s->buf_end)
    612 				ed->files[i].buf_start -= old_len;
    613 			if (ed->files[i].buf_end > s->buf_start)
    614 				ed->files[i].buf_end -= old_len;
    615 		}
    616 		s->buf_end = s->buf_start;
    617 		/* Insert new content */
    618 		if (data && len > 0) {
    619 			strbuf_insert(&ed->text, s->buf_start, data, len);
    620 			/* Adjust boundaries after insert */
    621 			s->buf_end = s->buf_start + (int)len;
    622 			for (int i = 0; i < ed->files_count; i++) {
    623 				if (i == idx) continue;
    624 				if (ed->files[i].buf_start >= s->buf_start)
    625 					ed->files[i].buf_start += (int)len;
    626 				if (ed->files[i].buf_end > s->buf_start)
    627 					ed->files[i].buf_end += (int)len;
    628 			}
    629 		}
    630 		s->dirty = true;
    631 		editor_unlock(ed);
    632 	} else {
    633 		editor_unlock(ed);
    634 	}
    635 	wake_render();
    636 }
    637 
    638 static int op_release(const char *path, struct fuse_file_info *fi) {
    639 	FuseCtx *ctx = get_ctx();
    640 	Editor *ed = ctx->ed;
    641 
    642 	if (!fi->fh)
    643 		return 0;
    644 
    645 	WriteBuffer *wb = (WriteBuffer *)(uintptr_t)fi->fh;
    646 	fi->fh = 0;
    647 
    648 	/*
    649 	 * Temp file: save the accumulated data into the TempFile slot so
    650 	 * op_rename can pick it up.  Do NOT apply to the editor yet.
    651 	 */
    652 	TempFile *tf = find_temp(ctx, path);
    653 	if (tf) {
    654 		free(tf->data);
    655 		tf->data = wb->data; /* steal */
    656 		tf->len = wb->len;
    657 		wb->data = NULL;
    658 		free(wb);
    659 		return 0;
    660 	}
    661 
    662 	if (strcmp(path, "/cursor") == 0 && wb->data && wb->len > 0) {
    663 		char *tmp = malloc(wb->len + 1);
    664 		int cidx = 0, sidx = 0;
    665 		if (tmp) {
    666 			memcpy(tmp, wb->data, wb->len);
    667 			tmp[wb->len] = '\0';
    668 			sscanf(tmp, "%d %d", &cidx, &sidx);
    669 			free(tmp);
    670 		}
    671 		editor_lock(ed);
    672 		ed->cursor_idx = cidx;
    673 		ed->selection_anchor = sidx;
    674 		editor_unlock(ed);
    675 		wake_render();
    676 	} else {
    677 		const char *sub = NULL;
    678 		int idx = parse_buffer_path(path, &sub);
    679 		if (idx >= 0 && sub) {
    680 			if (strcmp(sub, "body") == 0) {
    681 				apply_slot_body(ed, idx, wb->data, wb->len);
    682 			} else if (strcmp(sub, "path") == 0 && wb->data) {
    683 				char *p = malloc(wb->len + 1);
    684 				if (p) {
    685 					memcpy(p, wb->data, wb->len);
    686 					p[wb->len] = '\0';
    687 					editor_lock(ed);
    688 					if (idx < ed->files_count) {
    689 						free(ed->files[idx].path);
    690 						ed->files[idx].path = p;
    691 					} else {
    692 						free(p);
    693 					}
    694 					editor_unlock(ed);
    695 				}
    696 			} else if (strcmp(sub, "range") == 0 && wb->data) {
    697 				char *tmp = malloc(wb->len + 1);
    698 				if (tmp) {
    699 					memcpy(tmp, wb->data, wb->len);
    700 					tmp[wb->len] = '\0';
    701 					int rs = 0, re = 0;
    702 					sscanf(tmp, "%d %d", &rs, &re);
    703 					free(tmp);
    704 					editor_set_file_slot_range(ed, idx, rs, re);
    705 				}
    706 			}
    707 		}
    708 	}
    709 
    710 	free(wb->data);
    711 	free(wb);
    712 	return 0;
    713 }
    714 
    715 static int op_rename(const char *from, const char *to) {
    716 	FuseCtx *ctx = get_ctx();
    717 	Editor *ed = ctx->ed;
    718 
    719 	TempFile *tf = find_temp(ctx, from);
    720 	if (!tf)
    721 		return -ENOENT;
    722 
    723 	const char *sub = NULL;
    724 	int idx = parse_buffer_path(to, &sub);
    725 	if (idx >= 0 && sub && strcmp(sub, "body") == 0)
    726 		apply_slot_body(ed, idx, tf->data, tf->len);
    727 
    728 	free_temp(tf);
    729 	return 0;
    730 }
    731 
    732 static int op_unlink(const char *path) {
    733 	FuseCtx *ctx = get_ctx();
    734 	TempFile *tf = find_temp(ctx, path);
    735 	if (!tf)
    736 		return -ENOENT;
    737 	free_temp(tf);
    738 	return 0;
    739 }
    740 
    741 /* ---- FUSE thread ----------------------------------------------------- */
    742 
    743 static int fuse_thread_fn(void *arg) {
    744 	fuse_loop((struct fuse *)arg);
    745 	return 0;
    746 }
    747 
    748 /* ---- operations table ------------------------------------------------ */
    749 
    750 static const struct fuse_operations ops = {
    751     .getattr = op_getattr,
    752     .readdir = op_readdir,
    753     .readlink = op_readlink,
    754     .open = op_open,
    755     .create = op_create,
    756     .read = op_read,
    757     .write = op_write,
    758     .truncate = op_truncate,
    759     .ftruncate = op_ftruncate,
    760     .release = op_release,
    761     .rename = op_rename,
    762     .unlink = op_unlink,
    763 };
    764 
    765 /* ---- Public API ------------------------------------------------------ */
    766 
    767 FuseIPC *fuse_ipc_start(Editor *ed) {
    768 	FuseIPC *ipc = calloc(1, sizeof(FuseIPC));
    769 	if (!ipc)
    770 		return NULL;
    771 
    772 	FuseCtx *ctx = calloc(1, sizeof(FuseCtx));
    773 	if (!ctx) {
    774 		free(ipc);
    775 		return NULL;
    776 	}
    777 	ctx->ed = ed;
    778 	ipc->ctx = ctx;
    779 
    780 	const char *xdg = getenv("XDG_RUNTIME_DIR");
    781 	const char *base = xdg ? xdg : "/tmp";
    782 
    783 	char esc_dir[240];
    784 	snprintf(esc_dir, sizeof(esc_dir), "%s/esc", base);
    785 	mkdir(esc_dir, 0700); /* ignore EEXIST */
    786 
    787 	snprintf(ipc->mountpoint, sizeof(ipc->mountpoint), "%s/esc/%d", base,
    788 		 (int)getpid());
    789 
    790 	if (mkdir(ipc->mountpoint, 0700) != 0) {
    791 		if (errno == EEXIST) {
    792 			/*
    793 			 * Stale mount from a previous crash — detach it so
    794 			 * we can reuse the directory.
    795 			 */
    796 			char cmd[512];
    797 			snprintf(cmd, sizeof(cmd),
    798 				 "fusermount -u -- %s 2>/dev/null",
    799 				 ipc->mountpoint);
    800 			system(cmd); /* ignore errors */
    801 		} else {
    802 			SDL_Log("fuse_ipc: mkdir %s failed: %s",
    803 				ipc->mountpoint, strerror(errno));
    804 			free(ctx);
    805 			free(ipc);
    806 			return NULL;
    807 		}
    808 	}
    809 
    810 	struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
    811 
    812 	ipc->chan = fuse_mount(ipc->mountpoint, &args);
    813 	if (!ipc->chan) {
    814 		SDL_Log("fuse_ipc: fuse_mount failed");
    815 		rmdir(ipc->mountpoint);
    816 		free(ctx);
    817 		free(ipc);
    818 		return NULL;
    819 	}
    820 
    821 	ipc->fuse = fuse_new(ipc->chan, &args, &ops, sizeof(ops), ctx);
    822 	if (!ipc->fuse) {
    823 		SDL_Log("fuse_ipc: fuse_new failed");
    824 		fuse_unmount(ipc->mountpoint, ipc->chan);
    825 		rmdir(ipc->mountpoint);
    826 		free(ctx);
    827 		free(ipc);
    828 		return NULL;
    829 	}
    830 
    831 	ipc->thread = SDL_CreateThread(fuse_thread_fn, "FuseIPC", ipc->fuse);
    832 	if (!ipc->thread) {
    833 		SDL_Log("fuse_ipc: SDL_CreateThread failed");
    834 		fuse_destroy(ipc->fuse);
    835 		fuse_unmount(ipc->mountpoint, ipc->chan);
    836 		rmdir(ipc->mountpoint);
    837 		free(ctx);
    838 		free(ipc);
    839 		return NULL;
    840 	}
    841 
    842 	SDL_Log("fuse_ipc: mounted at %s", ipc->mountpoint);
    843 	return ipc;
    844 }
    845 
    846 void fuse_ipc_stop(FuseIPC *ipc) {
    847 	if (!ipc)
    848 		return;
    849 	/* Unmount first: tears down the kernel-side channel, causing
    850 	 * fuse_loop's blocking read to return an error and the thread
    851 	 * to exit.  Calling fuse_exit alone is not enough because
    852 	 * fuse_loop only checks the exit flag after receiving a request. */
    853 	fuse_unmount(ipc->mountpoint, ipc->chan);
    854 	SDL_WaitThread(ipc->thread, NULL);
    855 	fuse_destroy(ipc->fuse);
    856 	rmdir(ipc->mountpoint);
    857 	free(ipc->ctx);
    858 	free(ipc);
    859 }