├── .gitignore ├── .rspec ├── .travis.yml ├── .yardopts ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── i3ipc.gemspec ├── lib ├── i3ipc.rb └── i3ipc │ ├── connection.rb │ ├── protocol.rb │ ├── reply.rb │ └── version.rb └── spec ├── i3_mock_server.rb ├── i3ipc ├── connection_spec.rb ├── protocol_spec.rb └── reply_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation --color 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | - 2.1 6 | - 2.2 7 | 8 | script: 9 | - bundle exec rspec 10 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown 2 | --hide-void-return 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'rspec' 4 | 5 | group :development do 6 | gem 'guard' 7 | gem 'guard-rspec' 8 | gem 'pry' 9 | end 10 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, cmd: 'rspec' do 2 | watch(%r{^spec/.+_spec\.rb$}) 3 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 4 | watch('spec/spec_helper.rb') { "spec" } 5 | end 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 veelenga 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # I3ipc [![Gem Version](https://badge.fury.io/rb/i3ipc.svg)](https://rubygems.org/gems/i3ipc) [![Build Status](https://api.travis-ci.org/veelenga/i3ipc-ruby.svg?branch=master)](https://travis-ci.org/veelenga/i3ipc-ruby) 2 | 3 | - [Installation](#installation) 4 | - [Usage](#usage) 5 | - [Command](#command) 6 | - [Workspaces](#workspaces) 7 | - [Subscribe](#subscribe) 8 | - [Outputs](#outputs) 9 | - [Tree](#tree) 10 | - [Marks](#marks) 11 | - [Bar config](#bar-config) 12 | - [Version](#version) 13 | - [Contributing](#contributing) 14 | 15 | 16 | An improved Ruby library to control [i3wm](http://i3wm.org/). 17 | 18 | i3's interprocess communication (or [ipc](http://i3wm.org/docs/ipc.html)) is the interface i3 wm uses to receive commands from the clients. It also features a publish/subscribe mechanism for notifying interested parties of window manager events. 19 | 20 | This gem will be useful for example for controlling i3 windows manager or to get various information like the current workspaces or to implement external workspace bar in `Ruby` language. 21 | 22 | Inspired by [i3ipc-python](https://github.com/acrisci/i3ipc-python), [i3ipc-gjs](https://github.com/acrisci/i3ipc-gjs), [i3ipc-lua](https://github.com/acrisci/i3ipc-lua) and reworked mainly from [i3-ipc](https://github.com/badboy/i3-ipc) (thanks to [@badboy](https://github.com/badboy) for this gem). 23 | 24 | ## Installation 25 | 26 | Nothing special here: 27 | ```sh 28 | $ gem install i3ipc 29 | ``` 30 | ## Usage 31 | 32 | Usage is very simple and straightforward: 33 | 34 | ```ruby 35 | require 'i3ipc' 36 | 37 | i3 = I3Ipc::Connection.new 38 | # communicate with i3 server... 39 | # ... 40 | i3.close 41 | 42 | ``` 43 | 44 | Below you can find examples of usage some replies from local i3 wm. Output depend on my config and will be different in other env. A list of messages to send and replies you can find in [Receiving replies from i3](https://i3wm.org/docs/ipc.html#_receiving_replies_from_i3). 45 | 46 | Each reply from i3 wm will be parsed and packed in a special object. That object responds to any method with a name of an original name of i3 wm attribute in the reply. So you can access attributes in a very useful way. Find examples below. 47 | 48 | ### Command 49 | 50 | Executes one or more command at a time. Reply contains the property `success (bool)` for each command: 51 | 52 | ```ruby 53 | >> command = i3.command('workspace 0; focus left') 54 | >> puts command[0] 55 | { 56 | "success": true 57 | } 58 | >> puts command[0].success 59 | true 60 | ``` 61 | 62 | or a human readable error message in the property `error (string)` 63 | 64 | ```ruby 65 | >> command = i3.command('this a bad command') 66 | >> puts command[0].success 67 | false 68 | >> puts command[0].error 69 | Expected one of these tokens: , '[', 'move', 'exec', 'exit', 'restart', 'reload', 'shmlog', 'debuglog', 'border', 'layout', 'append_layout', 'workspace', 'focus', 'kill', 'open', 'fullscreen', 'split', 'floating', 'mark', 'unmark', 'resize', 'rename', 'nop', 'scratchpad', 'mode', 'bar' 70 | ``` 71 | 72 | ### Workspaces 73 | 74 | Reply consists of a list of workspaces. Each workspace has some properties: 75 | 76 | ```ruby 77 | >> workspaces = i3.workspaces 78 | >> puts workspaces[0] 79 | { 80 | "num": 1, 81 | "name": "1 Browse", 82 | "visible": true, 83 | "focused": false, 84 | "rect": { 85 | "x": 1366, 86 | "y": 20, 87 | "width": 1920, 88 | "height": 1060 89 | }, 90 | "output": "VGA1", 91 | "urgent": false 92 | } 93 | >> puts workspaces[0].name 94 | 1 Browse 95 | >> puts workspaces[0].rect.width 96 | 1920 97 | ``` 98 | 99 | ### Subscribe 100 | 101 | Takes an [event](http://i3wm.org/docs/ipc.html#_available_events) and 102 | a Proc object. The Proc object will be called with i3's response 103 | whenever i3 generates the specified event. `subscribe` returns a 104 | Thread; the block will execute in this thread until the thread is 105 | killed. 106 | 107 | ```ruby 108 | block = Proc.new do |reply| 109 | if reply.change == 'title' 110 | puts "title changed for window #{reply.container.name}" 111 | end 112 | end 113 | 114 | pid = i3.subscribe('window', block) 115 | pid.join 116 | ``` 117 | 118 | It is recommended to use separate `Connection`s for each subscription, 119 | since replies to subscription events may be sent by i3 at any time. 120 | 121 | ### Outputs 122 | 123 | Reply consists of a list of outputs: 124 | 125 | ```ruby 126 | >> outputs = i3.outputs 127 | >> puts oututs[0].name 128 | LVDS1 129 | ``` 130 | 131 | ### Tree 132 | 133 | The reply consists information about i3 tree. Each node in the tree (representing one container) has some properties: 134 | 135 | ```ruby 136 | >> tree = i3.tree 137 | >> puts tree.id 138 | 8214416 139 | >> puts tree.nodes[0].name 140 | VGA1 141 | ``` 142 | 143 | ### Marks 144 | 145 | Reply consists of a single array of string for each container that has a mark. 146 | 147 | First we need to create some marks: 148 | 149 | ```ruby 150 | >> i3.command('mark terminal; focus right; mark vim') 151 | ``` 152 | 153 | Then can get a list of available marks: 154 | 155 | ```ruby 156 | >> puts i3.marks 157 | terminal 158 | vim 159 | ``` 160 | 161 | And use those marks: 162 | 163 | ```ruby 164 | >> i3.command("focus right; [con_mark=\"terminal\"] focus") 165 | ``` 166 | 167 | ### Bar config 168 | 169 | ```ruby 170 | >> puts i3.bar_config 171 | bar-0 172 | ``` 173 | 174 | ### Version 175 | 176 | Reply describes a current version of i3 windows manager: 177 | 178 | ```ruby 179 | >> puts i3.version 180 | { 181 | "major": 4, 182 | "minor": 10, 183 | "patch": 2, 184 | "human_readable": "4.10.2 (2015-04-16, branch \"4.10.2\")" 185 | } 186 | ``` 187 | 188 | ## Contributing 189 | 190 | 1. Fork it 191 | 1. Create your feature branch (`git checkout -b my-new-feature`) 192 | 1. Commit your changes (`git commit -am 'Add some feature'`) 193 | 1. Run tests (`bundle exec rspec`) 194 | 1. Push to the branch (`git push origin my-new-feature`) 195 | 1. Create a new Pull Request 196 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: [:spec] 7 | 8 | -------------------------------------------------------------------------------- /i3ipc.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'i3ipc/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'i3ipc' 8 | spec.version = I3ipc::VERSION 9 | spec.authors = ['Vitalii Elengaupt'] 10 | spec.email = ['velenhaupt@gmail.com'] 11 | spec.summary = 'Interprocess communication with i3 wm' 12 | spec.description = <<-DESC 13 | Implementation of interface for i3 tiling window manager. 14 | Useful for example to remote-control i3 or to get various 15 | information like the current workspace to implement an 16 | external workspace bar etc. in Ruby language. 17 | DESC 18 | spec.homepage = 'https://github.com/veelenga/i3ipc-ruby' 19 | spec.license = 'MIT' 20 | 21 | spec.files = `git ls-files`.split($RS) 22 | spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) } 23 | spec.test_files = spec.files.grep(/^spec\//) 24 | spec.require_paths = ['lib'] 25 | 26 | spec.required_ruby_version = '>= 1.9.3' 27 | 28 | spec.add_development_dependency 'bundler', '~> 1.7' 29 | spec.add_development_dependency 'rake', '~> 10.0' 30 | spec.add_development_dependency 'rspec', '~> 3.0' 31 | end 32 | -------------------------------------------------------------------------------- /lib/i3ipc.rb: -------------------------------------------------------------------------------- 1 | require 'i3ipc/version' 2 | require 'i3ipc/protocol' 3 | require 'i3ipc/reply' 4 | require 'i3ipc/connection' 5 | -------------------------------------------------------------------------------- /lib/i3ipc/connection.rb: -------------------------------------------------------------------------------- 1 | require 'i3ipc/protocol' 2 | require 'i3ipc/reply' 3 | 4 | module I3Ipc 5 | # Throws when subscribing to an invalid event. Valid events are 6 | # listed in function event_number(). 7 | class WrongEvent < RuntimeError 8 | def initialize(event_name) 9 | @event_name = event_name 10 | end 11 | 12 | def message 13 | %Q{Tried to subscribe to invalid event type '#{@event_name}'} 14 | end 15 | end 16 | 17 | # Entry point for communication with i3-ipc. 18 | # Able to send/receive messages and convert 19 | # responses. 20 | # 21 | # @example 22 | # con = Connection.new 23 | # p con.version.human_readable # => 4.10.2 (2015-0... 24 | # p con.command('focus left').success? # => true 25 | # p con.workspaces[0].name # => 0 Term 26 | # # ... 27 | # con.close 28 | class Connection 29 | 30 | def initialize(protocol = Protocol.new, autoconnect = true) 31 | @protocol = protocol 32 | open if autoconnect 33 | end 34 | 35 | def open 36 | @protocol.connect 37 | end 38 | 39 | def close 40 | @protocol.disconnect 41 | end 42 | 43 | def command(cmds) 44 | reply_for(0, cmds) 45 | end 46 | 47 | def workspaces 48 | reply_for(1) 49 | end 50 | 51 | def subscribe(event, block) 52 | event_number = event_number(event) 53 | 54 | # Send subscription request 55 | @protocol.send(2, [event]) 56 | 57 | reply = Reply.parse(@protocol.receive 2) 58 | raise WrongEvent.new(event) unless reply.success? 59 | 60 | pid = Thread.new do 61 | while true 62 | reply = Reply.parse(@protocol.receive_event event_number) 63 | block.call(reply) 64 | end 65 | end 66 | 67 | pid 68 | end 69 | 70 | def outputs 71 | reply_for(3) 72 | end 73 | 74 | def tree 75 | reply_for(4) 76 | end 77 | 78 | def marks 79 | reply_for(5) 80 | end 81 | 82 | def bar_config 83 | reply_for(6) 84 | end 85 | 86 | def version 87 | reply_for(7) 88 | end 89 | 90 | private 91 | 92 | def reply_for(type, message = nil) 93 | @protocol.send(type, message) 94 | 95 | Reply.parse(@protocol.receive type) 96 | end 97 | 98 | def event_number(event) 99 | case event 100 | when 'workspace' then 0 101 | when 'output' then 1 102 | when 'mode' then 2 103 | when 'window' then 3 104 | when 'barconfig_update' then 4 105 | when 'binding' then 5 106 | else raise WrongEvent.new(event) 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/i3ipc/protocol.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | 3 | module I3Ipc 4 | # Communication interface with i3-ipc. 5 | # Can connect to i3-ipc socket, disconnect, send and receive messages. 6 | # 7 | # For i3-ipc interface details refer to https://i3wm.org/docs/ipc.html. 8 | # 9 | # @example 10 | # protocol = Protocol.new 11 | # protocol.send(7) 12 | # puts protocol.receive 13 | # protocol.disconnect 14 | # 15 | class Protocol 16 | # Magic string for i3-ipc protocol to ensure the integrity of messages. 17 | MAGIC_STRING = 'i3-ipc' 18 | 19 | # Throws when received data with not expected magic string. 20 | # Usually this means that protocol is not compatible with 21 | # current i3-ipc version. 22 | class WrongMagicString < RuntimeError 23 | def initialize(magic_string) 24 | @magic_string = magic_string 25 | end 26 | 27 | def message 28 | %Q{Magic code expected '#{MAGIC_STRING}', but was '#{@magic_string}'} 29 | end 30 | end 31 | 32 | # Throws when received data with not expected type. 33 | class WrongType < RuntimeError 34 | def initialize(expected, actual) 35 | @expected = expected 36 | @actual = actual 37 | end 38 | 39 | def message 40 | %Q{Message type expected '#{@expected}', but was '#{@actual}'} 41 | end 42 | end 43 | 44 | # Throws when protocol trying to do some action 45 | # on non-connected channel. 46 | class NotConnected < RuntimeError; end 47 | 48 | def initialize(socketpath = nil) 49 | @socketpath = socketpath ? socketpath : get_socketpath 50 | end 51 | 52 | # Connects to i3-ipc server socket using {::UNIXSocket}. 53 | # Does nothing if already connected. 54 | def connect 55 | @socket = UNIXSocket.new(@socketpath) unless @socket 56 | end 57 | 58 | # Disconnects from i3-ipc server socket. 59 | # Does nothing if not connected. 60 | def disconnect 61 | @socket && @socket.close 62 | @socket = nil 63 | end 64 | 65 | # Sends packed message to i3-ipc server socket. 66 | # @param [Integer] type type of the message. 67 | # @param [String] payload message payload. 68 | # 69 | # @raise [NotConnected] if protocol is not connected. 70 | def send(type, payload = nil) 71 | check_connected 72 | @socket.write(pack(type, payload)) 73 | end 74 | 75 | # Receives message from i3-ipc server socket. 76 | # 77 | # @param [Integer] type expected type of the message. 78 | # 79 | # @return [String] unpacked response from i3 server. 80 | # 81 | # @raise [NotConnected] if protocol is not connected. 82 | # @raise [WrongMagicString] if got message with wrong magic string. 83 | # @raise [WrongType] if got message with not expected type. 84 | def receive(type = nil) 85 | check_connected 86 | # length of "i3-ipc" + 4 bytes length + 4 bytes type 87 | data = @socket.read 14 88 | magic, len, recv_type = unpack_header(data) 89 | 90 | raise WrongMagicString.new(magic) unless MAGIC_STRING.eql? magic 91 | type && (raise WrongType.new(type, recv_type) unless type == recv_type) 92 | 93 | @socket.read(len) 94 | end 95 | 96 | # Receives event from i3-ipc server socket. 97 | # 98 | # @param [Integer] type expected type of the message. 99 | # 100 | # @return [String] unpacked response from i3 server. 101 | # 102 | # @raise [NotConnected] if protocol is not connected. 103 | # @raise [WrongMagicString] if got message with wrong magic string. 104 | # @raise [WrongType] if got message with not expected type. 105 | def receive_event(type = nil) 106 | check_connected 107 | # length of "i3-ipc" + 4 bytes length + 4 bytes type 108 | data = @socket.read 14 109 | magic, len, recv_type = unpack_header(data) 110 | 111 | # Strip highest bit 112 | recv_type = recv_type & 2147483647 113 | 114 | raise WrongMagicString.new(magic) unless MAGIC_STRING.eql? magic 115 | type && (raise WrongType.new(type, recv_type) unless type == recv_type) 116 | 117 | @socket.read(len) 118 | end 119 | 120 | private 121 | 122 | # Packs the message. 123 | # A typical message looks like: 124 | # @example 125 | #
126 | # where a header is: 127 | # @example 128 | # 129 | # 130 | # @param [Integer] type type of the message. 131 | # @param [String] payload payload of the message. 132 | def pack(type, payload=nil) 133 | size = payload ? payload.to_s.bytes.count : 0 134 | msg = MAGIC_STRING + [size, type].pack("LL") 135 | msg << payload.to_s if payload 136 | msg 137 | end 138 | 139 | # Unpacks the header. 140 | # A typical header looks like: 141 | # @example 142 | # 143 | # 144 | # @param [String] data: data to be unpacked. 145 | def unpack_header(data) 146 | struct_header_len = MAGIC_STRING.size 147 | magic_message = data[0, struct_header_len] 148 | len, type = data[struct_header_len..-1].unpack("LL") 149 | [magic_message, len, type] 150 | end 151 | 152 | def get_socketpath 153 | if !ENV['I3SOCK'].nil? 154 | ENV['I3SOCK'] 155 | else 156 | cmd = if system('i3 --version') 157 | 'i3' 158 | elsif system('sway --version') 159 | 'sway' 160 | else 161 | raise 'Unable to find i3 compatible window manager' 162 | end 163 | 164 | path = `#{cmd} --get-socketpath`.chomp! 165 | raise 'Unable to get i3 compatible socketpath' unless path 166 | path 167 | end 168 | end 169 | 170 | def check_connected 171 | raise NotConnected unless @socket 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /lib/i3ipc/reply.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module I3Ipc 4 | # Wrapper for reply from i3-ipc. 5 | # 6 | # Able to parse Numeric, String, TrueClass, FalseClass, 7 | # Array, Hash from passed string in json format. 8 | # 9 | # @example 10 | # response = Reply.parse( 11 | # %Q{ 12 | # { 13 | # "name": "LVDS1", 14 | # "active": true, 15 | # "current_workspace": "4", 16 | # "rect": { 17 | # "x": 0, 18 | # "y": 0, 19 | # "width": 1280, 20 | # "height": 800 21 | # } 22 | # } 23 | # } 24 | # ) 25 | # 26 | # p response.name # => "LVDS1" 27 | # p response.active # => true 28 | # p response.rect.width # => 1280 29 | # # ... 30 | # 31 | # response = Reply.parse(%Q{ {"data": [{"key1": true}, {"key2": false}]} }) 32 | # p response.data[0].key1 # => true 33 | # p response.data[0].key2 # => false 34 | class Reply 35 | def initialize(data) 36 | @data = data.dup 37 | end 38 | 39 | # Parses response from I3-ipc protocol. 40 | # 41 | # @param [String] response response from i3 in json format. 42 | # 43 | # @return [Reply] object with dynamically accessed values. 44 | def self.parse(response) 45 | parse_data JSON.parse(response) 46 | end 47 | 48 | # Indicates whether this reply is successful or not. 49 | # 50 | # @return false if this reply represents and error from i3-ipc protocol. 51 | # Otherwise returns true, which means that request is successful and 52 | # reply has some data. 53 | def success? 54 | not self.respond_to? :error 55 | end 56 | 57 | def method_missing(name, *args, &block) 58 | if @data.include?(name) 59 | raise ArgumentError.new('wrong number of arguments (%d for 0)' % args.length) if args.length > 0 60 | return @data[name] 61 | else 62 | super 63 | end 64 | end 65 | 66 | def respond_to?(method_sym, include_private = false) 67 | if @data.include?(method_sym) 68 | true 69 | else 70 | super 71 | end 72 | end 73 | 74 | def to_s 75 | JSON.pretty_generate(to_h) 76 | end 77 | 78 | def to_h 79 | data = @data.dup 80 | data.each do |k, v| 81 | data[k] = Reply.unparse_data v 82 | end 83 | end 84 | 85 | private 86 | 87 | def self.parse_data(data) 88 | case data 89 | when Numeric, String, TrueClass, FalseClass, NilClass 90 | return data 91 | when Array 92 | return data.map {|v| parse_data(v)} 93 | when Hash 94 | data.each do |k, v| 95 | data[k] = parse_data v 96 | end 97 | return Reply.new(Hash[data.map {|k, v| [k.to_sym, v]}]) 98 | else 99 | raise "Unable to parse data of type #{data.class}" 100 | end 101 | end 102 | 103 | def self.unparse_data(data) 104 | case data 105 | when Numeric, String, TrueClass, FalseClass, NilClass 106 | data 107 | when Reply 108 | data.to_h 109 | when Array 110 | data.map! {|x| self.unparse_data(x)} 111 | data 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/i3ipc/version.rb: -------------------------------------------------------------------------------- 1 | module I3ipc 2 | VERSION = "0.4.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/i3_mock_server.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'fileutils' 3 | 4 | module I3Ipc 5 | # Simple socket server that communicates with 6 | # client simulating i3-ipc messages. 7 | class I3MockServer 8 | SOCKET_PATH = '/tmp/i3-mock-server.sock' 9 | 10 | def initialize 11 | remove_sock 12 | @server = UNIXServer.new(SOCKET_PATH) 13 | end 14 | 15 | def accept_client 16 | @client = @server.accept_nonblock 17 | self 18 | rescue IO::WaitReadable, Errno::EINTR 19 | nil 20 | end 21 | 22 | def client_alive? 23 | return false unless @client 24 | @client.write 'hi' 25 | true 26 | rescue Errno::EPIPE 27 | false 28 | end 29 | 30 | def receive(len) 31 | raise 'Client not accepted yet' unless @client 32 | @client.read(len) 33 | end 34 | 35 | def send(data) 36 | raise 'Client not accepted yet' unless @client 37 | @client.write(data) 38 | end 39 | 40 | def close_client 41 | @client.close if @client 42 | @client = nil 43 | end 44 | 45 | def close 46 | close_client 47 | @server.close unless @server.closed? 48 | remove_sock 49 | end 50 | 51 | private 52 | 53 | def remove_sock 54 | FileUtils.rm(SOCKET_PATH) if File.exist?(SOCKET_PATH) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/i3ipc/connection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module I3Ipc 4 | describe Connection do 5 | 6 | let(:protocol) { Protocol.new '' } 7 | let(:stub_protocol) do 8 | allow(protocol).to receive(:send) 9 | allow(protocol).to receive(:receive) 10 | end 11 | subject { Connection.new protocol, false } 12 | 13 | describe '#command' do 14 | it 'returns i3ipc reply' do 15 | stub_protocol.and_return('{}') 16 | expect(subject.command('cmd')).to be_a Reply 17 | end 18 | 19 | it 'sends correct command and receives success response' do 20 | stub_protocol.and_return({ success: true }.to_json) 21 | expect(subject.command('focus left').success?).to be true 22 | end 23 | 24 | it 'sends incorrect command and receives error response' do 25 | stub_protocol.and_return({ success: false, error: 'wrong command' }.to_json) 26 | reply = subject.command('my command') 27 | expect(reply.success).to be false 28 | expect(reply.error).to eql 'wrong command' 29 | end 30 | end 31 | 32 | describe '#workspaces' do 33 | it 'returns i3ipc reply' do 34 | stub_protocol.and_return('{}') 35 | expect(subject.workspaces).to be_a Reply 36 | end 37 | end 38 | 39 | describe '#outputs' do 40 | it 'returns i3ipc reply' do 41 | stub_protocol.and_return('{}') 42 | expect(subject.outputs).to be_a Reply 43 | end 44 | end 45 | 46 | describe '#tree' do 47 | it 'returns i3ipc reply' do 48 | stub_protocol.and_return('{}') 49 | expect(subject.tree).to be_a Reply 50 | end 51 | end 52 | 53 | describe '#marks' do 54 | it 'returns i3ipc reply' do 55 | stub_protocol.and_return('{}') 56 | expect(subject.marks).to be_a Reply 57 | end 58 | end 59 | 60 | describe '#bar_config' do 61 | it 'returns i3ipc reply' do 62 | stub_protocol.and_return('{}') 63 | expect(subject.bar_config).to be_a Reply 64 | end 65 | end 66 | 67 | describe '#version' do 68 | it 'returns i3ipc reply' do 69 | stub_protocol.and_return('{}') 70 | expect(subject.version).to be_a Reply 71 | end 72 | 73 | it 'able to parse version attributes' do 74 | resp = { human_readable: '4.2', minor: 2, patch: 0, major: 4 } 75 | stub_protocol.and_return(resp.to_json) 76 | reply = subject.version 77 | expect(reply.human_readable).to eq resp[:human_readable] 78 | expect(reply.minor).to eq resp[:minor] 79 | expect(reply.patch).to eq resp[:patch] 80 | expect(reply.major).to eq resp[:major] 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/i3ipc/protocol_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module I3Ipc 4 | describe Protocol do 5 | 6 | before(:all) do 7 | # open internal methods for verification needs 8 | class Protocol 9 | public :pack, :unpack_header 10 | end 11 | end 12 | 13 | before(:each, :i3 => :simulate) do 14 | @i3_srv = I3MockServer.new 15 | subject.connect 16 | @i3_srv.accept_client 17 | end 18 | 19 | after(:each, :i3 => :simulate) do 20 | @i3_srv.close 21 | subject.disconnect 22 | end 23 | 24 | subject { Protocol.new(I3MockServer::SOCKET_PATH) } 25 | 26 | it 'has MAGIC_STRING string constant' do 27 | expect(Protocol::MAGIC_STRING).to be_a String 28 | end 29 | 30 | describe '#connect' do 31 | it 'fails to connect if server not running' do 32 | expect { subject.connect }.to raise_error Errno::ENOENT 33 | end 34 | 35 | it 'connects if server running', :i3 => :simulate do 36 | expect(@i3_srv.client_alive?).to be true 37 | end 38 | 39 | it 'reconnects if disconnected', :i3 => :simulate do 40 | subject.disconnect 41 | subject.connect 42 | expect(@i3_srv.accept_client.client_alive?).to be true 43 | end 44 | 45 | it 'does nothing if already connected', :i3 => :simulate do 46 | subject.connect 47 | expect(@i3_srv.accept_client).to be nil 48 | expect(@i3_srv.client_alive?).to be true 49 | end 50 | end 51 | 52 | describe '#disconnect', :i3 => :simulate do 53 | it 'disconnects if connected' do 54 | subject.disconnect 55 | expect(@i3_srv.client_alive?).to be false 56 | end 57 | 58 | it 'does nothing if aready disconnected' do 59 | subject.disconnect 60 | subject.disconnect 61 | expect(@i3_srv.client_alive?).to be false 62 | end 63 | end 64 | 65 | describe '#send', :i3 => :simulate do 66 | let(:type) { 42 } 67 | let(:message) { 'test_send' } 68 | 69 | it 'is able to send packed type' do 70 | subject.send(type) 71 | 72 | data = @i3_srv.receive(14) 73 | header = subject.unpack_header data 74 | expect(header).to match_array [Protocol::MAGIC_STRING, 0, type] 75 | end 76 | 77 | it 'is able to send packed type and payload' do 78 | subject.send(type, message) 79 | 80 | data = @i3_srv.receive(14) 81 | header = subject.unpack_header data 82 | expect(header).to match_array [Protocol::MAGIC_STRING, message.size, type] 83 | 84 | data = @i3_srv.receive(header[1]) 85 | expect(data).to eq message 86 | end 87 | 88 | it 'throws error if not connected' do 89 | subject.disconnect 90 | expect { subject.send(type) }.to raise_error Protocol::NotConnected 91 | end 92 | end 93 | 94 | describe '#receive', :i3 => :simulate do 95 | let(:type) { 40 } 96 | let(:message) { 'test_receive' } 97 | 98 | context 'if server send packed message' do 99 | before (:each) do 100 | data = subject.pack(type, message) 101 | @i3_srv.send(data) 102 | end 103 | 104 | it 'returns unpacked message' do 105 | recv_message = subject.receive 106 | expect(recv_message).to eq(message) 107 | end 108 | 109 | it 'throws WrongType if received type does not match expected one' do 110 | expect { subject.receive(type + 1) }.to raise_error Protocol::WrongType 111 | end 112 | end 113 | 114 | context 'if server send wrong message' do 115 | before (:each) do 116 | data = 'i4-ipc' + [message.size, type].pack("LL") + message 117 | @i3_srv.send(data) 118 | end 119 | 120 | it 'throws WrongMagicString' do 121 | expect { subject.receive(type) }.to raise_error Protocol::WrongMagicString 122 | end 123 | end 124 | 125 | it 'throws error if not connected' do 126 | subject.disconnect 127 | expect { subject.receive }.to raise_error Protocol::NotConnected 128 | end 129 | end 130 | end 131 | end 132 | 133 | -------------------------------------------------------------------------------- /spec/i3ipc/reply_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module I3Ipc 4 | describe Reply do 5 | 6 | describe '.parse' do 7 | context 'when valid JSON string passed' do 8 | 9 | it 'properly parses boolean attributes' do 10 | expect(Reply.parse('{"success": true}').success).to be true 11 | expect(Reply.parse('{"success": false}').success).to be false 12 | end 13 | 14 | it 'properly parses numeric attributes' do 15 | reply = Reply.parse(%Q[{ "int": 2, "float": 4.2 }]) 16 | expect(reply.int).to eql 2 17 | expect(reply.float).to eql 4.2 18 | end 19 | 20 | it 'properly parses string attributes' do 21 | expect(Reply.parse('{"output": "LVDS1"}').output).to eql "LVDS1" 22 | end 23 | 24 | it 'properly parses array of hashes' do 25 | reply = Reply.parse( %Q[{ "arr": [{"key1": true}, {"key2": false}] }]) 26 | expect(reply.arr[0].key1).to be true 27 | expect(reply.arr[1].key2).to be false 28 | end 29 | 30 | it 'properly parses sub-hashes' do 31 | reply = Reply.parse(%Q[{ "ha":{ "key1": "val1", "key2": "val2"} }]) 32 | expect(reply.ha.key1).to eql 'val1' 33 | expect(reply.ha.key2).to eql 'val2' 34 | end 35 | 36 | it 'properly parses sub-arrays' do 37 | reply = Reply.parse(%Q[{ "arr": [[ 1, 2 ]] }]) 38 | expect(reply.arr[0]).to match_array [1, 2] 39 | end 40 | 41 | it 'properly parses empty array' do 42 | reply = Reply.parse(%Q[{"ar" : []}]) 43 | expect(reply.ar).to be_a Array 44 | expect(reply.ar).to be_empty 45 | end 46 | 47 | it 'property parses empty hash' do 48 | reply = Reply.parse(%Q[{"ha": {}}]); 49 | expect(reply.ha).to be_a Reply 50 | expect(reply.ha.to_s).to eq "{\n}" 51 | end 52 | 53 | it 'returns new Reply object' do 54 | expect(Reply.parse('{}')).to be_a Reply 55 | end 56 | end 57 | 58 | context 'when NOT valid JSON string passed' do 59 | it 'raise JSON::ParserError' do 60 | expect{Reply.parse(%Q[{"data": }])}.to raise_error JSON::ParserError 61 | end 62 | end 63 | end 64 | 65 | describe '#success?' do 66 | it 'returns true if response without error' do 67 | reply = Reply.parse(%Q[{}]) 68 | expect(reply.success?).to be true 69 | end 70 | 71 | it 'returns false if response with error' do 72 | reply = Reply.parse(%Q[{"error": "Wrong command"}]) 73 | expect(reply.success?).to be false 74 | expect(reply.error).to eql 'Wrong command' 75 | end 76 | end 77 | 78 | describe '#to_h' do 79 | it 'converts it back to hash' do 80 | hash = {:f => 1, :a => 2, :inner => {:ar => [true, false, "v"]}} 81 | reply = Reply.parse(hash.to_json) 82 | expect(reply.to_h). to eql hash 83 | end 84 | end 85 | 86 | describe '#to_s' do 87 | it 'returns property formatter structure with hash, arrays and primitives' do 88 | reply = Reply.parse(%Q[{"d":{"1":[{"k":"v"}]} }]) 89 | expect(reply.to_s).to eql "{\n \"d\": {\n \"1\": [\n {\n \"k\": \"v\"\n }\n ]\n }\n}" 90 | end 91 | end 92 | 93 | describe '#method_missing' do 94 | it 'returns value with dynamic method based on input data' do 95 | expect(Reply.new({:meth => 'val'}).meth).to eql 'val' 96 | end 97 | 98 | it 'throws ArgumentError if one or more parameters passed to dynamic method' do 99 | expect{ Reply.new({:meth => 'val'}).meth(0) }.to raise_error ArgumentError 100 | end 101 | 102 | it 'still throws NoMethodError if not existed method called' do 103 | expect{ Reply.new({:meth => 'val'}).no_such_method }.to raise_error NoMethodError 104 | end 105 | end 106 | 107 | describe '#respond_to' do 108 | it 'responds to method from input data' do 109 | expect(Reply.new({:meth => 'val'})).to respond_to :meth 110 | end 111 | 112 | it 'still does not respond to not existed methods' do 113 | expect(Reply.new({:meth => 'val'})).not_to respond_to :no_such_method 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'i3ipc' 2 | 3 | require 'i3ipc/protocol' 4 | require 'i3ipc/reply' 5 | require 'i3ipc/connection' 6 | 7 | require 'i3_mock_server' 8 | 9 | --------------------------------------------------------------------------------