okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
82ecef8
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"bytes" 6"context" 7"encoding/base64" 8"encoding/hex" 9"errors" 10"fmt" 11"image" 12 _"image/jpeg" 13 _"image/png" 14"io" 15"os" 16"os/exec" 17"runtime" 18"strings" 19"time" 20 21 tea"charm.land/bubbletea/v2" 22"github.com/atotto/clipboard" 23) 24 25const ( 26MaxClipboardImageBytes = 5 * 1024 * 1024 27MaxClipboardImageEdge = 16384 28) 29 30func execClipboardCommand (ctx context.Context ,name string ,args ... string ) ([]byte ,error ) { 31cmd := exec .CommandContext (ctx ,name ,args ... ) 32stdout ,err := cmd .StdoutPipe () 33if err != nil { 34return nil ,err 35 } 36defer stdout .Close () 37if err = cmd .Start ();err != nil { 38return nil ,err 39 } 40// Killing the command does not close stdout held open by a descendant. 41stopClosingStdout := context .AfterFunc (ctx ,func () {_ = stdout .Close () }) 42defer stopClosingStdout () 43 44// The extra byte detects oversized output without buffering the rest. 45limited := io .LimitReader (stdout ,MaxClipboardImageBytes + 1 ) 46buf ,err := io .ReadAll (limited ) 47if err != nil || len (buf )> MaxClipboardImageBytes { 48// Once reading stops, a child still writing to the pipe could block Wait. 49_ = cmd .Process .Kill () 50 } 51waitErr := cmd .Wait () 52 53if len (buf )> MaxClipboardImageBytes { 54return nil ,fmt .Errorf ("clipboard image exceeds %d byte limit" ,MaxClipboardImageBytes ) 55 } 56if ctx .Err ()!= nil { 57return buf ,ctx .Err () 58 } 59if err != nil { 60return buf ,err 61 } 62return buf ,waitErr 63} 64 65// CopyText puts text on the clipboard of the machine albedo runs on and asks 66// the terminal to put it on its own with OSC 52. Those differ when you reach 67// albedo over ssh or attach to its session from another device, and only the 68// terminal's is the one you paste from; a terminal without OSC 52 still has 69// the local copy. 70func CopyText (text string ) tea.Cmd { 71return tea .Batch (tea .SetClipboard (text ),func () tea.Msg { 72_ = clipboard .WriteAll (text ) 73return nil 74 }) 75} 76 77func isRemote ()bool { 78return os .Getenv ("SSH_CONNECTION" )!= "" || os .Getenv ("SSH_CLIENT" )!= "" || os .Getenv ("MOSH_CONNECTION" )!= "" 79} 80 81func ClipboardHasImage ()bool { 82if isRemote () { 83return false 84 } 85 86ctx ,cancel := context .WithTimeout (context .Background (),1 * time .Second ) 87defer cancel () 88 89switch runtime .GOOS { 90case "darwin" : 91if _ ,err := exec .LookPath ("pngpaste" );err == nil { 92if out ,err := execClipboardCommand (ctx ,"pngpaste" ,"-b" );err == nil && len (out )== 0 { 93return true 94 } 95 } 96out ,err := execClipboardCommand (ctx ,"osascript" ,"-e" ,"clipboard info" ) 97if err != nil { 98return false 99 } 100s := string (out ) 101return strings .Contains (s ,"«class PNGf»" )|| strings .Contains (s ,"JPEG picture" )|| strings .Contains (s ,"TIFF picture" ) 102 103case "linux" : 104// Wayland first: under a compositor both may be set. 105probes := []struct { 106env ,tool string 107args []string 108 }{ 109 {"WAYLAND_DISPLAY" ,"wl-paste" , []string {"--list-types" }}, 110 {"DISPLAY" ,"xclip" , []string {"-selection" ,"clipboard" ,"-t" ,"TARGETS" ,"-o" }}, 111 } 112for _ ,probe := range probes { 113if os .Getenv (probe .env )== "" { 114continue 115 } 116if _ ,err := exec .LookPath (probe .tool );err != nil { 117continue 118 } 119out ,err := execClipboardCommand (ctx ,probe .tool ,probe .args ... ) 120if err == nil && strings .Contains (string (out ),"image/" ) { 121return true 122 } 123 } 124return false 125 } 126 127return false 128} 129 130func parseAppleScriptHex (out []byte ) ([]byte ,error ) { 131s ,ok1 := strings .CutPrefix (strings .TrimSpace (string (out )),"«data " ) 132s ,ok2 := strings .CutSuffix (s ,"»" ) 133if ! ok1 || ! ok2 { 134return nil ,errors .New ("not an AppleScript hex payload" ) 135 } 136fields := strings .Fields (s ) 137if len (fields )== 0 { 138return nil ,errors .New ("empty AppleScript data" ) 139 } 140// Skip the 4-char ostype code (e.g. PNGf, JPEG) 141if len (fields [0 ])<= 4 { 142return nil ,errors .New ("missing hex payload in AppleScript data" ) 143 } 144return hex .DecodeString (fields [0 ][4 :]) 145} 146 147func ReadClipboardImage () (* daemon.ImageAttachment ,error ) { 148if isRemote () { 149return nil ,errors .New ("remote clipboard image unsupported" ) 150 } 151 152ctx ,cancel := context .WithTimeout (context .Background (),3 * time .Second ) 153defer cancel () 154 155var data []byte 156var err error 157 158switch runtime .GOOS { 159case "darwin" : 160if _ ,errPath := exec .LookPath ("pngpaste" );errPath == nil { 161data ,err = execClipboardCommand (ctx ,"pngpaste" ,"-" ) 162 }else { 163rawOut ,errCmd := execClipboardCommand (ctx ,"osascript" ,"-e" ,"get the clipboard as «class PNGf»" ) 164if errCmd != nil { 165return nil ,errCmd 166 } 167if len (rawOut )> MaxClipboardImageBytes { 168return nil ,fmt .Errorf ("clipboard image too large: %d bytes (max %d)" ,len (rawOut ),MaxClipboardImageBytes ) 169 } 170data ,err = parseAppleScriptHex (rawOut ) 171 } 172 173case "linux" : 174if os .Getenv ("WAYLAND_DISPLAY" )!= "" { 175data ,err = execClipboardCommand (ctx ,"wl-paste" ,"-t" ,"image/png" ) 176 }else if os .Getenv ("DISPLAY" )!= "" { 177data ,err = execClipboardCommand (ctx ,"xclip" ,"-selection" ,"clipboard" ,"-t" ,"image/png" ,"-o" ) 178 }else { 179return nil ,errors .New ("no display or clipboard tool available" ) 180 } 181 182default : 183return nil ,fmt .Errorf ("clipboard images are not supported on %s" ,runtime .GOOS ) 184 } 185 186if err != nil { 187return nil ,fmt .Errorf ("could not read clipboard image: %w" ,err ) 188 } 189 190return clipboardImage (data ) 191} 192 193func clipboardImage (data []byte ) (* daemon.ImageAttachment ,error ) { 194if len (data )== 0 { 195return nil ,errors .New ("clipboard contains no image data" ) 196 } 197 198if len (data )> MaxClipboardImageBytes { 199return nil ,fmt .Errorf ("clipboard image too large: %d bytes (max %d)" ,len (data ),MaxClipboardImageBytes ) 200 } 201 202// Strictly validate config - rejects truncated or 0-dimension images 203cfg ,format ,err := image .DecodeConfig (bytes .NewReader (data )) 204if err != nil { 205return nil ,fmt .Errorf ("clipboard image is invalid or incomplete: %w" ,err ) 206 } 207if cfg .Width <= 0 || cfg .Height <= 0 { 208return nil ,errors .New ("invalid image dimensions: zero or negative" ) 209 } 210if cfg .Width > MaxClipboardImageEdge || cfg .Height > MaxClipboardImageEdge { 211return nil ,fmt .Errorf ("image dimensions too large: %dx%d (max edge %d)" ,cfg .Width ,cfg .Height ,MaxClipboardImageEdge ) 212 } 213 214mime := daemon .ImagePNG 215switch format { 216case "jpeg" ,"jpg" : 217mime = daemon .ImageJPEG 218case "webp" : 219mime = daemon .ImageWEBP 220 } 221 222encoded := base64 .StdEncoding .EncodeToString (data ) 223return & daemon.ImageAttachment { 224ImageMetadata : daemon.ImageMetadata { 225MimeType :mime , 226Width :cfg .Width , 227Height :cfg .Height , 228Bytes :len (data ), 229 }, 230Data :encoded , 231 },nil 232} 233 234type ClipboardImagePastedMsg struct { 235Err error 236Image * daemon.ImageAttachment 237SessionID string 238Generation int64 239// thumb is set once transmit has handed the image to the terminal. 240thumb * thumbnail 241transmit string 242// hint says why the terminal shows no thumbnail when a setting would fix it. 243hint string 244} 245 246func PasteClipboardImageCmd (sessionID string ,gen int64 ) tea.Cmd { 247return func () tea.Msg { 248if ! ClipboardHasImage () { 249return ClipboardImagePastedMsg {SessionID :sessionID ,Generation :gen } 250 } 251img ,err := ReadClipboardImage () 252return clipboardImagePasted (sessionID ,gen ,img ,err ) 253 } 254} 255 256func clipboardImagePasted (sessionID string ,gen int64 ,img * daemon.ImageAttachment ,err error )ClipboardImagePastedMsg { 257msg := ClipboardImagePastedMsg {SessionID :sessionID ,Generation :gen ,Image :img ,Err :err } 258if img != nil { 259g := terminalGraphics () 260if thumb ,transmit ,ok := transmitThumbnail (g ,* img );ok { 261msg .thumb ,msg .transmit = & thumb ,transmit 262 } 263msg .hint = g .hint 264 } 265return msg 266}