eui-ruby 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.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'dsl'
4
+ require_relative 'errors'
5
+
6
+ module EUI
7
+ # One component: state, handlers, and a view that is a function of the
8
+ # state.
9
+ #
10
+ # class Counter < EUI::Component
11
+ # def mount(_params) = @count = 0
12
+ #
13
+ # on "increment" { @count += 1 }
14
+ #
15
+ # def render
16
+ # column(gap: 4, pad: 8) do
17
+ # [text(@count.to_s, size: "4xl"), button("+", "increment")]
18
+ # end
19
+ # end
20
+ # end
21
+ #
22
+ # A handler changes state and returns; it never touches the tree. What
23
+ # reaches the client is the *difference* the change made, which is the
24
+ # one thing this protocol is for.
25
+ class Component
26
+ include DSL
27
+
28
+ class << self
29
+ def handlers
30
+ @handlers ||= superclass.respond_to?(:handlers) ? superclass.handlers.dup : {}
31
+ end
32
+
33
+ # Name an event this component answers. The name is the one the view
34
+ # put on a node, not the event kind: `{"on" => {"wake" => "tick"}}`
35
+ # arrives here as `tick`.
36
+ def on(name, &block)
37
+ handlers[name.to_s] = block
38
+ self
39
+ end
40
+
41
+ def handler_for(name) = handlers[name.to_s]
42
+ end
43
+
44
+ attr_reader :session, :viewport
45
+
46
+ def initialize(session: nil)
47
+ @session = session
48
+ @viewport = {}
49
+ end
50
+
51
+ # Called once, when the socket has said Hello. `params["viewport"]` is
52
+ # the window as it is right now; a `viewport` event follows every
53
+ # resize, so nothing has to ask.
54
+ def mount(params)
55
+ @viewport = params['viewport'] || {}
56
+ end
57
+
58
+ # Called when the session ends, for whatever reason.
59
+ def unmount; end
60
+
61
+ # The view: a hash, and a pure function of the state. It is called
62
+ # after every handler, so it must be cheap and must not have effects.
63
+ def render
64
+ raise NotImplementedError, "#{self.class} has no render"
65
+ end
66
+
67
+ # Dispatch. A block registered with `on` wins; otherwise a public
68
+ # method of the same name; otherwise the event is dropped with a line
69
+ # in the log, because a view naming a handler nobody wrote is a typo
70
+ # and not a reason to end somebody's session.
71
+ def handle(name, params)
72
+ @viewport = params['viewport'] if name == 'viewport' && params['viewport']
73
+
74
+ block = self.class.handler_for(name)
75
+ return instance_exec(params, &block) if block
76
+
77
+ method_name = name.to_sym
78
+ return public_send(method_name, params) if respond_to?(method_name) && method(method_name).arity != 0
79
+ return public_send(method_name) if respond_to?(method_name)
80
+ return if name == 'viewport'
81
+
82
+ raise Error, "no handler for '#{name}'"
83
+ end
84
+
85
+ # Render again although nothing arrived: a timer, a message from
86
+ # another session, anything this process knows and the client does not.
87
+ def refresh! = @session&.refresh!
88
+
89
+ def notify(title, body: '', tag: '') = @session&.notify(title, body: body, tag: tag)
90
+
91
+ def close(reason = 'the application closed the session') = @session&.close(reason)
92
+
93
+ # Whether the person granted this application something it asked for.
94
+ # Being granted is a separate act from asking, and this is the only
95
+ # place the answer shows up.
96
+ def granted?(capability) = @session ? @session.granted?(capability) : false
97
+
98
+ # The window, in device-independent pixels. Every width worth having is
99
+ # derived from it: a fixed one is what reads as unfinished on somebody
100
+ # else's screen.
101
+ def width = @viewport['width'].to_i
102
+ def height = @viewport['height'].to_i
103
+ def dark? = @viewport['mode'] == 'dark'
104
+ end
105
+ end
data/lib/eui/dsl.rb ADDED
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EUI
4
+ # The view, written as Ruby.
5
+ #
6
+ # Every helper answers a plain hash — `{"k" =>, "s" =>, "c" =>}` — so a
7
+ # view is data all the way down: printable, comparable, testable without
8
+ # a socket. Style keys are the spec's own vocabulary, passed as keyword
9
+ # arguments; `on:`, `props:` and `key:` are the three that are not style.
10
+ #
11
+ # column(gap: 4, pad: 6, bg: "surface.base") do
12
+ # [text("Hello", size: "2xl", weight: "bold"),
13
+ # button("Increment", "increment")]
14
+ # end
15
+ module DSL
16
+ module_function
17
+
18
+ RESERVED = %i[on props key intern].freeze
19
+
20
+ def node(kind, children = nil, text: nil, **options)
21
+ style = options.reject { |k, _| RESERVED.include?(k) }
22
+ out = { 'k' => kind.to_s }
23
+ out['s'] = style unless style.empty?
24
+ out['t'] = text unless text.nil?
25
+ out['key'] = options[:key].to_s if options[:key]
26
+ out['intern'] = true if options[:intern]
27
+ out['p'] = stringify(options[:props]) if options[:props]
28
+ out['on'] = stringify(options[:on]) if options[:on]
29
+ kids = Array(children).compact
30
+ out['c'] = kids unless kids.empty?
31
+ out
32
+ end
33
+
34
+ def stringify(hash)
35
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
36
+ end
37
+
38
+ # ------------------------------------------------------------ primitives
39
+
40
+ def box(children = nil, **options, &block)
41
+ node('box', children || block&.call, **options)
42
+ end
43
+
44
+ def row(children = nil, **options, &block)
45
+ box(children || block&.call, **options.merge(display: 'row'))
46
+ end
47
+
48
+ def column(children = nil, **options, &block)
49
+ box(children || block&.call, **options.merge(display: 'column'))
50
+ end
51
+
52
+ # Children overlap, ordered by `z`: menus, tooltips, a badge on a
53
+ # corner.
54
+ def stack(children = nil, **options, &block)
55
+ box(children || block&.call, **options.merge(display: 'stack'))
56
+ end
57
+
58
+ def text(content, **options)
59
+ node('text', nil, text: content.to_s, **options)
60
+ end
61
+
62
+ def image(src, **options)
63
+ node('image', nil, **options.merge(props: (options[:props] || {}).merge('src' => src)))
64
+ end
65
+
66
+ def icon(name, **options)
67
+ node('icon', nil, **options.merge(props: (options[:props] || {}).merge('name' => name)))
68
+ end
69
+
70
+ def input(value, on_change: nil, **options)
71
+ props = (options[:props] || {}).merge('value' => value.to_s)
72
+ on = options[:on] || {}
73
+ on = on.merge('change' => on_change) if on_change
74
+ node('input', nil, **options.merge(props: props, on: on))
75
+ end
76
+
77
+ def textarea(value, on_change: nil, **options)
78
+ props = (options[:props] || {}).merge('value' => value.to_s)
79
+ on = options[:on] || {}
80
+ on = on.merge('change' => on_change) if on_change
81
+ node('textarea', nil, **options.merge(props: props, on: on))
82
+ end
83
+
84
+ def scroll(children = nil, **options, &block)
85
+ node('scroll', children || block&.call, **options)
86
+ end
87
+
88
+ # A virtualised list: only the window in view is laid out, and the
89
+ # client asks for another range with a `window` event.
90
+ def list(children = nil, **options, &block)
91
+ node('list', children || block&.call, **options)
92
+ end
93
+
94
+ def canvas(paths, **options)
95
+ node('canvas', nil, **options.merge(props: (options[:props] || {}).merge('paths' => paths)))
96
+ end
97
+
98
+ def overlay(children = nil, **options, &block)
99
+ node('overlay', children || block&.call, **options)
100
+ end
101
+
102
+ def sizer(children = nil, **options, &block)
103
+ node('sizer', children || block&.call, **options)
104
+ end
105
+
106
+ # Flexible empty space. Inert: no text, no props, no handlers.
107
+ def spacer(**options)
108
+ node('spacer', nil, **options.merge(grow: options.fetch(:grow, 1)))
109
+ end
110
+
111
+ def divider(**options)
112
+ node('divider', nil, **{ height: 1, bg: 'border.subtle', width: '100%' }.merge(options))
113
+ end
114
+
115
+ # Identity for reconciliation: a row that moved is a row that moved,
116
+ # rather than every row below it having changed.
117
+ def keyed(key, node)
118
+ node.merge('key' => key.to_s)
119
+ end
120
+
121
+ # -------------------------------------------------------------- widgets
122
+ #
123
+ # Composed from the primitives above and nothing else, which is the
124
+ # whole reason the catalogue can grow without shipping a new client.
125
+
126
+ TONES = {
127
+ 'accent' => %w[accent.base accent.on],
128
+ 'danger' => %w[danger.base danger.on],
129
+ 'success' => %w[success.base success.on],
130
+ 'warning' => %w[warning.base warning.on],
131
+ 'info' => %w[info.base info.on],
132
+ 'quiet' => %w[surface.raised text.default]
133
+ }.freeze
134
+
135
+ def button(label, event, tone: 'accent', size: 'md', **options)
136
+ bg, fg = TONES.fetch(tone.to_s) { raise ViewError, "unknown button tone '#{tone}'" }
137
+ pad = { 'sm' => [1, 3], 'md' => [2, 4], 'lg' => [3, 5] }.fetch(size.to_s, [2, 4])
138
+ style = {
139
+ display: 'row', justify: 'center', align: 'center',
140
+ bg: bg, radius: 'md', pad: pad, cursor: 'pointer', transition: 'fast'
141
+ }.merge(options.reject { |k, _| RESERVED.include?(k) })
142
+ on = (options[:on] || {}).merge('click' => event)
143
+ box([text(label, fg: fg, weight: 'medium')], **style, on: on, key: options[:key])
144
+ end
145
+
146
+ # A surface a thing sits on: raised, padded, with a hairline.
147
+ def card(children = nil, **options, &block)
148
+ style = {
149
+ display: 'column', bg: 'surface.raised', radius: 'md', pad: 5, gap: 4,
150
+ border: 1, border_color: 'border.subtle'
151
+ }.merge(options.reject { |k, _| RESERVED.include?(k) })
152
+ box(children || block&.call, **style, on: options[:on], key: options[:key])
153
+ end
154
+
155
+ # A label above a field, the pair kept together.
156
+ def field(label, value, on_change:, **options)
157
+ column(gap: 2, **options.reject { |k, _| RESERVED.include?(k) }) do
158
+ [
159
+ text(label, size: 'sm', fg: 'text.muted'),
160
+ input(value, on_change: on_change, bg: 'surface.sunken', radius: 'sm',
161
+ pad: [2, 3], border: 1, border_color: 'border.default')
162
+ ]
163
+ end
164
+ end
165
+ end
166
+ end
data/lib/eui/errors.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EUI
4
+ # Anything this library raises on its own.
5
+ class Error < StandardError; end
6
+
7
+ # Bytes that are not a legal encoding of what they claim to be.
8
+ #
9
+ # The decoder refuses rather than repairs: a non-minimal varint, a value
10
+ # outside an enumeration, a length that does not account for every byte.
11
+ # "Ignore what you don't understand" is how one implementation's frame
12
+ # becomes another's smuggling channel.
13
+ class DecodeError < Error; end
14
+
15
+ # A view the protocol has no way to carry: an unknown style key, a colour
16
+ # role nobody defined, a tree deeper than the client accepts. Raised while
17
+ # encoding, which is the only moment at which the author can still fix it.
18
+ class ViewError < Error; end
19
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'fileutils'
5
+ require_relative 'proto'
6
+ require_relative 'assets'
7
+
8
+ module EUI
9
+ # The application manifest served at `/.well-known/eui`
10
+ # (`spec/01-transport.md` §2.1): an `EUIM` record, signed by the
11
+ # publisher, that a client reads before it opens a session.
12
+ #
13
+ # The signature is what makes trust-on-first-use mean anything: the
14
+ # client pins `publisher_key` against `app_id` on first run and refuses a
15
+ # different one later. So the key belongs to the *application*, not to a
16
+ # deployment — keep the file, and keep it out of the repository.
17
+ class Manifest
18
+ MAGIC = 'EUIM'
19
+ RECORD_VERSION = 1
20
+ MAX_STR = 256
21
+
22
+ KEY = {
23
+ app_id: 0, name: 1, version: 2, protocol_min: 3, protocol_max: 4,
24
+ publisher_key: 5, capabilities: 6, theme: 7, entry: 8, rotation: 9,
25
+ signature: 10
26
+ }.freeze
27
+
28
+ attr_accessor :app_id, :name, :version, :protocol_min, :protocol_max,
29
+ :capabilities, :theme, :entry
30
+
31
+ def initialize(app_id:, name:, key:, version: '0.1.0', entry: '/_eui/session',
32
+ capabilities: 0, theme: nil, protocol_min: 1, protocol_max: Proto::PROTOCOL_VERSION)
33
+ @app_id = app_id
34
+ @name = name
35
+ @version = version
36
+ @key = key
37
+ @entry = entry
38
+ @capabilities = capabilities.is_a?(Integer) ? capabilities : Proto::Caps.mask(capabilities)
39
+ @theme = theme
40
+ @protocol_min = protocol_min
41
+ @protocol_max = protocol_max
42
+ end
43
+
44
+ # An Ed25519 key kept on disk, generated on first use. Never committed:
45
+ # whoever holds it can publish as this application.
46
+ def self.publisher_key(path)
47
+ if File.exist?(path)
48
+ OpenSSL::PKey.read(File.read(path))
49
+ else
50
+ key = OpenSSL::PKey.generate_key('ED25519')
51
+ FileUtils.mkdir_p(File.dirname(path))
52
+ File.write(path, key.private_to_pem)
53
+ File.chmod(0o600, path)
54
+ key
55
+ end
56
+ end
57
+
58
+ def public_key_hex = @key.raw_public_key.unpack1('H*')
59
+
60
+ # The bytes the publisher signs: every field but the signature. A
61
+ # decoder rebuilds them exactly, because the order is fixed.
62
+ def signed_bytes = write(nil)
63
+
64
+ def encode
65
+ write(@key.sign(nil, signed_bytes))
66
+ end
67
+
68
+ # The capability names this application asks for. Being granted them is
69
+ # a separate act, and one this server never hears the answer to unless
70
+ # the client reports it in its Hello.
71
+ def capability_names = Proto::Caps.names(@capabilities)
72
+
73
+ private
74
+
75
+ def write(signature)
76
+ fields = [
77
+ [KEY[:app_id], Proto::Value.str(check(@app_id, 'app_id'))],
78
+ [KEY[:name], Proto::Value.str(check(@name, 'name'))],
79
+ [KEY[:version], Proto::Value.str(check(@version, 'version'))],
80
+ [KEY[:protocol_min], Proto::Value.int(@protocol_min)],
81
+ [KEY[:protocol_max], Proto::Value.int(@protocol_max)],
82
+ [KEY[:publisher_key], Proto::Value.str(public_key_hex)],
83
+ [KEY[:capabilities], Proto::Value.int(@capabilities)],
84
+ [KEY[:theme], @theme ? Proto::Value.str(Assets.hex(@theme)) : Proto::Value.null],
85
+ [KEY[:entry], Proto::Value.str(check(@entry, 'entry'))],
86
+ # No rotation: this key has never been anything else. When one is
87
+ # needed it is the previous key and its signature over the new one,
88
+ # and the client's pin moves rather than the session failing.
89
+ [KEY[:rotation], Proto::Value.null]
90
+ ]
91
+ fields << [KEY[:signature], Proto::Value.str(signature.unpack1('H*'))] if signature
92
+
93
+ w = Proto::Writer.new
94
+ w.raw(MAGIC).u8(RECORD_VERSION).varint(fields.length)
95
+ fields.each do |(k, v)|
96
+ w.varint(k)
97
+ v.encode(w)
98
+ end
99
+ w.to_s
100
+ end
101
+
102
+ def check(value, what)
103
+ string = value.to_s
104
+ raise Error, "manifest #{what} is at most #{MAX_STR} bytes" if string.bytesize > MAX_STR
105
+
106
+ string
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'op'
4
+
5
+ module EUI
6
+ module Proto
7
+ # Which palette the viewer is using. A *client* fact: it reaches the
8
+ # server only so a server can pick a matching image, never so it can
9
+ # resolve a colour.
10
+ module ThemeMode
11
+ BY_CODE = { 0 => 'light', 1 => 'dark', 2 => 'high_contrast' }.freeze
12
+ def self.name(code)
13
+ BY_CODE.fetch(code) { raise DecodeError, "unknown theme mode #{code}" }
14
+ end
15
+ end
16
+
17
+ # How tightly controls are packed.
18
+ module Density
19
+ BY_CODE = { 0 => 'compact', 1 => 'cozy', 2 => 'comfortable' }.freeze
20
+ def self.name(code)
21
+ BY_CODE.fetch(code) { raise DecodeError, "unknown density #{code}" }
22
+ end
23
+ end
24
+
25
+ # Capability bits, as granted by the person and reported to the server
26
+ # (`spec/01-transport.md` §2.1). Nothing is granted by being asked for.
27
+ module Caps
28
+ CAMERA = 1 << 0
29
+ MICROPHONE = 1 << 1
30
+ CLIPBOARD_READ = 1 << 2
31
+ CLIPBOARD_WRITE = 1 << 3
32
+ NOTIFICATIONS = 1 << 4
33
+ LOCATION = 1 << 5
34
+ FS_PICK = 1 << 6
35
+ FS_SAVE = 1 << 7
36
+ NFC = 1 << 8
37
+ SCENE = 1 << 9
38
+ NET_OPEN = 1 << 10
39
+
40
+ NAMES = {
41
+ 'camera' => CAMERA, 'microphone' => MICROPHONE,
42
+ 'clipboard.read' => CLIPBOARD_READ, 'clipboard.write' => CLIPBOARD_WRITE,
43
+ 'notifications' => NOTIFICATIONS, 'location' => LOCATION,
44
+ 'fs.pick' => FS_PICK, 'fs.save' => FS_SAVE, 'nfc' => NFC,
45
+ 'scene' => SCENE, 'net.open' => NET_OPEN
46
+ }.freeze
47
+
48
+ # Every bit this revision defines. A bit outside it is a decode error:
49
+ # a client that does not know what a bit means must not agree to it,
50
+ # and neither must a server.
51
+ ALL = 0x7FF
52
+
53
+ def self.bit(name)
54
+ NAMES.fetch(name.to_s) { raise Error, "unknown capability '#{name}'" }
55
+ end
56
+
57
+ def self.mask(names) = Array(names).reduce(0) { |acc, n| acc | bit(n) }
58
+
59
+ def self.names(mask) = NAMES.select { |_, bit| (mask & bit) != 0 }.keys
60
+ end
61
+
62
+ # The viewer's presentation state.
63
+ Viewport = Struct.new(:width, :height, :scale, :mode, :density, :font_scale) do
64
+ def self.default = new(0, 0, 100, 0, 1, 100)
65
+
66
+ def self.decode(reader)
67
+ new(reader.varint32, reader.varint32, reader.u16,
68
+ ThemeMode::BY_CODE.key?(m = reader.u8) ? m : (raise DecodeError, "unknown theme mode #{m}"),
69
+ Density::BY_CODE.key?(d = reader.u8) ? d : (raise DecodeError, "unknown density #{d}"),
70
+ reader.u16)
71
+ end
72
+
73
+ def encode(writer)
74
+ writer.varint(width).varint(height).u16(scale).u8(mode).u8(density).u16(font_scale)
75
+ end
76
+
77
+ # What a view sees: pixels, a ratio, and names rather than codes.
78
+ def to_h
79
+ {
80
+ 'width' => width, 'height' => height, 'scale' => scale / 100.0,
81
+ 'mode' => ThemeMode.name(mode), 'density' => Density.name(density),
82
+ 'font_scale' => font_scale / 100.0
83
+ }
84
+ end
85
+ end
86
+
87
+ # A session the client still holds a tree for, offered back after the
88
+ # socket broke (`spec/01-transport.md` §4.1). An offer, not a claim.
89
+ Resume = Struct.new(:session, :acked)
90
+
91
+ Hello = Struct.new(:version, :viewport, :granted, :resume)
92
+ Welcome = Struct.new(:version, :session, :resumed)
93
+ EventFrame = Struct.new(:node, :event, :name, :payload)
94
+ Transfer = Struct.new(:id, :seq, :flag, :bytes) do
95
+ MORE = 0
96
+ LAST = 1
97
+ ABORT = 2
98
+
99
+ def self.decode(reader)
100
+ id = reader.varint32
101
+ seq = reader.varint32
102
+ flag = reader.u8
103
+ raise DecodeError, "unknown chunk flag #{flag}" if flag > ABORT
104
+
105
+ max = flag == ABORT ? Limits::MAX_ABORT_REASON : Limits::MAX_TRANSFER_CHUNK_BYTES
106
+ new(id, seq, flag, reader.bytes(max, 'transfer chunk'))
107
+ end
108
+
109
+ def encode(writer)
110
+ writer.varint(id).varint(seq).u8(flag).bytes(bytes)
111
+ end
112
+ end
113
+
114
+ # A whole session message (`spec/01-transport.md` §3). One frame per
115
+ # WebSocket binary message, and a text frame ends the session.
116
+ class Frame
117
+ HELLO = 0x01
118
+ WELCOME = 0x02
119
+ BATCH = 0x03
120
+ EVENT = 0x04
121
+ ACK = 0x05
122
+ PING = 0x06
123
+ PONG = 0x07
124
+ ERROR = 0x08
125
+ RESYNC = 0x09
126
+ VIEWPORT = 0x0A
127
+ UPLOAD = 0x0B
128
+ BLOB = 0x0C
129
+
130
+ attr_reader :kind, :body
131
+
132
+ def initialize(kind, body = nil)
133
+ @kind = kind
134
+ @body = body
135
+ end
136
+
137
+ class << self
138
+ def hello(h) = new(HELLO, h)
139
+ def welcome(w) = new(WELCOME, w)
140
+ def batch(b) = new(BATCH, b)
141
+ def event(e) = new(EVENT, e)
142
+ def ack(seq) = new(ACK, seq)
143
+ def ping(nonce) = new(PING, nonce)
144
+ def pong(nonce) = new(PONG, nonce)
145
+ def error(code, message) = new(ERROR, [code, message])
146
+ def resync = new(RESYNC)
147
+ def viewport(v) = new(VIEWPORT, v)
148
+ def upload(t) = new(UPLOAD, t)
149
+ def blob(t) = new(BLOB, t)
150
+ end
151
+
152
+ # Decode a complete WebSocket message. Trailing bytes are an error:
153
+ # a length that does not account for every byte of the message is how
154
+ # one implementation's frame becomes another's smuggling channel.
155
+ def self.decode(message)
156
+ r = Reader.new(message)
157
+ kind = r.u8
158
+ len = r.varint
159
+ raise DecodeError, 'frame length' if len > Limits::MAX_FRAME_BYTES
160
+
161
+ payload = r.take(len)
162
+ r.finish!
163
+ p = Reader.new(payload)
164
+
165
+ frame =
166
+ case kind
167
+ when HELLO
168
+ version = p.varint32
169
+ viewport = Viewport.decode(p)
170
+ granted = p.varint32
171
+ raise DecodeError, 'unknown capability bit' if (granted & ~Caps::ALL) != 0
172
+
173
+ resume =
174
+ case (tag = p.u8)
175
+ when 0 then nil
176
+ when 1 then Resume.new(p.take(16), p.varint)
177
+ else raise DecodeError, "unknown resume tag #{tag}"
178
+ end
179
+ hello(Hello.new(version, viewport, granted, resume))
180
+ when WELCOME
181
+ version = p.varint32
182
+ session = p.take(16)
183
+ resumed = case p.u8
184
+ when 0 then false
185
+ when 1 then true
186
+ else raise DecodeError, 'resumed must be 0 or 1'
187
+ end
188
+ welcome(Welcome.new(version, session, resumed))
189
+ when BATCH then batch(Batch.decode(p))
190
+ when EVENT
191
+ node = p.varint32
192
+ event = p.u8
193
+ EventKind.name(event)
194
+ event(EventFrame.new(node, event, p.varint32, Value.decode(p)))
195
+ when ACK then ack(p.varint)
196
+ when PING then ping(p.take(8))
197
+ when PONG then pong(p.take(8))
198
+ when ERROR then error(p.varint32, p.str(Limits::MAX_INLINE_STR, 'error message'))
199
+ when RESYNC then resync
200
+ when VIEWPORT then viewport(Viewport.decode(p))
201
+ when UPLOAD then upload(Transfer.decode(p))
202
+ when BLOB then blob(Transfer.decode(p))
203
+ else raise DecodeError, "unknown frame kind #{kind}"
204
+ end
205
+ p.finish!
206
+ frame
207
+ end
208
+
209
+ def encode
210
+ body = Writer.new
211
+ case @kind
212
+ when HELLO
213
+ body.varint(@body.version)
214
+ @body.viewport.encode(body)
215
+ body.varint(@body.granted)
216
+ if @body.resume
217
+ body.u8(1).raw(@body.resume.session).varint(@body.resume.acked)
218
+ else
219
+ body.u8(0)
220
+ end
221
+ when WELCOME
222
+ body.varint(@body.version).raw(@body.session).u8(@body.resumed ? 1 : 0)
223
+ when BATCH then @body.encode(body)
224
+ when EVENT
225
+ body.varint(@body.node).u8(@body.event).varint(@body.name)
226
+ @body.payload.encode(body)
227
+ when ACK then body.varint(@body)
228
+ when PING, PONG then body.raw(@body)
229
+ when ERROR then body.varint(@body[0]).str(@body[1])
230
+ when RESYNC then nil
231
+ when VIEWPORT then @body.encode(body)
232
+ when UPLOAD, BLOB then @body.encode(body)
233
+ else raise Error, "cannot encode frame kind #{@kind}"
234
+ end
235
+
236
+ payload = body.to_s
237
+ out = Writer.new
238
+ out.u8(@kind).varint(payload.bytesize).raw(payload)
239
+ out.to_s
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EUI
4
+ module Proto
5
+ # Protocol limits, normative in `spec/02-wire-format.md` §6.
6
+ #
7
+ # Every one of them is checked while decoding, before the memory it
8
+ # bounds is allocated: a hostile peer can be annoying, it cannot make
9
+ # this process exhaust itself.
10
+ module Limits
11
+ MAX_FRAME_BYTES = 8 * 1024 * 1024
12
+ MAX_TREE_DEPTH = 256
13
+ MAX_NODES = 1_000_000
14
+ MAX_ATOMS = 65_535
15
+ MAX_ATOM_BYTES = 64 * 1024
16
+ MAX_ATOM_TOTAL_BYTES = 8 * 1024 * 1024
17
+ MAX_STYLES = 65_535
18
+ MAX_COLORS = 4_095
19
+ MAX_CHUNKS = 4_095
20
+ MAX_CHILDREN = 65_535
21
+ MAX_PROPS = 64
22
+ MAX_HANDLERS = 16
23
+ MAX_OPS_PER_BATCH = 65_535
24
+ MAX_INLINE_STR = 4 * 1024
25
+ MAX_VALUE_DEPTH = 4
26
+ MAX_VALUE_LIST = 1_000_000
27
+
28
+ MAX_CHUNK_BYTES = 64 * 1024
29
+ MAX_TRANSFER_CHUNK_BYTES = 256 * 1024
30
+ MAX_UPLOAD_BYTES = 64 * 1024 * 1024
31
+ DEFAULT_UPLOAD_BYTES = 16 * 1024 * 1024
32
+ MAX_SAVE_BYTES = 256 * 1024 * 1024
33
+ MAX_ABORT_REASON = 256
34
+
35
+ MAX_NOTIFY_TITLE = 256
36
+ MAX_NOTIFY_BODY = 1024
37
+ MAX_NOTIFY_TAG = 64
38
+ MAX_NOTIFY_PER_BATCH = 4
39
+
40
+ STYLE_RECORD_BYTES = 64
41
+ HASH_BYTES = 32
42
+
43
+ MAX_FONT_ROLE = 9
44
+ MAX_FACES_PER_ROLE = 8
45
+ end
46
+ end
47
+ end