# frozen_string_literal: true # This file is part of the ruby-dbus project # Copyright (C) 2007 Arnaud Cornet and Paul van Tilburg # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License, version 2.1 as published by the Free Software Foundation. # See the file "COPYING" for the exact licensing terms. require_relative "core_ext/class/attribute" module DBus PROPERTY_INTERFACE = "org.freedesktop.DBus.Properties" # Exported object type # = Exportable D-Bus object class # # Objects that are going to be exported by a D-Bus service # should inherit from this class. At the client side, use {ProxyObject}. class Object # @return [ObjectPath] The path of the object. attr_reader :path # The interfaces that the object supports. Hash: String => Interface my_class_attribute :intfs self.intfs = {} @@cur_intf = nil # Interface @@intfs_mutex = Mutex.new # Create a new object with a given _path_. # Use ObjectServer#export to export it. # @param path [ObjectPath] The path of the object. def initialize(path) @path = path # TODO: what parts of our API are supposed to work before we're exported? self.object_server = nil end # @return [ObjectServer] the server the object is exported by def object_server # tests may mock the old ivar @object_server || @service end # @param server [ObjectServer] the server the object is exported by # @note only the server itself should call this in its #export/#unexport def object_server=(server) # until v0.22.1 there was attr_writer :service # so subclasses only could use @service @object_server = @service = server end # Dispatch a message _msg_ to call exported methods # @param msg [Message] only METHOD_CALLS do something # @api private def dispatch(msg) case msg.message_type when Message::METHOD_CALL reply = nil begin iface = intfs[msg.interface] if !iface raise DBus.error("org.freedesktop.DBus.Error.UnknownMethod"), "Interface \"#{msg.interface}\" of object \"#{msg.path}\" doesn't exist" end member_sym = msg.member.to_sym meth = iface.methods[member_sym] if !meth raise DBus.error("org.freedesktop.DBus.Error.UnknownMethod"), "Method \"#{msg.member}\" on interface \"#{msg.interface}\" of object \"#{msg.path}\" doesn't exist" end methname = Object.make_method_name(msg.interface, msg.member) retdata = method(methname).call(*msg.params) retdata = [*retdata] reply = Message.method_return(msg) rsigs = meth.rets.map(&:type) rsigs.zip(retdata).each do |rsig, rdata| reply.add_param(rsig, rdata) end rescue StandardError => e dbus_msg_exc = msg.annotate_exception(e) reply = ErrorMessage.from_exception(dbus_msg_exc).reply_to(msg) end # TODO: this method chain is too long, # we should probably just return reply [Message] like we get a [Message] object_server.connection.message_queue.push(reply) end end # Select (and create) the interface that the following defined methods # belong to. # @param name [String] interface name like "org.example.ManagerManager" # @see https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-interface def self.dbus_interface(name) @@intfs_mutex.synchronize do @@cur_intf = intfs[name] if !@@cur_intf @@cur_intf = Interface.new(name) # validates the name # As this is a mutable class_attr, we cannot use # self.intfs[name] = @@cur_intf # Hash#[]= # as that would modify parent class attr in place. # Using the setter lets a subclass have the new value # while the superclass keeps the old one. self.intfs = intfs.merge(name => @@cur_intf) end begin yield ensure @@cur_intf = nil end end end # Forgetting to declare the interface for a method/signal/property # is a ScriptError. class UndefinedInterface < ScriptError # rubocop:disable Lint/InheritException def initialize(sym) super "No interface specified for #{sym}. Enclose it in dbus_interface." end end # Declare the behavior of PropertiesChanged signal, # common for all properties in this interface # (individual properties may override it) # @example # self.emits_changed_signal = :invalidates # @param [true,false,:const,:invalidates] value def self.emits_changed_signal=(value) raise UndefinedInterface, :emits_changed_signal if @@cur_intf.nil? @@cur_intf.emits_changed_signal = EmitsChangedSignal.new(value) end # A read-write property accessing an instance variable. # A combination of `attr_accessor` and {.dbus_accessor}. # # PropertiesChanged signal will be emitted whenever `foo_bar=` is used # but not when @foo_bar is written directly. # # @param ruby_name [Symbol] :foo_bar is exposed as FooBar; # use dbus_name to override # @param type [Type,SingleCompleteType] # a signature like "s" or "a(uus)" or Type::STRING # @param dbus_name [String] if not given it is made # by CamelCasing the ruby_name. foo_bar becomes FooBar # to convert the Ruby convention to the DBus convention. # @param emits_changed_signal [true,false,:const,:invalidates] # see {EmitsChangedSignal}; if unspecified, ask the interface. # @return [void] def self.dbus_attr_accessor(ruby_name, type, dbus_name: nil, emits_changed_signal: nil) attr_accessor(ruby_name) dbus_accessor(ruby_name, type, dbus_name: dbus_name, emits_changed_signal: emits_changed_signal) end # A read-only property accessing a read-write instance variable. # A combination of `attr_accessor` and {.dbus_reader}. # # @param (see .dbus_attr_accessor) # @return (see .dbus_attr_accessor) def self.dbus_reader_attr_accessor(ruby_name, type, dbus_name: nil, emits_changed_signal: nil) attr_accessor(ruby_name) dbus_reader(ruby_name, type, dbus_name: dbus_name, emits_changed_signal: emits_changed_signal) end # A read-only property accessing an instance variable. # A combination of `attr_reader` and {.dbus_reader}. # # You may be instead looking for a variant which is read-write from the Ruby side: # {.dbus_reader_attr_accessor}. # # Whenever the property value gets changed from "inside" the object, # you should emit the `PropertiesChanged` signal by calling # {#dbus_properties_changed}. # # dbus_properties_changed(interface_name, {dbus_name.to_s => value}, []) # # or, omitting the value in the signal, # # dbus_properties_changed(interface_name, {}, [dbus_name.to_s]) # # @param (see .dbus_attr_accessor) # @return (see .dbus_attr_accessor) def self.dbus_attr_reader(ruby_name, type, dbus_name: nil, emits_changed_signal: nil) attr_reader(ruby_name) dbus_reader(ruby_name, type, dbus_name: dbus_name, emits_changed_signal: emits_changed_signal) end # A write-only property accessing an instance variable. # A combination of `attr_writer` and {.dbus_writer}. # # @param (see .dbus_attr_accessor) # @return (see .dbus_attr_accessor) def self.dbus_attr_writer(ruby_name, type, dbus_name: nil, emits_changed_signal: nil) attr_writer(ruby_name) dbus_writer(ruby_name, type, dbus_name: dbus_name, emits_changed_signal: emits_changed_signal) end # A read-write property using a pair of reader/writer methods # (which must already exist). # (To directly access an instance variable, use {.dbus_attr_accessor} instead) # # Uses {.dbus_watcher} to set up the PropertiesChanged signal. # # @param (see .dbus_attr_accessor) # @return (see .dbus_attr_accessor) def self.dbus_accessor(ruby_name, type, dbus_name: nil, emits_changed_signal: nil) raise UndefinedInterface, ruby_name if @@cur_intf.nil? dbus_name = make_dbus_name(ruby_name, dbus_name: dbus_name) property = Property.new(dbus_name, type, :readwrite, ruby_name: ruby_name) @@cur_intf.define(property) dbus_watcher(ruby_name, dbus_name: dbus_name, emits_changed_signal: emits_changed_signal) end # A read-only property accessing a reader method (which must already exist). # (To directly access an instance variable, use {.dbus_attr_reader} instead) # # At the D-Bus side the property is read only but it makes perfect sense to # implement it with a read-write attr_accessor. In that case this method # uses {.dbus_watcher} to set up the PropertiesChanged signal. # # attr_accessor :foo_bar # dbus_reader :foo_bar, "s" # # The above two declarations have a shorthand: # # dbus_reader_attr_accessor :foo_bar, "s" # # If the property value should change by other means than its attr_writer, # you should emit the `PropertiesChanged` signal by calling # {#dbus_properties_changed}. # # dbus_properties_changed(interface_name, {dbus_name.to_s => value}, []) # # or, omitting the value in the signal, # # dbus_properties_changed(interface_name, {}, [dbus_name.to_s]) # # @param (see .dbus_attr_accessor) # @return (see .dbus_attr_accessor) def self.dbus_reader(ruby_name, type, dbus_name: nil, emits_changed_signal: nil) raise UndefinedInterface, ruby_name if @@cur_intf.nil? dbus_name = make_dbus_name(ruby_name, dbus_name: dbus_name) property = Property.new(dbus_name, type, :read, ruby_name: ruby_name) @@cur_intf.define(property) ruby_name_eq = "#{ruby_name}=".to_sym return unless method_defined?(ruby_name_eq) dbus_watcher(ruby_name, dbus_name: dbus_name, emits_changed_signal: emits_changed_signal) end # A write-only property accessing a writer method (which must already exist). # (To directly access an instance variable, use {.dbus_attr_writer} instead) # # Uses {.dbus_watcher} to set up the PropertiesChanged signal. # # @param (see .dbus_attr_accessor) # @return (see .dbus_attr_accessor) def self.dbus_writer(ruby_name, type, dbus_name: nil, emits_changed_signal: nil) raise UndefinedInterface, ruby_name if @@cur_intf.nil? dbus_name = make_dbus_name(ruby_name, dbus_name: dbus_name) property = Property.new(dbus_name, type, :write, ruby_name: ruby_name) @@cur_intf.define(property) dbus_watcher(ruby_name, dbus_name: dbus_name, emits_changed_signal: emits_changed_signal) end # Enables automatic sending of the PropertiesChanged signal. # For *ruby_name* `foo_bar`, wrap `foo_bar=` so that it sends # the signal for FooBar. # The original version remains as `_original_foo_bar=`. # # @param ruby_name [Symbol] :foo_bar and :foo_bar= both mean the same thing # @param dbus_name [String] if not given it is made # by CamelCasing the ruby_name. foo_bar becomes FooBar # to convert the Ruby convention to the DBus convention. # @param emits_changed_signal [true,false,:const,:invalidates] # see {EmitsChangedSignal}; if unspecified, ask the interface. # @return [void] def self.dbus_watcher(ruby_name, dbus_name: nil, emits_changed_signal: nil) raise UndefinedInterface, ruby_name if @@cur_intf.nil? interface_name = @@cur_intf.name ruby_name = ruby_name.to_s.sub(/=$/, "").to_sym ruby_name_eq = "#{ruby_name}=".to_sym original_ruby_name_eq = "_original_#{ruby_name_eq}" dbus_name = make_dbus_name(ruby_name, dbus_name: dbus_name) emits_changed_signal = EmitsChangedSignal.new(emits_changed_signal, interface: @@cur_intf) # the argument order is alias_method(new_name, existing_name) alias_method original_ruby_name_eq, ruby_name_eq define_method ruby_name_eq do |value| result = public_send(original_ruby_name_eq, value) case emits_changed_signal.value when true # signature: "interface:s, changed_props:a{sv}, invalidated_props:as" dbus_properties_changed(interface_name, { dbus_name.to_s => value }, []) when :invalidates dbus_properties_changed(interface_name, {}, [dbus_name.to_s]) when :const # Oh my, seeing a value change of a supposedly constant property. # Maybe should have raised at declaration time, don't make a fuss now. when false # Do nothing end result end end # Defines an exportable method on the object with the given name _sym_, # _prototype_ and the code in a block. # @param prototype [Prototype] def self.dbus_method(sym, prototype = "", &block) raise UndefinedInterface, sym if @@cur_intf.nil? @@cur_intf.define(Method.new(sym.to_s).from_prototype(prototype)) ruby_name = Object.make_method_name(@@cur_intf.name, sym.to_s) # ::Module#define_method(name) { body } define_method(ruby_name, &block) end # Emits a signal from the object with the given _interface_, signal # _sig_ and arguments _args_. # @param intf [Interface] # @param sig [Signal] # @param args arguments for the signal def emit(intf, sig, *args) raise "Cannot emit signal #{intf.name}.#{sig.name} before #{path} is exported" if object_server.nil? object_server.connection.emit(nil, self, intf, sig, *args) end # Defines a signal for the object with a given name _sym_ and _prototype_. def self.dbus_signal(sym, prototype = "") raise UndefinedInterface, sym if @@cur_intf.nil? cur_intf = @@cur_intf signal = Signal.new(sym.to_s).from_prototype(prototype) cur_intf.define(signal) # ::Module#define_method(name) { body } define_method(sym.to_s) do |*args| emit(cur_intf, signal, *args) end end # Helper method that returns a method name generated from the interface # name _intfname_ and method name _methname_. # @api private def self.make_method_name(intfname, methname) "#{intfname}%%#{methname}" end # TODO: borrow a proven implementation # @param str [String] # @return [String] # @api private def self.camelize(str) str.split(/_/).map(&:capitalize).join("") end # Make a D-Bus conventional name, CamelCased. # @param ruby_name [String,Symbol] eg :do_something # @param dbus_name [String,Symbol,nil] use this if given # @return [Symbol] eg DoSomething def self.make_dbus_name(ruby_name, dbus_name: nil) dbus_name ||= camelize(ruby_name.to_s) dbus_name.to_sym end # Use this instead of calling PropertiesChanged directly. This one # considers not only the PC signature (which says that all property values # are variants) but also the specific property type. # @param interface_name [String] interface name like "org.example.ManagerManager" # @param changed_props [Hash{String => ::Object}] # changed properties (D-Bus names) and their values. # @param invalidated_props [Array<String>] # names of properties whose changed value is not specified def dbus_properties_changed(interface_name, changed_props, invalidated_props) typed_changed_props = changed_props.map do |dbus_name, value| property = dbus_lookup_property(interface_name, dbus_name) type = property.type typed_value = Data.make_typed(type, value) variant = Data::Variant.new(typed_value, member_type: type) [dbus_name, variant] end.to_h PropertiesChanged(interface_name, typed_changed_props, invalidated_props) end # @param interface_name [String] # @param property_name [String] # @return [Property] # @raise [DBus::Error] # @api private def dbus_lookup_property(interface_name, property_name) # what should happen for unknown properties # plasma: InvalidArgs (propname), UnknownInterface (interface) # systemd: UnknownProperty interface = intfs[interface_name] if !interface raise DBus.error("org.freedesktop.DBus.Error.UnknownProperty"), "Property '#{interface_name}.#{property_name}' (on object '#{@path}') not found: no such interface" end property = interface.properties[property_name.to_sym] if !property raise DBus.error("org.freedesktop.DBus.Error.UnknownProperty"), "Property '#{interface_name}.#{property_name}' (on object '#{@path}') not found" end property end # Generates information about interfaces and properties of the object # # Returns a hash containing interfaces names as keys. Each value is the # same hash that would be returned by the # org.freedesktop.DBus.Properties.GetAll() method for that combination of # object path and interface. If an interface has no properties, the empty # hash is returned. # # @return [Hash{String => Hash{String => Data::Base}}] interface -> property -> value def interfaces_and_properties get_all_method = self.class.make_method_name("org.freedesktop.DBus.Properties", :GetAll) intfs.keys.each_with_object({}) do |interface, hash| hash[interface] = public_send(get_all_method, interface).first end end #################################################################### # use the above defined methods to declare the property-handling # interfaces and methods dbus_interface PROPERTY_INTERFACE do dbus_method :Get, "in interface_name:s, in property_name:s, out value:v" do |interface_name, property_name| property = dbus_lookup_property(interface_name, property_name) if property.readable? ruby_name = property.ruby_name value = public_send(ruby_name) # may raise, DBus.error or https://ruby-doc.com/core-3.1.0/TypeError.html typed_value = Data.make_typed(property.type, value) [typed_value] else raise DBus.error("org.freedesktop.DBus.Error.PropertyWriteOnly"), "Property '#{interface_name}.#{property_name}' (on object '#{@path}') is not readable" end end dbus_method :Set, "in interface_name:s, in property_name:s, in val:v" do |interface_name, property_name, value| property = dbus_lookup_property(interface_name, property_name) if property.writable? ruby_name_eq = "#{property.ruby_name}=" # TODO: declare dbus_method :Set to take :exact argument # and type check it here before passing its :plain value # to the implementation public_send(ruby_name_eq, value) else raise DBus.error("org.freedesktop.DBus.Error.PropertyReadOnly"), "Property '#{interface_name}.#{property_name}' (on object '#{@path}') is not writable" end end dbus_method :GetAll, "in interface_name:s, out value:a{sv}" do |interface_name| interface = intfs[interface_name] if !interface raise DBus.error("org.freedesktop.DBus.Error.UnknownProperty"), "Properties '#{interface_name}.*' (on object '#{@path}') not found: no such interface" end p_hash = {} interface.properties.each do |p_name, property| next unless property.readable? ruby_name = property.ruby_name begin # D-Bus spec says: # > If GetAll is called with a valid interface name for which some # > properties are not accessible to the caller (for example, due # > to per-property access control implemented in the service), # > those properties should be silently omitted from the result # > array. # so we will silently omit properties that fail to read. # Get'ting them individually will send DBus.Error value = public_send(ruby_name) # may raise, DBus.error or https://ruby-doc.com/core-3.1.0/TypeError.html typed_value = Data.make_typed(property.type, value) p_hash[p_name.to_s] = typed_value rescue StandardError DBus.logger.debug "Property '#{interface_name}.#{p_name}' (on object '#{@path}')" \ " has raised during GetAll, omitting it" end end [p_hash] end dbus_signal :PropertiesChanged, "interface:s, changed_properties:a{sv}, invalidated_properties:as" end dbus_interface "org.freedesktop.DBus.Introspectable" do dbus_method :Introspect, "out xml_data:s" do # The body is not used, Connection#process handles it instead # which is more efficient and handles paths without objects. end end end end