#!/usr/bin/env ruby require 'socket' require 'io/console' require 'optparse' PUBLIC_HOST = '0.0.0.0' PRIVATE_HOST = '127.0.0.1' DEFAULT_CLIENT_HOST = '127.0.0.1' VERSION = { :SRV_TXT => '0.01', :CLN_TXT => '0.01', :CLN_ARO => '0.02', :SRV_CO8 => '0.30', # 8-color :CLN_CO8 => '0.30', # 8-color :SRV_GSZ => '0.40', :FIL_TXT => '0.01', :FIL_CUP => '0.02', :FIL_CO8 => '0.30', } options = { :port => 7570, :server => false, :host => DEFAULT_CLIENT_HOST, :server_host => PRIVATE_HOST, :rows => 24, :cols => 40, :file => nil, :file_lockout => false, :file_interval => 600, } OptionParser.new do |opt| opt.on('-pPORT', '--port PORT', Integer, 'Port to serve/listen on (defaults to 7570)') do |o| options[:port] = o end opt.on('-S', '--server', 'Make this instance function as a server' ) do options[:server] = true end opt.on('-s', '--client', 'Make this instance function as a client' ) do options[:server] = false end opt.on('-HHOST', '--host HOST', String, '(client only) Host to connect to (defaults to `localhost`)') do |o| options[:host] = o end opt.on('-tSECONDS', '--save-interval SECONDS', Float, '(server only) How long to wait between file writes in seconds. (defaults to 600 which is 10 minutes)' ) do |o| options[:file_interval] = o end opt.on('-rROWS', '--rows ROWS', Integer, '(server only) The number of rows to have in the shared buffer' ) do |o| options[:rows] = o end opt.on('-cCOLS', '--cols COLS', Integer, '(server only) The number of columns to have in the shared buffer' ) do |o| options[:cols] = o end opt.on('--expose-this-instance-for-the-world-to-see-because-I-know-what-I\'m-doing [please]', # X -- eXposed '(server only) Serve publicly, rather than privately. Please ONLY add this option if you entierly know what you\'re doing and have permission from/are the server owner. Say please to expose.' ) do |o| if o == "please" options[:server_host] = PUBLIC_HOST end end opt.on('-x', '--private', # x -- not visible '(server only) Serve privately, rather than publicly (default)' ) do options[:server_host] = PRIVATE_HOST end opt.on('-f FILE', '--save-file FILE', '(server only) File to read initial state from and save state to.' ) do |f| break if options[:file_lockout] options[:file_lockout] = true file_stuff = { :name => f, :text => '', :color => [], :cursor => 0, :writeme => false } unless File.exist? f file_stuff[:writeme] = true options[:file] = file_stuff options[:file_lockout] = false next end File.readlines(f, chomp: true).each do |msg| ver, msg = msg.chomp.split('|',2) next unless msg != nil case ver when VERSION[:FIL_TXT] file_stuff[:text] = msg when VERSION[:FIL_CO8] msg.each_char.with_index do |c, i| next unless c =~ /[0-7]/ file_stuff[:color] << { :co8 => 7 } until file_stuff[:color].length > i file_stuff[:color][i][:co8] = c.to_i.clamp(0,7) end when VERSION[:FIL_CUP] file_stuff[:cursor] = msg.to_i end end options[:file] = file_stuff end opt.on('-h', '--help', 'Get help with this command') do puts opt exit end end.parse! HOST = options[:host] SRV_HOST = options[:server_host] PORT = options[:port] ROWS = options[:rows] COLS = options[:cols] FILE_INTERVAL = options[:file_interval] def do_server(file_opts) def write_current(wi, buffer, cobuf, file_opts) return if file_opts.nil? File.open(file_opts[:name], 'w') do |f| f.puts("#{VERSION[:FIL_TXT]}|#{clean_chars(buffer)}\n") f.write("#{VERSION[:FIL_CO8]}|") cobuf.each do |color| f.write(color[:co8].to_s) end f.puts '' f.puts("#{VERSION[:FIL_CUP]}|#{wi}") end end server = TCPServer.new(SRV_HOST, PORT) sockets = [server] sockats = { server => { :color => { :co8 => 7 } } } if file_opts.nil? || file_opts[:writeme] pre = "#{ROWS}r#{COLS}c >>> " buffer = pre.ljust(ROWS*COLS)[0...ROWS*COLS] cobuf = Array.new(ROWS*COLS, { :co8 => 7 }) windex = pre.length # Wipe your buffer clean with your write index else buffer = file_opts[:text].ljust(ROWS*COLS)[0...ROWS*COLS] cobuf = file_opts[:color] if cobuf.length < ROWS*COLS cobuf += Array.new(ROWS*COLS - cobuf.length ) { {:co8 => 7} } else cobuf = cobuf.first(ROWS*COLS) end windex = file_opts[:cursor] end windex = windex%buffer.length if not file_opts.nil? write_current(windex, buffer, cobuf, file_opts) end puts "PORT: #{PORT}" puts "GRID SIZE: #{ROWS}r#{COLS}c" if SRV_HOST == PUBLIC_HOST then puts "EXPOSED" end puts 'Listening...' motions = [] def new_buf_cob_wi_out(buf, cob, wi, newtext, newcolors, motions: []) buf = buf.dup cob = cob.dup out = '' nti = 0 # New Text Index wco = {} #Writing Color cur = motions.length > 0 #Update cursor at end while (nti < newtext.length) || (motions.length > 0) if motions.length > 0 && nti >= motions[0][:nti] case motions[0][:dir] when 'u' wi -= COLS when 'd' wi += COLS when 'l' wi -= 1 when 'r' wi += 1 end wi = wi%(ROWS*COLS) motions.shift # remove first motion end next if nti >= newtext.length if newcolors[nti] != wco wco = newcolors[nti] if wco.has_key? :co8 out << "#{VERSION[:SRV_CO8]}|#{wco[:co8]}\n" end end out << "#{VERSION[:SRV_TXT]}|" out << ((wi/COLS)+1).to_s + '|' out << ((wi%COLS)+1).to_s + '|' loop do out << newtext[nti] buf[wi] = newtext[nti] cob[wi] = wco wi += 1 nti += 1 break if newcolors[nti] != wco break if motions.length > 0 && nti >= motions[0][:nti] break if nti >= newtext.length break if wi%COLS == 0 end wi = wi%(ROWS*COLS) out << "\n" end if cur out << "#{VERSION[:SRV_TXT]}|" out << ((wi/COLS)+1).to_s + '|' out << ((wi%COLS)+1).to_s + "|\n" end [buf, cob, wi, out] end begin old = { :windex => windex.dup, :buffer => buffer.dup, :cobuf => cobuf.dup, :lastsave => Time.now, } Thread.new do loop do time_since_next_save = Time.now - old[:lastsave] time_till_next_save = FILE_INTERVAL - time_since_next_save # To ensure thread doesn't bog down cpu too much..... sleep [ [ [1.0, # Wait a second time_till_next_save/2 # or half the remaining time ].max, # Whichever's longer time_till_next_save].min, # Unless the remaining time is shorter than a second 1.0].max # But don't wait longer than no time at all unless (old[:windex] == windex && old[:buffer] == buffer && old[:cobuf] == cobuf) next if file_opts.nil? next if time_since_next_save < FILE_INTERVAL old = { :lastsave => Time.now, :windex => windex.dup, :buffer => buffer.dup, :cobuf => cobuf.dup } write_current(windex, buffer, cobuf, file_opts) puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] File autosaved." end end end loop do ready, = IO.select(sockets) newtext = '' newcolors = [] ready.each do |sck| if sck == server client = server.accept sockats[client] = { :color => { :co8 => 7 } } sockets << client puts "Connection: #{client.peeraddr}" client.puts "#{VERSION[:SRV_GSZ]}|#{ROWS}|#{COLS}" client.puts new_buf_cob_wi_out(buffer,cobuf,0,buffer,cobuf)[3] if buffer.length > 1 client.puts new_buf_cob_wi_out( buffer,cobuf,windex-1, buffer[windex-1],[cobuf[windex-1]])[3] end next end begin msg = sck.gets unless msg puts "Disconnect: #{sck.peeraddr}" sck.close sockets.delete(sck) sockats.delete(sck) next end ver, msg = msg.chomp.split('|',2) next unless msg != nil case ver when VERSION[:CLN_TXT] newtext << clean_chars(msg) msg.length.times do newcolors << sockats[sck][:color].dup end when VERSION[:CLN_ARO] dir = 'l' case msg when 'A' dir = 'u' when 'B' dir = 'd' when 'C' dir = 'r' when 'D' dir = 'l' else next end motions << { :nti => newtext.length - 1, :dir => dir, } when VERSION[:CLN_CO8] sockats[sck][:color][:co8] = msg.to_i.clamp(0,7) end rescue Errno::ECONNRESET, Errno::EPIPE puts "Disconnect (err): #{sck.peeraddr}" sck.close sockets.delete(sck) end buffer, cobuf, windex, out = new_buf_cob_wi_out(buffer,cobuf,windex,newtext,newcolors,motions:motions) motions = [] clients = sockets - [server] clients.each do |c| begin c.puts out rescue Errno::ECONNRESET, Errno::EPIPE puts "Disconnect (err): #{c.peeraddr}" c.close sockets.delete(c) end end #if out != '' # puts "#{windex}: (#{(windex/COLS)+1},#{(windex%COLS)+1})" #end end end ensure write_current(windex, buffer, cobuf, file_opts) puts "File saved on exit." end end def clean_chars(chars) out = '' chars.each_char do |char| if char.ord < 0x20 # Non printing out << '^'+(char.ord + 0x40).chr elsif char.ord == 0x7F # DEL (Non print) out << '^?' else out << char end end out end def do_client socket = TCPSocket.new(HOST, PORT) grid_rows = 24 grid_cols = 40 puts "PORT: #{PORT}" puts "HOST: #{HOST}" def print_mode(mode,gridrows) return if ENV["TERM"].downcase == "dumb" #print ("\x1b[s"+ # Save cursor # "\x1b[m"+ # Reset color # "\x1b[#{gridrows+1};1H"+# Cursor pos # "\x1b[2K"+ # Clear line # mode+ # Print mode # "\x1b[u") # Restore cursor print "\e[s\e[m\e[#{gridrows+1};1H\e[2K#{mode}\e[u" end def parse_esckey(gridrows) begin char2 = STDIN.read_nonblock(1) return [:RAW, "\e#{char2}"] unless char2 == '[' rescue return [:KEY_ESCAPE, ''] end begin char3 = STDIN.read_nonblock(1) rescue return [:RAW, "\e#{char2}"] end case char3 when 'A' return [:ARROW, 'A'] when 'B' return [:ARROW, 'B'] when 'C' return [:ARROW, 'C'] when 'D' return [:ARROW, 'D'] else loop do begin char3 << STDIN.read_nonblock(1) break unless char3[-1] =~ /[0-9:;\<=\>\?]/ rescue break end end print_mode "#{clean_chars("\e"+char2+char3)} -- I'm unfamiliar with that key.",gridrows return [:RAW, "\e#{char2}#{char3}"] end end begin print "\x1b[?1049h\x1b[2J\x1b[3J" # Open and clear alternate buffer print "\x1b[m" # Reset color #reader = Thread.new do Thread.new do loop do msg = socket.gets exit 0 unless msg ver, msg = msg.split('|',2) next unless msg != nil case ver when VERSION[:SRV_TXT] row,col,chars = msg.split('|',3) print "\x1b[#{row};#{col}H#{chars.chomp}" when VERSION[:SRV_CO8] next if ENV["TERM"].downcase == "dumb" print "\x1b[3#{msg.to_i.clamp(0,7)}m" when VERSION[:SRV_GSZ] grid_rows, grid_cols = msg.split('|',2).map { |p| p.to_i } print_mode ":3",grid_rows end end end socket.puts "#{VERSION[:CLN_CO8]}|#{rand(1..7)}" while (char = STDIN.noecho(&:getch)) print_mode ":3",grid_rows exit if char == "\x04" # ^D if char == "\x0F" # (^O) Color change if /[0-7]/.match? (char2 = STDIN.noecho(&:getch).upcase) socket.puts "#{VERSION[:CLN_CO8]}|#{char2}" next else char << char2 end elsif char == "\x1b" # Detect arrows keytype, keytext = parse_esckey(grid_rows) if keytype == :ARROW socket.puts "#{VERSION[:CLN_ARO]}|#{keytext}" next elsif keytype != :KEY_ESCAPE char = keytext end elsif (char == "\x08" || # backspace char == "\x7F" ) # delete socket.puts "#{VERSION[:CLN_ARO]}|D" socket.puts "#{VERSION[:CLN_TXT]}| " socket.puts "#{VERSION[:CLN_ARO]}|D" next end socket.puts "#{VERSION[:CLN_TXT]}|#{clean_chars(char)}" end ensure print "\x1b[m" # Reset color print "\x1b[?1049l" # Back to regular buffer end end if options[:server] do_server(options[:file]) else do_client end