From 56de5846f45b08e2d8ff67e62d96fd88ea629bd9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Nov 2022 15:12:00 -0800 Subject: [PATCH] OSC 52: Clipboard Control (#52) This adds support for OSC 52 -- applications can read/write the clipboard. Due to the security risk of this, the default configuration allows for writing but _not reading_. This is configurable using two new settings: `clipboard-read` and `clipboard-write` (both booleans). --- src/Window.zig | 71 +++++++++++++++++++++++++++++++++++++++++ src/config.zig | 6 ++++ src/terminal/Parser.zig | 25 +++++++++++++++ src/terminal/osc.zig | 12 +++---- src/terminal/stream.zig | 6 ++++ src/termio/Exec.zig | 22 +++++++++++++ src/window/message.zig | 11 +++++++ 7 files changed, 147 insertions(+), 6 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index d76938a1b..ca1ff1b72 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -592,9 +592,80 @@ pub fn handleMessage(self: *Window, msg: Message) !void { }, .cell_size => |size| try self.setCellSize(size), + + .clipboard_read => |kind| try self.clipboardRead(kind), + + .clipboard_write => |req| switch (req) { + .small => |v| try self.clipboardWrite(v.data[0..v.len]), + .stable => |v| try self.clipboardWrite(v), + .alloc => |v| { + defer v.alloc.free(v.data); + try self.clipboardWrite(v.data); + }, + }, } } +fn clipboardRead(self: *const Window, kind: u8) !void { + if (!self.config.@"clipboard-read") { + log.info("application attempted to read clipboard, but 'clipboard-read' setting is off", .{}); + return; + } + + const data = glfw.getClipboardString() catch |err| { + log.warn("error reading clipboard: {}", .{err}); + return; + }; + + // Even if the clipboard data is empty we reply, since presumably + // the client app is expecting a reply. We first allocate our buffer. + // This must hold the base64 encoded data PLUS the OSC code surrounding it. + const enc = std.base64.standard.Encoder; + const size = enc.calcSize(data.len); + var buf = try self.alloc.alloc(u8, size + 9); // const for OSC + defer self.alloc.free(buf); + + // Wrap our data with the OSC code + const prefix = try std.fmt.bufPrint(buf, "\x1b]52;{c};", .{kind}); + assert(prefix.len == 7); + buf[buf.len - 2] = '\x1b'; + buf[buf.len - 1] = '\\'; + + // Do the base64 encoding + const encoded = enc.encode(buf[prefix.len..], data); + assert(encoded.len == size); + + _ = self.io_thread.mailbox.push(try termio.Message.writeReq( + self.alloc, + buf, + ), .{ .forever = {} }); + self.io_thread.wakeup.send() catch {}; +} + +fn clipboardWrite(self: *const Window, data: []const u8) !void { + if (!self.config.@"clipboard-write") { + log.info("application attempted to write clipboard, but 'clipboard-write' setting is off", .{}); + return; + } + + const dec = std.base64.standard.Decoder; + + // Build buffer + const size = try dec.calcSizeForSlice(data); + var buf = try self.alloc.allocSentinel(u8, size, 0); + defer self.alloc.free(buf); + buf[buf.len] = 0; + + // Decode + try dec.decode(buf, data); + assert(buf[buf.len] == 0); + + glfw.setClipboardString(buf) catch |err| { + log.err("error setting clipboard string err={}", .{err}); + return; + }; +} + /// Change the cell size for the terminal grid. This can happen as /// a result of changing the font size at runtime. fn setCellSize(self: *Window, size: renderer.CellSize) !void { diff --git a/src/config.zig b/src/config.zig index a6526ec43..6b40a0d85 100644 --- a/src/config.zig +++ b/src/config.zig @@ -141,6 +141,12 @@ pub const Config = struct { /// specified in the configuration "font-size" will be used. @"window-inherit-font-size": bool = true, + /// Whether to allow programs running in the terminal to read/write to + /// the system clipboard (OSC 52, for googling). The default is to + /// disallow clipboard reading but allow writing. + @"clipboard-read": bool = false, + @"clipboard-write": bool = true, + /// Additional configuration files to read. @"config-file": RepeatableString = .{}, diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 032fa42d9..9773da63f 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -567,6 +567,31 @@ test "osc: change window title" { const cmd = a[0].?.osc_dispatch; try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("abc", cmd.change_window_title); + } +} + +test "osc: change window title (end in esc)" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(']'); + _ = p.next('0'); + _ = p.next(';'); + _ = p.next('a'); + _ = p.next('b'); + _ = p.next('c'); + + { + const a = p.next(0x1B); + _ = p.next('\\'); + try testing.expect(p.state == .ground); + try testing.expect(a[0].? == .osc_dispatch); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + + const cmd = a[0].?.osc_dispatch; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("abc", cmd.change_window_title); } } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 7f02d1631..494938d58 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -326,11 +326,7 @@ pub const Parser = struct { else => self.state = .invalid, }, - .string => { - // Complete once we receive one character since we have - // at least SOME value for the expected string value. - self.complete = true; - }, + .string => self.complete = true, } } @@ -352,6 +348,10 @@ pub const Parser = struct { } } + fn endString(self: *Parser) void { + self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; + } + /// End the sequence and return the command, if any. If the return value /// is null, then no valid command was found. pub fn end(self: *Parser) ?Command { @@ -364,7 +364,7 @@ pub const Parser = struct { switch (self.state) { .semantic_exit_code => self.endSemanticExitCode(), .semantic_option_value => self.endSemanticOptionValue(), - .string => self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx], + .string => self.endString(), else => {}, } diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index acdfe1c31..cdde8addb 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -464,6 +464,12 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + .clipboard_contents => |clip| { + if (@hasDecl(T, "clipboardContents")) { + try self.handler.clipboardContents(clip.kind, clip.data); + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + else => if (@hasDecl(T, "oscUnimplemented")) try self.handler.oscUnimplemented(cmd) else diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index deea06466..6bb59501a 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -815,4 +815,26 @@ const StreamHandler = struct { .set_title = buf, }, .{ .forever = {} }); } + + pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { + // Note: we ignore the "kind" field and always use the primary clipboard. + // iTerm also appears to do this but other terminals seem to only allow + // certain. Let's investigate more. + + // Get clipboard contents + if (data.len == 1 and data[0] == '?') { + _ = self.window_mailbox.push(.{ + .clipboard_read = kind, + }, .{ .forever = {} }); + return; + } + + // Write clipboard contents + _ = self.window_mailbox.push(.{ + .clipboard_write = try Window.Message.WriteReq.init( + self.alloc, + data, + ), + }, .{ .forever = {} }); + } }; diff --git a/src/window/message.zig b/src/window/message.zig index ae14ffcdf..159b6e92a 100644 --- a/src/window/message.zig +++ b/src/window/message.zig @@ -1,9 +1,14 @@ const App = @import("../App.zig"); const Window = @import("../Window.zig"); const renderer = @import("../renderer.zig"); +const termio = @import("../termio.zig"); /// The message types that can be sent to a single window. pub const Message = union(enum) { + /// Represents a write request. Magic number comes from the max size + /// we want this union to be. + pub const WriteReq = termio.MessageData(u8, 256); + /// Set the title of the window. /// TODO: we should change this to a "WriteReq" style structure in /// the termio message so that we can more efficiently send strings @@ -12,6 +17,12 @@ pub const Message = union(enum) { /// Change the cell size. cell_size: renderer.CellSize, + + /// Read the clipboard and write to the pty. + clipboard_read: u8, + + /// Write the clipboard contents. + clipboard_write: WriteReq, }; /// A window mailbox.