require 'osx/cocoa' require 'net/http' require 'cgi' require 'fileutils' require File.expand_path('../Report', __FILE__) class SACrashReporter < OSX::NSWindowController attr_accessor :isExpanded VERSION = 1.2 WINDOW_EXPANSION_AMOUNT = 240 # to bring total height of crash log textfield to 300 from 60 # tmp fix for HDCrashReporter that crashes if there's no crash log, but there are traces in the prefs(?). #`touch #{File.expand_path("~/Library/Logs/CrashReporter/Crasher.crash.log")}` # Returns the report instance. def self.report @@report ||= Report.new end # Configure the report layout. # +configure+ will yield the report instance on which you can then call +order+. # You can optionally specify the subclass of +Report+ to use. # # class ReportSubclass < SACrashReporter::Report # def my_custom_log_1 # 'foo' # end # def my_custom_log_2 # 'bar' # end # end # # SACrashReporter.configure :report_class => ReportSubclass do |report| # report.order [:host_name], [:my_custom_log_1, :my_custom_log_2], [:os_version, :pid] # end def self.configure(options = {:report_class => Report}) @@report = options[:report_class].new yield report end # Call this method from your rb_main.rb file to start the ruby exception catching. # If you don't need any customisation you can simply replace the code that starts the # app by +run_app+ like so: # # # rb_main.rb: # # def rb_main_init # path = OSX::NSBundle.mainBundle.resourcePath.fileSystemRepresentation # rbfiles = Dir.entries(path).select {|x| /\.rb\z/ =~ x} # rbfiles -= [ File.basename(__FILE__) ] # rbfiles.each do |path| # require( File.basename(path) ) # end # end # # # if $0 == __FILE__ then # # rb_main_init # # OSX.NSApplicationMain(0, nil) # # end # # SACrashReporter.run_app # # If you need custom code to start the application you can specify it in a block passed # to +run_app+. Please note that you are then also responsible for starting the app with OSX.NSApplicationMain(). # # SACrashReporter.run_app do # # some special startup code # OSX.NSApplicationMain(0, nil) # end # # See the +configure+ method if you want to set any SACrashReporter/Report options. def self.run_app begin if block_given? yield else # Run the normal RubyCocoa init. #rb_main_init @stdoutList=[] @oldstdout = $stdout @oldstderr = $stderr $stdout = self $stderr = self OSX.NSApplicationMain(nil) end rescue Exception => exception # write backtrace to crash log so it can be reported on next launch, # unless it's a signal exception unless exception.is_a?(SignalException) print "SACrashReporter\n" report.exception = exception File.open(new_crash_log_path, "a") do |file| @stdoutList.each { |line| file.write line } file.write report.message end end # re-raise the exception raise exception end end def self.write(object) @stdoutList << object.to_s @stdoutList[-1,100] @oldstdout.write(object) end def self.flush @oldstdout.flush end # Returns the name of the application. def self.app_name OSX::NSBundle.mainBundle.infoDictionary['CFBundleExecutable'] end # Returns the name of the developer, which is retrieved from the Info.plist with key 'SACrashReporterDeveloperName'. def self.developer OSX::NSBundle.mainBundle.infoDictionary['SACrashReporterDeveloperName'] end @@crash_log_path = nil # Returns the full path to the crash log for the current application. def self.crash_log_path return @@crash_log_path if @@crash_log_path crash_log_dir = File.expand_path("~/Library/Logs/CrashReporter/") FileUtils.mkdir_p(crash_log_dir) unless File.exist?(crash_log_dir) log_files = Dir.entries(crash_log_dir).select {|f| f[0..(app_name.length - 1)] == app_name } rescue [] return new_crash_log_path if log_files.empty? @@crash_log_path = File.join(crash_log_dir, log_files.sort.last) @@crash_log_path end def self.new_crash_log_path if SAFoundation::OS.os_version.to_f < 10.5 File.expand_path("~/Library/Logs/CrashReporter/#{app_name}.crash.log") else time = Time.now time_str = format("%02d-%02d-%02d-%02d%02d%02d", time.year, time.month, time.day, time.hour, time.min, time.sec) File.expand_path("~/Library/Logs/CrashReporter/#{app_name}_#{time_str}_#{SAFoundation::OS.host_name.sub(/\.(\w)+$/, '')}.crash") end end # Returns the SHA1 checksum for the crash log of the current application. def self.crash_log_checksum @@crash_log_checksum ||= `/usr/bin/openssl sha1 #{crash_log_path}`.scan(/\)=\s([a-z0-9]+)\n$/)[0][0] end # Returns +true+ or +false+ depending on if the crash log for the current application exists. def self.new_crash_log_exists? return false unless File.exist? crash_log_path last_checksum = OSX::NSUserDefaults.standardUserDefaults['SACrashReporterLastCheckSum'] last_checksum.nil? or crash_log_checksum != last_checksum end # Returns the last entrie of the crash log for the current application. def self.crash_log_data @@crash_log_data ||= File.read(crash_log_path).split('**********').last.sub(/^\n*/, '').chomp end # This method should called from somewhere in your code where the application has started. # # class AppController < OSX::NSObject # def awakeFromNib # SACrashReporter.submit # end # end def self.submit defaults = OSX::NSUserDefaults.standardUserDefaults unless defaults['SACrashReporterInitialized'] defaults['SACrashReporterInitialized'] = true if new_crash_log_exists? defaults['SACrashReporterLastCheckSum'] = crash_log_checksum end defaults.synchronize else if new_crash_log_exists? @@crash_reporter_controller = SACrashReporter.alloc.init @@crash_reporter_controller.showWindow(self) defaults['SACrashReporterLastCheckSum'] = crash_log_checksum defaults.synchronize end end end # Instance methods ib_outlet :crashLogDataTextfield ib_outlet :commentTextfield ib_outlet :footnoteTextfield ib_outlet :sendReportButton ib_outlet :statusSpinner ib_outlet :statusTextField ib_outlet :crashLogWindow def init return self if self.initWithWindowNibPath_owner(File.expand_path('../SACrashReporter.nib', __FILE__), self) end def windowDidLoad set_title_for_app set_footnote_text_with_dev set_button_text_with_dev @crashLogDataTextfield.string = SACrashReporter.crash_log_data end def set_title_for_app window.title = "Problem Report for #{SACrashReporter.app_name}" end def set_footnote_text_with_dev @footnoteTextfield.stringValue = "Your response will help #{SACrashReporter.developer || 'us'} improve this software. Your personal information will not be sent with this report, and you will not be contacted unless you request it." end def set_button_text_with_dev @sendReportButton.title = "Send to #{SACrashReporter.developer}..." end def sendReport(sender) @statusTextField.hidden = false @statusSpinner.startAnimation(self) params_hash = { 'app_name' => SACrashReporter.app_name, 'crash_log' => SACrashReporter.crash_log_data, 'comment' => @commentTextfield.string } params = params_hash.inject('') {|v,i| v << "#{i[0].to_s}=#{CGI.escape(i[1].to_s)}&"}.chop params_data = OSX::NSString.stringWithString(params).dataUsingEncoding(OSX::NSASCIIStringEncoding) url = OSX::NSURL.URLWithString(OSX::NSBundle.mainBundle.infoDictionary['SACrashReporterPostURL']) request = OSX::NSMutableURLRequest.requestWithURL_cachePolicy_timeoutInterval(url, OSX::NSURLRequestUseProtocolCachePolicy, 30.0) request.setHTTPMethod('POST') request.setHTTPBody(params_data) OSX::NSURLConnection.alloc.initWithRequest_delegate(request, self) end def connectionDidFinishLoading(connection) close end def connection_didFailWithError(connection, error) close end def toggleExpansion frame = @crashLogWindow.frame unless @isExpanded frame.origin.y = frame.origin.y - WINDOW_EXPANSION_AMOUNT frame.size.height = frame.size.height + WINDOW_EXPANSION_AMOUNT else frame.origin.y = frame.origin.y + WINDOW_EXPANSION_AMOUNT frame.size.height = frame.size.height - WINDOW_EXPANSION_AMOUNT end @crashLogWindow.setFrame_display_animate frame, true, true maxHeight = @isExpanded ? @crashLogWindow.minSize.height - WINDOW_EXPANSION_AMOUNT : @crashLogWindow.minSize.height + WINDOW_EXPANSION_AMOUNT @crashLogWindow.setMinSize(OSX::NSSize.new(@crashLogWindow.minSize.width, maxHeight)) @isExpanded = !@isExpanded end end