session: route osc 5522 clipboard requests and the 5522 probe to the viewer that typed last
Signed-off-by: dawn <90008@klbr.net>
18 unmodified lines19202122232425262728293031323320 unmodified lines18 unmodified linesunlike tmux, tobi doesn't nest. running tobi inside a tobi session will justreplace your session with the target session. multiple viewers can attach toa single tobi session (in that case minimum terminal size will be used).
## clipboard images
tobi passes osc 5522 (kitty's clipboard protocol, used for pasting images) andthe `CSI ? 5522 $ p` probe that detects it through to your terminal. withseveral viewers attached, only the one that typed last sees them, so one pastedoesn't prompt every terminal. your terminal still has to implement it.
## requirements
tobi uses `tssh` if it's in PATH, otherwise `ssh`. `tailscale` binary is onlyrequired for discovery to function, nothing else.20 unmodified lines367 unmodified linesExpand all36836937037137237337437537637723 unmodified lines401402403404405406407408409410411412413414415416417418419420119 unmodified linesExpand all5405415425435445455465475485495505515525535545555565575585595605615625635645655305665675325685695705365715725735745755405765775785795805815825835845855495505865875885895905915925935945955965975984 unmodified lines60360460560656356456560760860961061161261361461561661761842 unmodified lines661662663664665666667668669119 unmodified linesExpand all367 unmodified linesExpand all cmd *exec.Cmd listener net.Listener mu sync.Mutex viewers map[*viewerClient]struct{} // the viewer that sent input last, so a paste lands on the terminal it came from active atomic.Pointer[viewerClient] // the osc color handler reads colorSets while mu is held for vt writes, // so it gets its own lock colorMu sync.Mutex colorSets map[*viewerClient]map[int]termcolor.Color23 unmodified lines vc.send(outMsg{t: protocol.TypeOutput, p: p}) }}
// activeOutputLocked sends p to the viewer that typed last, or to everyone when// that viewer is gone or nobody has typed yetfunc (o *Owner) activeOutputLocked(p []byte) { if vc := o.active.Load(); vc != nil { if _, ok := o.viewers[vc]; ok { vc.send(outMsg{t: protocol.TypeOutput, p: p}) return } } o.outputLocked(p)}
func (o *Owner) hop(p []byte) { o.mu.Lock() viewers := lo.Keys(o.viewers) o.mu.Unlock()119 unmodified linesExpand all}
type queryFilter struct{ held []byte }
// what one chunk of pty output becomestype filtered struct { emulator []byte // the owner's own terminal state, which answers the queries shared []byte // every viewer routed []byte // only the active viewer}
const clipboardOSC = "\x1b]5522;"
// clipboardProbe asks whether the terminal speaks the kitty clipboard protocol.const clipboardProbe = "\x1b[?5522$p"
// routedToActive reports whether seq belongs to the terminal the user is typing// into. a clipboard (osc 5522) request prompts there and the reply goes into// the pty, which two replying terminals would corrupt. the probe is that// terminal's to answer, not the emulator's, which would report the mode unknown.func routedToActive(seq []byte) bool { return bytes.HasPrefix(seq, []byte(clipboardOSC)) || string(seq) == clipboardProbe}
// Feed splits pty output. terminal queries stay out of shared because the// emulator answers them; routed sequences stay out of the emulator.func (f *queryFilter) Feed(chunk []byte) []byte {func (f *queryFilter) Feed(chunk []byte) (out filtered) { f.held = append(f.held, chunk...) var out []byte for { i := bytes.IndexByte(f.held, 0x1b) if i < 0 { out = append(out, f.held...) out.emulator = append(out.emulator, f.held...) out.shared = append(out.shared, f.held...) f.held = f.held[:0] return out } out = append(out, f.held[:i]...) out.emulator = append(out.emulator, f.held[:i]...) out.shared = append(out.shared, f.held[:i]...) f.held = f.held[i:] n, query := querySpan(f.held) if n == 0 && len(f.held) <= 64<<10 { return out } if n == 0 { n, query = len(f.held), true } if !query { out = append(out, f.held[:n]...) seq := f.held[:n] switch { case query: out.emulator = append(out.emulator, seq...) case routedToActive(seq): out.routed = append(out.routed, seq...) default: out.emulator = append(out.emulator, seq...) out.shared = append(out.shared, seq...) } f.held = f.held[n:] }}4 unmodified lines for { n, err := o.ptyFile.Read(buf) if n > 0 { o.mu.Lock() _, _ = o.vt.Write(buf[:n]) if chunk := filter.Feed(buf[:n]); len(chunk) > 0 { o.outputLocked(chunk) out := filter.Feed(buf[:n]) _, _ = o.vt.Write(out.emulator) if len(out.shared) > 0 { o.outputLocked(out.shared) } if len(out.routed) > 0 { o.activeOutputLocked(out.routed) } o.mu.Unlock() }
if err != nil {42 unmodified lines }
switch ft { case protocol.TypeInput: o.active.Store(vc) _, _ = o.ptyFile.Write(fp) case protocol.TypeResize: if c, r, err := protocol.DecodeSize(fp); err == nil && c >= 1 && r >= 1 { o.mu.Lock()119 unmodified linesExpand all139 unmodified linesExpand all140141142143144144145146147148149150151152153154155156157158159160161162163164165166167168169170126 unmodified linesExpand all2972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453467 unmodified lines139 unmodified linesExpand all {"\x1b]4;1;?;2", ""}, {";?\a", ""}, {"hi\x1b[?6nthere\x1b[2", "hithere"}, {"Jok", "\x1b[2Jok"}, {"ok\x1b", "ok"}, {"[31mred", "\x1b[31mred"}, } { if got := string(f.Feed([]byte(tc.in))); got != tc.want { if got := f.Feed([]byte(tc.in)).shared; string(got) != tc.want { t.Fatalf("Feed(%q) = %q, want %q", tc.in, got, tc.want) } }}
func TestQueryFilterRoutesClipboardTraffic(t *testing.T) { var f queryFilter req := "\x1b]5522;type=read:id=a;aW1hZ2UvcG5n\x1b\\" for _, tc := range []struct{ in, emulator, shared, routed string }{ {"hi\x1b]5522;type=re", "hi", "hi", ""}, {"ad:id=a;aW1hZ2UvcG5n\x1b\\there", "there", "there", req}, {"\x1b]52;c;?\a", "\x1b]52;c;?\a", "\x1b]52;c;?\a", ""}, {"\x1b[?5522$p", "", "", "\x1b[?5522$p"}, {"\x1b[?2026$p", "\x1b[?2026$p", "", ""}, } { got := f.Feed([]byte(tc.in)) if string(got.emulator) != tc.emulator || string(got.shared) != tc.shared || string(got.routed) != tc.routed { t.Fatalf("Feed(%q) = %q; want emulator %q, shared %q, routed %q", tc.in, got, tc.emulator, tc.shared, tc.routed) } }}
func TestStaleSocketCleanup(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := filepath.Join(RuntimeDir(), "stale123456.sock") _ = os.WriteFile(sp, []byte("stale"), 0600)126 unmodified linesExpand all time.Sleep(100 * time.Millisecond) ask(v1, 11) expectFrame(t, v1, protocol.TypeOutput, "\x1b]11;rgb:0000/0000/0000")}
// every terminal that sees a clipboard read request prompts and answers it into// the same pty, so only the viewer that last typed (the one who pressed paste)// may see itfunc TestClipboardRequestReachesOnlyTheTypingViewer(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh", "-c", "stty raw -echo; cat") time.Sleep(200 * time.Millisecond) // until the tty stops echoing, which would show v2 the request v1, v2 := attach(t, sp), attach(t, sp) defer v1.Close() defer v2.Close()
_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("\x1b]5522;type=read:id=x;aW1hZ2UvcG5n\x1b\\")) expectFrame(t, v1, protocol.TypeOutput, "\x1b]5522;type=read:id=x;") expectNoOutput(t, v2, "5522")
// the reply goes back through the same viewer and reaches the app, which cat echoes _ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("replydone\n")) expectFrame(t, v1, protocol.TypeOutput, "replydone") expectFrame(t, v2, protocol.TypeOutput, "replydone")
_ = protocol.WriteFrame(v2, protocol.TypeInput, []byte("\x1b]5522;type=read:id=y;aW1hZ2UvcG5n\x1b\\")) expectFrame(t, v2, protocol.TypeOutput, "\x1b]5522;type=read:id=y;") expectNoOutput(t, v1, "id=y")}
// the owner's emulator would answer the capability probe with "mode unknown"// before the real terminal could, so the probe must reach only that terminalfunc TestClipboardProbeIsLeftToTheTypingViewersTerminal(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh", "-c", "stty raw -echo; cat") time.Sleep(200 * time.Millisecond) v1, v2 := attach(t, sp), attach(t, sp) defer v1.Close() defer v2.Close()
_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("\x1b[?5522$p")) expectFrame(t, v1, protocol.TypeOutput, "\x1b[?5522$p") expectNoOutput(t, v1, "5522;") expectNoOutput(t, v2, "5522")}
func TestAttachPayloadKeepsLegacySize(t *testing.T) { colors := map[int]termcolor.Color{termcolor.Foreground: {R: 1, G: 2, B: 3}} payload := append(protocol.EncodeSize(120, 40), termcolor.EncodeColors(colors)...) c, r, err := protocol.DecodeSize(payload)7 unmodified lines