autohotinterceptor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f6ca9147e075e5c526a88f2e8d5d20698af2685760850af505331977503983c0
4
+ data.tar.gz: 9a03a67b3468bddf50b6074eae8a88cfbbfa506d4d51424d4f02bfea1cdc5e4a
5
+ SHA512:
6
+ metadata.gz: f8905b63009dade7bea706606501521b6ef0f885a2717cde8f44dae3b6ae78f0a63f940b2c75be126592acc628854f9342a512c56f122d999e359182f217c9ed
7
+ data.tar.gz: 63a4dbcd0264b17769f99d20a06c2d80aa935cda8e80581c252212fd1d560cabcc32d134cd8fd01e4bdb4ae082794375894288be02a7c93a9a7459f53e9cebad
data/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TitanCSA
4
+
5
+ This license applies to the user-mode source code, examples, build scripts,
6
+ documentation and precompiled user-mode recovery binaries in this repository.
7
+ It does not apply to the prebuilt kernel
8
+ driver, driver catalogs, or INF files under InstallerUI/Package/Payload; those files are
9
+ covered by licenses/BINARY-LICENSE.txt. Third-party names and projects retain their own
10
+ licenses and trademarks.
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # AutoHotInterceptor Ruby SDK
2
+
3
+ `gem install autohotinterceptor`
4
+
5
+ Requires Ruby 3.1+ on Windows x64. Direct Fiddle bindings to this product's native SDK, with no Python dependency. Supported families: keyboard, mouse. Driver and original licensing requirements remain unchanged.
6
+
7
+ ```ruby
8
+ require 'autohotinterceptor'
9
+ AutoHotInterceptor::Interceptor.open do |client|
10
+ p client.info
11
+ end
12
+ ```
13
+
14
+ The block form closes the native session even on an exception. Importing/constructing the SDK does not install a driver or start capture. See [API documentation](docs/API.md).
data/docs/API.md ADDED
@@ -0,0 +1,7 @@
1
+ # AutoHotInterceptor Ruby API
2
+
3
+ `AutoHotInterceptor::Interceptor.open(library_path: nil) { |client| ... }`; or construct explicitly and call `close`. `initialize_devices`, `info`, `devices`, `receive(timeout: nil)`, `send(batch)`, `discard(batch)`, `cancel`, `descriptor(token, report: false)`, `inject(kind, state = {}, token: nil, report: nil)` expose the native C ABI.
4
+
5
+ Edit input fields through `event.field(name).value` or the unambiguous descriptor metadata in `event.fields`; set `event.blocked = true` to drop an input. Do not resize reports/fields or reuse stale/foreign batches. Partial failures expose `NativeError#code` and `#accepted`; retry sends only the remaining inputs. Sequence/timestamp use Ruby integers preserving unsigned 64-bit values. Cancellation works from another Ruby thread.
6
+
7
+ Supported families: keyboard, mouse. Unsupported injection raises Windows error 50. Driver installation and real capture are separate from package installation; original trial/license checks remain intact. The package was tested with its own synthetic backend and lazy production open/close. Native adapter source: `../AutoHotInterceptor-C-ffi`.
data/examples/info.rb ADDED
@@ -0,0 +1,2 @@
1
+ require 'autohotinterceptor'
2
+ AutoHotInterceptor::Interceptor.open { |client| p client.info }
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+ require 'fiddle'
3
+ require 'thread'
4
+
5
+ module AutoHotInterceptor
6
+ VERSION = '1.0.0'
7
+ KINDS = ['', 'keyboard', 'mouse', 'hid', 'xinput', 'xinputhid', 'gip'].freeze
8
+ class NativeError < StandardError
9
+ attr_reader :code, :accepted
10
+ def initialize(code, message, accepted = 0)
11
+ @code, @accepted = code, accepted
12
+ super(message)
13
+ end
14
+ end
15
+ Field = Struct.new(:value, :normalized, :data, :metadata, keyword_init: true)
16
+ Input = Struct.new(:kind, :token, :sequence, :timestamp, :fields, :metadata, :blocked, keyword_init: true) do
17
+ def field(name)
18
+ found = fields.select { |f| f.metadata[:name] == name }
19
+ raise ArgumentError, 'Field is absent or ambiguous; use descriptor metadata' unless found.length == 1
20
+ found.first
21
+ end
22
+ end
23
+ class Batch
24
+ include Enumerable
25
+ attr_reader :events
26
+ def initialize(events) = @events = events.freeze
27
+ def each(&block) = @events.each(&block)
28
+ def [](index) = @events[index]
29
+ def size = @events.size
30
+ alias length size
31
+ end
32
+
33
+ # All pointer/layout operations stay private; callers edit copied field values.
34
+ class Native
35
+ I = Fiddle::TYPE_INT
36
+ Q = Fiddle::TYPE_LONG_LONG
37
+ P = Fiddle::TYPE_VOIDP
38
+ SPECS = {
39
+ version: [], sizeof: [I], open: [P,P], close: [Q,P], initialize: [Q,P,P],
40
+ info_get: [Q,P,P], devices_get: [Q,P,P], receive: [Q,I,P,P], send: [Q,Q,P],
41
+ discard: [Q,Q,P], cancel: [Q,P], descriptor: [Q,P,I,P,P], inject: [Q,P,I,P]
42
+ }.freeze
43
+ @libraries = {}
44
+ @libraries_mutex = Mutex.new
45
+ def self.load(path)
46
+ @libraries_mutex.synchronize { @libraries[path] ||= new(path) }
47
+ end
48
+ def initialize(path)
49
+ @library = Fiddle.dlopen(path)
50
+ @functions = SPECS.to_h { |name, args| [name, Fiddle::Function.new(@library["autohotinterceptor_#{name}"], args, I)] }
51
+ end
52
+ def call(name, *arguments) = @functions.fetch(name).call(*arguments)
53
+ def self.buffer(length)
54
+ pointer = Fiddle::Pointer.malloc([length,1].max, Fiddle::RUBY_FREE)
55
+ pointer[0,[length,1].max] = "\0" * [length,1].max
56
+ pointer
57
+ end
58
+ def self.u32(p, off) = p[off,4].unpack1('L<')
59
+ def self.u64(p, off) = p[off,8].unpack1('Q<')
60
+ def self.i64(p, off) = p[off,8].unpack1('q<')
61
+ def self.pointer(p, off) = Fiddle::Pointer.new(u64(p,off))
62
+ def self.text(p) = p.null? ? '' : p.to_s.force_encoding(Encoding::UTF_8)
63
+ def self.signed64(value) = value >= (1 << 63) ? value - (1 << 64) : value
64
+ end
65
+
66
+ class Interceptor
67
+ def initialize(library_path: nil)
68
+ raise LoadError, 'Interceptor requires Windows x64 Ruby' unless /mswin|mingw/ =~ RUBY_PLATFORM && Fiddle::SIZEOF_VOIDP == 8
69
+ path = library_path || ENV['AUTOHOTINTERCEPTOR_NATIVE_PATH'] || File.expand_path('../native/AutoHotInterceptorBridge.dll', __dir__)
70
+ @native = Native.load(File.expand_path(path)); @mutex = Mutex.new; @session = 0; @current = nil
71
+ raise LoadError, 'Incompatible native ABI' unless @native.call(:version) == 1 && [120,64,24,40,776,56].each_with_index.all? { |size,index| @native.call(:sizeof,index+1) == size }
72
+ out = Native.buffer(8); checked(:open,out); @session = Native.u64(out,0)
73
+ end
74
+ def checked(name, *arguments)
75
+ error = Native.buffer(776)
76
+ code = @native.call(name, *arguments, error)
77
+ raise NativeError.new(code, error[8,768].split("\0",2).first.force_encoding('UTF-8'), Native.u32(error,4)) unless code.zero?
78
+ Native.u32(error,4)
79
+ end
80
+ private :checked
81
+ def active
82
+ raise IOError, 'Interceptor is closed' if @session.zero?
83
+ Native.signed64(@session)
84
+ end
85
+ private :active
86
+ def initialize_devices
87
+ @mutex.synchronize do
88
+ out = Native.buffer(24); checked(:initialize,active,out)
89
+ %i[ready reboot_required associated already_associated skipped].each_with_index.to_h { |name,index| [name,Native.u32(out,index*4)] }
90
+ end
91
+ end
92
+ def info
93
+ @mutex.synchronize do
94
+ out = Native.buffer(56); checked(:info_get,active,out)
95
+ result = %i[installed active trial_state input_mask controller_mask device_count queued last_reset].each_with_index.to_h { |name,index| [name,Native.u32(out,index*4)] }
96
+ %i[remaining_ms timestamp_frequency generation].each_with_index { |name,index| result[name] = Native.u64(out,32+index*8) }
97
+ result
98
+ end
99
+ end
100
+ def devices
101
+ @mutex.synchronize do
102
+ out = Native.buffer(8); checked(:devices_get,active,out); list = Native.pointer(out,0); items = Native.pointer(list,8)
103
+ Array.new(Native.u32(list,0)) do |i|
104
+ d = items + i*40
105
+ result = { kind: KINDS[Native.u32(d,0)], id: Native.u32(d,4) }
106
+ %i[vid pid usage_page usage].each_with_index { |name,index| result[name] = d[8+index*2,2].unpack1('S<') }
107
+ %i[token name hardware_id].each_with_index { |name,index| result[name] = Native.text(Native.pointer(d,16+index*8)) }
108
+ result
109
+ end
110
+ end
111
+ end
112
+ def receive(timeout: nil)
113
+ raise ArgumentError, 'Invalid timeout' unless timeout.nil? || timeout.is_a?(Integer) && timeout.between?(0,0xfffffffe)
114
+ @mutex.synchronize do
115
+ @current = nil; @header = nil
116
+ out = Native.buffer(8); checked(:receive,active,timeout.nil? ? -1 : (timeout >= (1 << 31) ? timeout - (1 << 32) : timeout),out)
117
+ @header = Native.pointer(out,0); @ticket = Native.u64(@header,0); @views = Native.pointer(@header,16)
118
+ events = Array.new(Native.u32(@header,8)) do |i|
119
+ e = @views + i*64; fs = Native.pointer(e,32)
120
+ fields = Array.new(Native.u32(e,40)) do |j|
121
+ f = fs + j*120
122
+ m = {name: Native.text(Native.pointer(f,0)), minimum: Native.i64(f,16), maximum: Native.i64(f,24), physical_minimum: Native.i64(f,32), physical_maximum: Native.i64(f,40)}
123
+ %i[group index usage_page usage collection report_id data_index element flags units units_exponent bit_size].each_with_index { |name,index| m[name] = Native.u32(f,56+index*4) }
124
+ bytes = Native.u32(f,112)
125
+ Field.new(value: Native.i64(f,8), normalized: f[48,8].unpack1('E'), data: bytes.zero? ? ''.b : Native.pointer(f,104)[0,bytes], metadata: m.freeze)
126
+ end.freeze
127
+ Input.new(kind: KINDS[Native.u32(e,0)], token: Native.text(Native.pointer(e,24)), sequence: Native.u64(e,8), timestamp: Native.u64(e,16), fields: fields,
128
+ metadata: %i[decode_error decode_complete parser_status device].each_with_index.to_h { |name,index| [name,Native.u32(e,44+index*4)] }.freeze, blocked: Native.u32(e,4) != 0)
129
+ end
130
+ @issued_fields = events.map(&:fields); @current = Batch.new(events)
131
+ end
132
+ end
133
+ def validate(batch)
134
+ active
135
+ raise ArgumentError, 'Stale or foreign batch' unless batch.equal?(@current) && @header && Native.u64(@header,0) == @ticket && batch.size == Native.u32(@header,8)
136
+ batch.each_with_index { |event,index| raise ArgumentError, 'Do not replace/reorder fields' unless event.fields.equal?(@issued_fields[index]) }
137
+ end
138
+ private :validate
139
+ def send(batch)
140
+ @mutex.synchronize do
141
+ validate(batch)
142
+ batch.each_with_index do |event,i|
143
+ fs = Native.pointer(@views+i*64,32)
144
+ event.fields.each_with_index do |field,j|
145
+ raise ArgumentError, 'Field value must fit signed 64 bits' unless field.value.is_a?(Integer) && field.value.between?(-(1<<63),(1<<63)-1)
146
+ raise ArgumentError, 'Normalized values must be finite' unless field.normalized.is_a?(Numeric) && field.normalized.finite?
147
+ raise ArgumentError, 'Do not resize binary/report data' unless field.data.is_a?(String) && field.data.bytesize == Native.u32(fs+j*120,112)
148
+ end
149
+ end
150
+ batch.each_with_index do |event,i|
151
+ e = @views+i*64; e[4,4] = [event.blocked ? 1 : 0].pack('L<'); fs = Native.pointer(e,32)
152
+ event.fields.each_with_index do |field,j|
153
+ f=fs+j*120;f[8,8]=[field.value].pack('q<');f[48,8]=[field.normalized].pack('E')
154
+ Native.pointer(f,104)[0,field.data.bytesize]=field.data unless field.data.empty?
155
+ end
156
+ end
157
+ checked(:send,active,Native.signed64(@ticket))
158
+ end
159
+ end
160
+ def discard(batch) = @mutex.synchronize { validate(batch); checked(:discard,active,Native.signed64(@ticket)); nil }
161
+ def cancel = (checked(:cancel,active); nil)
162
+ def descriptor(token, report: false)
163
+ raise ArgumentError, 'Token contains NUL' if token.include?("\0")
164
+ @mutex.synchronize do
165
+ out=Native.buffer(24);checked(:descriptor,active,token+"\0",report ? 1 : 0,out);length=Native.u32(out,8)
166
+ { applicable: Native.u32(out,0)!=0, format: Native.u32(out,4), data: length.zero? ? ''.b : Native.pointer(out,16)[0,length] }
167
+ end
168
+ end
169
+ def inject(kind, state = {}, token: nil, report: nil)
170
+ k=KINDS.index(kind);raise ArgumentError, 'Unknown input kind' unless k && k>0
171
+ @mutex.synchronize do
172
+ count=state.size+(report.nil? ? 0 : 1);fields=Native.buffer(count*120);strings=[]
173
+ state.each_with_index do |(name,value),i|
174
+ raise ArgumentError, 'Invalid state name or value' unless name.is_a?(String) && !name.include?("\0") && value.is_a?(Integer) && value.between?(-(1<<63),(1<<63)-1)
175
+ name_pointer=Fiddle::Pointer[name+"\0"];strings << name_pointer;f=fields+i*120;f[0,8]=[name_pointer.to_i].pack('Q<');f[8,8]=[value].pack('q<')
176
+ end
177
+ unless report.nil?
178
+ raise ArgumentError, 'Report must be a byte string' unless report.is_a?(String)
179
+ name=Fiddle::Pointer["report\0"];data=Fiddle::Pointer[report];strings.push(name,data);f=fields+state.size*120;f[0,8]=[name.to_i].pack('Q<');f[56,4]=[7].pack('L<');f[104,8]=[data.to_i].pack('Q<');f[112,4]=[report.bytesize].pack('L<')
180
+ end
181
+ event=Native.buffer(64);event[0,4]=[k].pack('L<');event[32,8]=[fields.to_i].pack('Q<');event[40,4]=[count].pack('L<')
182
+ if token
183
+ raise ArgumentError, 'Token contains NUL' if token.include?("\0")
184
+ t=Fiddle::Pointer[token+"\0"];strings << t;event[24,8]=[t.to_i].pack('Q<')
185
+ end
186
+ checked(:inject,active,event,1)
187
+ end
188
+ end
189
+ def close
190
+ return if @session.zero?
191
+ begin; cancel; rescue NativeError; end
192
+ @mutex.synchronize do
193
+ unless @session.zero?
194
+ id=@session;@session=0;@current=nil;@header=nil;checked(:close,Native.signed64(id))
195
+ end
196
+ end
197
+ nil
198
+ end
199
+ def self.open(**options)
200
+ io=new(**options)
201
+ return io unless block_given?
202
+ begin; yield io; ensure; io.close; end
203
+ end
204
+ end
205
+ end
Binary file
data/test/protocol.rb ADDED
@@ -0,0 +1,32 @@
1
+ require 'autohotinterceptor'
2
+ require 'fiddle'
3
+ sdk=AutoHotInterceptor
4
+ fixture=ARGV.fetch(0)
5
+ mode=Fiddle::Function.new(Fiddle::Handle.new(fixture)['autohotinterceptor_test_mode'],[Fiddle::TYPE_INT],Fiddle::TYPE_VOID)
6
+ def check(value);raise 'Product Ruby SDK contract failed' unless value;end
7
+ kinds=["keyboard", "mouse"]
8
+ sdk::Interceptor.open(library_path: fixture) do |client|
9
+ client.initialize_devices
10
+ check(client.info[:input_mask]==3 && client.info[:controller_mask]==0)
11
+ check(client.devices.map{|d|d[:kind]}.sort==kinds.sort)
12
+ batch=client.receive(timeout: 0);check(batch.map(&:kind).sort==kinds.sort && batch.all?{|e|e.sequence>(1<<63)})
13
+ batch.each do |event|
14
+ event.field('scan_code').value=0x30 if event.kind=='keyboard'
15
+ event.field('y').value=-7 if event.kind=='mouse'
16
+ end
17
+ check(client.send(batch)==kinds.size);check(client.send(batch)==0)
18
+ current=client.receive(timeout: 0)
19
+ begin;client.send(batch);raise 'Stale batch accepted';rescue ArgumentError;end
20
+ current[0].blocked=true;check(client.send(current)==kinds.size-1)
21
+ retry_batch=client.receive(timeout: 0);mode.call(2)
22
+ begin;client.send(retry_batch);raise 'Partial send missing';rescue sdk::NativeError=>e;check(e.code==170 && e.accepted==1);end
23
+ check(client.send(retry_batch)==kinds.size-1);mode.call(0)
24
+ client.discard(client.receive(timeout: 0))
25
+ (sdk::KINDS.drop(1)-kinds).each do |kind|
26
+ begin;client.inject(kind);raise 'Unsupported family accepted';rescue sdk::NativeError=>e;check(e.code==50);end
27
+ end
28
+ mode.call(1);reader=Thread.new{check(client.receive(timeout: 2000).size.zero?)}
29
+ sleep 0.03;client.cancel;reader.value;mode.call(0)
30
+ end
31
+ sdk::Interceptor.open { |_| }
32
+ puts 'AutoHotInterceptor Ruby SDK: product masks, edits, blocking, partial sends, stale batches, uint64, cancellation and production DLL passed'
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: autohotinterceptor
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - TitanCSA
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: fiddle
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '2'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '1.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '2'
32
+ description: 'Native bindings to the product SDK: receive, edit, send, descriptors,
33
+ injection and cancellation. Matching driver installed separately.'
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - LICENSE
39
+ - README.md
40
+ - docs/API.md
41
+ - examples/info.rb
42
+ - lib/autohotinterceptor.rb
43
+ - native/AutoHotInterceptorBridge.dll
44
+ - test/protocol.rb
45
+ homepage: https://github.com/TitanCSA/AutoHotInterceptor
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ source_code_uri: https://github.com/TitanCSA/AutoHotInterceptor/tree/main/AutoHotInterceptor-Ruby-rubygems
50
+ documentation_uri: https://github.com/TitanCSA/AutoHotInterceptor/tree/main/AutoHotInterceptor-Ruby-rubygems/docs
51
+ allowed_push_host: https://rubygems.org
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '3.1'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 4.0.20
67
+ specification_version: 4
68
+ summary: Direct Windows x64 AutoHotInterceptor input SDK
69
+ test_files: []