claude-inbox 0.2.0 → 0.4.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 +4 -4
- data/README.md +107 -177
- data/exe/claude-inbox +67 -4
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +68 -62
- data/lib/claude_inbox/app.rb +88 -38
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +3 -1
- data/lib/claude_inbox/headless.rb +31 -0
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +25 -10
- data/lib/claude_inbox/keymap.rb +1 -3
- data/lib/claude_inbox/mod.rb +71 -0
- data/lib/claude_inbox/new_session_form.rb +43 -52
- data/lib/claude_inbox/painter.rb +31 -0
- data/lib/claude_inbox/palette.rb +4 -7
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +48 -24
- data/lib/claude_inbox/pull_requests.rb +36 -22
- data/lib/claude_inbox/rate_limits.rb +1 -3
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +140 -0
- data/lib/claude_inbox/remote/icon.png +0 -0
- data/lib/claude_inbox/remote/icon.svg +8 -0
- data/lib/claude_inbox/remote/listener.rb +435 -0
- data/lib/claude_inbox/remote/page.html +477 -0
- data/lib/claude_inbox/remote/pairing.rb +111 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
- data/lib/claude_inbox/remote/start.rb +201 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +61 -61
- data/lib/claude_inbox/session.rb +7 -21
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +0 -1
- data/lib/claude_inbox/settings.rb +20 -7
- data/lib/claude_inbox/slash_commands.rb +1 -0
- data/lib/claude_inbox/snapshot.rb +48 -0
- data/lib/claude_inbox/store/entry.rb +0 -2
- data/lib/claude_inbox/store/row.rb +7 -3
- data/lib/claude_inbox/store.rb +24 -11
- data/lib/claude_inbox/subprocess.rb +9 -1
- data/lib/claude_inbox/switch.rb +40 -0
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +7 -1
- data/lib/claude_inbox/text_buffer.rb +84 -33
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -1
- data/lib/claude_inbox/writer_lock.rb +65 -0
- data/lib/claude_inbox.rb +1 -10
- data/mod/.claude-plugin/plugin.json +20 -0
- data/mod/hooks/hooks.json +1 -0
- data/mod/hooks/register.tsx +470 -0
- data/mod/tests/contract.test.tsx +23 -0
- data/mod/tests/pane.test.tsx +199 -0
- data/mod/tests/snapshot.fixture.ts +47 -0
- data/mod/tests/snooze.test.tsx +31 -0
- data/mod/tsconfig.json +3 -0
- data/mod/types/index.d.ts +30 -0
- metadata +29 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "socket"
|
|
6
|
+
require_relative "../agents_client"
|
|
7
|
+
require_relative "http"
|
|
8
|
+
require_relative "pairing"
|
|
9
|
+
require_relative "pairing_dialog"
|
|
10
|
+
require_relative "start"
|
|
11
|
+
require_relative "../subprocess"
|
|
12
|
+
require_relative "../text"
|
|
13
|
+
|
|
14
|
+
module ClaudeInbox
|
|
15
|
+
module Remote
|
|
16
|
+
# The opt-in HTTP listener a phone starts sessions through: the port, the
|
|
17
|
+
# connection limits, the static files and the Pairing token, in front of
|
|
18
|
+
# Start. Like the Poller it reaches App only through the queue, and it
|
|
19
|
+
# never writes to the terminal: every failure becomes a response or a
|
|
20
|
+
# notice.
|
|
21
|
+
class Listener
|
|
22
|
+
DEFAULT_PORT = 7433
|
|
23
|
+
DEFAULT_MODES = %w[default auto plan].freeze
|
|
24
|
+
LOCK_PATH = File.join(Dir.home, ".config", "claude-inbox", "listen.lock")
|
|
25
|
+
MAX_CONNECTIONS = 4
|
|
26
|
+
# Per host, so one host's idle sockets can't shut the others out. Two,
|
|
27
|
+
# so a phone loading the page doesn't turn its own requests away.
|
|
28
|
+
MAX_UNAUTHENTICATED = 2
|
|
29
|
+
MAX_TURNED_AWAY = 8
|
|
30
|
+
RETRY_EVERY = 5
|
|
31
|
+
WAITING = %i[in_use held].freeze
|
|
32
|
+
HEAD_TIMEOUT = 10
|
|
33
|
+
LINGER = 2
|
|
34
|
+
RECENT = 5
|
|
35
|
+
# The phone's form. Everything it needs is inline, and it talks to
|
|
36
|
+
# nothing but this listener.
|
|
37
|
+
PAGE = File.read(File.join(__dir__, "page.html"), encoding: Encoding::UTF_8).freeze
|
|
38
|
+
PAGE_TYPE = {
|
|
39
|
+
"Content-Type" => "text/html; charset=utf-8",
|
|
40
|
+
"Content-Security-Policy" => "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " \
|
|
41
|
+
"img-src blob: data:; connect-src 'self'; manifest-src 'self'; form-action 'none'; frame-ancestors 'none'"
|
|
42
|
+
}.freeze
|
|
43
|
+
# What Add to Home Screen reads to open the page as an app of its own.
|
|
44
|
+
# The icon is drawn by icon.svg: rsvg-convert -w 512 icon.svg -o icon.png
|
|
45
|
+
MANIFEST = JSON.generate(name: "Claude Inbox", start_url: "/", scope: "/", display: "standalone",
|
|
46
|
+
icons: [{src: "/icon.png", sizes: "512x512", type: "image/png"}]).freeze
|
|
47
|
+
ICON = File.binread(File.join(__dir__, "icon.png")).freeze
|
|
48
|
+
# Served to anyone, like the page itself: none of it is a secret.
|
|
49
|
+
FILES = {
|
|
50
|
+
"/" => [PAGE_TYPE, PAGE],
|
|
51
|
+
"/manifest.webmanifest" => [{"Content-Type" => "application/manifest+json"}, MANIFEST],
|
|
52
|
+
"/icon.png" => [{"Content-Type" => "image/png"}, ICON]
|
|
53
|
+
}.freeze
|
|
54
|
+
ROUTES = FILES.transform_values { "GET" }.merge(Start::VERBS).freeze
|
|
55
|
+
|
|
56
|
+
# What `N` and the header chip show, as of one moment. `state` is :off,
|
|
57
|
+
# :listening, :in_use (the port is taken), :held (another inbox has the
|
|
58
|
+
# lock, as `held_by`) or :failed (for good, as `error` says). `urls` and
|
|
59
|
+
# `firewall` are nil until `refresh`.
|
|
60
|
+
Snapshot = Data.define(:state, :port, :lan, :urls, :firewall, :allowed_modes, :recent, :held_by, :fixture, :error) do
|
|
61
|
+
def initialize(error: nil, **) = super
|
|
62
|
+
|
|
63
|
+
# Over a VPN only an address reaches the Mac: multicast DNS, which
|
|
64
|
+
# the .local name needs, stays on the LAN.
|
|
65
|
+
def pairing_url = (lan && urls&.[](1)) || urls&.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# `count` is how many times `via` got `result`, the last at `at`.
|
|
69
|
+
Outcome = Data.define(:at, :via, :result, :count) do
|
|
70
|
+
def initialize(at:, via:, result:, count: 1) = super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# App's `listen:` from the flags, else the environment; nil when neither
|
|
74
|
+
# asks for it. --listen-lan wins over --listen, whichever came last.
|
|
75
|
+
def self.options(argv, env)
|
|
76
|
+
flags = argv.filter_map { |arg| arg.match(/\A--listen(-lan)?(?:=(.*))?\z/) }
|
|
77
|
+
lan, port =
|
|
78
|
+
if flags.any?
|
|
79
|
+
flag = flags.reverse.find { |m| m[1] } || flags.last
|
|
80
|
+
[!flag[1].nil?, flag[2]]
|
|
81
|
+
elsif !(value = env["CLAUDE_INBOX_LISTEN"].to_s.strip).empty?
|
|
82
|
+
m = value.match(/\A(?:(lan)(?::(.*))?|(\d+))\z/i)
|
|
83
|
+
raise ArgumentError, "CLAUDE_INBOX_LISTEN is a port, lan or lan:PORT, not #{value}" unless m
|
|
84
|
+
[!m[1].nil?, m[2] || m[3]]
|
|
85
|
+
else
|
|
86
|
+
return nil
|
|
87
|
+
end
|
|
88
|
+
modes = argv.filter_map { |arg| arg[/\A--listen-allow-modes=(.*)\z/, 1] }.last || env["CLAUDE_INBOX_LISTEN_ALLOW_MODES"]
|
|
89
|
+
{port: port_option(port), lan: lan, allowed_modes: modes_option(modes)}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.port_option(value)
|
|
93
|
+
return DEFAULT_PORT if value.nil?
|
|
94
|
+
raise ArgumentError, "the listen port is a number up to 65535, not #{value}" unless value.match?(/\A\d{1,5}\z/) && value.to_i <= 65_535
|
|
95
|
+
value.to_i
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.modes_option(value)
|
|
99
|
+
return DEFAULT_MODES if value.nil? || value.strip.empty?
|
|
100
|
+
modes = value.split(",").map(&:strip).reject(&:empty?)
|
|
101
|
+
unknown = modes - AgentsClient::PERMISSION_MODES
|
|
102
|
+
raise ArgumentError, "unknown permission mode #{unknown.first}: one of #{AgentsClient::PERMISSION_MODES.join(", ")}" if unknown.any?
|
|
103
|
+
modes.uniq.freeze
|
|
104
|
+
end
|
|
105
|
+
private_class_method :port_option, :modes_option
|
|
106
|
+
|
|
107
|
+
# For tests, --fixture without a listen flag, and anything embedding
|
|
108
|
+
# App: never binds, and answers :off.
|
|
109
|
+
def self.disabled = new(client: nil, store: nil, queue: nil, pairing: nil, port: nil)
|
|
110
|
+
|
|
111
|
+
# `start_options` are Start's own.
|
|
112
|
+
def initialize(queue:, port:, pairing: Pairing.new, lan: false, allowed_modes: DEFAULT_MODES,
|
|
113
|
+
lock_path: LOCK_PATH, fixture: false, retry_every: RETRY_EVERY, **start_options)
|
|
114
|
+
@queue = queue
|
|
115
|
+
@pairing = pairing
|
|
116
|
+
@port = port
|
|
117
|
+
@lan = lan
|
|
118
|
+
@allowed_modes = allowed_modes
|
|
119
|
+
@lock_path = lock_path
|
|
120
|
+
@fixture = fixture
|
|
121
|
+
@retry_every = retry_every
|
|
122
|
+
@start = Start.new(queue: queue, allowed_modes: allowed_modes, fixture: fixture, **start_options)
|
|
123
|
+
@mutex = Mutex.new
|
|
124
|
+
@state = :off
|
|
125
|
+
@recent = []
|
|
126
|
+
@threads = Set.new
|
|
127
|
+
@alive = 0
|
|
128
|
+
@unauthenticated = Hash.new(0)
|
|
129
|
+
@turning_away = 0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Tries again every few seconds while the port is taken or another
|
|
133
|
+
# inbox has the listener, so this one takes over once it is free.
|
|
134
|
+
def start
|
|
135
|
+
return if @port.nil? || @accept || @retry || !WAITING.include?(claim_port)
|
|
136
|
+
@retry = Thread.new { retry_claim }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Runs after the terminal is restored and can't fail, so a bad socket
|
|
140
|
+
# never leaves the terminal raw.
|
|
141
|
+
def stop
|
|
142
|
+
quietly { @retry&.kill }
|
|
143
|
+
quietly { @accept&.kill }
|
|
144
|
+
quietly { @mutex.synchronize { @threads.to_a }.each(&:kill) }
|
|
145
|
+
quietly { @server&.close }
|
|
146
|
+
release_lock
|
|
147
|
+
@mutex.synchronize { @state = :off }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def port = @bound_port || @port
|
|
151
|
+
|
|
152
|
+
def snapshot
|
|
153
|
+
@mutex.synchronize do
|
|
154
|
+
Snapshot.new(state: @state, port: port, lan: @lan, urls: @urls, firewall: @firewall, allowed_modes: @allowed_modes,
|
|
155
|
+
recent: @recent.dup.freeze, held_by: @held_by, fixture: @fixture, error: @error)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Looks up the pairing URLs and the firewall for the snapshot. It
|
|
160
|
+
# forks, so it runs off the main thread, when `N` opens; asked for
|
|
161
|
+
# before the port is ours, it runs once it is.
|
|
162
|
+
def refresh
|
|
163
|
+
@refresh_wanted = true
|
|
164
|
+
return unless snapshot.state == :listening
|
|
165
|
+
urls = @pairing.urls(port: port, lan: @lan).freeze
|
|
166
|
+
firewall = @pairing.firewall if @lan
|
|
167
|
+
@mutex.synchronize do
|
|
168
|
+
@urls = urls
|
|
169
|
+
@firewall = firewall
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The addresses fork scutil, so they fill in once looked up.
|
|
174
|
+
def pairing_dialog
|
|
175
|
+
in_background { refresh }
|
|
176
|
+
PairingDialog.new(-> { snapshot })
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def copy_pairing_url
|
|
180
|
+
url = snapshot.pairing_url
|
|
181
|
+
return @queue << [:notice, "still looking up this Mac's addresses"] unless url
|
|
182
|
+
in_background do
|
|
183
|
+
r = Subprocess.capture("osascript", "-e", "on run argv", "-e", "set the clipboard to item 1 of argv", "-e", "end run", url)
|
|
184
|
+
@queue << [:notice, r.success? ? "pairing URL copied" : "couldn't copy: #{r.err.strip}"]
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def rotate
|
|
189
|
+
in_background do
|
|
190
|
+
@pairing.rotate!
|
|
191
|
+
refresh
|
|
192
|
+
@queue << [:notice, "new token: phones pair again with N"]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# One request read from `io` and answered on it. The connection's
|
|
197
|
+
# `slot` stops counting as unauthenticated once the token checks out.
|
|
198
|
+
def handle(io, via:, slot: nil)
|
|
199
|
+
authorized = false
|
|
200
|
+
request = Http.read_head(io, deadline: Http.monotonic + HEAD_TIMEOUT)
|
|
201
|
+
file = route(request)
|
|
202
|
+
return Http.write(io, 200, *file) if file
|
|
203
|
+
authorize(request, via)
|
|
204
|
+
authorized = true
|
|
205
|
+
authenticated(slot) if slot
|
|
206
|
+
Http.write(io, *answer(request, io, via))
|
|
207
|
+
rescue Http::Error => e
|
|
208
|
+
Http.write(io, e.status, Http::JSON_TYPE.merge(e.headers), JSON.generate(e.body))
|
|
209
|
+
rescue => e
|
|
210
|
+
# A message can name a path under the home directory, which is
|
|
211
|
+
# nobody's business before the token checks out.
|
|
212
|
+
Http.write(io, 500, Http::JSON_TYPE, JSON.generate(error: authorized ? Http.utf8(e.message) : "internal error"))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
# Without SO_REUSEADDR the bind fails while another program holds the
|
|
218
|
+
# port on any address, rather than sharing it (macOS gives loopback to
|
|
219
|
+
# the more specific bind); the cost is a bind refused during TIME_WAIT.
|
|
220
|
+
def claim_port
|
|
221
|
+
return :held unless take_lock
|
|
222
|
+
server = Socket.new(:INET, :STREAM)
|
|
223
|
+
server.bind(Addrinfo.tcp(@lan ? "0.0.0.0" : "127.0.0.1", @port))
|
|
224
|
+
server.listen(8)
|
|
225
|
+
@server = server
|
|
226
|
+
@bound_port = server.local_address.ip_port
|
|
227
|
+
@mutex.synchronize { @state = :listening }
|
|
228
|
+
@accept = Thread.new { accept_loop }
|
|
229
|
+
refresh_quietly if @refresh_wanted
|
|
230
|
+
:listening
|
|
231
|
+
rescue Errno::EADDRINUSE
|
|
232
|
+
give_up(server, :in_use)
|
|
233
|
+
rescue SystemCallError => e
|
|
234
|
+
give_up(server, :failed, e.message)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def retry_claim
|
|
238
|
+
loop do
|
|
239
|
+
sleep @retry_every
|
|
240
|
+
break unless WAITING.include?(claim_port)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def give_up(server, state, error = nil)
|
|
245
|
+
quietly { server&.close }
|
|
246
|
+
release_lock
|
|
247
|
+
@mutex.synchronize do
|
|
248
|
+
@state = state
|
|
249
|
+
@error = error && Text.printable(error)
|
|
250
|
+
end
|
|
251
|
+
state
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# On the retry thread, where nothing would report a failure; the dialog
|
|
255
|
+
# just goes on saying it is looking the addresses up.
|
|
256
|
+
def refresh_quietly
|
|
257
|
+
refresh
|
|
258
|
+
rescue
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def take_lock
|
|
263
|
+
FileUtils.mkdir_p(File.dirname(@lock_path))
|
|
264
|
+
file = File.open(@lock_path, File::RDWR | File::CREAT, 0o600)
|
|
265
|
+
if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
266
|
+
file.truncate(0)
|
|
267
|
+
file.write(Process.pid.to_s)
|
|
268
|
+
file.flush
|
|
269
|
+
@mutex.synchronize { @held_by = nil }
|
|
270
|
+
@lock = file
|
|
271
|
+
else
|
|
272
|
+
holder = file.read.to_i
|
|
273
|
+
file.close
|
|
274
|
+
@mutex.synchronize do
|
|
275
|
+
@state = :held
|
|
276
|
+
@held_by = holder.positive? ? holder : nil
|
|
277
|
+
end
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def release_lock
|
|
283
|
+
quietly { @lock&.close }
|
|
284
|
+
@lock = nil
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Only closing the socket ends it; anything else, a full file table
|
|
288
|
+
# say, is waited out.
|
|
289
|
+
def accept_loop
|
|
290
|
+
loop do
|
|
291
|
+
sock, addr = @server.accept
|
|
292
|
+
admit(sock, peer(addr))
|
|
293
|
+
rescue IOError
|
|
294
|
+
break
|
|
295
|
+
rescue
|
|
296
|
+
sleep 0.1
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def peer(addr) = addr.ip_address
|
|
301
|
+
|
|
302
|
+
def admit(sock, via)
|
|
303
|
+
slot = @mutex.synchronize { take_slot(via) }
|
|
304
|
+
return quietly { sock.close } unless slot
|
|
305
|
+
thread = Thread.new { serve(sock, slot) }
|
|
306
|
+
@mutex.synchronize { @threads << thread if thread.alive? }
|
|
307
|
+
rescue ThreadError
|
|
308
|
+
quietly { sock.close }
|
|
309
|
+
free(slot)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Over the cap, a 503 rather than a queue. It lingers like any answer,
|
|
313
|
+
# so it isn't lost to a reset; past a few of those, only a close.
|
|
314
|
+
def take_slot(via)
|
|
315
|
+
if @alive < MAX_CONNECTIONS && @unauthenticated[via] < MAX_UNAUTHENTICATED
|
|
316
|
+
@alive += 1
|
|
317
|
+
@unauthenticated[via] += 1
|
|
318
|
+
{via: via, authenticated: false}
|
|
319
|
+
elsif @turning_away < MAX_TURNED_AWAY
|
|
320
|
+
@turning_away += 1
|
|
321
|
+
{busy: true}
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def free(slot)
|
|
326
|
+
@mutex.synchronize do
|
|
327
|
+
if slot[:busy] then @turning_away -= 1
|
|
328
|
+
else
|
|
329
|
+
@alive -= 1
|
|
330
|
+
release_unauthenticated(slot[:via]) unless slot[:authenticated]
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Under the mutex. A host leaves the hash at zero, so it holds only
|
|
336
|
+
# hosts connected now.
|
|
337
|
+
def release_unauthenticated(via)
|
|
338
|
+
@unauthenticated[via] -= 1
|
|
339
|
+
@unauthenticated.delete(via) if @unauthenticated[via].zero?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# `handle` answers every failure it can; what is left is the socket
|
|
343
|
+
# itself going away, and a thread must not report that on stderr.
|
|
344
|
+
def serve(sock, slot)
|
|
345
|
+
if slot[:busy]
|
|
346
|
+
Http.write(sock, 503, Http::JSON_TYPE.merge("Retry-After" => "2"), JSON.generate(error: "busy: try again in a moment"))
|
|
347
|
+
else
|
|
348
|
+
handle(sock, via: slot[:via], slot: slot)
|
|
349
|
+
end
|
|
350
|
+
linger(sock)
|
|
351
|
+
rescue
|
|
352
|
+
nil
|
|
353
|
+
ensure
|
|
354
|
+
quietly { sock.close }
|
|
355
|
+
free(slot)
|
|
356
|
+
@mutex.synchronize { @threads.delete(Thread.current) }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def authenticated(slot)
|
|
360
|
+
@mutex.synchronize do
|
|
361
|
+
release_unauthenticated(slot[:via]) unless slot[:authenticated]
|
|
362
|
+
slot[:authenticated] = true
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# The client closes first: a close on unread bytes resets the answer,
|
|
367
|
+
# and a server-side close leaves the TIME_WAIT that refuses a rebind.
|
|
368
|
+
def linger(sock)
|
|
369
|
+
deadline = Http.monotonic + LINGER
|
|
370
|
+
loop do
|
|
371
|
+
left = deadline - Http.monotonic
|
|
372
|
+
break unless left > 0 && IO.select([sock], nil, nil, left)
|
|
373
|
+
break if sock.read_nonblock(Http::CHUNK, exception: false).nil?
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# The file a path serves to anyone, or nil for one Start answers.
|
|
378
|
+
def route(request)
|
|
379
|
+
raise Http::Error.new(405, "only GET and POST", headers: {"Allow" => "GET, POST"}) unless %w[GET POST].include?(request.verb)
|
|
380
|
+
raise Http::Error.new(421, "the Host header isn't a name this inbox answers to") unless @pairing.hosts(lan: @lan).include?(host(request))
|
|
381
|
+
verb = ROUTES[request.path]
|
|
382
|
+
raise Http::Error.new(404, "no such path") unless verb
|
|
383
|
+
raise Http::Error.new(405, "#{request.path} takes #{verb}", headers: {"Allow" => verb}) unless request.verb == verb
|
|
384
|
+
FILES[request.path]
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# The name without its port: a tunnel or a proxy can change the port,
|
|
388
|
+
# and only the name tells this Mac from a site pointed at it.
|
|
389
|
+
def host(request) = request.headers["host"].to_s.downcase.sub(/:\d+\z/, "")
|
|
390
|
+
|
|
391
|
+
def authorize(request, via)
|
|
392
|
+
return if @pairing.matches?(request.headers["authorization"].to_s[/\ABearer +(\S+)\z/i, 1])
|
|
393
|
+
remember(via, "token rejected")
|
|
394
|
+
raise Http::Error.new(401, "missing or wrong token: press N in the inbox and pair again", headers: {"WWW-Authenticate" => "Bearer"})
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# The same outcome from the same place again moves up with a count, so
|
|
398
|
+
# a host sending bad tokens can't push everything else out of the list.
|
|
399
|
+
# Every start, and every refusal once the token has checked out, is
|
|
400
|
+
# listed for `N`.
|
|
401
|
+
def answer(request, io, via)
|
|
402
|
+
status, headers, body, note = @start.call(request, io, via)
|
|
403
|
+
remember(via, note) if note
|
|
404
|
+
[status, headers, body]
|
|
405
|
+
rescue Http::Error => e
|
|
406
|
+
remember(via, e.note || "refused: #{e.message}")
|
|
407
|
+
raise
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def remember(via, result)
|
|
411
|
+
result = Text.printable(result)
|
|
412
|
+
@mutex.synchronize do
|
|
413
|
+
same = @recent.find { |o| o.via == via && o.result == result }
|
|
414
|
+
outcome = Outcome.new(at: Time.now, via: via, result: result, count: (same&.count || 0) + 1)
|
|
415
|
+
@recent = (@recent - [same] + [outcome]).last(RECENT)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# A notice, not an :error: the next poll clears an :error.
|
|
420
|
+
def in_background
|
|
421
|
+
Thread.new do
|
|
422
|
+
yield
|
|
423
|
+
rescue => e
|
|
424
|
+
@queue << [:notice, Text.printable(e.message)]
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def quietly
|
|
429
|
+
yield
|
|
430
|
+
rescue IOError, SystemCallError
|
|
431
|
+
nil
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|