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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +192 -0
- data/examples/counter.rb +65 -0
- data/lib/eui/app.rb +83 -0
- data/lib/eui/assets.rb +76 -0
- data/lib/eui/blake3.rb +216 -0
- data/lib/eui/component.rb +105 -0
- data/lib/eui/dsl.rb +166 -0
- data/lib/eui/errors.rb +19 -0
- data/lib/eui/manifest.rb +109 -0
- data/lib/eui/proto/frame.rb +243 -0
- data/lib/eui/proto/limits.rb +47 -0
- data/lib/eui/proto/node.rb +385 -0
- data/lib/eui/proto/op.rb +196 -0
- data/lib/eui/proto/reader.rb +125 -0
- data/lib/eui/proto/style.rb +250 -0
- data/lib/eui/proto/writer.rb +89 -0
- data/lib/eui/proto.rb +22 -0
- data/lib/eui/server.rb +175 -0
- data/lib/eui/session.rb +289 -0
- data/lib/eui/theme.rb +73 -0
- data/lib/eui/version.rb +5 -0
- data/lib/eui/view/diff.rb +212 -0
- data/lib/eui/view/style.rb +198 -0
- data/lib/eui/view/tree.rb +378 -0
- data/lib/eui/websocket.rb +195 -0
- data/lib/eui-ruby.rb +4 -0
- data/lib/eui.rb +32 -0
- metadata +80 -0
|
@@ -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
|
data/lib/eui/manifest.rb
ADDED
|
@@ -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
|