withruntime 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/GUIDE.md +406 -0
- data/LICENSE +55 -0
- data/lib/withruntime/client.rb +79 -0
- data/lib/withruntime/credentials.rb +85 -0
- data/lib/withruntime/errors.rb +114 -0
- data/lib/withruntime/fields.rb +40 -0
- data/lib/withruntime/file_watch.rb +73 -0
- data/lib/withruntime/images.rb +258 -0
- data/lib/withruntime/network_products.rb +49 -0
- data/lib/withruntime/parity_products.rb +51 -0
- data/lib/withruntime/products.rb +373 -0
- data/lib/withruntime/record.rb +85 -0
- data/lib/withruntime/sandbox.rb +618 -0
- data/lib/withruntime/tar.rb +107 -0
- data/lib/withruntime/transport.rb +362 -0
- data/lib/withruntime/tunnel.rb +275 -0
- data/lib/withruntime/version.rb +5 -0
- data/lib/withruntime/websocket.rb +249 -0
- data/lib/withruntime.rb +32 -0
- metadata +64 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module WithRuntime
|
|
6
|
+
# The pieces every product shares.
|
|
7
|
+
class Product
|
|
8
|
+
def initialize(transport)
|
|
9
|
+
@t = transport
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def seg(value) = Transport.segment(value)
|
|
15
|
+
|
|
16
|
+
def page(path, query)
|
|
17
|
+
body = @t.json("GET", path, query: query)
|
|
18
|
+
Page.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"]) do |cursor|
|
|
19
|
+
page(path, query.merge("cursor" => cursor))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# +runtime.snapshots+. Take one with +sbx.snapshot+, start from one with
|
|
25
|
+
# +runtime.sandboxes.create(snapshot: id)+, or do both with +sbx.fork+.
|
|
26
|
+
class Snapshots < Product
|
|
27
|
+
# Snapshots a sandbox by id, as it is: the API answers how the sandbox must be.
|
|
28
|
+
def create(sandbox_id, name: nil, labels: nil, retention_days: nil, idempotency_key: nil)
|
|
29
|
+
body = Fields.body(name: name, labels: labels, retention_days: retention_days)
|
|
30
|
+
Record.new(@t.json("POST", "/v1/sandboxes/#{seg(sandbox_id)}:snapshot", body: body, wait: 10, key: idempotency_key))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(id) = Record.new(@t.json("GET", "/v1/snapshots/#{seg(id)}"))
|
|
34
|
+
|
|
35
|
+
# state: capturing, ready, failed or deleting.
|
|
36
|
+
def list(sandbox_id: nil, name: nil, state: nil, limit: nil)
|
|
37
|
+
page("/v1/snapshots", { "sandboxId" => sandbox_id, "name" => name, "state" => state, "limit" => limit })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delete(id)
|
|
41
|
+
@t.json("POST", "/v1/snapshots/#{seg(id)}:delete", body: {})
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Keeps a snapshot for +retention_days+ from now.
|
|
46
|
+
def extend_retention(id, retention_days)
|
|
47
|
+
Record.new(@t.json("POST", "/v1/snapshots/#{seg(id)}:extend", body: { "retentionDays" => retention_days }))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# +runtime.volumes+: persistent disks. Create one, then attach it when
|
|
52
|
+
# creating a sandbox: +volumes: [{ volume_id: v.id, path: "/data" }]+. A
|
|
53
|
+
# volume lives on one host; its backups can restore onto another host.
|
|
54
|
+
class Volumes < Product
|
|
55
|
+
# Creates a volume and waits (up to 10 seconds) until it is ready.
|
|
56
|
+
def create(size_mib: nil, name: nil, labels: nil, region: nil, from_backup: nil, idempotency_key: nil)
|
|
57
|
+
body = Fields.body(size_mib: size_mib, name: name, labels: labels, region: region, from_backup: from_backup)
|
|
58
|
+
Record.new(@t.json("POST", "/v1/volumes", body: body, wait: 10, key: idempotency_key))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def backup(id, name: nil, labels: nil, retention_days: nil, wait: 60, idempotency_key: nil)
|
|
62
|
+
Record.new(@t.json("POST", "/v1/volumes/#{seg(id)}:backup", body: Fields.body(name: name, labels: labels, retention_days: retention_days), wait: wait, key: idempotency_key))
|
|
63
|
+
end
|
|
64
|
+
def set_backup_policy(id, daily: nil, retention_days: nil) = Record.new(@t.json("POST", "/v1/volumes/#{seg(id)}:backup-policy", body: Fields.body(daily: daily, retention_days: retention_days)))
|
|
65
|
+
def backups(volume_id: nil, state: nil, limit: nil) = page("/v1/volume-backups", { "volumeId" => volume_id, "state" => state, "limit" => limit })
|
|
66
|
+
def get_backup(id) = Record.new(@t.json("GET", "/v1/volume-backups/#{seg(id)}"))
|
|
67
|
+
def delete_backup(id) = Record.new(@t.json("POST", "/v1/volume-backups/#{seg(id)}:delete", body: {}))
|
|
68
|
+
def restore(id, **options) = create(**options.merge(from_backup: id))
|
|
69
|
+
|
|
70
|
+
def get(id) = Record.new(@t.json("GET", "/v1/volumes/#{seg(id)}"))
|
|
71
|
+
|
|
72
|
+
def list(state: nil, name: nil, limit: nil) = page("/v1/volumes", { "state" => state, "name" => name, "limit" => limit })
|
|
73
|
+
|
|
74
|
+
# Deletes a volume and everything on it.
|
|
75
|
+
def delete(id) = Record.new(@t.json("POST", "/v1/volumes/#{seg(id)}:delete", body: {}))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# +runtime.secrets+: a value your sandboxes use without seeing it. Every
|
|
79
|
+
# sandbox of the organization has an environment variable of the secret's
|
|
80
|
+
# name holding a placeholder; the egress proxy puts the value into HTTPS
|
|
81
|
+
# requests to its hosts. The value is never returned.
|
|
82
|
+
class Secrets < Product
|
|
83
|
+
# Stores or replaces a secret for +hosts+ ("api.openai.com", "*.github.com").
|
|
84
|
+
# With +header:+, that header is set on every request to them, +format:+'s
|
|
85
|
+
# {value} replaced.
|
|
86
|
+
def set(name, value:, hosts:, header: nil, format: nil)
|
|
87
|
+
body = { "value" => value, "hosts" => hosts, "header" => header, "format" => format }.compact
|
|
88
|
+
Record.new(@t.json("PUT", "/v1/egress-secrets/#{seg(name)}", body: body))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Names, hosts and placeholders. Never values.
|
|
92
|
+
def list = @t.json("GET", "/v1/egress-secrets")["secrets"].map { |secret| Record.new(secret) }
|
|
93
|
+
|
|
94
|
+
# Erases a secret; its placeholder stops working.
|
|
95
|
+
def delete(name)
|
|
96
|
+
@t.json("DELETE", "/v1/egress-secrets/#{seg(name)}")
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# +runtime.limits+: whether this key is read-only, and what its agent may still spend today.
|
|
102
|
+
class Limits < Product
|
|
103
|
+
def get = Record.new(@t.json("GET", "/v1/limits"))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# +runtime.referrals+: the account's referral link and what it earned.
|
|
107
|
+
class Referrals < Product
|
|
108
|
+
def get = Record.new(@t.json("GET", "/v1/referrals"))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# +runtime.switching+: compare usage with a rival's published rates, and record a switch.
|
|
112
|
+
class Switching < Product
|
|
113
|
+
# Your settled usage over the last +days+ (30 by default, at most 90) priced
|
|
114
|
+
# on Runtime and at +provider+ (e2b, daytona, vercel, modal, cloudflare, fly, fly-machines).
|
|
115
|
+
def compare(provider, days: nil) = Record.new(@t.json("GET", "/v1/usage/compare", query: { "provider" => provider, "days" => days }))
|
|
116
|
+
|
|
117
|
+
def get = Record.new(@t.json("GET", "/v1/switching"))
|
|
118
|
+
|
|
119
|
+
# Once per organization, before its first top-up.
|
|
120
|
+
def record(provider) = Record.new(@t.json("POST", "/v1/switching", body: { "provider" => provider }))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# +runtime.events+: lifecycle events, newest first.
|
|
124
|
+
class Events < Product
|
|
125
|
+
def list(resource_id: nil, type: nil, limit: nil)
|
|
126
|
+
page("/v1/events", { "resourceId" => resource_id, "type" => type, "limit" => limit })
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# +runtime.webhooks+: signed lifecycle events POSTed to your URL.
|
|
131
|
+
class Webhooks < Product
|
|
132
|
+
# Returns the webhook with its +secret+, shown this once. +events+ nil is every event.
|
|
133
|
+
def create(url:, events: nil, description: nil)
|
|
134
|
+
Record.new(@t.json("POST", "/v1/webhooks", body: { "url" => url, "events" => events, "description" => description }.compact))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def list = @t.json("GET", "/v1/webhooks")["data"].map { |hook| Record.new(hook) }
|
|
138
|
+
|
|
139
|
+
def get(id) = Record.new(@t.json("GET", "/v1/webhooks/#{seg(id)}"))
|
|
140
|
+
|
|
141
|
+
# Changes url, events, description or enabled; fields left out stay as they are.
|
|
142
|
+
def update(id, **patch) = Record.new(@t.json("POST", "/v1/webhooks/#{seg(id)}:update", body: Fields.body(patch)))
|
|
143
|
+
|
|
144
|
+
# A new secret, returned once. The old one keeps signing for
|
|
145
|
+
# +keep_previous_seconds+ (a day by default, a week at most; 0 ends it now).
|
|
146
|
+
def rotate_secret(id, keep_previous_seconds: nil)
|
|
147
|
+
body = { "keepPreviousSeconds" => keep_previous_seconds }.compact
|
|
148
|
+
Record.new(@t.json("POST", "/v1/webhooks/#{seg(id)}:rotate-secret", body: body))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def delete(id)
|
|
152
|
+
@t.json("POST", "/v1/webhooks/#{seg(id)}:delete", body: {})
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Sends a signed webhook.test now and says how your endpoint answered.
|
|
157
|
+
def test(id) = Record.new(@t.json("POST", "/v1/webhooks/#{seg(id)}:test", body: {}, wait: 10))
|
|
158
|
+
|
|
159
|
+
# state: pending, succeeded, failed or cancelled.
|
|
160
|
+
def deliveries(id, state: nil) = page("/v1/webhooks/#{seg(id)}/deliveries", { "state" => state })
|
|
161
|
+
|
|
162
|
+
# Sends one delivery again, once, now.
|
|
163
|
+
def retry(delivery_id) = Record.new(@t.json("POST", "/v1/webhook-deliveries/#{seg(delivery_id)}:retry", body: {}))
|
|
164
|
+
|
|
165
|
+
# Checks a delivery's Runtime-Signature header against the raw body and
|
|
166
|
+
# your secret (or secrets, during a rotation), and returns the event.
|
|
167
|
+
# Raises WebhookVerificationError when the signature does not match or is
|
|
168
|
+
# older than +tolerance+ seconds (300), which stops a captured delivery
|
|
169
|
+
# being replayed. Pass the body exactly as received.
|
|
170
|
+
def self.verify(body, header, secret, tolerance: 300, now: Time.now.to_i)
|
|
171
|
+
raise WebhookVerificationError, "Missing Runtime-Signature header." if header.nil? || header.empty?
|
|
172
|
+
|
|
173
|
+
parts = header.split(",").map { |part| part.strip.split("=", 2) }
|
|
174
|
+
stamp = parts.find { |key, _| key == "t" }&.last
|
|
175
|
+
given = parts.select { |key, value| key == "v1" && value && !value.empty? }.map(&:last)
|
|
176
|
+
raise WebhookVerificationError, "Malformed Runtime-Signature header." unless stamp&.match?(/\A\d+\z/) && !given.empty?
|
|
177
|
+
raise WebhookVerificationError, "The signature is too old; the delivery may be a replay." if (now - stamp.to_i).abs > tolerance
|
|
178
|
+
|
|
179
|
+
Array(secret).each do |key|
|
|
180
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", key, "#{stamp}.#{body}")
|
|
181
|
+
next unless given.any? { |value| value.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(value, expected) }
|
|
182
|
+
|
|
183
|
+
begin
|
|
184
|
+
return Record.new(JSON.parse(body))
|
|
185
|
+
rescue JSON::ParserError
|
|
186
|
+
raise WebhookVerificationError, "The body is not JSON."
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
raise WebhookVerificationError, "No signature matches the secret."
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# +runtime.otel+: events (as logs) and CPU and memory (as metrics) pushed to
|
|
194
|
+
# an OpenTelemetry endpoint over OTLP/HTTP.
|
|
195
|
+
class Otel < Product
|
|
196
|
+
# +headers+ authenticate to the endpoint and are never shown back.
|
|
197
|
+
def create(endpoint:, headers: nil, signals: nil)
|
|
198
|
+
Record.new(@t.json("POST", "/v1/otel-exports", body: { "endpoint" => endpoint, "headers" => headers, "signals" => signals }.compact))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def list = @t.json("GET", "/v1/otel-exports")["data"].map { |export| Record.new(export) }
|
|
202
|
+
def get(id) = Record.new(@t.json("GET", "/v1/otel-exports/#{seg(id)}"))
|
|
203
|
+
def update(id, **patch) = Record.new(@t.json("POST", "/v1/otel-exports/#{seg(id)}:update", body: Fields.body(patch)))
|
|
204
|
+
|
|
205
|
+
# Pushes now instead of at the next interval.
|
|
206
|
+
def flush(id) = Record.new(@t.json("POST", "/v1/otel-exports/#{seg(id)}:flush", body: {}))
|
|
207
|
+
|
|
208
|
+
def delete(id)
|
|
209
|
+
@t.json("POST", "/v1/otel-exports/#{seg(id)}:delete", body: {})
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# +runtime.audit+: the account's audit log, newest first.
|
|
215
|
+
class Audit < Product
|
|
216
|
+
# +events+ and +next+ (pass it as +before:+). +action:+ is an action
|
|
217
|
+
# (key.created) or a group ending in a dot (member.).
|
|
218
|
+
def list(action: nil, limit: nil, before: nil)
|
|
219
|
+
Record.new(@t.json("GET", "/v1/audit", query: { "action" => action, "limit" => limit, "before" => before }))
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# +runtime.feedback+: tell the Runtime team something.
|
|
224
|
+
class Feedback < Product
|
|
225
|
+
# +kind+ is bug, missing_feature, competitor_gap, migration_blocker, docs, pricing, praise or other.
|
|
226
|
+
def submit(kind:, summary:, detail: nil, competitor: nil, resource_id: nil, request_id: nil, context: nil)
|
|
227
|
+
body = Fields.body(kind: kind, summary: summary, detail: detail, competitor: competitor,
|
|
228
|
+
resource_id: resource_id, request_id: request_id, context: context)
|
|
229
|
+
Record.new(@t.json("POST", "/v1/feedback", body: body))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# This account's reports and where each stands.
|
|
233
|
+
def list(limit: nil) = @t.json("GET", "/v1/feedback", query: { "limit" => limit })["data"].map { |item| Record.new(item) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# +runtime.support+: ask Runtime support.
|
|
237
|
+
class Support < Product
|
|
238
|
+
# When the status is "working", +read+ it again in a minute. Not retried:
|
|
239
|
+
# support cannot deduplicate a message.
|
|
240
|
+
def message(message = nil, conversation_id: nil, approve_action_id: nil, approve_input_hash: nil, deny_action_id: nil)
|
|
241
|
+
body = Fields.body(message: message, conversation_id: conversation_id, approve_action_id: approve_action_id,
|
|
242
|
+
approve_input_hash: approve_input_hash, deny_action_id: deny_action_id)
|
|
243
|
+
Record.new(@t.json("POST", "/v1/support/messages", body: body, no_retry: true, timeout: 120))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def read(conversation_id) = Record.new(@t.json("GET", "/v1/support/conversations/#{seg(conversation_id)}"))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# +sbx.network+: turn the sandbox's internet off or on, narrow it to a list,
|
|
250
|
+
# refuse destinations, or open host:port pairs. Changes apply at once.
|
|
251
|
+
class Network
|
|
252
|
+
def initialize(sandbox)
|
|
253
|
+
@sandbox = sandbox
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def get = Record.new(@sandbox.transport.json("GET", @sandbox.path("/network")))
|
|
257
|
+
|
|
258
|
+
# Replaces the rules: +internet:+, +allow:+ (example.com, *.example.com, an
|
|
259
|
+
# address or a CIDR range), +deny:+, and +connect:+ (host:port pairs beyond
|
|
260
|
+
# 80 and 443; paid accounts only).
|
|
261
|
+
def set(internet:, allow: nil, deny: nil, connect: nil)
|
|
262
|
+
body = { "internet" => internet, "allow" => allow, "deny" => deny, "connect" => connect }.compact
|
|
263
|
+
Record.new(@sandbox.transport.json("PUT", @sandbox.path("/network"), body: body))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def off = set(internet: false)
|
|
267
|
+
def on = set(internet: true)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# +sbx.interpreter+: stateful Python and JavaScript cells, like a notebook.
|
|
271
|
+
class Interpreter
|
|
272
|
+
RESULT = %r{\A/workspace/\.runtime/interpreter/([a-z0-9][a-z0-9-]*)/out/([A-Za-z0-9][A-Za-z0-9_.-]*)\z}
|
|
273
|
+
|
|
274
|
+
def initialize(sandbox)
|
|
275
|
+
@sandbox = sandbox
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Runs a cell. +language:+ is "python" (the default) or "javascript";
|
|
279
|
+
# +context:+ is a context id. With +on_stdout+, +on_stderr+ or +on_result+,
|
|
280
|
+
# output streams as it happens. Returns the execution: status (ok, error,
|
|
281
|
+
# interrupted, timeout, lost), stdout, stderr, results and error.
|
|
282
|
+
def run(code, language: nil, context: nil, timeout: nil, on_stdout: nil, on_stderr: nil, on_result: nil)
|
|
283
|
+
body = { "code" => code, "language" => language, "context" => context,
|
|
284
|
+
"timeoutMs" => timeout && (timeout * 1000).round }.compact
|
|
285
|
+
return Record.new(t.json("POST", path(":run"), body: body)) unless on_stdout || on_stderr || on_result
|
|
286
|
+
|
|
287
|
+
t.events("POST", path(":run"), body: body.merge("stream" => true)) do |event|
|
|
288
|
+
case event["k"]
|
|
289
|
+
when "stdout" then on_stdout&.call(event["text"])
|
|
290
|
+
when "stderr" then on_stderr&.call(event["text"])
|
|
291
|
+
when "result" then on_result&.call(Record.new(event))
|
|
292
|
+
when "execution" then return Record.new(event["execution"])
|
|
293
|
+
when "failure" then raise Error.new(event["message"], code: event["code"], hint: event["hint"])
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
raise Error.new("The interpreter stream ended without a result.", code: "stream_failed")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def contexts = t.json("GET", path("/contexts"))["data"].map { |context| Record.new(context) }
|
|
300
|
+
|
|
301
|
+
def create_context(id: nil, language: nil, cwd: nil, env: nil)
|
|
302
|
+
Record.new(t.json("POST", path("/contexts"), body: Fields.body(id: id, language: language, cwd: cwd, env: env)))
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def restart_context(id) = Record.new(t.json("POST", path("/contexts/#{Transport.segment(id)}:restart"), body: {}))
|
|
306
|
+
def interrupt_context(id) = t.json("POST", path("/contexts/#{Transport.segment(id)}:interrupt"), body: {})["interrupted"] == true
|
|
307
|
+
def remove_context(id) = t.json("DELETE", path("/contexts/#{Transport.segment(id)}"))["deleted"] == true
|
|
308
|
+
|
|
309
|
+
# The bytes of a result too large to travel inline (a refs entry's path).
|
|
310
|
+
def result(result_path)
|
|
311
|
+
match = RESULT.match(result_path) or raise ArgumentError, "Not an interpreter result path."
|
|
312
|
+
t.bytes("GET", path("/contexts/#{match[1]}/results/#{match[2]}"), accept: "application/octet-stream")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
|
|
317
|
+
def t = @sandbox.transport
|
|
318
|
+
def path(suffix) = @sandbox.path("/interpreter#{suffix}")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# +sbx.desktop+: a Linux desktop in the sandbox, driven like a person would.
|
|
322
|
+
# Coordinates are pixels from the top left of the screen.
|
|
323
|
+
class Desktop
|
|
324
|
+
def initialize(sandbox)
|
|
325
|
+
@sandbox = sandbox
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def recordings = Recordings.new(@sandbox)
|
|
329
|
+
|
|
330
|
+
# Starts the desktop; +stream_url+ opens it live in a browser.
|
|
331
|
+
def start(width: nil, height: nil)
|
|
332
|
+
Record.new(t.json("POST", @sandbox.path("/desktop:start"), body: { "width" => width, "height" => height }.compact))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def stop = t.json("POST", @sandbox.path("/desktop:stop"), body: {}) && nil
|
|
336
|
+
|
|
337
|
+
# PNG bytes, or JPEG with +format: "jpeg"+.
|
|
338
|
+
def screenshot(format: nil, quality: nil)
|
|
339
|
+
t.bytes("GET", @sandbox.path("/desktop/screenshot"), query: { "format" => format, "quality" => quality })
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def move(x, y) = act("action" => "move", "x" => x, "y" => y)
|
|
343
|
+
def click(x = nil, y = nil, button: nil, double: nil) = act({ "action" => "click", "x" => x, "y" => y, "button" => button, "double" => double }.compact)
|
|
344
|
+
def double_click(x = nil, y = nil) = click(x, y, double: true)
|
|
345
|
+
def right_click(x = nil, y = nil) = click(x, y, button: "right")
|
|
346
|
+
def mouse_down(button = "left") = act("action" => "mouseDown", "button" => button)
|
|
347
|
+
def mouse_up(button = "left") = act("action" => "mouseUp", "button" => button)
|
|
348
|
+
def drag(from, to) = act("action" => "drag", "from" => from, "to" => to)
|
|
349
|
+
|
|
350
|
+
# Wheel clicks: positive +dy+ scrolls down, positive +dx+ right.
|
|
351
|
+
def scroll(dy, dx: nil, x: nil, y: nil) = act({ "action" => "scroll", "dy" => dy, "dx" => dx, "x" => x, "y" => y }.compact)
|
|
352
|
+
|
|
353
|
+
def type(text, delay_ms: nil) = act({ "action" => "type", "text" => text, "delayMs" => delay_ms }.compact)
|
|
354
|
+
|
|
355
|
+
# xdotool key names, space separated: "ctrl+l", "Return", "alt+Tab".
|
|
356
|
+
def press(keys) = act("action" => "key", "keys" => keys)
|
|
357
|
+
|
|
358
|
+
def cursor = act("action" => "cursor")
|
|
359
|
+
def windows = act("action" => "windows")["windows"]
|
|
360
|
+
def focus(window_id) = act("action" => "focus", "windowId" => window_id)
|
|
361
|
+
|
|
362
|
+
# Opens +url+ in Firefox on the desktop.
|
|
363
|
+
def open(url) = act("action" => "open", "url" => url)
|
|
364
|
+
|
|
365
|
+
# Starts a program on the desktop, detached.
|
|
366
|
+
def launch(*argv) = act("action" => "launch", "argv" => argv)
|
|
367
|
+
|
|
368
|
+
private
|
|
369
|
+
|
|
370
|
+
def t = @sandbox.transport
|
|
371
|
+
def act(body) = Record.new(t.json("POST", @sandbox.path("/desktop:act"), body: body))
|
|
372
|
+
end
|
|
373
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WithRuntime
|
|
4
|
+
# An answer from the API: the whole JSON object, read by snake_case name
|
|
5
|
+
# (+info.expires_at+, +info.memory_mib+) or by the API's own key
|
|
6
|
+
# (+info["expiresAt"]+). A field newer than this SDK is always there.
|
|
7
|
+
class Record
|
|
8
|
+
attr_reader :to_h
|
|
9
|
+
|
|
10
|
+
def initialize(hash)
|
|
11
|
+
@to_h = (hash || {}).freeze
|
|
12
|
+
@index = @to_h.keys.to_h { |key| [key.to_s.delete("_").downcase, key] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def [](key) = @to_h[key.to_s]
|
|
16
|
+
|
|
17
|
+
def key?(name) = @index.key?(name.to_s.delete("_").downcase)
|
|
18
|
+
|
|
19
|
+
# A field by snake_case name; nil when the answer does not carry it, as a
|
|
20
|
+
# missing key reads in the other SDKs.
|
|
21
|
+
def method_missing(name, *args)
|
|
22
|
+
return super unless args.empty? && name.match?(/\A[a-z][a-z0-9_]*\??\z/) && !name.start_with?("to_")
|
|
23
|
+
|
|
24
|
+
key = @index[name.to_s.delete_suffix("?").delete("_").downcase]
|
|
25
|
+
value = key && wrap(@to_h[key])
|
|
26
|
+
name.end_with?("?") ? !!value : value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def respond_to_missing?(name, include_private = false)
|
|
30
|
+
@index.key?(name.to_s.delete_suffix("?").delete("_").downcase) || super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ==(other) = other.is_a?(Record) && other.to_h == to_h
|
|
34
|
+
|
|
35
|
+
def to_s = JSON.generate(to_h)
|
|
36
|
+
|
|
37
|
+
def inspect = "#<#{self.class.name} #{to_s}>"
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def wrap(value)
|
|
42
|
+
case value
|
|
43
|
+
when Hash then Record.new(value)
|
|
44
|
+
when Array then value.map { |item| wrap(item) }
|
|
45
|
+
else value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A finished command. A timeout is a result (+timed_out+), not an exception.
|
|
51
|
+
class CommandResult < Record
|
|
52
|
+
def exit_code = self["exitCode"]
|
|
53
|
+
def stdout = self["stdout"] || ""
|
|
54
|
+
def stderr = self["stderr"] || ""
|
|
55
|
+
def timed_out = self["timedOut"] == true
|
|
56
|
+
def ok? = !timed_out && exit_code == 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# One page of a list. +next_page+ reads the one after it; +each+ walks every
|
|
60
|
+
# item on this page and on every page after it.
|
|
61
|
+
class Page
|
|
62
|
+
include Enumerable
|
|
63
|
+
attr_reader :data, :next_cursor
|
|
64
|
+
|
|
65
|
+
def initialize(data, next_cursor, &fetch)
|
|
66
|
+
@data = data
|
|
67
|
+
@next_cursor = next_cursor
|
|
68
|
+
@fetch = fetch
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def more? = !(next_cursor.nil? || next_cursor.empty?)
|
|
72
|
+
|
|
73
|
+
def next_page = more? ? @fetch.call(next_cursor) : nil
|
|
74
|
+
|
|
75
|
+
def each(&block)
|
|
76
|
+
return enum_for(:each) unless block
|
|
77
|
+
|
|
78
|
+
page = self
|
|
79
|
+
while page
|
|
80
|
+
page.data.each(&block)
|
|
81
|
+
page = page.next_page
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|