Hello, I am a heavy screen user, and recently started to use hyperlinks in my terminal apps, but I've found that screen doesn't supports this, so I have created some changes to bring OSC 8 hyperlink supported in screen.
Please find attached my patch wrt to this, feel free to take it in original or modified form into the screen. I made the patch on the debian's 5.0.2 patched screen version,available also on my github ( https://github.com/kedazo/screen#gnu-screen-502--osc-8-hyperlinks ). Best regards, David Kedves
From: David Kedves <[email protected]> Date: Tue, 29 Sep 2026 16:02:36 +0200 Subject: Add OSC 8 hyperlink support Description: Programs like "ls --hyperlink" print OSC 8 hyperlinks, which screen silently dropped. Hyperlinks are now stored per cell (a new link id plane next to attr/font/colors), so they survive redraws, window switches, split regions, copy mode, resizing and reattaching. . A process wide link table hands out monotonic, never reused ids and is garbage collected by mark & sweep over all windows (incl. scrollback) and displays. URIs up to 8192 bytes are accepted; control characters (C0, DEL, C1, also UTF-8 encoded) are rejected so nothing can be injected into the outer terminal. Links are sent with a screen generated id=, so pieces of one link drawn separately stay one link. . Links are only sent to terminals with the new "HL" capability, which is set by default for known capable terminal names (xterm*, *-256color, kitty*, foot*, ...) and can be forced with "termcapinfo name HL" or "termcapinfo name HL@". The new "hyperlinks on|off" command switches the feature globally (default on). . Also: over long OSC strings are now swallowed up to their terminator instead of spilling the rest onto the screen. Author: David Kedves <[email protected]> Forwarded: no Last-Update: 2026-09-29 --- diff --git a/Makefile.in b/Makefile.in index 8fc308e..aee3524 100644 --- a/Makefile.in +++ b/Makefile.in @@ -50,7 +50,7 @@ SHELL=/bin/sh CFILES= screen.c \ acls.c ansi.c attacher.c backtick.c canvas.c comm.c \ - display.c encoding.c fileio.c help.c input.c kmapdef.c layer.c \ + display.c encoding.c fileio.c help.c hyperlink.c input.c kmapdef.c layer.c \ layout.c list_display.c list_generic.c list_license.o list_window.c logfile.c mark.c \ misc.c process.c pty.c resize.c sched.c search.c socket.c telnet.c \ term.c termcap.c tty.c utmp.c viewport.c window.c winmsg.c \ @@ -176,116 +176,117 @@ depend.in: $(CFILES) term.h ### Dependencies: screen.o: screen.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h winmsg.h winmsgbuf.h winmsgcond.h backtick.h \ fileio.h mark.h attacher.h encoding.h help.h misc.h process.h socket.h \ termcap.h tty.h utmp.h ansi.o: ansi.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h winmsg.h winmsgbuf.h winmsgcond.h backtick.h encoding.h \ fileio.h help.h mark.h misc.h process.h resize.h fileio.o: fileio.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h fileio.h misc.h process.h winmsgbuf.h termcap.h encoding.h mark.o: mark.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h encoding.h fileio.h mark.h process.h winmsgbuf.h search.h misc.o: misc.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h resize.o: resize.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h process.h winmsgbuf.h resize.h telnet.h socket.o: socket.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h encoding.h fileio.h list_generic.h misc.h process.h \ winmsgbuf.h resize.h socket.h termcap.h tty.h utmp.h search.o: search.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h mark.h input.h tty.o: tty.c config.h screen.h os.h ansi.h sched.h acls.h comm.h layer.h \ - term.h image.h canvas.h display.h layout.h viewport.h window.h logfile.h \ + term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h logfile.h \ fileio.h misc.h pty.h telnet.h tty.h term.o: term.c term.h window.o: window.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h winmsg.h winmsgbuf.h winmsgcond.h backtick.h fileio.h help.h \ input.h mark.h misc.h process.h pty.h resize.h telnet.h termcap.h tty.h \ utmp.h utmp.o: utmp.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h misc.h tty.h utmp.h help.o: help.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h misc.h list_generic.h process.h winmsgbuf.h termcap.o: termcap.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h encoding.h misc.h process.h winmsgbuf.h resize.h termcap.h input.o: input.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h misc.h attacher.o: attacher.c config.h screen.h os.h ansi.h sched.h acls.h \ comm.h layer.h term.h image.h canvas.h display.h layout.h viewport.h \ - window.h logfile.h misc.h socket.h tty.h + window.h hyperlink.h logfile.h misc.h socket.h tty.h pty.o: pty.c config.h screen.h os.h ansi.h sched.h acls.h comm.h layer.h \ - term.h image.h canvas.h display.h layout.h viewport.h window.h logfile.h + term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h logfile.h process.o: process.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h winmsg.h winmsgbuf.h winmsgcond.h backtick.h encoding.h \ fileio.h help.h input.h kmapdef.h list_generic.h mark.h misc.h process.h \ resize.h search.h socket.h telnet.h termcap.h tty.h utmp.h display.o: display.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h winmsg.h winmsgbuf.h winmsgcond.h backtick.h encoding.h mark.h \ misc.h process.h pty.h resize.h termcap.h tty.h comm.o: comm.c config.h os.h screen.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h kmapdef.o: kmapdef.c config.h acls.o: acls.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h mark.h misc.h process.h winmsgbuf.h logfile.o: logfile.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h misc.h layer.o: layer.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h encoding.h mark.h tty.h winmsg.o: winmsg.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h winmsg.h winmsgbuf.h winmsgcond.h backtick.h fileio.h \ process.h mark.h winmsgbuf.o: winmsgbuf.c winmsgbuf.h screen.h os.h ansi.h sched.h acls.h \ comm.h layer.h term.h image.h canvas.h display.h layout.h viewport.h \ - window.h logfile.h + window.h hyperlink.h logfile.h winmsgcond.o: winmsgcond.c winmsgcond.h +hyperlink.o: hyperlink.c hyperlink.h backtick.o: backtick.c backtick.h screen.h os.h ansi.h sched.h acls.h \ comm.h layer.h term.h image.h canvas.h display.h layout.h viewport.h \ - window.h logfile.h fileio.h + window.h hyperlink.h logfile.h fileio.h sched.o: sched.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h telnet.o: telnet.c config.h comm.h encoding.o: encoding.c config.h screen.h os.h ansi.h sched.h acls.h \ comm.h layer.h term.h image.h canvas.h display.h layout.h viewport.h \ - window.h logfile.h encoding.h fileio.h + window.h hyperlink.h logfile.h encoding.h fileio.h canvas.o: canvas.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h help.h list_generic.h resize.h layout.o: layout.c config.h screen.h os.h ansi.h sched.h acls.h comm.h \ - layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h \ + layer.h term.h image.h canvas.h display.h layout.h viewport.h window.h hyperlink.h \ logfile.h fileio.h misc.h process.h winmsgbuf.h resize.h viewport.o: viewport.c config.h screen.h os.h ansi.h sched.h acls.h \ comm.h layer.h term.h image.h canvas.h display.h layout.h viewport.h \ - window.h logfile.h + window.h hyperlink.h logfile.h list_display.o: list_display.c config.h screen.h os.h ansi.h sched.h \ acls.h comm.h layer.h term.h image.h canvas.h display.h layout.h \ - viewport.h window.h logfile.h list_generic.h misc.h + viewport.h window.h hyperlink.h logfile.h list_generic.h misc.h list_generic.o: list_generic.c config.h screen.h os.h ansi.h sched.h \ acls.h comm.h layer.h term.h image.h canvas.h display.h layout.h \ - viewport.h window.h logfile.h input.h list_generic.h misc.h + viewport.h window.h hyperlink.h logfile.h input.h list_generic.h misc.h list_window.o: list_window.c config.h screen.h os.h ansi.h sched.h acls.h \ comm.h layer.h term.h image.h canvas.h display.h layout.h viewport.h \ - window.h logfile.h winmsg.h winmsgbuf.h winmsgcond.h backtick.h input.h \ + window.h hyperlink.h logfile.h winmsg.h winmsgbuf.h winmsgcond.h backtick.h input.h \ list_generic.h misc.h process.h list_license.o: list_license.c list_generic.h misc.h comm.h diff --git a/ansi.c b/ansi.c index 0d93cdf..adcfa11 100644 --- a/ansi.c +++ b/ansi.c @@ -39,6 +39,7 @@ #include "encoding.h" #include "fileio.h" #include "help.h" +#include "hyperlink.h" #include "logfile.h" #include "mark.h" #include "misc.h" @@ -64,8 +65,8 @@ struct mline mline_blank; struct mline mline_null; struct mchar mchar_null; -struct mchar mchar_blank = { ' ', 0, 0, 0, 0, 0 }; -struct mchar mchar_so = { ' ', A_RV, 0, 0, 0, 0}; +struct mchar mchar_blank = { ' ', 0, 0, 0, 0, 0, 0 }; +struct mchar mchar_so = { ' ', A_RV, 0, 0, 0, 0, 0}; uint64_t renditions[NUM_RENDS] = { 65529 /* =ub */ , 65531 /* =b */ , 65533 /* =u */ }; @@ -1205,11 +1206,40 @@ static void StringStart(Window *win, enum string_t type) { win->w_StringType = type; win->w_stringp = win->w_string; + win->w_stringovf = false; win->w_state = ASTR; } +/* + * The string buffer is full: OSC strings (e.g. OSC 8 hyperlinks with long + * URIs) are swallowed up to their terminator and then dropped by + * StringEnd(), instead of spilling the rest onto the screen. Other string + * types keep the traditional behaviour of falling back to literal mode. + */ +static void StringOverflow(Window *win) +{ + if (win->w_StringType == OSC) + win->w_stringovf = true; + else + win->w_state = LIT; +} + +/* only OSC 8 hyperlinks (long URIs) may use the whole, larger buffer */ +static size_t StringLimit(Window *win) +{ + if (win->w_StringType == OSC && win->w_stringp - win->w_string >= 2 + && win->w_string[0] == '8' && win->w_string[1] == ';') + return sizeof(win->w_string); + return MAXSTR; +} + static void StringChar(Window *win, int c) { + char *end = win->w_string + StringLimit(win) - 1; + + if (win->w_stringovf) + return; + /* * WriteString() decodes UTF-8 input to Unicode code points. * String buffers are byte-oriented, so non-ASCII characters must be @@ -1219,8 +1249,8 @@ static void StringChar(Window *win, int c) char utf8[7]; int n = ToUtf8(utf8, c); - if (win->w_stringp + n > win->w_string + MAXSTR - 1) - win->w_state = LIT; + if (win->w_stringp + n > end) + StringOverflow(win); else { memcpy(win->w_stringp, utf8, n); win->w_stringp += n; @@ -1228,12 +1258,59 @@ static void StringChar(Window *win, int c) return; } - if (win->w_stringp >= win->w_string + MAXSTR - 1) - win->w_state = LIT; + if (win->w_stringp >= end) + StringOverflow(win); else *(win->w_stringp)++ = c; } +/*****************************************************************/ +/* OSC 8 hyperlinks */ + +static void MarkLinkLines(struct mline *ml, int n, int w) +{ + for (; n > 0; n--, ml++) { + if (ml->linkid == NULL || ml->linkid == null) + continue; + for (int x = 0; x < w; x++) + if (ml->linkid[x]) + hl_mark(ml->linkid[x]); + } +} + +/* free the links no window and no display refers to anymore */ +static void HyperlinkGC(void) +{ + hl_gc_begin(); + for (Window *w = mru_window; w; w = w->w_prev_mru) { + MarkLinkLines(w->w_mlines, w->w_height, w->w_width + 1); + MarkLinkLines(w->w_hlines, w->w_histheight, w->w_width + 1); + MarkLinkLines(w->w_alt.mlines, w->w_alt.height, w->w_alt.width + 1); + MarkLinkLines(w->w_alt.hlines, w->w_alt.histheight, w->w_alt.width + 1); + hl_mark(w->w_rend.linkid); /* saved cursors never hold links */ + } + for (Display *d = displays; d; d = d->d_next) { + hl_mark(d->d_rend.linkid); + hl_mark(d->d_lpchar.linkid); + } + hl_gc_end(); +} + +/* OSC 8 ; params ; URI: open (or with an empty URI close) a hyperlink */ +static void HyperlinkOSC(Window *win, char *payload) +{ + char *app_id, *uri; + uint32_t id = 0; + + /* a malformed or rejected link must not extend a previous one */ + if (hl_parse(payload, &app_id, &uri) == 0 && *uri) { + if (hl_gc_wanted()) + HyperlinkGC(); + id = hl_intern(app_id, uri); + } + win->w_rend.linkid = id; +} + /* * Do string processing. Returns -1 if output should be suspended * until status is gone. @@ -1251,12 +1328,21 @@ static int StringEnd(Window *win) win->w_state = LIT; *win->w_stringp = '\0'; + if (win->w_stringovf) { + /* over long OSC string, see StringOverflow() */ + win->w_stringovf = false; + return 0; + } switch (win->w_StringType) { case OSC: /* special xterm compatibility hack */ if (win->w_string[0] == ';' || (p = strchr(win->w_string, ';')) == NULL) break; typ = atoi(win->w_string); p++; + if (typ == 8) { + HyperlinkOSC(win, p); + break; + } if (typ == 83) { /* 83 = 'S' */ /* special execute commands sequence */ char *args[MAXARGS]; @@ -1333,7 +1419,8 @@ static int StringEnd(Window *win) } return -1; case DCS: - LAY_DISPLAYS(&win->w_layer, AddStr(win->w_string)); + /* raw passthrough, don't let it land inside an open hyperlink */ + LAY_DISPLAYS(&win->w_layer, (SetLink(0), AddStr(win->w_string))); break; case AKA: if (win->w_title == win->w_akabuf && !*win->w_string) @@ -1468,6 +1555,7 @@ static void SaveCursor(Window *win, struct cursor *cursor) cursor->x = win->w_x; cursor->y = win->w_y; cursor->Rend = win->w_rend; + cursor->Rend.linkid = 0; /* see RestoreCursor() */ cursor->Charset = win->w_Charset; cursor->CharsetR = win->w_CharsetR; memmove((char *)cursor->Charsets, (char *)win->w_charsets, 4 * sizeof(int)); @@ -1480,7 +1568,10 @@ static void RestoreCursor(Window *win, struct cursor *cursor) LGotoPos(&win->w_layer, cursor->x, cursor->y); win->w_x = cursor->x; win->w_y = cursor->y; + /* like xterm/VTE: an open hyperlink is not part of the saved state */ + uint32_t linkid = win->w_rend.linkid; win->w_rend = cursor->Rend; + win->w_rend.linkid = linkid; memmove((char *)win->w_charsets, (char *)cursor->Charsets, 4 * sizeof(int)); win->w_Charset = cursor->Charset; win->w_CharsetR = cursor->CharsetR; @@ -1957,6 +2048,13 @@ static void MFixLine(Window *win, int y, struct mchar *mc) WMsg(win, 0, "Warning: no space for color foreground - turned off"); } } + if (mc->linkid && ml->linkid == null) { + if ((ml->linkid = calloc(win->w_width + 1, 4)) == NULL) { + ml->linkid = null; + mc->linkid = win->w_rend.linkid = 0; + WMsg(win, 0, "Warning: no space for hyperlink - turned off"); + } + } } /*****************************************************************/ @@ -2046,6 +2144,9 @@ static void MScrollV(Window *win, int n, int ys, int ye, int bce) if (ml->colorfg != null) free(ml->colorfg); ml->colorfg = null; + if (ml->linkid != null) + free(ml->linkid); + ml->linkid = null; memmove(ml->image, blank, (win->w_width + 1) * 4); if (bce) MBceLine(win, i, 0, win->w_width, bce); @@ -2079,6 +2180,9 @@ static void MScrollV(Window *win, int n, int ys, int ye, int bce) if (ml->colorfg != null) free(ml->colorfg); ml->colorfg = null; + if (ml->linkid != null) + free(ml->linkid); + ml->linkid = null; memmove(ml->image, blank, (win->w_width + 1) * 4); if (bce) MBceLine(win, i, 0, win->w_width, bce); @@ -2259,6 +2363,12 @@ static void WAddLineToHist(Window *win, struct mline *ml) o = hml->colorfg; hml->colorfg = q; ml->colorfg = null; + if (o != null) + free(o); + q = ml->linkid; + o = hml->linkid; + hml->linkid = q; + ml->linkid = null; if (o != null) free(o); @@ -2282,6 +2392,8 @@ int MFindUsedLine(Window *win, int ye, int ys) break; if (ml->colorfg != null && memcmp(ml->colorfg, null, win->w_width * 4)) break; + if (ml->linkid != null && memcmp(ml->linkid, null, win->w_width * 4)) + break; if (win->w_encoding == UTF8) { if (ml->font != null && memcmp(ml->font, null, win->w_width)) break; diff --git a/comm.c b/comm.c index dd7f650..ba954da 100644 --- a/comm.c +++ b/comm.c @@ -128,6 +128,7 @@ struct comm comms[RC_LAST + 1] = { "help", NEED_LAYER|ARGS_02, {NULL} }, { "history", NEED_DISPLAY|NEED_FORE|ARGS_0, {NULL} }, { "hstatus", NEED_FORE|ARGS_1, {NULL} }, + { "hyperlinks", ARGS_01, {NULL} }, { "idle", ARGS_0|ARGS_ORMORE, {NULL} }, { "ignorecase", ARGS_01, {NULL} }, { "info", CAN_QUERY|NEED_LAYER|ARGS_0, {NULL} }, diff --git a/display.c b/display.c index e129682..7ab9182 100644 --- a/display.c +++ b/display.c @@ -44,6 +44,7 @@ #include "canvas.h" #include "encoding.h" +#include "hyperlink.h" #include "mark.h" #include "misc.h" #include "process.h" @@ -78,6 +79,9 @@ static void INSERTCHAR(uint32_t); static void RAW_PUTCHAR(uint32_t); static void SetBackColor(int); static void RemoveStatusMinWait(void); +static void AddStrRaw(const char *); +static void LinkOff(void); +static void LinkReset(void); Display *display, *displays; @@ -330,6 +334,8 @@ void InitTerm(int adapt) AddCStr(D_ME); AddCStr(D_EA); AddCStr(D_CE0); + /* no hyperlink is open yet; don't emit anything here, a later + * "hyperlinks off" in the rc file must be able to prevent all OSC 8 */ D_rend = mchar_null; D_atyp = 0; if (adapt == 0) @@ -858,6 +864,7 @@ void ClearArea(int x1, int y1, int xs, int xe, int x2, int y2, int bce, int usel xs = x1; if (xe == -1) xe = x2; + LinkOff(); /* erased cells must not become part of a link */ if (D_UT) /* Safe to erase ? */ SetRendition(&mchar_null); if (D_BE) @@ -985,6 +992,7 @@ void ScrollH(int y, int xs, int xe, int n, int bce, struct mline *oml) /* UpdateLine(oml, y, xs, xe); */ return; } + LinkOff(); GotoPos(xs, y); if (D_UT) SetRendition(&mchar_null); @@ -1054,6 +1062,7 @@ void ScrollV(int xs, int ys, int xe, int ye, int n, int bce) if (n == 0) return; + LinkOff(); if (n >= ye - ys + 1 || -n >= ye - ys + 1) { ClearArea(xs, ys, xs, xe, xe, ye, bce, 0); return; @@ -1412,6 +1421,8 @@ void SetRendition(struct mchar *mc) SetColor(mc->colorfg, mc->colorbg); if (D_rend.font != mc->font) SetFont(mc->font); + if (D_rend.linkid != mc->linkid) + SetLink(mc->linkid); } void SetRenditionMline(struct mline *ml, int x) @@ -1428,6 +1439,79 @@ void SetRenditionMline(struct mline *ml, int x) } if (D_rend.font != ml->font[x]) SetFont(ml->font[x]); + if (D_rend.linkid != ml->linkid[x]) + SetLink(ml->linkid[x]); +} + +/* + * OSC 8 hyperlinks + * + * D_rend.linkid is the link currently open on the terminal. It is tracked + * even for displays that don't get hyperlinks, only the output is skipped. + * Links are independent of SGR: ME ("\033[m") does not close them. + */ + +static bool HyperlinksOn(void) +{ + return hyperlinks && D_CHL; +} + +static void EmitLink(uint32_t id) +{ + static pid_t pid; + const char *uri = hl_uri(id); + char buf[48]; + + if (uri) { + if (!pid) + pid = getpid(); + /* our own id: pieces of a link drawn separately stay one link, + * and links of different windows/sessions never merge */ + sprintf(buf, "\033]8;id=scr%ld-%lu;", (long)pid, (unsigned long)id); + AddStrRaw(buf); + AddStrRaw(uri); /* already UTF-8, see StringChar() */ + } else + AddStrRaw("\033]8;;"); + AddStrRaw("\033\\"); +} + +void SetLink(uint32_t id) +{ + if (!display || D_rend.linkid == id) + return; + if (HyperlinksOn()) + EmitLink(id); + D_rend.linkid = id; +} + +/* close the open link, e.g. before the terminal erases or scrolls cells */ +static void LinkOff(void) +{ + if (D_rend.linkid) + SetLink(0); +} + +/* the terminal's link state is unknown (fresh or flushed output): close */ +static void LinkReset(void) +{ + if (HyperlinksOn()) + EmitLink(0); + D_rend.linkid = 0; +} + +/* runtime switch of the global "hyperlinks" setting */ +void SetHyperlinks(bool on) +{ + Display *olddisplay = display; + + if (on == hyperlinks) + return; + /* close open links while we still may talk OSC 8 */ + if (!on) + for (display = displays; display; display = display->d_next) + LinkOff(); + display = olddisplay; + hyperlinks = on; } void MakeStatus(char *msg) @@ -1906,6 +1990,7 @@ void ClearLine(struct mline *oml, int y, int from, int to, int bce) int x; struct mchar bcechar; + LinkOff(); if (D_UT) /* Safe to erase ? */ SetRendition(&mchar_null); if (D_BE) @@ -2397,6 +2482,7 @@ void NukePending(void) AddCStr(D_CE0); D_rend = mchar_null; D_atyp = 0; + LinkReset(); AddCStr(D_DS); D_hstatus = false; AddCStr(D_VE); @@ -2987,6 +3073,7 @@ void KillBlanker(void) AddCStr(D_CE0); D_rend = mchar_null; D_atyp = 0; + LinkReset(); D_curvis = 0; D_x = D_y = -1; ChangeScrollRegion(oldtop, oldbot); diff --git a/display.h b/display.h index 96a76c1..32dcd6a 100644 --- a/display.h +++ b/display.h @@ -362,6 +362,8 @@ void BracketedPasteMode (bool); void CursorStyle (int); void SetRendition (struct mchar *); void SetRenditionMline (struct mline *, int); +void SetLink (uint32_t); +void SetHyperlinks (bool); void MakeStatus (char *); void RemoveStatus (void); int ResizeDisplay (int, int); diff --git a/doc/screen.1 b/doc/screen.1 index 3270bb7..7fdb3af 100644 --- a/doc/screen.1 +++ b/doc/screen.1 @@ -2406,6 +2406,17 @@ scrollback buffer). Change the window's hardstatus line to the string \fIstatus\fP. .RE .TP +.BR "hyperlinks " [ on | off ] +.RS 0 +.PP +Enables or disables forwarding of OSC 8 hyperlinks (as printed e.g. by +\*Qls \-\-hyperlink\*U) to the terminal. Hyperlinks are stored with the text, +so they survive redraws, window switches, split regions, copy mode and +reattaching. They are only sent to terminals that support them, see the +\fBHL\fP capability. Without an argument the current setting is displayed. +Default is `on'. +.RE +.TP .IR "\fBidle\fR " [ timeout [ "cmd-args" ]] .RS 0 .PP @@ -5066,6 +5077,13 @@ Terminal needs bold to display high-intensity colors (e.g. Eterm). .TP 13 .BI TF " (bool)" Add missing capabilities to the termcap/info entry. (Set by default). +.TP 13 +.BI HL " (bool)" +Terminal supports OSC 8 hyperlinks. Set by default for terminals whose name +matches xterm*, *\-256color, *\-direct, foot*, kitty*, alacritty*, wezterm*, +ghostty*, contour*, tmux*, rxvt\-unicode*, mintty*, iterm*, vte*, gnome*, +konsole* or st\-*. Use \*Qtermcapinfo name HL\*U to enable or +\*Qtermcapinfo name HL@\*U to disable hyperlinks for a terminal. .SH CHARACTER TRANSLATION \fIScreen\fP has a powerful mechanism to translate characters to arbitrary diff --git a/doc/screen.texinfo b/doc/screen.texinfo index 4cfbd31..e50e622 100644 --- a/doc/screen.texinfo +++ b/doc/screen.texinfo @@ -1348,6 +1348,10 @@ Find previous command beginning @dots{}. Change the window's hardstatus line. @xref{Hardstatus}. +@item hyperlinks [@var{state}] +Forward OSC 8 hyperlinks to the terminal. +@xref{Special Capabilities}. + @item idle [@var{timeout} [@var{cmd} @var{args}]] Define a screen saver command. @xref{Screen Saver}. @@ -5282,8 +5286,26 @@ Terminal needs bold to display high-intensity colors (e.g. Eterm). @item TF (bool)@* Add missing capabilities to the termcap/info entry. (Set by default). + +@item HL +(bool)@* +Terminal supports OSC 8 hyperlinks. Set by default for terminals whose +name matches xterm*, *-256color, *-direct, foot*, kitty*, alacritty*, +wezterm*, ghostty*, contour*, tmux*, rxvt-unicode*, mintty*, iterm*, vte*, +gnome*, konsole* or st-*. Use @samp{termcapinfo name HL} to enable or +@samp{termcapinfo name HL@@} to disable hyperlinks for a terminal. @end table +@deffn Command hyperlinks [@code{on|off}] +(none)@* +Enables or disables forwarding of OSC 8 hyperlinks (as printed e.g. by +@samp{ls --hyperlink}) to the terminal. Hyperlinks are stored with the +text, so they survive redraws, window switches, split regions, copy mode +and reattaching. They are only sent to terminals with the @code{HL} +capability. Without an argument the current setting is displayed. +Default is @samp{on}. +@end deffn + @node Autonuke @section Autonuke @deffn Command autonuke @var{state} diff --git a/encoding.c b/encoding.c index b9f210b..c32a213 100644 --- a/encoding.c +++ b/encoding.c @@ -506,6 +506,7 @@ struct mline *recode_mline(struct mline *ml, int w, int from, int to) rl->attr = ml->attr; rl->colorbg = ml->colorbg; rl->colorfg = ml->colorfg; + rl->linkid = ml->linkid; for (i = 0; i < w; i++) { c = ml->image[i] | (ml->font[i] << 8); if (from != UTF8 && c < 256) diff --git a/image.h b/image.h index 29a4e9a..33d6c9b 100644 --- a/image.h +++ b/image.h @@ -40,6 +40,7 @@ struct mchar { uint32_t colorbg; /* background color */ uint32_t colorfg; /* foreground color */ uint32_t mbcs; /* used for multi byte character sets; TODO: possible to remove? use image now that it has 32 bits*/ + uint32_t linkid; /* OSC 8 hyperlink id (see hyperlink.h), 0 = none */ }; struct mline { @@ -48,6 +49,7 @@ struct mline { uint32_t *font; uint32_t *colorbg; uint32_t *colorfg; + uint32_t *linkid; }; @@ -58,6 +60,7 @@ struct mline { memmove(mline_old.font, (ml)->font, (n) * 4); \ memmove(mline_old.colorbg, (ml)->colorbg, (n) * 4); \ memmove(mline_old.colorfg, (ml)->colorfg, (n) * 4); \ + memmove(mline_old.linkid, (ml)->linkid, (n) * 4); \ } #define copy_mline(ml, xf, xt, n) { \ @@ -66,6 +69,7 @@ struct mline { memmove((ml)->font + (xt), (ml)->font + (xf), (n) * 4); \ memmove((ml)->colorbg + (xt), (ml)->colorbg + (xf), (n) * 4); \ memmove((ml)->colorfg + (xt), (ml)->colorfg + (xf), (n) * 4); \ + memmove((ml)->linkid + (xt), (ml)->linkid + (xf), (n) * 4); \ } #define clear_mline(ml, x, n) { \ @@ -74,6 +78,7 @@ struct mline { if ((ml)->font != null) memset((ml)->font + (x), 0, (n) * 4); \ if ((ml)->colorbg != null) memset((ml)->colorbg + (x), 0, (n) * 4); \ if ((ml)->colorfg != null) memset((ml)->colorfg + (x), 0, (n) * 4); \ + if ((ml)->linkid != null) memset((ml)->linkid + (x), 0, (n) * 4); \ } #define cmp_mline(ml1, ml2, x) ( \ @@ -82,6 +87,7 @@ struct mline { && (ml1)->font[x] == (ml2)->font[x] \ && (ml1)->colorbg[x] == (ml2)->colorbg[x] \ && (ml1)->colorfg[x] == (ml2)->colorfg[x] \ + && (ml1)->linkid[x] == (ml2)->linkid[x] \ ) #define cmp_mchar(mc1, mc2) ( \ @@ -90,6 +96,7 @@ struct mline { && (mc1)->font == (mc2)->font \ && (mc1)->colorbg == (mc2)->colorbg \ && (mc1)->colorfg == (mc2)->colorfg \ + && (mc1)->linkid == (mc2)->linkid \ ) #define cmp_mchar_mline(mc, ml, x) ( \ @@ -98,6 +105,7 @@ struct mline { && (mc)->font == (ml)->font[x] \ && (mc)->colorbg == (ml)->colorbg[x] \ && (mc)->colorfg == (ml)->colorfg[x] \ + && (mc)->linkid == (ml)->linkid[x] \ ) #define copy_mchar2mline(mc, ml, x) { \ @@ -106,6 +114,7 @@ struct mline { (ml)->font[x] = (mc)->font; \ (ml)->colorbg[x] = (mc)->colorbg; \ (ml)->colorfg[x] = (mc)->colorfg; \ + (ml)->linkid[x] = (mc)->linkid; \ } #define copy_mline2mchar(mc, ml, x) { \ @@ -114,6 +123,7 @@ struct mline { (mc)->font = (ml)->font[x]; \ (mc)->colorbg = (ml)->colorbg[x]; \ (mc)->colorfg = (ml)->colorfg[x]; \ + (mc)->linkid = (ml)->linkid[x]; \ (mc)->mbcs = 0; \ } diff --git a/layer.c b/layer.c index d7e82bf..6f4c460 100644 --- a/layer.c +++ b/layer.c @@ -56,6 +56,7 @@ static struct mline *mlineoffset(const struct mline *ml, const int offset) mml.font = ml->font + offset; mml.colorbg = ml->colorbg + offset; mml.colorfg = ml->colorfg + offset; + mml.linkid = ml->linkid + offset; return &mml; } diff --git a/process.c b/process.c index edb4996..25b7be8 100644 --- a/process.c +++ b/process.c @@ -4682,6 +4682,20 @@ static void DoCommandTruecolor(struct action *act) Activate(-1); } +static void DoCommandHyperlinks(struct action *act) +{ + bool b; + + if (!act->args[0]) { + OutputMsg(0, "OSC 8 hyperlinks are turned %s", hyperlinks ? "on" : "off"); + return; + } + if (ParseOnOff(act, &b) == 0 && b != hyperlinks) { + SetHyperlinks(b); + Activate(-1); + } +} + void DoAction(struct action *act) { int nr = act->nr; @@ -5198,6 +5212,9 @@ void DoAction(struct action *act) case RC_DEFHSTATUS: DoCommandDefhstatus(act); break; + case RC_HYPERLINKS: + DoCommandHyperlinks(act); + break; case RC_HSTATUS: DoCommandHstatus(act); break; @@ -6305,6 +6322,12 @@ static void ShowDInfo(void) l -= w; p += w; } + if (D_CHL && hyperlinks) { + strncpy(p, " hyperlinks", l); + w = strlen(p); + l -= w; + p += w; + } if (D_CG0) strncpy(p, " iso2022", l); else if (D_CS0 && *D_CS0) diff --git a/resize.c b/resize.c index 895b96a..7e92ccb 100644 --- a/resize.c +++ b/resize.c @@ -59,7 +59,8 @@ static struct mline mline_zero = { .attr = NULL, .font = NULL, .colorbg = NULL, - .colorfg = NULL + .colorfg = NULL, + .linkid = NULL }; /* @@ -314,6 +315,8 @@ static void FreeMline(struct mline *ml) free(ml->colorbg); if (ml->colorfg && ml->colorfg != null) free(ml->colorfg); + if (ml->linkid && ml->linkid != null) + free(ml->linkid); *ml = mline_zero; } @@ -324,6 +327,7 @@ static int AllocMline(struct mline *ml, int w) ml->font = null; ml->colorbg = null; ml->colorfg = null; + ml->linkid = null; if (ml->image == NULL) return -1; return 0; @@ -358,6 +362,12 @@ static int BcopyMline(struct mline *mlf, int xf, struct mline *mlt, int xt, int } if (mlt->colorfg != null) memmove(mlt->colorfg + xt, mlf->colorfg + xf, l * 4); + if (mlf->linkid != null && mlt->linkid == null) { + if ((mlt->linkid = calloc(w, 4)) == NULL) + mlt->linkid = null, r = -1; + } + if (mlt->linkid != null) + memmove(mlt->linkid + xt, mlf->linkid + xf, l * 4); return r; } @@ -383,7 +393,8 @@ static void CheckMaxSize(int wi) mline_old.font = xrealloc(mline_old.font, maxwidth * 4); mline_old.colorbg = xrealloc(mline_old.colorbg, maxwidth * 4); mline_old.colorfg = xrealloc(mline_old.colorfg, maxwidth * 4); - if (!(blank && null && mline_old.image && mline_old.attr && mline_old.font && mline_old.colorbg && mline_old.colorfg)) + mline_old.linkid = xrealloc(mline_old.linkid, maxwidth * 4); + if (!(blank && null && mline_old.image && mline_old.attr && mline_old.font && mline_old.colorbg && mline_old.colorfg && mline_old.linkid)) Panic(0, "%s", strnomem); MakeBlankLine(blank, maxwidth); @@ -399,6 +410,8 @@ static void CheckMaxSize(int wi) mline_null.colorbg = null; mline_blank.colorfg = null; mline_null.colorfg = null; + mline_blank.linkid = null; + mline_null.linkid = null; #define RESET_AFC(x, bl) \ do { \ @@ -415,6 +428,7 @@ do { \ RESET_AFC(ml->font, null); \ RESET_AFC(ml->colorbg, null); \ RESET_AFC(ml->colorfg, null); \ + RESET_AFC(ml->linkid, null); \ } \ } while (0) diff --git a/screen.c b/screen.c index 93d5a9f..11c473c 100644 --- a/screen.c +++ b/screen.c @@ -142,6 +142,7 @@ int rflag; int dflag; int queryflag = -1; bool hastruecolor = false; +bool hyperlinks = true; /* forward OSC 8 hyperlinks to capable displays */ char *multi; int multiattach; diff --git a/screen.h b/screen.h index efb2835..3b571fe 100644 --- a/screen.h +++ b/screen.h @@ -274,6 +274,7 @@ extern bool cjkwidth; extern bool default_startup; extern bool do_auth; extern bool hastruecolor; +extern bool hyperlinks; extern bool iflag; extern bool logtstamp_on; extern bool lsflag; diff --git a/term.c b/term.c index ec8c6b3..8d40408 100644 --- a/term.c +++ b/term.c @@ -168,6 +168,7 @@ struct term term[T_N] = { "VN", T_STR }, { "TF", T_FLG }, { "XT", T_FLG }, + { "HL", T_FLG }, /* d_font setting */ { "G0", T_FLG }, diff --git a/termcap.c b/termcap.c index e971180..514f833 100644 --- a/termcap.c +++ b/termcap.c @@ -31,6 +31,8 @@ #include "termcap.h" #include <sys/types.h> +#include <fnmatch.h> +#include <stdbool.h> #include <stdint.h> /* to get index() declaration on Solaris */ @@ -53,6 +55,8 @@ static int copyarg(char **, char *); static int e_tgetent(char *, char *); static char *e_tgetstr(char *, char **); static int e_tgetflag(char *); +static int e_tgetflag_extra(char *); +static bool HyperlinkTerm(const char *); static int e_tgetnum(char *); static int findseq_ge(char *, int, unsigned char **); static void setseqoff(unsigned char *, int, int); @@ -188,6 +192,11 @@ int InitTermcap(int width, int height) if (D_CXT) D_BE = 1; } + /* OSC 8 hyperlinks: "HL" / "HL@" via termcapinfo, else known terminals */ + if ((t = e_tgetflag_extra("HL")) >= 0) + D_CHL = t; + else + D_CHL = HyperlinkTerm(D_termname); if (nwin_default.flowflag == nwin_undef.flowflag) nwin_default.flowflag = D_CNF ? FLOW_OFF : D_NX ? FLOW_ON : FLOW_AUTOFLAG; D_CLP |= (!D_AM || D_XV || D_XN); @@ -1181,6 +1190,32 @@ static int e_tgetflag(char *cap) return tgetflag(cap) > 0; } +/* 1: cap set, 0: cancelled with "cap@", -1: not given via termcap/terminfo commands */ +static int e_tgetflag_extra(char *cap) +{ + char buf[2], *bufp; + char *tep; + bufp = buf; + if ((tep = findcap(cap, &bufp, 2))) + return (*tep == '@') ? 0 : 1; + return -1; +} + +/* terminals known to support OSC 8 hyperlinks (or to ignore them silently) */ +static bool HyperlinkTerm(const char *termname) +{ + static const char *const patterns[] = { + "xterm*", "*-256color", "*-direct", "foot*", "kitty*", "alacritty*", + "wezterm*", "ghostty*", "contour*", "tmux*", "rxvt-unicode*", + "mintty*", "iterm*", "vte*", "gnome*", "konsole*", "st-*", + }; + + for (size_t i = 0; i < ARRAY_SIZE(patterns); i++) + if (fnmatch(patterns[i], termname, 0) == 0) + return true; + return false; +} + static int e_tgetnum(char *cap) { char buf[20], *bufp; diff --git a/window.h b/window.h index e1c1a68..519fac9 100644 --- a/window.h +++ b/window.h @@ -40,6 +40,10 @@ #include "screen.h" #include "layer.h" #include "display.h" +#include "hyperlink.h" + +/* OSC 8 needs room for "8;", the params and a HL_MAX_URI long URI */ +#define MAXOSCSTR (HL_MAX_URI + 512) struct NewWindow { int StartAt; /* where to start the search for the slot */ @@ -211,8 +215,9 @@ struct Window { bool w_c1; /* enable C1 flag */ int w_decodestate; /* state of our input decoder */ int w_mbcs; /* saved char for multibytes charset */ - char w_string[MAXSTR]; + char w_string[MAXOSCSTR]; /* only OSC 8 may use more than MAXSTR */ char *w_stringp; + bool w_stringovf; /* over long OSC string being swallowed */ char *w_tabs; /* line with tabs */ int w_bell; /* bell status of this window */ int w_flow; /* flow flags */ diff --git a/hyperlink.c b/hyperlink.c new file mode 100644 index 0000000..39fd02f --- /dev/null +++ b/hyperlink.c @@ -0,0 +1,380 @@ +/* Copyright (c) 2026 + * David Kedves ([email protected]) + * + * This file is part of GNU screen. + * + * GNU screen is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program (see the file COPYING); if not, see + * <https://www.gnu.org/licenses>. + * + **************************************************************** + */ + +#include <stdlib.h> +#include <string.h> + +#include "hyperlink.h" + +struct hl_entry { + uint32_t id; + uint32_t keyhash; /* hash of (app_id, uri), valid if app_id != NULL */ + bool marked; /* gc mark */ + char *app_id; /* id= given by the application, or NULL */ + char *uri; +}; + +/* Two open addressing (linear probing) tables over the same entries: + * by_id holds every entry, by_key only those with an application id. + * Entries are only ever removed by hl_gc_end()/hl_reset(), which rebuild + * the tables from scratch, so no tombstones are needed. */ +static struct hl_entry **by_id; +static size_t by_id_cap; /* 0 or a power of two */ +static size_t n_links; +static struct hl_entry **by_key; +static size_t by_key_cap; /* 0 or a power of two */ +static size_t n_keyed; + +static uint32_t next_id = 1; +static size_t gc_threshold = HL_GC_MIN; + +static size_t id_slot(uint32_t id, size_t cap) +{ + return (size_t)(id * 2654435761u) & (cap - 1); +} + +static uint32_t key_hash(const char *app_id, const char *uri) +{ + uint32_t h = 2166136261u; /* FNV-1a */ + const unsigned char *p; + + for (p = (const unsigned char *)app_id; *p; p++) + h = (h ^ *p) * 16777619u; + h *= 16777619u; /* the '\0' separator */ + for (p = (const unsigned char *)uri; *p; p++) + h = (h ^ *p) * 16777619u; + return h; +} + +static void put_id(struct hl_entry **tab, size_t cap, struct hl_entry *e) +{ + size_t i = id_slot(e->id, cap); + + while (tab[i]) + i = (i + 1) & (cap - 1); + tab[i] = e; +} + +static void put_key(struct hl_entry **tab, size_t cap, struct hl_entry *e) +{ + size_t i = e->keyhash & (cap - 1); + + while (tab[i]) + i = (i + 1) & (cap - 1); + tab[i] = e; +} + +static struct hl_entry *find_id(uint32_t id) +{ + size_t i; + + if (id == 0 || by_id_cap == 0) + return NULL; + for (i = id_slot(id, by_id_cap); by_id[i]; i = (i + 1) & (by_id_cap - 1)) + if (by_id[i]->id == id) + return by_id[i]; + return NULL; +} + +static struct hl_entry *find_key(const char *app_id, const char *uri, uint32_t h) +{ + size_t i; + + if (by_key_cap == 0) + return NULL; + for (i = h & (by_key_cap - 1); by_key[i]; i = (i + 1) & (by_key_cap - 1)) + if (by_key[i]->keyhash == h && !strcmp(by_key[i]->app_id, app_id) && !strcmp(by_key[i]->uri, uri)) + return by_key[i]; + return NULL; +} + +/* smallest power of two table (min 64) keeping the load factor <= 1/2 */ +static size_t cap_for(size_t n) +{ + size_t cap = 64; + + while (cap < 2 * n) + cap *= 2; + return cap; +} + +/* make room for one more entry in by_id (and by_key if keyed) */ +static bool reserve(bool keyed) +{ + struct hl_entry **tab; + size_t cap, i; + + cap = cap_for(n_links + 1); + if (cap > by_id_cap) { + if ((tab = calloc(cap, sizeof(*tab))) == NULL) + return false; + for (i = 0; i < by_id_cap; i++) + if (by_id[i]) + put_id(tab, cap, by_id[i]); + free(by_id); + by_id = tab; + by_id_cap = cap; + } + if (!keyed) + return true; + cap = cap_for(n_keyed + 1); + if (cap > by_key_cap) { + if ((tab = calloc(cap, sizeof(*tab))) == NULL) + return false; + for (i = 0; i < by_key_cap; i++) + if (by_key[i]) + put_key(tab, cap, by_key[i]); + free(by_key); + by_key = tab; + by_key_cap = cap; + } + return true; +} + +static char *dupstr(const char *s) +{ + size_t l = strlen(s) + 1; + char *d = malloc(l); + + if (d) + memcpy(d, s, l); + return d; +} + +static void free_entry(struct hl_entry *e) +{ + free(e->app_id); + free(e->uri); + free(e); +} + +/* + * Only printable ASCII and well formed UTF-8 for code points >= U+00A0 is + * accepted. The URI is sent to the outer terminal again later, so control + * characters (C0, DEL and C1 - also in their UTF-8 encoding) must never + * get through: they could terminate the OSC early and inject sequences. + */ +static bool valid_text(const char *s, bool ascii_only) +{ + const unsigned char *p = (const unsigned char *)s; + + while (*p) { + uint32_t c = *p, cp; + int n, i; + + if (c >= 0x20 && c < 0x7f) { + p++; + continue; + } + if (ascii_only) + return false; + if (c >= 0xc2 && c <= 0xdf) + n = 1, cp = c & 0x1f; + else if (c >= 0xe0 && c <= 0xef) + n = 2, cp = c & 0x0f; + else if (c >= 0xf0 && c <= 0xf4) + n = 3, cp = c & 0x07; + else + return false; + for (i = 1; i <= n; i++) { + if ((p[i] & 0xc0) != 0x80) + return false; + cp = cp << 6 | (p[i] & 0x3f); + } + if (cp < 0xa0 || (n == 2 && cp < 0x800) || (n == 3 && cp < 0x10000) + || (cp >= 0xd800 && cp <= 0xdfff) || cp > 0x10ffff) + return false; + p += n + 1; + } + return true; +} + +static bool valid_app_id(const char *app_id) +{ + return app_id && *app_id && strlen(app_id) <= HL_MAX_ID && valid_text(app_id, true); +} + +int hl_parse(char *payload, char **app_id, char **uri) +{ + char *sep, *p, *next; + + *app_id = NULL; + *uri = NULL; + if (payload == NULL || (sep = strchr(payload, ';')) == NULL) + return -1; + *sep = '\0'; + if (strlen(sep + 1) > HL_MAX_URI || !valid_text(sep + 1, false)) + return -1; + *uri = sep + 1; + /* params are "key=value" pairs separated by ':', we only care for id */ + for (p = payload; p; p = next) { + if ((next = strchr(p, ':')) != NULL) + *next++ = '\0'; + if (!strncmp(p, "id=", 3)) + *app_id = valid_app_id(p + 3) ? p + 3 : NULL; + } + return 0; +} + +static uint32_t new_id(void) +{ + uint32_t id; + + do { + id = next_id++; + if (next_id == 0) + next_id = 1; + } while (id == 0 || find_id(id)); + return id; +} + +uint32_t hl_intern(const char *app_id, const char *uri) +{ + struct hl_entry *e; + uint32_t h = 0; + + if (uri == NULL || *uri == '\0' || strlen(uri) > HL_MAX_URI || !valid_text(uri, false)) + return 0; + if (app_id && !valid_app_id(app_id)) + app_id = NULL; + if (app_id) { + h = key_hash(app_id, uri); + if ((e = find_key(app_id, uri, h)) != NULL) + return e->id; + } + if (!reserve(app_id != NULL)) + return 0; + if ((e = calloc(1, sizeof(*e))) == NULL) + return 0; + e->uri = dupstr(uri); + e->app_id = app_id ? dupstr(app_id) : NULL; + if (e->uri == NULL || (app_id && e->app_id == NULL)) { + free_entry(e); + return 0; + } + e->keyhash = h; + e->id = new_id(); + put_id(by_id, by_id_cap, e); + n_links++; + if (app_id) { + put_key(by_key, by_key_cap, e); + n_keyed++; + } + return e->id; +} + +const char *hl_uri(uint32_t id) +{ + struct hl_entry *e = find_id(id); + + return e ? e->uri : NULL; +} + +size_t hl_count(void) +{ + return n_links; +} + +bool hl_gc_wanted(void) +{ + return n_links >= gc_threshold; +} + +void hl_gc_begin(void) +{ + size_t i; + + for (i = 0; i < by_id_cap; i++) + if (by_id[i]) + by_id[i]->marked = false; +} + +void hl_mark(uint32_t id) +{ + struct hl_entry *e = find_id(id); + + if (e) + e->marked = true; +} + +void hl_gc_end(void) +{ + struct hl_entry **nid, **nkey = NULL; + size_t live = 0, keyed = 0, idcap, keycap = 0, i; + + for (i = 0; i < by_id_cap; i++) + if (by_id[i] && by_id[i]->marked) { + live++; + if (by_id[i]->app_id) + keyed++; + } + idcap = cap_for(live); + nid = calloc(idcap, sizeof(*nid)); + if (keyed) { + keycap = cap_for(keyed); + nkey = calloc(keycap, sizeof(*nkey)); + } + if (nid == NULL || (keyed && nkey == NULL)) { + /* collection is only an optimisation: keep everything, retry later */ + free(nid); + free(nkey); + gc_threshold = 2 * n_links > HL_GC_MIN ? 2 * n_links : HL_GC_MIN; + return; + } + for (i = 0; i < by_id_cap; i++) { + struct hl_entry *e = by_id[i]; + + if (e == NULL) + continue; + if (!e->marked) { + free_entry(e); + continue; + } + put_id(nid, idcap, e); + if (e->app_id) + put_key(nkey, keycap, e); + } + free(by_id); + free(by_key); + by_id = nid; + by_id_cap = idcap; + n_links = live; + by_key = nkey; + by_key_cap = keycap; + n_keyed = keyed; + gc_threshold = 2 * live > HL_GC_MIN ? 2 * live : HL_GC_MIN; +} + +void hl_reset(void) +{ + size_t i; + + for (i = 0; i < by_id_cap; i++) + if (by_id[i]) + free_entry(by_id[i]); + free(by_id); + free(by_key); + by_id = by_key = NULL; + by_id_cap = by_key_cap = 0; + n_links = n_keyed = 0; + gc_threshold = HL_GC_MIN; +} diff --git a/hyperlink.h b/hyperlink.h new file mode 100644 index 0000000..45b9265 --- /dev/null +++ b/hyperlink.h @@ -0,0 +1,82 @@ +/* Copyright (c) 2026 + * David Kedves ([email protected]) + * + * This file is part of GNU screen. + * + * GNU screen is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program (see the file COPYING); if not, see + * <https://www.gnu.org/licenses>. + * + **************************************************************** + */ + +#ifndef SCREEN_HYPERLINK_H +#define SCREEN_HYPERLINK_H + +/* + * OSC 8 hyperlink table. + * + * Every cell carries a 32 bit link id (struct mchar.linkid), 0 meaning + * "no link". Ids are process wide, handed out monotonically and never + * reused, so a stale id can only ever miss on lookup; it can never + * resolve to somebody else's URI. + * + * Unreferenced entries are reclaimed by mark & sweep: when hl_gc_wanted() + * says so, the caller runs hl_gc_begin(), hl_mark()s every id still + * referenced anywhere and finishes with hl_gc_end(). + * + * This module deliberately knows nothing about windows or displays so it + * can be unit tested on its own (tests/test-hyperlink.c). + */ + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +#define HL_MAX_URI 8192 /* longest URI we store */ +#define HL_MAX_ID 250 /* longest application supplied id= we honour */ +#define HL_GC_MIN 1024 /* never collect below this many live links */ + +/* + * Split an OSC 8 payload ("params;URI", i.e. the part after "8;") in place. + * *app_id is set to the id= parameter (NULL if absent or too long), *uri to + * the URI ("" means: close the current link). Returns -1 for a malformed + * payload (no ';' separator or URI longer than HL_MAX_URI), 0 otherwise. + */ +int hl_parse(char *payload, char **app_id, char **uri); + +/* + * Register a link and return its id. Links with the same application id + * and URI share one id (so an application's multi part link stays one + * link); links without an application id always get a fresh one. + * Returns 0 if the link could not be stored (out of memory, bad input). + */ +uint32_t hl_intern(const char *app_id, const char *uri); + +/* URI of a link, or NULL if the id is 0 or unknown (e.g. already collected) */ +const char *hl_uri(uint32_t id); + +/* number of live links */ +size_t hl_count(void); + +/* true once enough links accumulated that a collection is worthwhile */ +bool hl_gc_wanted(void); + +void hl_gc_begin(void); +void hl_mark(uint32_t id); +void hl_gc_end(void); + +/* drop every link (id numbering continues) */ +void hl_reset(void); + +#endif /* SCREEN_HYPERLINK_H */ diff --git a/tests/test-hyperlink.c b/tests/test-hyperlink.c new file mode 100644 index 0000000..6caebb6 --- /dev/null +++ b/tests/test-hyperlink.c @@ -0,0 +1,251 @@ +/* Copyright (c) 2026 + * David Kedves ([email protected]) + * + * This file is part of GNU screen. + * + * GNU screen is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program (see the file COPYING); if not, see + * <https://www.gnu.org/licenses>. + * + **************************************************************** + */ + +#include <stdbool.h> +#include <string.h> + +#include "../hyperlink.h" +#include "signature.h" +#include "macros.h" + +SIGNATURE_CHECK(hl_parse, int, (char *, char **, char **)); +SIGNATURE_CHECK(hl_intern, uint32_t, (const char *, const char *)); +SIGNATURE_CHECK(hl_uri, const char *, (uint32_t)); +SIGNATURE_CHECK(hl_count, size_t, (void)); +SIGNATURE_CHECK(hl_gc_wanted, bool, (void)); +SIGNATURE_CHECK(hl_gc_begin, void, (void)); +SIGNATURE_CHECK(hl_mark, void, (uint32_t)); +SIGNATURE_CHECK(hl_gc_end, void, (void)); +SIGNATURE_CHECK(hl_reset, void, (void)); + +extern bool _mallocmock_fail; + +static int parse(const char *in, char **app_id, char **uri) +{ + static char buf[HL_MAX_URI + 1024]; + + strcpy(buf, in); + return hl_parse(buf, app_id, uri); +} + +static void test_parse(void) +{ + static char big[HL_MAX_URI + 16]; + char *id, *uri; + + /* plain link, no params */ + ASSERT(parse(";https://example.com/", &id, &uri) == 0); + ASSERT(id == NULL); + ASSERT(STREQ(uri, "https://example.com/")); + + /* close */ + ASSERT(parse(";", &id, &uri) == 0); + ASSERT(id == NULL); + ASSERT(STREQ(uri, "")); + + /* id param; ';' inside the URI belongs to the URI */ + ASSERT(parse("id=foo;http://a/b;c", &id, &uri) == 0); + ASSERT(STREQ(id, "foo")); + ASSERT(STREQ(uri, "http://a/b;c")); + + /* other params are ignored */ + ASSERT(parse("x=1:id=bar:y=2;file:///tmp", &id, &uri) == 0); + ASSERT(STREQ(id, "bar")); + ASSERT(STREQ(uri, "file:///tmp")); + + /* malformed: no separator */ + ASSERT(parse("https://example.com/", &id, &uri) == -1); + ASSERT(hl_parse(NULL, &id, &uri) == -1); + + /* UTF-8 is fine */ + ASSERT(parse(";https://example.com/\xc3\xa4\xe2\x82\xac\xf0\x9f\x98\x80", &id, &uri) == 0); + + /* control characters could inject sequences into the outer terminal */ + ASSERT(parse(";http://x/\033[31m", &id, &uri) == -1); + ASSERT(parse(";http://x/\x7f", &id, &uri) == -1); + ASSERT(parse(";http://x/\xc2\x9c", &id, &uri) == -1); /* C1 ST as UTF-8 */ + ASSERT(parse(";http://x/\xc2\x80", &id, &uri) == -1); + /* invalid / overlong / surrogate UTF-8 */ + ASSERT(parse(";http://x/\xe4", &id, &uri) == -1); + ASSERT(parse(";http://x/\xc0\xaf", &id, &uri) == -1); + ASSERT(parse(";http://x/\xe0\x80\xaf", &id, &uri) == -1); + ASSERT(parse(";http://x/\xed\xa0\x80", &id, &uri) == -1); + ASSERT(parse(";http://x/\xf4\x90\x80\x80", &id, &uri) == -1); + + /* bad ids are dropped, the link itself stays valid */ + ASSERT(parse("id=\xc3\xa4;http://x/", &id, &uri) == 0); + ASSERT(id == NULL); + ASSERT(parse("id=;http://x/", &id, &uri) == 0); + ASSERT(id == NULL); + memcpy(big, "id=", 3); + memset(big + 3, 'i', HL_MAX_ID + 1); + strcpy(big + 3 + HL_MAX_ID + 1, ";http://x/"); + ASSERT(parse(big, &id, &uri) == 0); + ASSERT(id == NULL); + + /* URI length limit */ + big[0] = ';'; + memset(big + 1, 'u', HL_MAX_URI); + big[HL_MAX_URI + 1] = '\0'; + ASSERT(parse(big, &id, &uri) == 0); + ASSERT(strlen(uri) == HL_MAX_URI); + big[HL_MAX_URI + 1] = 'u'; + big[HL_MAX_URI + 2] = '\0'; + ASSERT(parse(big, &id, &uri) == -1); +} + +static void test_intern(void) +{ + uint32_t a, b, c, d, e; + + hl_reset(); + ASSERT(hl_count() == 0); + ASSERT(hl_uri(0) == NULL); + + /* without app id every open is a new link */ + a = hl_intern(NULL, "https://a/"); + b = hl_intern(NULL, "https://a/"); + ASSERT(a != 0 && b != 0 && a != b); + ASSERT(b > a); + ASSERT(STREQ(hl_uri(a), "https://a/")); + ASSERT(STREQ(hl_uri(b), "https://a/")); + + /* with app id: same (id, uri) is the same link */ + c = hl_intern("x", "https://a/"); + d = hl_intern("x", "https://a/"); + e = hl_intern("x", "https://b/"); + ASSERT(c != 0 && c == d); + ASSERT(e != 0 && e != c); + ASSERT(hl_intern("y", "https://a/") != c); + ASSERT(hl_count() == 5); + + /* rejected input */ + ASSERT(hl_intern(NULL, "") == 0); + ASSERT(hl_intern(NULL, NULL) == 0); + ASSERT(hl_intern(NULL, "http://x/\033") == 0); + ASSERT(hl_count() == 5); + + /* unknown ids */ + ASSERT(hl_uri(0xfffffff0u) == NULL); + + hl_reset(); + ASSERT(hl_count() == 0); + ASSERT(hl_uri(a) == NULL); + /* numbering continues after a reset: ids are never reused */ + ASSERT(hl_intern(NULL, "https://c/") > e); + hl_reset(); +} + +#define MANY 5000 + +static void test_gc(void) +{ + static uint32_t ids[MANY]; + uint32_t keyed, maxid = 0; + size_t i; + + hl_reset(); + ASSERT(!hl_gc_wanted()); + for (i = 0; i < MANY; i++) { + char uri[64]; + + sprintf(uri, "https://example.com/%zu", i); + ids[i] = hl_intern(NULL, uri); + ASSERT(ids[i] != 0); + if (ids[i] > maxid) + maxid = ids[i]; + } + keyed = hl_intern("keep", "https://keyed/"); + ASSERT(hl_count() == MANY + 1); + ASSERT(hl_gc_wanted()); + for (i = 0; i < MANY; i++) { + char uri[64]; + + sprintf(uri, "https://example.com/%zu", i); + ASSERT(STREQ(hl_uri(ids[i]), uri)); + } + + /* keep every third link and the keyed one */ + hl_gc_begin(); + for (i = 0; i < MANY; i += 3) + hl_mark(ids[i]); + hl_mark(keyed); + hl_mark(0); /* harmless */ + hl_mark(0xfffffff0u); /* unknown: harmless */ + hl_gc_end(); + + ASSERT(hl_count() == (MANY + 2) / 3 + 1); + for (i = 0; i < MANY; i++) { + if (i % 3 == 0) + ASSERT(hl_uri(ids[i]) != NULL); + else + ASSERT(hl_uri(ids[i]) == NULL); + } + ASSERT(!hl_gc_wanted()); + + /* dedupe still works for surviving keyed links */ + ASSERT(hl_intern("keep", "https://keyed/") == keyed); + /* collected ids are never handed out again */ + ASSERT(hl_intern(NULL, "https://new/") > maxid); + + /* collect everything */ + hl_gc_begin(); + hl_gc_end(); + ASSERT(hl_count() == 0); + ASSERT(hl_uri(keyed) == NULL); + /* a collected keyed link gets a new id when it shows up again */ + ASSERT(hl_intern("keep", "https://keyed/") != keyed); + hl_reset(); +} + +static void test_nomem(void) +{ + uint32_t id; + void *probe; + + /* the mock is bypassed e.g. under valgrind: nothing to test then */ + _mallocmock_fail = true; + probe = malloc(1); + _mallocmock_fail = false; + if (probe) { + free(probe); + return; + } + + hl_reset(); + _mallocmock_fail = true; + id = hl_intern(NULL, "https://oom/"); + _mallocmock_fail = false; + ASSERT(id == 0); + ASSERT(hl_count() == 0); + ASSERT(hl_intern(NULL, "https://oom/") != 0); + hl_reset(); +} + +int main(void) +{ + test_parse(); + test_intern(); + test_gc(); + test_nomem(); + return 0; +}
