wineole 0.1.0 → 0.2.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.
@@ -0,0 +1,144 @@
1
+ require_relative 'vba'
2
+
3
+ module WineOLE
4
+ module MSOffice
5
+ # A named span of code inside a VBA module, delimited by comment markers.
6
+ #
7
+ # The wrapper owns the span, never the module. A module may be one the
8
+ # caller wrote in, or one that cannot be deleted at all (ThisWorkbook, a
9
+ # worksheet), so "replace the module" is not available and would be too
10
+ # blunt even where it is.
11
+ module VBABlock
12
+ NAME = /\A[A-Za-z0-9_-]+\z/.freeze
13
+
14
+ # Matches any wineole marker line, open or close, for any name -- not
15
+ # just the one being written. Used to refuse a payload that would be
16
+ # indistinguishable from the wrapper's own delimiters.
17
+ MARKER_LINE = /\A'<\/?wineole:[A-Za-z0-9_-]+>\z/.freeze
18
+
19
+ def self.open_marker(name) = "'<wineole:#{name}>"
20
+ def self.close_marker(name) = "'</wineole:#{name}>"
21
+
22
+ # Replaces the block of this name if it is there, adds it if not.
23
+ def self.write(code_module, name, code)
24
+ check_name(name)
25
+ check_representable(code)
26
+ check_payload(code)
27
+ remove(code_module, name)
28
+ code_module.AddFromString(
29
+ "#{open_marker(name)}\n#{code.chomp}\n#{close_marker(name)}\n"
30
+ )
31
+ nil
32
+ end
33
+
34
+ # False when there was nothing of this name to remove. Otherwise the
35
+ # module's remaining lines, as an Array -- so a caller that needs to
36
+ # know whether the module is now blank does not have to fetch the
37
+ # whole body a second time to ask.
38
+ #
39
+ # Block names match case-insensitively, as VBA identifiers do: two
40
+ # blocks named `main` and `Main` would hold procedures that collide,
41
+ # and VBA answers "Ambiguous name detected" for the whole module from
42
+ # then on. So `Main` replaces (and removes) a block written as `main`.
43
+ def self.remove(code_module, name)
44
+ check_name(name)
45
+ lines = body(code_module)
46
+ return false if lines.empty?
47
+
48
+ first = lines.index { |l| l.strip.casecmp?(open_marker(name)) }
49
+ last = lines.index { |l| l.strip.casecmp?(close_marker(name)) }
50
+
51
+ if first.nil?
52
+ if last
53
+ raise ArgumentError,
54
+ "the #{name.inspect} block in this module has a closing marker " \
55
+ 'with no matching opening one -- the module is already ' \
56
+ 'corrupted; refusing to guess what to remove'
57
+ end
58
+ return false
59
+ end
60
+
61
+ if last.nil? || last < first
62
+ raise ArgumentError,
63
+ "the #{name.inspect} block in this module has no closing marker -- " \
64
+ 'refusing to guess where it ends'
65
+ end
66
+
67
+ code_module.DeleteLines(first + 1, last - first + 1)
68
+ lines[0...first] + lines[(last + 1)..]
69
+ end
70
+
71
+ # Nothing but whitespace. Not CountOfLines == 0: a module emptied of
72
+ # its blocks still reports the newlines that held them.
73
+ def self.blank?(code_module)
74
+ blank_lines?(body(code_module))
75
+ end
76
+
77
+ # The same emptiness rule #blank? uses, applied to lines the caller
78
+ # already has (typically the Array #remove just handed back) instead
79
+ # of fetching the body again.
80
+ def self.blank_lines?(lines)
81
+ lines.all? { |l| l.strip.empty? }
82
+ end
83
+
84
+ # One round trip, whatever the module's length. Excel reports 0 lines
85
+ # for a module never written to, and Lines(1, 0) is not a legal call.
86
+ def self.body(code_module)
87
+ count = code_module.CountOfLines.to_i
88
+ return [] if count.zero?
89
+
90
+ code_module.Lines(1, count).split(/\r?\n/)
91
+ end
92
+ private_class_method :body
93
+
94
+ # A module's text is held in the system ANSI codepage, not Unicode --
95
+ # measured, not assumed: on a CP932 host `café` comes back `cafe`,
96
+ # `✓` comes back `?`, and simplified Chinese comes back part `?`.
97
+ # Japanese survives only because CP932 can represent it, which is why
98
+ # an earlier measurement using Japanese alone concluded, wrongly, that
99
+ # this path carried Unicode.
100
+ #
101
+ # So this path is bound by exactly the same codepage as import_vba,
102
+ # and gets the same rule: refuse rather than substitute. Silently
103
+ # dropping an accent is the failure this whole phase exists to remove.
104
+ #
105
+ # ASCII skips the check, which is almost every call -- resolving the
106
+ # codepage costs a `wine reg` invocation.
107
+ def self.check_representable(code)
108
+ return if code.ascii_only?
109
+
110
+ code.encode(VBA.codepage)
111
+ rescue ::Encoding::UndefinedConversionError => e
112
+ VBA.unrepresentable!(e.error_char, 'this code')
113
+ end
114
+ private_class_method :check_representable
115
+
116
+ def self.check_name(name)
117
+ return if name.is_a?(::String) && name.match?(NAME)
118
+
119
+ raise ArgumentError,
120
+ "a block name must match #{NAME.inspect} -- it goes inside a VBA " \
121
+ "comment marker, so it cannot contain spaces, '>' or newlines. " \
122
+ "Got #{name.inspect}"
123
+ end
124
+ private_class_method :check_name
125
+
126
+ # A code body containing a line that is itself a wineole marker would
127
+ # be indistinguishable from a real one once written: #remove would
128
+ # find the caller's accidental marker instead of its own, delete up to
129
+ # the wrong place, and leave the rest as permanent garbage. The
130
+ # wrapper controls what it writes, so refusing the payload up front is
131
+ # what keeps that state from ever existing.
132
+ def self.check_payload(code)
133
+ offending = code.split(/\r?\n/).find { |line| line.strip.match?(MARKER_LINE) }
134
+ return if offending.nil?
135
+
136
+ raise ArgumentError,
137
+ "the code being written contains a line that is itself a wineole " \
138
+ "marker (#{offending.strip.inspect}) -- this cannot be told apart " \
139
+ 'from the wrapper\'s own markers, so it is refused'
140
+ end
141
+ private_class_method :check_payload
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,28 @@
1
+ # The bundled Microsoft Office wrapper: Address, Paths, Range, Sheet, Book,
2
+ # Excel, and the Controls and Forms wrappers, all under WineOLE::MSOffice.
3
+ #
4
+ # Deliberately NOT required from lib/wineole.rb. The core layer is a
5
+ # general-purpose COM bridge; this is Office-specific, and it is where a
6
+ # Word and a PowerPoint wrapper will land too, so it will only grow.
7
+ # Someone who wants the bridge should not have to carry an Excel wrapper to
8
+ # get it.
9
+ #
10
+ # Defines no top-level constant. An earlier draft aliased WineOLE::MSOffice
11
+ # to a root-level MSOffice for code written against the older msoffice.rb;
12
+ # reaching into the root namespace from a library is intrusive enough that
13
+ # the alias was not worth the two failure modes it brought with it (a
14
+ # silent no-op when something else already defined MSOffice, and a test
15
+ # whose result depended on process-wide load order).
16
+ require_relative 'msoffice/address'
17
+ require_relative 'msoffice/vba'
18
+ require_relative 'msoffice/vba_block'
19
+ require_relative 'msoffice/vba_api'
20
+ require_relative 'msoffice/color'
21
+ require_relative 'msoffice/format'
22
+ require_relative 'msoffice/paths'
23
+ require_relative 'msoffice/range'
24
+ require_relative 'msoffice/sheet'
25
+ require_relative 'msoffice/controls'
26
+ require_relative 'msoffice/forms'
27
+ require_relative 'msoffice/book'
28
+ require_relative 'msoffice/excel'
data/lib/wineole/proxy.rb CHANGED
@@ -1,21 +1,93 @@
1
1
  require 'time'
2
+ require 'date'
2
3
  require_relative 'errors'
4
+ # Stated here rather than left to wineole.rb: `ole_events` below names the
5
+ # constant, so anything that loads this file alone -- proxy_test.rb does --
6
+ # must get a working method rather than a NameError the first time it is
7
+ # called. events.rb requires nothing from here (it reaches Proxy at call
8
+ # time, not load time), so there is no cycle.
9
+ require_relative 'events'
3
10
 
4
11
  module WineOLE
5
12
  class Proxy
6
- def self.create(class_name, client)
7
- handle = client.call('create', {class_name: class_name})['$ole_ref']
13
+ def self.create(class_name, client, cleanup: nil)
14
+ params = {class_name: class_name}
15
+ params[:cleanup] = build_cleanup(cleanup) if cleanup
16
+ handle = client.call('create', params)['$ole_ref']
17
+ register_cleanup(client, handle, cleanup)
8
18
  new(client, session_id: client.object_id, handle: handle, created: true)
9
19
  end
10
20
 
11
- def self.connect(class_name, client)
12
- handle = client.call('connect', {class_name: class_name})['$ole_ref']
21
+ def self.connect(class_name, client, cleanup: nil)
22
+ params = {class_name: class_name}
23
+ params[:cleanup] = build_cleanup(cleanup) if cleanup
24
+ handle = client.call('connect', params)['$ole_ref']
25
+ register_cleanup(client, handle, cleanup)
13
26
  new(client, session_id: client.object_id, handle: handle, created: false)
14
27
  end
15
28
 
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'])
29
+ def self.connect_or_create(class_name, client, cleanup: nil)
30
+ params = {class_name: class_name}
31
+ params[:cleanup] = build_cleanup(cleanup) if cleanup
32
+ result = client.call('connect_or_create', params)
33
+ handle = result['$ole_ref']
34
+ register_cleanup(client, handle, cleanup)
35
+ new(client, session_id: client.object_id, handle: handle, created: result['created'])
36
+ end
37
+
38
+ # `cleanup` is `{ steps: [[name, *args], ...], on_cleanup: proc_or_nil }`
39
+ # in Ruby terms; the wire wants `{ steps: [{name:, args:}, ...], callback: bool }`.
40
+ # `callback` tells the bridge whether to hold the root open and emit a
41
+ # `$cleanup` event for a registered closure, or just run the steps and
42
+ # release outright -- so its value comes from whether `on_cleanup` is
43
+ # present, not from anything the caller states separately.
44
+ def self.build_cleanup(cleanup)
45
+ steps = (cleanup[:steps] || []).map do |name, *args|
46
+ {name: name, args: args}
47
+ end
48
+ {steps: steps, callback: !cleanup[:on_cleanup].nil?}
49
+ end
50
+ private_class_method :build_cleanup
51
+
52
+ # Register the client closure (if any) so the dispatcher can deliver
53
+ # $cleanup for this root handle. The real Dispatcher#register_cleanup
54
+ # lands in a later task; this only needs `client.dispatcher` to answer to
55
+ # it, which is exactly what the real Client already does.
56
+ def self.register_cleanup(client, handle, cleanup)
57
+ return unless cleanup && cleanup[:on_cleanup]
58
+
59
+ client.dispatcher.register_cleanup(handle, cleanup[:on_cleanup])
60
+ end
61
+ private_class_method :register_cleanup
62
+
63
+ # One wire value, in Ruby terms.
64
+ #
65
+ # On the class rather than private to an instance because two different
66
+ # holders of a client need it: an invoke's RESULT, and an event's
67
+ # ARGUMENTS (WineOLE::Events#build_args). A second copy of this walk over
68
+ # there is how the same tagged value ends up a Time when a call returns it
69
+ # and a raw {"$type" => "time"} Hash when an event carries it -- and how a
70
+ # nested $ole_ref reaches a callback unwrapped.
71
+ #
72
+ # Recursive, because a bulk range read comes back as an array of rows and
73
+ # the values needing conversion sit inside it, not at the top level. A
74
+ # non-recursive decode would hand back raw {"$type" => "time"} hashes for
75
+ # every date cell in the range.
76
+ def self.decode(client, value)
77
+ case value
78
+ when Array
79
+ value.map { |v| decode(client, v) }
80
+ when Hash
81
+ if value.key?('$ole_ref')
82
+ wrap(client, client.object_id, value['$ole_ref'])
83
+ elsif value['$type'] == 'time'
84
+ Time.iso8601(value['iso8601'])
85
+ else
86
+ value.transform_values { |v| decode(client, v) }
87
+ end
88
+ else
89
+ value
90
+ end
19
91
  end
20
92
 
21
93
  def self.wrap(client, session_id, ole_ref)
@@ -75,8 +147,38 @@ module WineOLE
75
147
  @created
76
148
  end
77
149
 
150
+ # Tells the bridge to leave this root instance running rather than
151
+ # closing it when the connection goes away -- the counterpart to a
152
+ # `cleanup:` closure a caller wants to run later, on its own terms,
153
+ # rather than as part of this process's teardown.
154
+ def ole_leave_open
155
+ @client.call('leave_open', {handle: @ole_handle})
156
+ nil
157
+ end
158
+
78
159
  def ole_release
79
- @client.call('release', {handle: @ole_handle})
160
+ result = @client.call('release', {handle: @ole_handle})
161
+ # A client closure must run before this handle is actually gone: the
162
+ # bridge answers with the $cleanup sequence number instead of
163
+ # releasing outright, and this blocks until the dispatcher has
164
+ # delivered it and the release_event that follows. See
165
+ # Client#await_cleanup.
166
+ if result.is_a?(Hash) && (seq = result['cleanup'])
167
+ @client.await_cleanup(seq)
168
+ end
169
+ nil
170
+ end
171
+
172
+ # COM events for this object. Named with the `ole_` prefix like every
173
+ # other bookkeeping method here: a Proxy forwards unknown names straight
174
+ # to COM, so a bare `events` would shadow a real `Events` member.
175
+ #
176
+ # Memoized, because the Events owns a dispatcher thread and a bridge-side
177
+ # subscription set: a fresh one per call would mean `on` and the `off`
178
+ # that is meant to undo it talked to different objects.
179
+ def ole_events
180
+ check_live!
181
+ @ole_events ||= Events.new(@client, @ole_handle)
80
182
  end
81
183
 
82
184
  def ole_const_load
@@ -126,6 +228,21 @@ module WineOLE
126
228
  'passed as an argument here'
127
229
  end
128
230
  {'$ole_ref' => value.ole_handle}
231
+ when Time
232
+ # The same tag the receive side emits for VT_DATE. The wall clock is
233
+ # sent as-is: a VT_DATE carries no timezone, so converting here would
234
+ # silently move the value the caller wrote. Matching
235
+ # wineole/proxy.py's `_encode`.
236
+ {'$type' => 'time', 'iso8601' => value.strftime('%Y-%m-%dT%H:%M:%S')}
237
+ when Date
238
+ # `date` is already loaded transitively -- `time`'s own lib/time.rb
239
+ # requires it -- so this require costs nothing new; it just states
240
+ # the real dependency instead of relying on another library's
241
+ # internals. DateTime is a subclass of Date, and both answer
242
+ # strftime, so one branch covers them. (Date is not in Time's
243
+ # hierarchy, which is why the `when Time` branch above doesn't catch
244
+ # it.) Matching wineole/proxy.py's `_encode`.
245
+ {'$type' => 'time', 'iso8601' => value.strftime('%Y-%m-%dT%H:%M:%S')}
129
246
  when Hash
130
247
  value.transform_values { |v| encode(v) }
131
248
  when Array
@@ -136,13 +253,7 @@ module WineOLE
136
253
  end
137
254
 
138
255
  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
256
+ Proxy.decode(@client, value)
146
257
  end
147
258
  end
148
259
  end
data/lib/wineole.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  require_relative 'wineole/errors'
2
2
  require_relative 'wineole/client'
3
3
  require_relative 'wineole/proxy'
4
+ require_relative 'wineole/dispatcher'
5
+ require_relative 'wineole/events'
4
6
 
5
7
  module WineOLE
6
8
  @default_client = nil
@@ -25,12 +27,19 @@ module WineOLE
25
27
  # racing on a nil default contend for the lock, and only the actual
26
28
  # winner calls Client.open -- everyone else sees it already set once they
27
29
  # acquire the lock, and the `||=` means they never call Client.open again.
30
+ #
31
+ # Public (a deliberate exception to "the core layer is not changed" --
32
+ # the same exception, and for the same reason, as Client#loopback?):
33
+ # bundled wrappers such as WineOLE::MSOffice::Excel need the Client their
34
+ # Proxy belongs to (Book needs it to answer #loopback?), and this module is
35
+ # already the layer holding that connection. Making the caller guess, or
36
+ # open a second connection just to answer that question, would be worse
37
+ # than letting the layer that already knows the answer say so.
28
38
  def self.default_client
29
39
  return @default_client if @default_client
30
40
 
31
41
  @mutex.synchronize { @default_client ||= Client.open }
32
42
  end
33
- private_class_method :default_client
34
43
 
35
44
  def self.create(class_name)
36
45
  default_client.create(class_name)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wineole
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - firelzrd
@@ -14,14 +14,33 @@ description: |
14
14
  that lets a Linux Ruby process drive Win32OLE/COM automation of
15
15
  Windows applications running under Wine, over a JSON Lines TCP
16
16
  protocol, without requiring a Windows build of Ruby.
17
- executables: []
17
+ executables:
18
+ - wineole-vba
18
19
  extensions: []
19
20
  extra_rdoc_files: []
20
21
  files:
21
22
  - LICENSE
23
+ - bin/wineole-vba
22
24
  - lib/wineole.rb
23
25
  - lib/wineole/client.rb
26
+ - lib/wineole/dispatcher.rb
24
27
  - lib/wineole/errors.rb
28
+ - lib/wineole/events.rb
29
+ - lib/wineole/msoffice.rb
30
+ - lib/wineole/msoffice/address.rb
31
+ - lib/wineole/msoffice/book.rb
32
+ - lib/wineole/msoffice/color.rb
33
+ - lib/wineole/msoffice/controls.rb
34
+ - lib/wineole/msoffice/excel.rb
35
+ - lib/wineole/msoffice/format.rb
36
+ - lib/wineole/msoffice/forms.rb
37
+ - lib/wineole/msoffice/passthrough.rb
38
+ - lib/wineole/msoffice/paths.rb
39
+ - lib/wineole/msoffice/range.rb
40
+ - lib/wineole/msoffice/sheet.rb
41
+ - lib/wineole/msoffice/vba.rb
42
+ - lib/wineole/msoffice/vba_api.rb
43
+ - lib/wineole/msoffice/vba_block.rb
25
44
  - lib/wineole/proxy.rb
26
45
  - wineole-bridge-dist/aarch64-pc-windows-gnullvm/wineole-bridge.exe
27
46
  - wineole-bridge-dist/i686-pc-windows-gnu/wineole-bridge.exe