wineole 0.1.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: 6bdd93426e11cfe60fca09d51d9f12e32c5ea984767e18ec271a2ad6a471aba9
4
+ data.tar.gz: a04c2be3c95c7283e06327bea2b4e7725f4794351ab21439a8593fc0c4798693
5
+ SHA512:
6
+ metadata.gz: 3cdd18036f72e3bd4d149c0fa71e9aab446cafa325936711bc2e5049584000431b9a224cd3c12cf508b6ac6d8c6f536039d4d86ea0adb94711dfc1f6f3e51dd2
7
+ data.tar.gz: 149e5ffe74bab247cf3849137956b425424a75598e2c6d24c54b36ecdabb6a2b8462eb58e12a73e75c7f7d3ae9da1ba170b1192c6249be00829bf4fbd29806d0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 firelzrd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,153 @@
1
+ require 'socket'
2
+ require 'json'
3
+ require 'rbconfig'
4
+ require 'tmpdir'
5
+ require_relative 'errors'
6
+ require_relative 'proxy'
7
+
8
+ module WineOLE
9
+ class Client
10
+ WINDOWS = !(RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/).nil?
11
+
12
+ ARCH_TRIPLES = {
13
+ 'x86_64' => 'x86_64-pc-windows-gnu',
14
+ 'i386' => 'i686-pc-windows-gnu',
15
+ 'i686' => 'i686-pc-windows-gnu',
16
+ }.freeze
17
+
18
+ def self.bridge_path_for_arch(host_cpu)
19
+ triple = ARCH_TRIPLES[host_cpu] || (ARCH_TRIPLES['i686'] if host_cpu =~ /^i.86$/)
20
+ if triple.nil?
21
+ raise Error, "no prebuilt wineole-bridge binary for host architecture #{host_cpu.inspect} " \
22
+ "(available: #{ARCH_TRIPLES.values.uniq.join(', ')})"
23
+ end
24
+ File.expand_path("../../wineole-bridge-dist/#{triple}/wineole-bridge.exe", __dir__)
25
+ end
26
+
27
+ def self.default_bridge_path
28
+ bridge_path_for_arch(RbConfig::CONFIG['host_cpu'])
29
+ end
30
+
31
+ DEFAULT_SPAWNER = lambda do |port|
32
+ # wineole-bridge.exe is a native Windows binary either way -- `wine`
33
+ # is only needed to run it on a non-Windows host. On Windows itself
34
+ # there is no `wine` command, so running it under one would fail
35
+ # immediately (Errno::ENOENT) rather than run natively as it should.
36
+ # The null device path is also platform-specific ('/dev/null' does
37
+ # not exist on Windows).
38
+ command = WINDOWS ? [default_bridge_path] : ['wine', default_bridge_path]
39
+ null_device = WINDOWS ? 'NUL' : '/dev/null'
40
+ Process.spawn(*command, port.to_s, %i[out err] => null_device)
41
+ end
42
+
43
+ def self.default_lockfile(port)
44
+ # The lock is per-port, not global: two bridges on two ports are
45
+ # entirely independent, and a single shared lock would make a client
46
+ # starting one wait on a client starting the other (design doc §7.1
47
+ # step 2). Dir.tmpdir rather than a hardcoded '/tmp' resolves
48
+ # correctly on Windows (%TEMP%) as well as every POSIX platform.
49
+ File.join(Dir.tmpdir, "wineole-bridge.#{port}.lock")
50
+ end
51
+
52
+ def self.open(host: '127.0.0.1', port: 47800, spawner: DEFAULT_SPAWNER,
53
+ lockfile: nil, timeout: 15, token: nil)
54
+ lockfile ||= default_lockfile(port)
55
+
56
+ socket = try_connect(host, port)
57
+ return handshake(new(socket), token) if socket
58
+
59
+ File.open(lockfile, File::CREAT | File::RDWR, 0o644) do |lock|
60
+ lock.flock(File::LOCK_EX)
61
+
62
+ socket = try_connect(host, port)
63
+ return handshake(new(socket), token) if socket
64
+
65
+ spawner.call(port)
66
+ deadline = Time.now + timeout
67
+ loop do
68
+ socket = try_connect(host, port)
69
+ return handshake(new(socket), token) if socket
70
+ raise Error, "wineole-bridge did not start within #{timeout}s" if Time.now > deadline
71
+ sleep 0.2
72
+ end
73
+ end
74
+ end
75
+
76
+ # Design doc §7.1 step 1: ping after connecting, to confirm protocol
77
+ # compatibility before using the connection. This is also the only place a
78
+ # token can be presented — a bridge started with WINEOLE_TOKEN rejects
79
+ # every non-loopback request until a matching ping has been accepted, so
80
+ # without this a tokened bridge is unreachable from this client.
81
+ #
82
+ # A failed ping (bad token, incompatible bridge) raises out of
83
+ # `Client#call` as a RemoteError; it must not be swallowed.
84
+ def self.handshake(client, token)
85
+ client.call('ping', token ? {token: token} : {})
86
+ client
87
+ rescue StandardError
88
+ client.close
89
+ raise
90
+ end
91
+ private_class_method :handshake
92
+
93
+ def self.try_connect(host, port)
94
+ TCPSocket.new(host, port)
95
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH
96
+ nil
97
+ end
98
+ private_class_method :try_connect
99
+
100
+ # A proc to close over the raw socket for ObjectSpace.define_finalizer.
101
+ # It deliberately does NOT close over `self` (the Client instance) --
102
+ # capturing the instance being finalized would keep it permanently
103
+ # reachable through ObjectSpace's own finalizer table, and it could
104
+ # never actually be collected. Capturing only the socket avoids this.
105
+ def self.finalizer(socket)
106
+ proc do
107
+ begin
108
+ socket.close
109
+ rescue StandardError
110
+ nil
111
+ end
112
+ end
113
+ end
114
+
115
+ def initialize(socket)
116
+ @socket = socket
117
+ @next_id = 0
118
+ @mutex = Mutex.new
119
+ ObjectSpace.define_finalizer(self, self.class.finalizer(socket))
120
+ end
121
+
122
+ def call(method, params = {})
123
+ @mutex.synchronize do
124
+ id = (@next_id += 1)
125
+ @socket.write(JSON.generate({id: id, method: method, params: params}) + "\n")
126
+ line = @socket.gets
127
+ raise ProtocolError, 'connection closed' if line.nil?
128
+ response = JSON.parse(line)
129
+ unless response['id'] == id
130
+ raise ProtocolError, "id mismatch: expected #{id}, got #{response['id']}"
131
+ end
132
+ raise RemoteError.new(response['error']['class'], response['error']['message']) if response['error']
133
+ response['result']
134
+ end
135
+ end
136
+
137
+ def close
138
+ @socket.close
139
+ end
140
+
141
+ def create(class_name)
142
+ Proxy.create(class_name, self)
143
+ end
144
+
145
+ def connect(class_name)
146
+ Proxy.connect(class_name, self)
147
+ end
148
+
149
+ def connect_or_create(class_name)
150
+ Proxy.connect_or_create(class_name, self)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,15 @@
1
+ module WineOLE
2
+ class Error < StandardError; end
3
+ class NotSerializableError < Error; end
4
+ class StaleReferenceError < Error; end
5
+ class ProtocolError < Error; end
6
+
7
+ class RemoteError < Error
8
+ attr_reader :remote_class
9
+
10
+ def initialize(remote_class, message)
11
+ @remote_class = remote_class
12
+ super("#{remote_class}: #{message}")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,148 @@
1
+ require 'time'
2
+ require_relative 'errors'
3
+
4
+ module WineOLE
5
+ class Proxy
6
+ def self.create(class_name, client)
7
+ handle = client.call('create', {class_name: class_name})['$ole_ref']
8
+ new(client, session_id: client.object_id, handle: handle, created: true)
9
+ end
10
+
11
+ def self.connect(class_name, client)
12
+ handle = client.call('connect', {class_name: class_name})['$ole_ref']
13
+ new(client, session_id: client.object_id, handle: handle, created: false)
14
+ end
15
+
16
+ def self.connect_or_create(class_name, client)
17
+ result = client.call('connect_or_create', {class_name: class_name})
18
+ new(client, session_id: client.object_id, handle: result['$ole_ref'], created: result['created'])
19
+ end
20
+
21
+ def self.wrap(client, session_id, ole_ref)
22
+ new(client, session_id: session_id, handle: ole_ref, created: nil)
23
+ end
24
+
25
+ # Ruby's implicit-conversion protocol. The interpreter probes these on
26
+ # arbitrary objects behind the scenes — `puts`/`p` and `Array()` look for
27
+ # `to_ary`/`to_a`, multiple assignment looks for `to_ary`, string
28
+ # interpolation and `Kernel#String` look for `to_str`, `IO` methods look
29
+ # for `to_io`, `&obj` looks for `to_proc`, `Integer()`/`format('%d', _)`/
30
+ # `"ab" * _`/array indexing look for `to_int`/`to_i`, `Float()` looks for
31
+ # `to_f`, `File.open` looks for `to_path`, and `1 + _` looks for `coerce`.
32
+ # Forwarding these to the remote object turns every `puts proxy` into a
33
+ # round trip that ends in DISP_E_UNKNOWNNAME, so they must behave exactly
34
+ # as they would on a plain Ruby object that doesn't define them:
35
+ # NoMethodError, and `respond_to?` == false (which is what stops the
36
+ # interpreter calling them at all).
37
+ IMPLICIT_CONVERSIONS = %i[
38
+ to_ary to_a to_hash to_str to_io to_proc
39
+ to_int to_i to_f to_path coerce
40
+ ].freeze
41
+
42
+ attr_reader :ole_handle, :ole_session_id
43
+
44
+ def initialize(client, session_id:, handle:, created:)
45
+ @client = client
46
+ @ole_session_id = session_id
47
+ @ole_handle = handle
48
+ @created = created
49
+ end
50
+
51
+ def method_missing(name, *args)
52
+ return super if IMPLICIT_CONVERSIONS.include?(name)
53
+
54
+ check_live!
55
+ named = args.last.is_a?(Hash) ? args.pop : {}
56
+ invoke(name.to_s, args, named)
57
+ end
58
+
59
+ def respond_to_missing?(name, _include_private = false)
60
+ !IMPLICIT_CONVERSIONS.include?(name)
61
+ end
62
+
63
+ def [](*args)
64
+ check_live!
65
+ invoke('', args, {})
66
+ end
67
+
68
+ # Was this instance freshly created by connect_or_create's
69
+ # fallback, or attached to something already running? `true` for
70
+ # `.create`, `false` for `.connect`, whatever the bridge reported for
71
+ # `.connect_or_create`, and `nil` for anything derived from another
72
+ # Proxy (e.g. `xl.Worksheets`) — attach-vs-create isn't a meaningful
73
+ # question for those.
74
+ def ole_created?
75
+ @created
76
+ end
77
+
78
+ def ole_release
79
+ @client.call('release', {handle: @ole_handle})
80
+ end
81
+
82
+ def ole_const_load
83
+ check_live!
84
+ @client.call('const_load', {handle: @ole_handle})
85
+ end
86
+
87
+ def marshal_dump
88
+ raise NotSerializableError, 'WineOLE::Proxy references are connection-scoped and cannot be persisted'
89
+ end
90
+
91
+ # Deliberately bare and public, unlike every other meta-method here
92
+ # (which are `ole_`-prefixed to avoid shadowing a same-named remote COM
93
+ # member): an explicit escape hatch for the rare case a COM object
94
+ # really does define e.g. an `ole_handle` member, matching real Ruby
95
+ # WIN32OLE's own choice to keep `invoke` public and unprefixed.
96
+ def invoke(name, args, named)
97
+ check_live!
98
+ params = {
99
+ handle: @ole_handle,
100
+ name: name,
101
+ args: args.map { |a| encode(a) },
102
+ named: named.transform_values { |v| encode(v) },
103
+ }
104
+ decode(@client.call('invoke', params))
105
+ end
106
+
107
+ private
108
+
109
+ def check_live!
110
+ return if @ole_session_id == @client.object_id
111
+
112
+ raise StaleReferenceError, 'this reference belongs to a previous connection'
113
+ end
114
+
115
+ def encode(value)
116
+ case value
117
+ when Proxy
118
+ # The argument's own liveness is not enough: a Proxy belonging to a
119
+ # *different* Client is live from its own point of view, yet its
120
+ # handle id means nothing in this connection's handle table (or,
121
+ # worse, means something unrelated). Check it against the receiver's
122
+ # client, which is the connection the id is about to be sent over.
123
+ unless value.ole_session_id == @client.object_id
124
+ raise StaleReferenceError,
125
+ 'this reference belongs to a different connection and cannot be ' \
126
+ 'passed as an argument here'
127
+ end
128
+ {'$ole_ref' => value.ole_handle}
129
+ when Hash
130
+ value.transform_values { |v| encode(v) }
131
+ when Array
132
+ value.map { |v| encode(v) }
133
+ else
134
+ value
135
+ end
136
+ end
137
+
138
+ def decode(value)
139
+ if value.is_a?(Hash) && value.key?('$ole_ref')
140
+ Proxy.wrap(@client, @client.object_id, value['$ole_ref'])
141
+ elsif value.is_a?(Hash) && value['$type'] == 'time'
142
+ Time.iso8601(value['iso8601'])
143
+ else
144
+ value
145
+ end
146
+ end
147
+ end
148
+ end
data/lib/wineole.rb ADDED
@@ -0,0 +1,57 @@
1
+ require_relative 'wineole/errors'
2
+ require_relative 'wineole/client'
3
+ require_relative 'wineole/proxy'
4
+
5
+ module WineOLE
6
+ @default_client = nil
7
+ @mutex = Mutex.new
8
+
9
+ # Opens a client the same way Client.open does, and also makes it the
10
+ # module's implicit default -- a subsequent WineOLE.create/.connect call
11
+ # uses this client rather than lazily creating a separate zero-config one.
12
+ # Calling .open again replaces the implicit default without closing
13
+ # whatever it previously pointed to -- the caller owns the returned
14
+ # client and is responsible for closing it if not relying on the
15
+ # implicit default's eventual GC-finalizer cleanup.
16
+ def self.open(...)
17
+ client = Client.open(...)
18
+ @mutex.synchronize { @default_client = client }
19
+ client
20
+ end
21
+
22
+ # The lazily-initialized implicit default client used by .create/.connect
23
+ # when nothing was ever explicitly opened via WineOLE.open. Thread-safe:
24
+ # the fast path avoids locking once initialized; only the first caller(s)
25
+ # racing on a nil default contend for the lock, and only the actual
26
+ # winner calls Client.open -- everyone else sees it already set once they
27
+ # acquire the lock, and the `||=` means they never call Client.open again.
28
+ def self.default_client
29
+ return @default_client if @default_client
30
+
31
+ @mutex.synchronize { @default_client ||= Client.open }
32
+ end
33
+ private_class_method :default_client
34
+
35
+ def self.create(class_name)
36
+ default_client.create(class_name)
37
+ end
38
+
39
+ def self.connect(class_name)
40
+ default_client.connect(class_name)
41
+ end
42
+
43
+ def self.connect_or_create(class_name)
44
+ default_client.connect_or_create(class_name)
45
+ end
46
+
47
+ # Closes the implicit default client, if one exists, and clears it so the
48
+ # next .create/.connect lazily opens a fresh one. Mainly for test hygiene
49
+ # (clearing state between test cases); also usable to release the
50
+ # implicit default early in a long-running process.
51
+ def self.close
52
+ @mutex.synchronize do
53
+ @default_client&.close
54
+ @default_client = nil
55
+ end
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wineole
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - firelzrd
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ A bridge (Rust, cross-compiled to run under Wine) plus a Ruby client
14
+ that lets a Linux Ruby process drive Win32OLE/COM automation of
15
+ Windows applications running under Wine, over a JSON Lines TCP
16
+ protocol, without requiring a Windows build of Ruby.
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - lib/wineole.rb
23
+ - lib/wineole/client.rb
24
+ - lib/wineole/errors.rb
25
+ - lib/wineole/proxy.rb
26
+ - wineole-bridge-dist/aarch64-pc-windows-gnullvm/wineole-bridge.exe
27
+ - wineole-bridge-dist/i686-pc-windows-gnu/wineole-bridge.exe
28
+ - wineole-bridge-dist/x86_64-pc-windows-gnu/wineole-bridge.exe
29
+ homepage: https://github.com/firelzrd/wineole
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ source_code_uri: https://github.com/firelzrd/wineole
34
+ bug_tracker_uri: https://github.com/firelzrd/wineole/issues
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.0'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 4.0.3
50
+ specification_version: 4
51
+ summary: Drive Win32OLE/COM automation of Wine-hosted Windows apps from Ruby
52
+ test_files: []