AXElements/AXElements

View on GitHub
lib/ax/application.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require 'ax/element'
require 'accessibility/keyboard'

##
# The accessibility object representing the running application. This
# class contains some additional constructors and conveniences for
# Application objects.
#
# As this class has evolved, it has gathered some functionality from
# the `NSRunningApplication` and `NSBundle` classes.
class AX::Application < AX::Element
  include Accessibility::Keyboard

  class << self
    ##
    # Asynchronously launch an application with given the bundle identifier
    #
    # @param bundle [String] bundle identifier for the app
    # @return [Boolean]
    def launch bundle
      NSWorkspace.sharedWorkspace.launchAppWithBundleIdentifier bundle,
                                                       options: NSWorkspace::NSWorkspaceLaunchAsync,
                                additionalEventParamDescriptor: nil,
                                              launchIdentifier: nil
    end

    ##
    # Find and return the dock application
    #
    # @return [AX::Application]
    def dock
      new 'com.apple.dock'
    end

    ##
    # Find and return the dock application
    #
    # @return [AX::Application]
    def finder
      new 'com.apple.finder'
    end

    ##
    # Find and return the notification center UI app
    #
    # Obviously, this will only work on OS X 10.8+
    #
    # @return [AX::Application]
    def notification_center
      new 'com.apple.notificationcenterui'
    end

    ##
    # Find and return the application which is frontmost
    #
    # This is often, but not necessarily, the same as the app that
    # owns the menu bar.
    #
    # @return [AX::Application]
    def frontmost_application
      new NSWorkspace.sharedWorkspace.frontmostApplication
    end
    alias_method :frontmost_app, :frontmost_application

    ##
    # Find and return the application which owns the menu bar
    #
    # This is often, but not necessarily, the same as the app that
    # is frontmost.
    #
    # @return [AX::Application]
    def menu_bar_owner
      new NSWorkspace.sharedWorkspace.menuBarOwningApplication
    end
  end

  ##
  # @note Initialization with bundle identifiers is case-sensitive
  #       (e.g. 'com.apple.iCal' is correct, 'com.apple.ical' is wrong)
  #
  # Standard way of creating a new application object
  #
  # You can initialize an application object with either the process
  # identifier (pid) of the application, the name of the application, the
  # bundle identifier string (e.g. 'com.company.appName'), an
  # `NSRunningApplication` instance for the application, or an accessibility
  # (`AXUIElementRef`) token.
  #
  # Given a PID, we try to lookup the application and wrap it.
  #
  # Given an `NSRunningApplication` instance, we simply wrap it.
  #
  # Given a string we do some complicated magic to try and figure out if
  # the string is a bundle identifier or the localized name of the
  # application. Given a bundle identifier we try to launch the app if
  # it is not already running, given a localized name we search the running
  # applications for the app. We wrap what we get back if we get anything
  # back.
  #
  # Note however, given a bundle identifier to launch the application our
  # implementation is a bit of a hack; I've tried to register for
  # notifications, launch synchronously, etc., but there is always a problem
  # with accessibility not being ready right away, so we will poll the app
  # to see when it is ready with a timeout of ~10 seconds.
  #
  # If this method fails to find an app then an exception will be raised.
  #
  # @example
  #
  #   AX::Application.new 'com.apple.mail'
  #   AX::Application.new 'Mail'
  #   AX::Application.new 578
  #   AX::Application.new 'com.apple.iCal'
  #   AX::Application.new 'Calendar'
  #   AX::Application.new 3782
  #   AX::Application.new 'com.apple.AddressBook'
  #   AX::Application.new 'Contacts'
  #   AX::Application.new 43567
  #
  # @param arg [Number,String,NSRunningApplication]
  def initialize arg
    @app = case arg
           when String
             init_with_bundle_id(arg) || init_with_name(arg) || try_launch(arg)
           when Fixnum
             NSRunningApplication.runningApplicationWithProcessIdentifier arg
           when NSRunningApplication
             arg
           else # assume it is an AXUIElementRef (Accessibility::Element)
             NSRunningApplication.runningApplicationWithProcessIdentifier arg.pid
           end
    super Accessibility::Element.application_for @app.processIdentifier
  end


  # @group Attributes

  # (see AX::Element#attribute)
  def attribute attr
    case attr
    when :focused?, :focused then active?
    when :hidden?,  :hidden  then hidden?
    else super
    end
  end

  # (see AX::Element#writable?)
  def writable? attr
    case attr
    when :focused?, :focused, :hidden?, :hidden then true
    else super
    end
  end

  ##
  # Ask the app whether or not it is the active app. This is equivalent
  # to the dynamic `#focused?` method, but might make more sense to use
  # in some cases.
  def active?
    spin
    @app.active?
  end
  alias_method :focused,  :active?
  alias_method :focused?, :active?

  ##
  # Ask the app whether or not it is hidden.
  def hidden?
    spin
    @app.hidden?
  end

  ##
  # Ask the app whether or not it is still running.
  def terminated?
    spin
    @app.terminated?
  end

  # (see AX::Element#set)
  def set attr, value
    case attr
    when :focused
      perform(value ? :unhide : :hide)
    when :active, :hidden
      perform(value ? :hide : :unhide)
    else
      super
    end
  end

  ##
  # Get the bundle identifier for the application.
  #
  # @example
  #
  #   safari.bundle_identifier 'com.apple.safari'
  #   daylite.bundle_identifier 'com.marketcircle.Daylite'
  #
  # @return [String]
  def bundle_identifier
    @app.bundleIdentifier
  end

  ##
  # Return the `Info.plist` data for the application. This is a plist
  # file that all bundles in OS X must contain.
  #
  # Many bits of metadata are stored in the plist, check the
  # [reference](https://developer.apple.com/library/mac/#documentation/MacOSX/Conceptual/BPRuntimeConfig/Articles/ConfigFiles.html)
  # for more details.
  #
  # @return [Hash]
  def info_plist
    bundle.infoDictionary
  end

  ##
  # Get the version string for the application.
  #
  # @example
  #
  #   AX::Application.new("Safari").version    # => "5.2"
  #   AX::Application.new("Terminal").version  # => "2.2.2"
  #   AX::Application.new("Daylite").version   # => "3.15 (build 3664)"
  #
  # @return [String]
  def version
    bundle.objectForInfoDictionaryKey 'CFBundleShortVersionString'
  end


  # @group Actions

  ##
  # @note This is often async and may return before the action is completed
  #
  # Ask the app to quit
  #
  # @return [Boolean]
  def terminate
    perform :terminate
  end

  ##
  # @note This is often async and may return before the action is completed
  #
  # Force the app to quit
  #
  # @return [Boolean]
  def terminate!
    perform :force_terminate
  end

  ##
  # @note This is often async and may return before the action is completed
  #
  # Ask the app to hide itself
  #
  # @return [Boolean]
  def hide
    perform :hide
  end

  ##
  # @note This is often async and may return before the action is completed
  #
  # As the app to unhide itself and bring to front
  #
  # @return [Boolean]
  def unhide
    perform :unhide
  end

  # @see AX::Element#perform
  def perform name
    case name
    when :terminate
      return true if terminated?
      @app.terminate; spin 0.25; terminated?
    when :force_terminate
      return true if terminated?
      @app.forceTerminate; spin 0.25; terminated?
    when :hide
      return true if hidden?
      @app.hide; spin 0.25; hidden?
    when :unhide
      return true if active?
      @app.activateWithOptions(NSRunningApplication::NSApplicationActivateIgnoringOtherApps)
      spin 0.25; active?
    else
      super
    end
  end

  ##
  # Send keyboard input to the focused control element; this is not necessarily
  # an element belonging to the receiving app.
  #
  # For details on how to format the string, check out the
  # [Keyboarding documentation](http://github.com/Marketcircle/AXElements/wiki/Keyboarding).
  #
  # @param string [String]
  # @return [Boolean]
  def type string
    perform(:unhide) unless focused?
    keyboard_events_for(string).each do |event|
      KeyCoder.post_event event
    end
  end
  alias_method :type_string, :type

  ##
  # Press the given modifier key and hold it down while yielding to
  # the given block. As with {#type}, the key events apply to the control
  # element which is currently focused.
  #
  # @example
  #
  #   hold_modifier "\\CONTROL" do
  #     drag_mouse_to point
  #   end
  #
  # @param key [String]
  # @return [Number,nil]
  def hold_modifier key
    code = EventGenerator::CUSTOM[key]
    raise ArgumentError, "Invalid modifier `#{key}' given" unless code
    KeyCoder.post_event([code, true])
    yield
  ensure
    KeyCoder.post_event([code, false]) if code
    code
  end

  ##
  # Navigate the menu bar menus for the receiver and select the menu
  # item at the end of the given path. This method will open each menu
  # in the path.
  #
  # @example
  #
  #   safari.select_menu_item 'Edit', 'Find', /Google/
  #
  # @param path [String,Regexp]
  # @return [AX::MenuItem]
  def select_menu_item *path
    target = navigate_menu(*path)
    target.perform :press
    target
  end

  ##
  # Navigate the menu bar menus for the receiver. This method will not
  # select the last item, but it will open each menu along the path.
  #
  # You may also be interested in {#select_menu_item}.
  #
  # @param path [String,Regexp]
  # @return [AX::MenuItem]
  def navigate_menu *path
    perform :unhide # can't navigate menus unless the app is up front
    bar_item = item = self.menu_bar.menu_bar_item(title: path.shift)
    path.each do |part|
      item.perform :press
      next_item = item.menu_item(title: part)
      item = next_item
    end
    item
  ensure
    # got to the end
    bar_item.perform :cancel unless item.title == path.last
  end

  ##
  # Show the "About" window for the app. Returns the window that is
  # opened.
  #
  # @return [AX::Window]
  def show_about_window
    windows = self.children.select { |x| x.kind_of? AX::Window }
    select_menu_item self.title, /^About /
    wait_for(:window, parent: self) { |window| !windows.include?(window) }
  end

  ##
  # @note This method assumes that the app has setup the standard
  #       CMD+, hotkey to open the pref window
  #
  # Try to open the preferences for the app. Returns the window that
  # is opened.
  #
  # @return [AX::Window]
  def show_preferences_window
    windows = self.children.select { |x| x.kind_of? AX::Window }
    type "\\COMMAND+,"
    wait_for(:window, parent: self) { |window| !windows.include?(window) }
  end

  # @endgroup


  ##
  # Override the base class to make sure the pid is included.
  def inspect
    super.sub! />$/, "#{pp_checkbox(:focused)} pid=#{pid}>"
  end

  ##
  # Find the element in the receiver that is at point given.
  #
  # `nil` will be returned if there was nothing at that point.
  #
  # @param point [#to_point]
  # @return [AX::Element,nil]
  def element_at point
    @ref.element_at(point).to_ruby
  end


  private

  # @return [NSBundle]
  def bundle
    @bundle ||= NSBundle.bundleWithURL @app.bundleURL
  end

  def init_with_bundle_id id
    app = NSRunningApplication.runningApplicationsWithBundleIdentifier id
    app.first
  end

  def init_with_name name
    spin
    NSWorkspace.sharedWorkspace.runningApplications.find { |app|
      app.localizedName == name
    }
  end

  def try_launch id
    10.times do
      app = init_with_bundle_id id
      return app if app
      if AX::Application.launch id
        spin 1
      else
        raise "#{id} is not a registered bundle identifier for the system"
      end
    end
    raise "#{id} failed to launch in time"
  end

end