claude-inbox 0.2.0 → 0.3.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -152
  3. data/exe/claude-inbox +23 -4
  4. data/lib/claude_inbox/agents_client.rb +57 -55
  5. data/lib/claude_inbox/app.rb +42 -26
  6. data/lib/claude_inbox/config.rb +24 -0
  7. data/lib/claude_inbox/dialog.rb +3 -1
  8. data/lib/claude_inbox/images.rb +37 -6
  9. data/lib/claude_inbox/job_state.rb +23 -8
  10. data/lib/claude_inbox/keymap.rb +1 -1
  11. data/lib/claude_inbox/new_session_form.rb +29 -39
  12. data/lib/claude_inbox/painter.rb +32 -0
  13. data/lib/claude_inbox/palette.rb +4 -7
  14. data/lib/claude_inbox/peek.rb +2 -2
  15. data/lib/claude_inbox/poller.rb +4 -3
  16. data/lib/claude_inbox/pull_requests.rb +5 -5
  17. data/lib/claude_inbox/records.rb +7 -2
  18. data/lib/claude_inbox/remote/http.rb +137 -0
  19. data/lib/claude_inbox/remote/icon.png +0 -0
  20. data/lib/claude_inbox/remote/icon.svg +8 -0
  21. data/lib/claude_inbox/remote/listener.rb +436 -0
  22. data/lib/claude_inbox/remote/page.html +477 -0
  23. data/lib/claude_inbox/remote/pairing.rb +111 -0
  24. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  25. data/lib/claude_inbox/remote/start.rb +202 -0
  26. data/lib/claude_inbox/remote.rb +10 -0
  27. data/lib/claude_inbox/renderer.rb +57 -57
  28. data/lib/claude_inbox/session.rb +7 -21
  29. data/lib/claude_inbox/session_request.rb +151 -0
  30. data/lib/claude_inbox/sessions.rb +0 -1
  31. data/lib/claude_inbox/settings.rb +19 -7
  32. data/lib/claude_inbox/store.rb +0 -2
  33. data/lib/claude_inbox/subprocess.rb +9 -1
  34. data/lib/claude_inbox/terminal.rb +9 -3
  35. data/lib/claude_inbox/text.rb +7 -1
  36. data/lib/claude_inbox/trust.rb +23 -0
  37. data/lib/claude_inbox/vt_screen.rb +1 -1
  38. data/lib/claude_inbox.rb +1 -1
  39. metadata +14 -1
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ module Remote
5
+ # The little of HTTP/1.1 the listener speaks: one request per
6
+ # connection, a body sized by Content-Length, and a response that closes
7
+ # the connection. Nothing here knows a route or a token.
8
+ module Http
9
+ MAX_LINE = 8 * 1024
10
+ MAX_HEADERS = 64
11
+ CHUNK = 64 * 1024
12
+ TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
13
+
14
+ REASONS = {
15
+ 100 => "Continue", 200 => "OK", 201 => "Created", 400 => "Bad Request", 401 => "Unauthorized",
16
+ 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict",
17
+ 411 => "Length Required", 413 => "Content Too Large", 415 => "Unsupported Media Type",
18
+ 421 => "Misdirected Request", 422 => "Unprocessable Content", 500 => "Internal Server Error",
19
+ 503 => "Service Unavailable"
20
+ }.freeze
21
+
22
+ HEADERS = {
23
+ "Connection" => "close",
24
+ "Cache-Control" => "no-store",
25
+ "X-Content-Type-Options" => "nosniff",
26
+ "Referrer-Policy" => "no-referrer"
27
+ }.freeze
28
+
29
+ JSON_TYPE = {"Content-Type" => "application/json"}.freeze
30
+
31
+ # A request refused: the status, the message and any other members for
32
+ # the JSON body, and the headers that status calls for. `note`, when
33
+ # set, is what the refusal is logged as in place of its message.
34
+ class Error < StandardError
35
+ attr_reader :status, :headers, :details, :note
36
+
37
+ def initialize(status, message, headers: {}, note: nil, **details)
38
+ super(message)
39
+ @status = status
40
+ @headers = headers
41
+ @note = note
42
+ @details = details
43
+ end
44
+
45
+ def body = {error: Http.utf8(message), **details.transform_values { |v| v.is_a?(Integer) ? v : Http.utf8(v) }}
46
+ end
47
+
48
+ # A request head. `headers` are keyed in lower case, and a repeated
49
+ # header is joined with ", ", so a second Content-Length or Host can't
50
+ # pass for the first. The bytes read past the head stay here for the
51
+ # body.
52
+ class Request
53
+ attr_reader :verb, :path, :query, :version, :headers
54
+
55
+ def initialize(verb, target, version, headers, rest)
56
+ @verb = verb
57
+ @path, @query = target.split("?", 2)
58
+ @version = version
59
+ @headers = headers
60
+ @rest = rest
61
+ end
62
+
63
+ # Read only once the request has earned it: a client that sent
64
+ # `Expect: 100-continue` holds the body back until it hears 100.
65
+ def read_body(io, max:, deadline:)
66
+ raise Error.new(400, "Transfer-Encoding isn't accepted; send Content-Length") if headers.key?("transfer-encoding")
67
+ length = headers["content-length"]
68
+ raise Error.new(411, "Content-Length is required") unless length
69
+ raise Error.new(400, "Content-Length isn't a number") unless length.match?(/\A\d{1,15}\z/)
70
+ size = length.to_i
71
+ raise Error.new(413, "the body is over #{max} bytes") if size > max
72
+ io.write("HTTP/1.1 100 Continue\r\n\r\n") if headers["expect"]&.casecmp?("100-continue") && @rest.bytesize < size
73
+ Http.fill(io, @rest, deadline) while @rest.bytesize < size
74
+ @rest.byteslice(0, size)
75
+ end
76
+ end
77
+
78
+ def self.monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+
80
+ # The JSON generator warns on stderr, which is the terminal, about a
81
+ # binary string and raises on a bad byte, and a message can quote CLI
82
+ # output or request bytes.
83
+ def self.utf8(text) = String.new(text.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD")
84
+
85
+ # `deadline` (on the monotonic clock) bounds the whole head, not each
86
+ # read: a per-read timeout lets a byte every few seconds hold the
87
+ # connection for ever.
88
+ def self.read_head(io, deadline:)
89
+ rest = +"".b
90
+ line = read_line(io, rest, deadline)
91
+ request = line.match(%r{\A(\S+) (/\S*) HTTP/(1\.[01])\z})
92
+ raise Error.new(400, "not an HTTP/1.x request line") unless request && request[1].match?(TOKEN)
93
+ headers = {}
94
+ count = 0
95
+ loop do
96
+ line = read_line(io, rest, deadline)
97
+ return Request.new(request[1], request[2], request[3], headers, rest) if line.empty?
98
+ raise Error.new(400, "more than #{MAX_HEADERS} headers") if (count += 1) > MAX_HEADERS
99
+ name, value = line.split(":", 2)
100
+ raise Error.new(400, "malformed header") unless value && name.match?(TOKEN)
101
+ key = name.downcase
102
+ value = value.strip
103
+ headers[key] = headers.key?(key) ? "#{headers[key]}, #{value}" : value
104
+ end
105
+ end
106
+
107
+ def self.write(io, status, headers = {}, body = "")
108
+ body = body.b
109
+ head = ["HTTP/1.1 #{status} #{REASONS.fetch(status)}"]
110
+ HEADERS.merge(headers, "Content-Length" => body.bytesize.to_s).each { |name, value| head << "#{name}: #{value}" }
111
+ io.write((head.join("\r\n") + "\r\n\r\n").b + body)
112
+ end
113
+
114
+ def self.read_line(io, rest, deadline)
115
+ loop do
116
+ i = rest.index("\n")
117
+ raise Error.new(400, "a line of the request head is over #{MAX_LINE} bytes") if (i || rest.bytesize) > MAX_LINE
118
+ return rest.slice!(0..i).chomp if i
119
+ fill(io, rest, deadline)
120
+ end
121
+ end
122
+
123
+ def self.fill(io, rest, deadline)
124
+ loop do
125
+ case (chunk = io.read_nonblock(CHUNK, exception: false))
126
+ when nil then raise Error.new(400, "the connection closed mid-request")
127
+ when :wait_readable
128
+ left = deadline - monotonic
129
+ raise Error.new(400, "the request took too long") unless left > 0 && IO.select([io], nil, nil, left)
130
+ else return rest << chunk
131
+ end
132
+ end
133
+ end
134
+ private_class_method :read_line
135
+ end
136
+ end
137
+ end
Binary file
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" fill="#0a7385"/>
3
+ <g fill="none" stroke="#fff" stroke-width="28" stroke-linejoin="round" stroke-linecap="round">
4
+ <path d="M136 150h240l44 132v92a28 28 0 0 1-28 28H120a28 28 0 0 1-28-28v-92z"/>
5
+ <path d="M92 282h92l26 46h92l26-46h92"/>
6
+ <path d="M256 170v88M220 222l36 36 36-36"/>
7
+ </g>
8
+ </svg>
@@ -0,0 +1,436 @@
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(client:, store:, 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(client: client, store: store, queue: queue, allowed_modes: allowed_modes,
123
+ fixture: fixture, **start_options)
124
+ @mutex = Mutex.new
125
+ @state = :off
126
+ @recent = []
127
+ @threads = Set.new
128
+ @alive = 0
129
+ @unauthenticated = Hash.new(0)
130
+ @turning_away = 0
131
+ end
132
+
133
+ # Tries again every few seconds while the port is taken or another
134
+ # inbox has the listener, so this one takes over once it is free.
135
+ def start
136
+ return if @port.nil? || @accept || @retry || !WAITING.include?(claim_port)
137
+ @retry = Thread.new { retry_claim }
138
+ end
139
+
140
+ # Runs after the terminal is restored and can't fail, so a bad socket
141
+ # never leaves the terminal raw.
142
+ def stop
143
+ quietly { @retry&.kill }
144
+ quietly { @accept&.kill }
145
+ quietly { @mutex.synchronize { @threads.to_a }.each(&:kill) }
146
+ quietly { @server&.close }
147
+ release_lock
148
+ @mutex.synchronize { @state = :off }
149
+ end
150
+
151
+ def port = @bound_port || @port
152
+
153
+ def snapshot
154
+ @mutex.synchronize do
155
+ Snapshot.new(state: @state, port: port, lan: @lan, urls: @urls, firewall: @firewall, allowed_modes: @allowed_modes,
156
+ recent: @recent.dup.freeze, held_by: @held_by, fixture: @fixture, error: @error)
157
+ end
158
+ end
159
+
160
+ # Looks up the pairing URLs and the firewall for the snapshot. It
161
+ # forks, so it runs off the main thread, when `N` opens; asked for
162
+ # before the port is ours, it runs once it is.
163
+ def refresh
164
+ @refresh_wanted = true
165
+ return unless snapshot.state == :listening
166
+ urls = @pairing.urls(port: port, lan: @lan).freeze
167
+ firewall = @pairing.firewall if @lan
168
+ @mutex.synchronize do
169
+ @urls = urls
170
+ @firewall = firewall
171
+ end
172
+ end
173
+
174
+ # The addresses fork scutil, so they fill in once looked up.
175
+ def pairing_dialog
176
+ in_background { refresh }
177
+ PairingDialog.new(-> { snapshot })
178
+ end
179
+
180
+ def copy_pairing_url
181
+ url = snapshot.pairing_url
182
+ return @queue << [:notice, "still looking up this Mac's addresses"] unless url
183
+ in_background do
184
+ r = Subprocess.capture("osascript", "-e", "on run argv", "-e", "set the clipboard to item 1 of argv", "-e", "end run", url)
185
+ @queue << [:notice, r.success? ? "pairing URL copied" : "couldn't copy: #{r.err.strip}"]
186
+ end
187
+ end
188
+
189
+ def rotate
190
+ in_background do
191
+ @pairing.rotate!
192
+ refresh
193
+ @queue << [:notice, "new token: phones pair again with N"]
194
+ end
195
+ end
196
+
197
+ # One request read from `io` and answered on it. The connection's
198
+ # `slot` stops counting as unauthenticated once the token checks out.
199
+ def handle(io, via:, slot: nil)
200
+ authorized = false
201
+ request = Http.read_head(io, deadline: Http.monotonic + HEAD_TIMEOUT)
202
+ file = route(request)
203
+ return Http.write(io, 200, *file) if file
204
+ authorize(request, via)
205
+ authorized = true
206
+ authenticated(slot) if slot
207
+ Http.write(io, *answer(request, io, via))
208
+ rescue Http::Error => e
209
+ Http.write(io, e.status, Http::JSON_TYPE.merge(e.headers), JSON.generate(e.body))
210
+ rescue => e
211
+ # A message can name a path under the home directory, which is
212
+ # nobody's business before the token checks out.
213
+ Http.write(io, 500, Http::JSON_TYPE, JSON.generate(error: authorized ? Http.utf8(e.message) : "internal error"))
214
+ end
215
+
216
+ private
217
+
218
+ # Without SO_REUSEADDR the bind fails while another program holds the
219
+ # port on any address, rather than sharing it (macOS gives loopback to
220
+ # the more specific bind); the cost is a bind refused during TIME_WAIT.
221
+ def claim_port
222
+ return :held unless take_lock
223
+ server = Socket.new(:INET, :STREAM)
224
+ server.bind(Addrinfo.tcp(@lan ? "0.0.0.0" : "127.0.0.1", @port))
225
+ server.listen(8)
226
+ @server = server
227
+ @bound_port = server.local_address.ip_port
228
+ @mutex.synchronize { @state = :listening }
229
+ @accept = Thread.new { accept_loop }
230
+ refresh_quietly if @refresh_wanted
231
+ :listening
232
+ rescue Errno::EADDRINUSE
233
+ give_up(server, :in_use)
234
+ rescue SystemCallError => e
235
+ give_up(server, :failed, e.message)
236
+ end
237
+
238
+ def retry_claim
239
+ loop do
240
+ sleep @retry_every
241
+ break unless WAITING.include?(claim_port)
242
+ end
243
+ end
244
+
245
+ def give_up(server, state, error = nil)
246
+ quietly { server&.close }
247
+ release_lock
248
+ @mutex.synchronize do
249
+ @state = state
250
+ @error = error && Text.printable(error)
251
+ end
252
+ state
253
+ end
254
+
255
+ # On the retry thread, where nothing would report a failure; the dialog
256
+ # just goes on saying it is looking the addresses up.
257
+ def refresh_quietly
258
+ refresh
259
+ rescue
260
+ nil
261
+ end
262
+
263
+ def take_lock
264
+ FileUtils.mkdir_p(File.dirname(@lock_path))
265
+ file = File.open(@lock_path, File::RDWR | File::CREAT, 0o600)
266
+ if file.flock(File::LOCK_EX | File::LOCK_NB)
267
+ file.truncate(0)
268
+ file.write(Process.pid.to_s)
269
+ file.flush
270
+ @mutex.synchronize { @held_by = nil }
271
+ @lock = file
272
+ else
273
+ holder = file.read.to_i
274
+ file.close
275
+ @mutex.synchronize do
276
+ @state = :held
277
+ @held_by = holder.positive? ? holder : nil
278
+ end
279
+ nil
280
+ end
281
+ end
282
+
283
+ def release_lock
284
+ quietly { @lock&.close }
285
+ @lock = nil
286
+ end
287
+
288
+ # Only closing the socket ends it; anything else, a full file table
289
+ # say, is waited out.
290
+ def accept_loop
291
+ loop do
292
+ sock, addr = @server.accept
293
+ admit(sock, peer(addr))
294
+ rescue IOError
295
+ break
296
+ rescue
297
+ sleep 0.1
298
+ end
299
+ end
300
+
301
+ def peer(addr) = addr.ip_address
302
+
303
+ def admit(sock, via)
304
+ slot = @mutex.synchronize { take_slot(via) }
305
+ return quietly { sock.close } unless slot
306
+ thread = Thread.new { serve(sock, slot) }
307
+ @mutex.synchronize { @threads << thread if thread.alive? }
308
+ rescue ThreadError
309
+ quietly { sock.close }
310
+ free(slot)
311
+ end
312
+
313
+ # Over the cap, a 503 rather than a queue. It lingers like any answer,
314
+ # so it isn't lost to a reset; past a few of those, only a close.
315
+ def take_slot(via)
316
+ if @alive < MAX_CONNECTIONS && @unauthenticated[via] < MAX_UNAUTHENTICATED
317
+ @alive += 1
318
+ @unauthenticated[via] += 1
319
+ {via: via, authenticated: false}
320
+ elsif @turning_away < MAX_TURNED_AWAY
321
+ @turning_away += 1
322
+ {busy: true}
323
+ end
324
+ end
325
+
326
+ def free(slot)
327
+ @mutex.synchronize do
328
+ if slot[:busy] then @turning_away -= 1
329
+ else
330
+ @alive -= 1
331
+ release_unauthenticated(slot[:via]) unless slot[:authenticated]
332
+ end
333
+ end
334
+ end
335
+
336
+ # Under the mutex. A host leaves the hash at zero, so it holds only
337
+ # hosts connected now.
338
+ def release_unauthenticated(via)
339
+ @unauthenticated[via] -= 1
340
+ @unauthenticated.delete(via) if @unauthenticated[via].zero?
341
+ end
342
+
343
+ # `handle` answers every failure it can; what is left is the socket
344
+ # itself going away, and a thread must not report that on stderr.
345
+ def serve(sock, slot)
346
+ if slot[:busy]
347
+ Http.write(sock, 503, Http::JSON_TYPE.merge("Retry-After" => "2"), JSON.generate(error: "busy: try again in a moment"))
348
+ else
349
+ handle(sock, via: slot[:via], slot: slot)
350
+ end
351
+ linger(sock)
352
+ rescue
353
+ nil
354
+ ensure
355
+ quietly { sock.close }
356
+ free(slot)
357
+ @mutex.synchronize { @threads.delete(Thread.current) }
358
+ end
359
+
360
+ def authenticated(slot)
361
+ @mutex.synchronize do
362
+ release_unauthenticated(slot[:via]) unless slot[:authenticated]
363
+ slot[:authenticated] = true
364
+ end
365
+ end
366
+
367
+ # The client closes first: a close on unread bytes resets the answer,
368
+ # and a server-side close leaves the TIME_WAIT that refuses a rebind.
369
+ def linger(sock)
370
+ deadline = Http.monotonic + LINGER
371
+ loop do
372
+ left = deadline - Http.monotonic
373
+ break unless left > 0 && IO.select([sock], nil, nil, left)
374
+ break if sock.read_nonblock(Http::CHUNK, exception: false).nil?
375
+ end
376
+ end
377
+
378
+ # The file a path serves to anyone, or nil for one Start answers.
379
+ def route(request)
380
+ raise Http::Error.new(405, "only GET and POST", headers: {"Allow" => "GET, POST"}) unless %w[GET POST].include?(request.verb)
381
+ raise Http::Error.new(421, "the Host header isn't a name this inbox answers to") unless @pairing.hosts(lan: @lan).include?(host(request))
382
+ verb = ROUTES[request.path]
383
+ raise Http::Error.new(404, "no such path") unless verb
384
+ raise Http::Error.new(405, "#{request.path} takes #{verb}", headers: {"Allow" => verb}) unless request.verb == verb
385
+ FILES[request.path]
386
+ end
387
+
388
+ # The name without its port: a tunnel or a proxy can change the port,
389
+ # and only the name tells this Mac from a site pointed at it.
390
+ def host(request) = request.headers["host"].to_s.downcase.sub(/:\d+\z/, "")
391
+
392
+ def authorize(request, via)
393
+ return if @pairing.matches?(request.headers["authorization"].to_s[/\ABearer +(\S+)\z/i, 1])
394
+ remember(via, "token rejected")
395
+ raise Http::Error.new(401, "missing or wrong token: press N in the inbox and pair again", headers: {"WWW-Authenticate" => "Bearer"})
396
+ end
397
+
398
+ # The same outcome from the same place again moves up with a count, so
399
+ # a host sending bad tokens can't push everything else out of the list.
400
+ # Every start, and every refusal once the token has checked out, is
401
+ # listed for `N`.
402
+ def answer(request, io, via)
403
+ status, headers, body, note = @start.call(request, io, via)
404
+ remember(via, note) if note
405
+ [status, headers, body]
406
+ rescue Http::Error => e
407
+ remember(via, e.note || "refused: #{e.message}")
408
+ raise
409
+ end
410
+
411
+ def remember(via, result)
412
+ result = Text.printable(result)
413
+ @mutex.synchronize do
414
+ same = @recent.find { |o| o.via == via && o.result == result }
415
+ outcome = Outcome.new(at: Time.now, via: via, result: result, count: (same&.count || 0) + 1)
416
+ @recent = (@recent - [same] + [outcome]).last(RECENT)
417
+ end
418
+ end
419
+
420
+ # A notice, not an :error: the next poll clears an :error.
421
+ def in_background
422
+ Thread.new do
423
+ yield
424
+ rescue => e
425
+ @queue << [:notice, Text.printable(e.message)]
426
+ end
427
+ end
428
+
429
+ def quietly
430
+ yield
431
+ rescue IOError, SystemCallError
432
+ nil
433
+ end
434
+ end
435
+ end
436
+ end