sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/

bin/sup (12539B) - raw

      1 #!/usr/bin/env ruby
      2 # encoding: utf-8
      3 
      4 $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
      5 
      6 require 'ncursesw'
      7 
      8 require 'sup/util/ncurses'
      9 require 'sup/util/locale_fiddler'
     10 require 'sup/util/axe'
     11 
     12 no_gpgme = false
     13 begin
     14   require 'gpgme'
     15 rescue LoadError
     16   no_gpgme = true
     17 end
     18 
     19 require 'fileutils'
     20 require 'optimist'
     21 require "sup"
     22 
     23 if ENV['SUP_PROFILE']
     24   require 'ruby-prof'
     25   RubyProf.start
     26 end
     27 
     28 if no_gpgme
     29   info "No 'gpgme' gem detected. Install it for email encryption, decryption and signatures."
     30 end
     31 
     32 $opts = Optimist::options do
     33   version "sup v#{Redwood::VERSION}"
     34   banner <<EOS
     35 Sup is a curses-based email client.
     36 
     37 Usage:
     38   sup [options]
     39 
     40 Options are:
     41 EOS
     42   opt :list_hooks, "List all hooks and descriptions, and quit. Use --hooks-matching to filter."
     43   opt :hooks_matching, "If given, list all hooks and descriptions matching the given pattern. Needs the --list-hooks option", short: "m", default: ""
     44   opt :no_threads, "Turn off threading. Helps with debugging. (Necessarily disables background polling for new messages.)"
     45   opt :no_initial_poll, "Don't poll for new messages when starting."
     46   opt :search, "Search for this query upon startup", :type => String
     47   opt :compose, "Compose message to this recipient upon startup", :type => String
     48   opt :subject, "When composing, use this subject", :type => String, :short => "j"
     49 end
     50 
     51 Optimist::die :subject, "requires --compose" if $opts[:subject] && !$opts[:compose]
     52 
     53 Redwood::HookManager.register "startup", <<EOS
     54 Executes at startup
     55 No variables.
     56 No return value.
     57 EOS
     58 
     59 Redwood::HookManager.register "shutdown", <<EOS
     60 Executes when sup is shutting down. May be run when sup is crashing,
     61 so don\'t do anything too important. Run before the label, contacts,
     62 and people are saved.
     63 No variables.
     64 No return value.
     65 EOS
     66 
     67 if $opts[:list_hooks]
     68   Redwood.start
     69   Redwood::HookManager.print_hooks $opts[:hooks_matching]
     70   exit
     71 end
     72 
     73 Thread.abort_on_exception = true # make debugging possible
     74 Thread.current.priority = 1 # keep ui responsive
     75 
     76 module Redwood
     77 
     78 global_keymap = Keymap.new do |k|
     79   k.add :quit_ask, "Quit Sup, but ask first", 'q'
     80   k.add :quit_now, "Quit Sup immediately", 'Q'
     81   k.add :help, "Show help", '?'
     82   k.add :roll_buffers, "Switch to next buffer", 'b'
     83   k.add :roll_buffers_backwards, "Switch to previous buffer", 'B'
     84   k.add :kill_buffer, "Kill the current buffer", 'x'
     85   k.add :list_buffers, "List all buffers", ';'
     86   k.add :list_contacts, "List contacts", 'C'
     87   k.add :redraw, "Redraw screen", :ctrl_l
     88   k.add :search, "Search all messages", '\\', 'F'
     89   k.add :search_unread, "Show all unread messages", 'U'
     90   k.add :list_labels, "List labels", 'L'
     91   k.add :poll, "Poll for new messages", 'P'
     92   k.add :poll_unusual, "Poll for new messages from unusual sources", '{'
     93   k.add :compose, "Compose new message", 'm', 'c'
     94   k.add :nothing, "Do nothing", :ctrl_g
     95   k.add :recall_draft, "Edit most recent draft message", 'R'
     96   k.add :show_inbox, "Show the Inbox buffer", 'I'
     97   k.add :clear_hooks, "Clear all hooks", 'H'
     98   k.add :show_console, "Show the Console buffer", '~'
     99 
    100   ## Submap for less often used keybindings
    101   k.add_multi "reload (c)olors, rerun (k)eybindings hook", 'O' do |kk|
    102     kk.add :reload_colors, "Reload colors", 'c'
    103     kk.add :run_keybindings_hook, "Rerun keybindings hook", 'k'
    104   end
    105 end
    106 
    107 require 'rbconfig'
    108 
    109 unless RbConfig::CONFIG['arch'] =~ /openbsd/
    110   debug "dynamically loading setlocale()"
    111   begin
    112     class LibC; extend LocaleFiddler; end
    113     debug "setting locale..."
    114     LibC.setlocale(6, "")
    115   rescue RuntimeError => e
    116     warn "cannot dlload setlocale(); ncurses wide character support probably broken."
    117     warn "dlload error was #{e.class}: #{e.message}"
    118   end
    119 end
    120 
    121 def start_cursing
    122   Ncurses.initscr
    123   Ncurses.noecho
    124   Ncurses.cbreak
    125   Ncurses.stdscr.keypad 1
    126   Ncurses.use_default_colors
    127   Ncurses.curs_set 0
    128   Ncurses.start_color
    129   Ncurses.prepare_form_driver
    130   $cursing = true
    131 end
    132 
    133 def stop_cursing
    134   return unless $cursing
    135   Ncurses.curs_set 1
    136   Ncurses.echo
    137   Ncurses.endwin
    138 end
    139 module_function :start_cursing, :stop_cursing
    140 
    141 Index.init
    142 Index.lock_interactively or exit
    143 
    144 begin
    145   Redwood::start
    146   Index.load
    147   Redwood::check_syncback_settings
    148   Index.start_sync_worker unless $opts[:no_threads]
    149 
    150   $die = false
    151   trap("TERM") { |x| $die = true }
    152   trap("WINCH") do |x|
    153    ::Thread.new do
    154      BufferManager.sigwinch_happened!
    155    end
    156   end
    157 
    158   if(s = Redwood::SourceManager.source_for DraftManager.source_name)
    159     DraftManager.source = s
    160   else
    161     debug "no draft source, auto-adding..."
    162     Redwood::SourceManager.add_source DraftManager.new_source
    163   end
    164 
    165   if(s = Redwood::SourceManager.source_for SentManager.source_uri)
    166     SentManager.source = s
    167   else
    168     Redwood::SourceManager.add_source SentManager.default_source
    169   end
    170 
    171   HookManager.run "startup"
    172   Redwood::Keymap.run_hook global_keymap
    173 
    174   debug "starting curses"
    175   Redwood::Logger.remove_sink $stderr
    176   start_cursing
    177 
    178   bm = BufferManager.init
    179   Colormap.new.populate_colormap
    180 
    181   debug "initializing log buffer"
    182   lmode = Redwood::LogMode.new "system log"
    183   lmode.on_kill { Logger.clear! }
    184   Logger.add_sink lmode
    185   Logger.force_message "Welcome to Sup! Log level is set to #{Logger.level}."
    186   if Logger::LEVELS.index(Logger.level) > 0
    187     Logger.force_message "For more verbose logging, restart with SUP_LOG_LEVEL=#{Logger::LEVELS[Logger::LEVELS.index(Logger.level)-1]}."
    188   end
    189 
    190   debug "initializing inbox buffer"
    191   imode = InboxMode.new
    192   ibuf = bm.spawn "Inbox", imode
    193 
    194   debug "ready for interaction!"
    195 
    196   bm.draw_screen
    197 
    198   Redwood::SourceManager.usual_sources.each do |s|
    199     next unless s.respond_to? :connect
    200     reporting_thread("call #connect on #{s}") do
    201       begin
    202         s.connect
    203       rescue SourceError => e
    204         error "fatal error loading from #{s}: #{e.message}"
    205       end
    206     end
    207   end unless $opts[:no_initial_poll]
    208 
    209   imode.load_threads :num => ibuf.content_height, :when_done => lambda { |num| reporting_thread("poll after loading inbox") { sleep 1; PollManager.poll } unless $opts[:no_threads] || $opts[:no_initial_poll] }
    210 
    211   if $opts[:compose]
    212     to = Person.from_address_list $opts[:compose]
    213     mode = ComposeMode.new :to => to, :subj => $opts[:subject]
    214     BufferManager.spawn "New Message", mode
    215     mode.default_edit_message
    216   end
    217 
    218   unless $opts[:no_threads]
    219     PollManager.start
    220     IdleManager.start
    221     Index.start_lock_update_thread
    222   end
    223 
    224   if $opts[:search]
    225     SearchResultsMode.spawn_from_query $opts[:search]
    226   end
    227 
    228   until Redwood::exceptions.nonempty? || $die
    229     c = begin
    230       Ncurses::CharCode.get false
    231     rescue Interrupt
    232       raise if BufferManager.ask_yes_or_no "Die ungracefully now?"
    233       BufferManager.draw_screen
    234       Ncurses::CharCode.empty
    235     end
    236 
    237     if c.empty?
    238       if BufferManager.sigwinch_happened?
    239         debug "redrawing screen on sigwinch"
    240         BufferManager.completely_redraw_screen
    241       end
    242       next
    243     end
    244 
    245     IdleManager.ping
    246 
    247     if c.is_keycode? 410
    248       ## this is ncurses's way of telling us it's detected a refresh.
    249       ## since we have our own sigwinch handler, we don't do anything.
    250       next
    251     end
    252 
    253     bm.erase_flash
    254 
    255     action =
    256       begin
    257         if bm.handle_input c
    258           :nothing
    259         else
    260           bm.resolve_input_with_keymap c, global_keymap
    261         end
    262       rescue InputSequenceAborted
    263         :nothing
    264       end
    265     case action
    266     when :quit_now
    267       break if bm.kill_all_buffers_safely
    268     when :quit_ask
    269       if bm.ask_yes_or_no "Really quit?"
    270         break if bm.kill_all_buffers_safely
    271       end
    272     when :help
    273       curmode = bm.focus_buf.mode
    274       bm.spawn_unless_exists("<help for #{curmode.name}>") { HelpMode.new curmode, global_keymap }
    275     when :roll_buffers
    276       bm.roll_buffers
    277     when :roll_buffers_backwards
    278       bm.roll_buffers_backwards
    279     when :kill_buffer
    280       bm.kill_buffer_safely bm.focus_buf
    281     when :list_buffers
    282       bm.spawn_unless_exists("buffer list", :system => true) { BufferListMode.new }
    283     when :list_contacts
    284       b, new = bm.spawn_unless_exists("Contact List") { ContactListMode.new }
    285       b.mode.load_in_background if new
    286     when :search
    287       completions = LabelManager.all_labels.map { |l| "label:#{LabelManager.string_for l}" }
    288       completions = completions.each { |l| l.fix_encoding! }
    289       completions += Index::COMPL_PREFIXES
    290       query = BufferManager.ask_many_with_completions :search, "Search all messages (enter for saved searches): ", completions
    291       unless query.nil?
    292         if query.empty?
    293           bm.spawn_unless_exists("Saved searches") { SearchListMode.new }
    294         else
    295           SearchResultsMode.spawn_from_query query
    296         end
    297       end
    298     when :search_unread
    299       SearchResultsMode.spawn_from_query "is:unread"
    300     when :list_labels
    301       labels = LabelManager.all_labels.map { |l| LabelManager.string_for l }
    302       labels = labels.each { |l| l.fix_encoding! }
    303 
    304       user_label = bm.ask_with_completions :label, "Show threads with label (enter for listing): ", labels
    305       unless user_label.nil?
    306         if user_label.empty?
    307           bm.spawn_unless_exists("Label list") { LabelListMode.new } if user_label && user_label.empty?
    308         else
    309           LabelSearchResultsMode.spawn_nicely user_label
    310         end
    311       end
    312     when :compose
    313       ComposeMode.spawn_nicely
    314     when :poll
    315       reporting_thread("user-invoked poll") { PollManager.poll }
    316     when :poll_unusual
    317       if BufferManager.ask_yes_or_no "Really poll unusual sources?"
    318         reporting_thread("user-invoked unusual poll") { PollManager.poll_unusual }
    319       end
    320     when :recall_draft
    321       case Index.num_results_for :label => :draft
    322       when 0
    323         bm.flash "No draft messages."
    324       when 1
    325         m = nil
    326         Index.each_id_by_date(:label => :draft) { |mid, builder| m = builder.call }
    327         r = ResumeMode.new(m)
    328         BufferManager.spawn "Edit message", r
    329         r.default_edit_message
    330       else
    331         b, new = BufferManager.spawn_unless_exists("All drafts") { LabelSearchResultsMode.new [:draft] }
    332         b.mode.load_threads :num => b.content_height if new
    333       end
    334     when :show_inbox
    335       BufferManager.raise_to_front ibuf
    336     when :clear_hooks
    337       HookManager.clear
    338     when :show_console
    339       b, new = bm.spawn_unless_exists("Console", :system => true) { ConsoleMode.new }
    340       b.mode.run
    341     when :reload_colors
    342       Colormap.reset
    343       Colormap.populate_colormap
    344       bm.completely_redraw_screen
    345       bm.flash "reloaded colors"
    346     when :run_keybindings_hook
    347       HookManager.clear_one 'keybindings'
    348       Keymap.run_hook global_keymap
    349       bm.flash "keybindings hook run"
    350     when :nothing, InputSequenceAborted
    351     when :redraw
    352       bm.completely_redraw_screen
    353     else
    354       bm.flash "Unknown keypress '#{c.to_character}' for #{bm.focus_buf.mode.name}."
    355     end
    356 
    357     bm.draw_screen
    358   end
    359 
    360   bm.kill_all_buffers if $die
    361 rescue Exception => e
    362   Redwood::record_exception e, "main"
    363 ensure
    364   unless $opts[:no_threads]
    365     PollManager.stop if PollManager.instantiated?
    366     IdleManager.stop if IdleManager.instantiated?
    367     Index.stop_lock_update_thread
    368   end
    369 
    370   HookManager.run "shutdown" if HookManager.instantiated?
    371 
    372   Index.stop_sync_worker
    373   Redwood::finish
    374   stop_cursing
    375   Redwood::Logger.remove_all_sinks!
    376   Redwood::Logger.add_sink $stderr, false
    377   debug "stopped cursing"
    378 
    379   if $die
    380     info "I've been ordered to commit seppuku. I obey!"
    381   end
    382 
    383   if Redwood::exceptions.empty?
    384     debug "no fatal errors. good job, william."
    385     Index.save
    386   else
    387     error "oh crap, an exception"
    388   end
    389 
    390   Index.unlock
    391 
    392   if (fn = ENV['SUP_PROFILE'])
    393     result = RubyProf.stop
    394     File.open(fn, 'w') { |io| RubyProf::CallTreePrinter.new(result).print(io) }
    395   end
    396 end
    397 
    398 unless Redwood::exceptions.empty?
    399   File.open(File.join(BASE_DIR, "exception-log.txt"), "w") do |f|
    400     Redwood::exceptions.each do |e, name|
    401       f.puts "--- #{e.class.name} from thread: #{name}"
    402       f.puts e.message, e.backtrace
    403     end
    404   end
    405   $stderr.puts <<EOS
    406 ----------------------------------------------------------------
    407 We are very sorry. It seems that an error occurred in Sup. Please
    408 accept our sincere apologies. Please submit the contents of
    409 #{BASE_DIR}/exception-log.txt and a brief report of the
    410 circumstances to https://github.com/sup-heliotrope/sup/issues so that
    411 we might address this problem. Thank you!
    412 
    413 Sincerely,
    414 The Sup Developers
    415 ----------------------------------------------------------------
    416 EOS
    417   Redwood::exceptions.each do |e, name|
    418     puts "--- #{e.class.name} from thread: #{name}"
    419     puts e.message, e.backtrace
    420   end
    421 end
    422 
    423 end