portage-cli 0.7.5 → 0.8.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/CHANGELOG.md +559 -0
- data/README.md +266 -5
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/browser_import/categorize.rb +59 -0
- data/lib/portage/cli/browser_import/confirm.rb +35 -0
- data/lib/portage/cli/browser_import/domains.rb +47 -0
- data/lib/portage/cli/browser_import/filter.rb +91 -0
- data/lib/portage/cli/browser_import/importer.rb +248 -0
- data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
- data/lib/portage/cli/browser_import/prober.rb +60 -0
- data/lib/portage/cli/browser_import/profiles.rb +114 -0
- data/lib/portage/cli/browser_import/readers.rb +179 -0
- data/lib/portage/cli/browser_import/saver.rb +62 -0
- data/lib/portage/cli/browser_import/sqlite.rb +68 -0
- data/lib/portage/cli/browser_import.rb +23 -0
- data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
- data/lib/portage/cli/browser_profile/bridge.rb +120 -0
- data/lib/portage/cli/browser_profile/browsers.rb +69 -0
- data/lib/portage/cli/browser_profile/cdp.rb +67 -0
- data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
- data/lib/portage/cli/browser_profile/errors.rb +26 -0
- data/lib/portage/cli/browser_profile/launcher.rb +34 -0
- data/lib/portage/cli/browser_profile/profile.rb +93 -0
- data/lib/portage/cli/browser_profile.rb +25 -0
- data/lib/portage/cli/buy.rb +521 -29
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +155 -1
- data/lib/portage/cli/dot_env.rb +55 -0
- data/lib/portage/cli/find.rb +96 -12
- data/lib/portage/cli/handoff_agents.rb +186 -0
- data/lib/portage/cli/handoff_only.rb +94 -0
- data/lib/portage/cli/handoff_reconciler.rb +15 -1
- data/lib/portage/cli/handoff_target.rb +61 -0
- data/lib/portage/cli/index/builder.rb +335 -0
- data/lib/portage/cli/index/exporter.rb +91 -0
- data/lib/portage/cli/index/known_cache.rb +155 -0
- data/lib/portage/cli/index/product_store.rb +101 -0
- data/lib/portage/cli/index/sources/browser.rb +31 -0
- data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
- data/lib/portage/cli/index/sources/stores_file.rb +58 -0
- data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
- data/lib/portage/cli/index/sources/wikidata.rb +95 -0
- data/lib/portage/cli/index/sources.rb +44 -0
- data/lib/portage/cli/index/store.rb +109 -0
- data/lib/portage/cli/index.rb +20 -0
- data/lib/portage/cli/known_stores_url.rb +15 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/search_backends.rb +337 -12
- data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
- data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
- data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
- data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
- data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
- data/lib/portage/cli/setup_wizard.rb +74 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +10 -3
- data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
- data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
- data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
- data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
- data/lib/portage/cli/webmcp_mappings.rb +84 -0
- data/lib/portage/cli.rb +525 -5
- metadata +58 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "portage/ucp/support/connection"
|
|
5
|
+
|
|
6
|
+
module Portage
|
|
7
|
+
module Cli
|
|
8
|
+
module BrowserProfile
|
|
9
|
+
# The plain-HTTP half of Chrome DevTools Protocol: the
|
|
10
|
+
# `http://127.0.0.1:<port>/json/*` endpoints every Chromium browser
|
|
11
|
+
# exposes once started with `--remote-debugging-port`. Used for
|
|
12
|
+
# "is the profile running, and what tab is it on" (`.version`/
|
|
13
|
+
# `.list`) and to open a tab already navigated to a URL (`.new_tab`)
|
|
14
|
+
# without needing the WebSocket layer (CdpSocket) at all for that.
|
|
15
|
+
#
|
|
16
|
+
# Every call is read-only against the local debugging port, GET/PUT
|
|
17
|
+
# requests only, `route: :probe` (same route category ProbeCache's
|
|
18
|
+
# own `/.well-known/ucp` probes use) so it's subject to whatever
|
|
19
|
+
# proxy config a `--no-proxy` list already exempts localhost from.
|
|
20
|
+
# A failure (not running, timeout, bad JSON) returns nil rather than
|
|
21
|
+
# raising — callers (Profile) decide what that means.
|
|
22
|
+
module Cdp
|
|
23
|
+
TIMEOUT = 3
|
|
24
|
+
HOST = "127.0.0.1".freeze
|
|
25
|
+
|
|
26
|
+
def self.version(port:, host: HOST) = get(host, port, "/json/version")
|
|
27
|
+
|
|
28
|
+
def self.list(port:, host: HOST) = Array(get(host, port, "/json/list"))
|
|
29
|
+
|
|
30
|
+
# Opens a new tab already navigated to `url` — Chrome's own
|
|
31
|
+
# `/json/new?<url>` endpoint, a PUT since it creates something
|
|
32
|
+
# server-side.
|
|
33
|
+
def self.new_tab(port:, url:, host: HOST)
|
|
34
|
+
get(host, port, "/json/new?#{URI.encode_www_form_component(url)}", method: :put)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `/json/close/<id>` answers plain text ("Target is closing"), not
|
|
38
|
+
# JSON — checked by success status only, never parsed as JSON.
|
|
39
|
+
def self.close_tab(port:, id:, host: HOST)
|
|
40
|
+
uri = URI("http://#{host}:#{port}/json/close/#{id}")
|
|
41
|
+
fetch(uri, :get).is_a?(Net::HTTPSuccess)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.get(host, port, path, method: :get)
|
|
47
|
+
uri = URI("http://#{host}:#{port}#{path}")
|
|
48
|
+
response = fetch(uri, method)
|
|
49
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
50
|
+
|
|
51
|
+
JSON.parse(response.body)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
private_class_method :get
|
|
56
|
+
|
|
57
|
+
def self.fetch(uri, method)
|
|
58
|
+
Portage::Ucp::Support::Connection.start(uri, route: :probe, open_timeout: TIMEOUT,
|
|
59
|
+
read_timeout: TIMEOUT) do |http|
|
|
60
|
+
method == :put ? http.request(Net::HTTP::Put.new(uri.request_uri)) : http.get(uri.request_uri)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
private_class_method :fetch
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "timeout"
|
|
8
|
+
|
|
9
|
+
module Portage
|
|
10
|
+
module Cli
|
|
11
|
+
module BrowserProfile
|
|
12
|
+
# A minimal, dependency-free WebSocket JSON-RPC client for a Chrome
|
|
13
|
+
# DevTools Protocol target — just enough to call `Runtime.evaluate`/
|
|
14
|
+
# `Page.navigate`/`Page.enable` on a page's own `webSocketDebuggerUrl`
|
|
15
|
+
# synchronously, one request at a time. Not a general WebSocket
|
|
16
|
+
# client: text frames only, no compression extension, and no
|
|
17
|
+
# concurrent in-flight requests — Bridge never has two CDP calls
|
|
18
|
+
# outstanding at once, so `#call` blocks until its own response (by
|
|
19
|
+
# `id`) comes back, discarding any unsolicited event frame in
|
|
20
|
+
# between.
|
|
21
|
+
#
|
|
22
|
+
# `transport:` is injectable (any object answering `#write`/`#read`/
|
|
23
|
+
# `#close`, e.g. a real TCPSocket) so specs never open a real socket
|
|
24
|
+
# — see cdp_socket_spec.rb.
|
|
25
|
+
class CdpSocket
|
|
26
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
|
|
27
|
+
TIMEOUT = 30
|
|
28
|
+
|
|
29
|
+
def self.connect(ws_url, transport: nil, timeout: TIMEOUT)
|
|
30
|
+
uri = URI.parse(ws_url)
|
|
31
|
+
transport ||= TCPSocket.new(uri.host, uri.port)
|
|
32
|
+
handshake!(transport, uri, timeout: timeout)
|
|
33
|
+
new(transport)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(transport)
|
|
37
|
+
@transport = transport
|
|
38
|
+
@next_id = 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash] the CDP "result" object.
|
|
42
|
+
# @raise [RuntimeError] the CDP "error" object's message, when the
|
|
43
|
+
# browser itself rejected the command (bad method/params, no
|
|
44
|
+
# such target) — distinct from an *evaluated script* raising,
|
|
45
|
+
# which comes back as a normal result with `exceptionDetails`.
|
|
46
|
+
def call(method, params = {})
|
|
47
|
+
id = @next_id
|
|
48
|
+
@next_id += 1
|
|
49
|
+
send_frame(JSON.generate(id: id, method: method, params: params))
|
|
50
|
+
await_response(id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def close
|
|
54
|
+
@transport.close
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def await_response(id)
|
|
62
|
+
loop do
|
|
63
|
+
message = JSON.parse(read_message)
|
|
64
|
+
next if message["id"] != id
|
|
65
|
+
|
|
66
|
+
raise message.dig("error", "message").to_s if message["error"]
|
|
67
|
+
|
|
68
|
+
return message["result"] || {}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- outbound framing (client -> server frames MUST be masked,
|
|
73
|
+
# RFC 6455 §5.1) ---
|
|
74
|
+
|
|
75
|
+
def send_frame(payload)
|
|
76
|
+
bytes = payload.b
|
|
77
|
+
mask = SecureRandom.random_bytes(4)
|
|
78
|
+
@transport.write(frame_header(0x1, bytes.bytesize) + mask + xor(bytes, mask))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def send_pong(payload)
|
|
82
|
+
bytes = payload.to_s.b
|
|
83
|
+
mask = SecureRandom.random_bytes(4)
|
|
84
|
+
@transport.write(frame_header(0xA, bytes.bytesize) + mask + xor(bytes, mask))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def frame_header(opcode, length)
|
|
88
|
+
first = 0x80 | opcode # FIN=1
|
|
89
|
+
return [first, 0x80 | length].pack("CC") if length <= 125
|
|
90
|
+
return [first, 0x80 | 126, length].pack("CCn") if length <= 0xFFFF
|
|
91
|
+
|
|
92
|
+
[first, 0x80 | 127, length].pack("CCQ>")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def xor(bytes, mask)
|
|
96
|
+
bytes.each_byte.with_index.map { |byte, i| byte ^ mask.getbyte(i % 4) }.pack("C*")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- inbound framing (server -> client frames are never masked)
|
|
100
|
+
# ---
|
|
101
|
+
|
|
102
|
+
# Reads whole logical messages (following any continuation
|
|
103
|
+
# frames), answering pings and dropping pongs, until it has one
|
|
104
|
+
# worth handing to #await_response.
|
|
105
|
+
def read_message
|
|
106
|
+
buffer = +""
|
|
107
|
+
loop do
|
|
108
|
+
fin, opcode, payload = read_one_frame
|
|
109
|
+
case opcode
|
|
110
|
+
when 0x9 then send_pong(payload)
|
|
111
|
+
when 0x8 then raise "CDP socket closed by the browser"
|
|
112
|
+
when 0xA then nil
|
|
113
|
+
else buffer << payload
|
|
114
|
+
end
|
|
115
|
+
return buffer if fin && [0x0, 0x1].include?(opcode)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def read_one_frame
|
|
120
|
+
b1, b2 = read_exactly(2).unpack("CC")
|
|
121
|
+
fin = b1[7] == 1
|
|
122
|
+
opcode = b1 & 0x0F
|
|
123
|
+
masked = b2[7] == 1
|
|
124
|
+
[fin, opcode, read_payload(b2 & 0x7F, masked)]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def read_payload(length, masked)
|
|
128
|
+
length = read_exactly(2).unpack1("n") if length == 126
|
|
129
|
+
length = read_exactly(8).unpack1("Q>") if length == 127
|
|
130
|
+
mask = read_exactly(4) if masked
|
|
131
|
+
payload = length.positive? ? read_exactly(length) : +""
|
|
132
|
+
mask ? xor(payload, mask) : payload
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_exactly(length)
|
|
136
|
+
return +"" if length <= 0
|
|
137
|
+
|
|
138
|
+
Timeout.timeout(TIMEOUT) do
|
|
139
|
+
data = +""
|
|
140
|
+
while data.bytesize < length
|
|
141
|
+
chunk = @transport.read(length - data.bytesize)
|
|
142
|
+
raise "CDP socket closed by the browser" if chunk.nil?
|
|
143
|
+
|
|
144
|
+
data << chunk
|
|
145
|
+
end
|
|
146
|
+
data
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# --- handshake (RFC 6455 §4) ---
|
|
151
|
+
|
|
152
|
+
def self.handshake!(transport, uri, timeout:)
|
|
153
|
+
key = SecureRandom.base64(16)
|
|
154
|
+
Timeout.timeout(timeout) { transport.write(handshake_request(uri, key)) }
|
|
155
|
+
headers = Timeout.timeout(timeout) { read_headers(transport) }
|
|
156
|
+
verify_handshake!(headers, key)
|
|
157
|
+
end
|
|
158
|
+
private_class_method :handshake!
|
|
159
|
+
|
|
160
|
+
def self.handshake_request(uri, key)
|
|
161
|
+
path = uri.path.to_s.empty? ? "/" : uri.path
|
|
162
|
+
path += "?#{uri.query}" if uri.query
|
|
163
|
+
"GET #{path} HTTP/1.1\r\nHost: #{uri.host}:#{uri.port}\r\nUpgrade: websocket\r\n" \
|
|
164
|
+
"Connection: Upgrade\r\nSec-WebSocket-Key: #{key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
|
|
165
|
+
end
|
|
166
|
+
private_class_method :handshake_request
|
|
167
|
+
|
|
168
|
+
def self.read_headers(transport)
|
|
169
|
+
data = +""
|
|
170
|
+
data << transport.read(1) until data.end_with?("\r\n\r\n")
|
|
171
|
+
data
|
|
172
|
+
end
|
|
173
|
+
private_class_method :read_headers
|
|
174
|
+
|
|
175
|
+
def self.verify_handshake!(headers, key)
|
|
176
|
+
raise "CDP handshake failed: #{headers.lines.first}" unless headers.start_with?("HTTP/1.1 101")
|
|
177
|
+
|
|
178
|
+
expected = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
|
|
179
|
+
accept = headers[/Sec-WebSocket-Accept:\s*(\S+)/i, 1]
|
|
180
|
+
raise "CDP handshake failed: unexpected Sec-WebSocket-Accept" unless accept == expected
|
|
181
|
+
end
|
|
182
|
+
private_class_method :verify_handshake!
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
module BrowserProfile
|
|
4
|
+
# Base for everything this feature raises — a caller (cli.rb, Buy)
|
|
5
|
+
# can rescue this one class instead of naming each subclass.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# `Browsers.binary_for` found nothing installed for the requested
|
|
9
|
+
# browser.
|
|
10
|
+
class BrowserNotFoundError < Error; end
|
|
11
|
+
|
|
12
|
+
# The profile process never answered its own CDP endpoint within the
|
|
13
|
+
# launch wait — a slow machine, a browser that failed to start, or a
|
|
14
|
+
# port already held by something else.
|
|
15
|
+
class LaunchError < Error; end
|
|
16
|
+
|
|
17
|
+
# docs/plans/buy-skill-and-local-browser.md Phase 6: driving stayed
|
|
18
|
+
# inside the allowed domains (the store being bought from, plus its
|
|
19
|
+
# checkout host once known) or this stopped the run rather than
|
|
20
|
+
# continue on an unexpected host. Never rescued into "try again
|
|
21
|
+
# somewhere else" — the whole point is that nothing here recovers by
|
|
22
|
+
# itself.
|
|
23
|
+
class DomainNotAllowedError < Error; end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
module BrowserProfile
|
|
4
|
+
# The one place `Process.spawn` is called from this feature —
|
|
5
|
+
# injectable (`spawn:`) so Profile's own specs never start a real
|
|
6
|
+
# browser (docs/plans/buy-skill-and-local-browser.md Phase 6's own
|
|
7
|
+
# "specs must not launch a real browser" rule).
|
|
8
|
+
#
|
|
9
|
+
# Flags: `--user-data-dir` is always the dedicated Portage profile
|
|
10
|
+
# directory (never the browser's default one — Chrome 136+ refuses
|
|
11
|
+
# remote debugging on the default profile anyway, and this never
|
|
12
|
+
# tries), `--remote-debugging-port` is this profile's own fixed
|
|
13
|
+
# port, and `--no-first-run`/`--no-default-browser-check` keep the
|
|
14
|
+
# launch from popping up onboarding UI the first time. Detached
|
|
15
|
+
# (its own process group, stdout/stderr discarded) so `portage
|
|
16
|
+
# browser profile open` returns immediately rather than blocking on
|
|
17
|
+
# the browser's own lifetime.
|
|
18
|
+
class Launcher
|
|
19
|
+
def initialize(spawn: ->(*args) { Process.spawn(*args) })
|
|
20
|
+
@spawn = spawn
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def launch(binary:, profile_dir:, port:, url: nil)
|
|
24
|
+
argv = [binary, "--user-data-dir=#{profile_dir}", "--remote-debugging-port=#{port}",
|
|
25
|
+
"--no-first-run", "--no-default-browser-check"]
|
|
26
|
+
argv << url if url
|
|
27
|
+
pid = @spawn.call(*argv, %i[out err] => File::NULL, pgroup: true)
|
|
28
|
+
Process.detach(pid)
|
|
29
|
+
pid
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require_relative "browsers"
|
|
3
|
+
require_relative "cdp"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "launcher"
|
|
6
|
+
|
|
7
|
+
module Portage
|
|
8
|
+
module Cli
|
|
9
|
+
module BrowserProfile
|
|
10
|
+
# `portage browser profile init|open|status` (docs/plans/
|
|
11
|
+
# buy-skill-and-local-browser.md Phase 6): a dedicated Chromium
|
|
12
|
+
# profile directory under ~/.portage/browser/<browser>/profile,
|
|
13
|
+
# launched with remote debugging bound to that profile only —
|
|
14
|
+
# never the browser's own default profile, and never a directory
|
|
15
|
+
# this class didn't create itself. The user signs into their
|
|
16
|
+
# shopping sites in this profile once; nothing here ever reads a
|
|
17
|
+
# credential, cookie or autofill store from it (that's Chromium's
|
|
18
|
+
# own job once the shopper is looking at the page).
|
|
19
|
+
class Profile
|
|
20
|
+
ROOT = File.join(Dir.home, ".portage", "browser").freeze
|
|
21
|
+
DEFAULT_PORT = 9223
|
|
22
|
+
|
|
23
|
+
attr_reader :browser, :port, :dir
|
|
24
|
+
|
|
25
|
+
def initialize(browser: "chrome", root: ROOT, port: DEFAULT_PORT, launcher: Launcher.new, cdp: Cdp,
|
|
26
|
+
poll_attempts: 20, poll_interval: 0.25, sleep_fn: ->(s) { sleep s })
|
|
27
|
+
raise ArgumentError, "unsupported browser #{browser.inspect} (want one of #{Browsers::CHROMIUM})" unless
|
|
28
|
+
Browsers::CHROMIUM.include?(browser)
|
|
29
|
+
|
|
30
|
+
@browser = browser
|
|
31
|
+
@dir = File.join(root, browser, "profile")
|
|
32
|
+
@port = port
|
|
33
|
+
@launcher = launcher
|
|
34
|
+
@cdp = cdp
|
|
35
|
+
@poll_attempts = poll_attempts
|
|
36
|
+
@poll_interval = poll_interval
|
|
37
|
+
@sleep_fn = sleep_fn
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Never launches anything — just makes sure the dedicated profile
|
|
41
|
+
# directory exists, so `portage browser profile open` always has
|
|
42
|
+
# somewhere of its own to point `--user-data-dir` at.
|
|
43
|
+
def init!
|
|
44
|
+
FileUtils.mkdir_p(@dir)
|
|
45
|
+
{ browser: @browser, dir: @dir, port: @port, created: true }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Hash] `running:` plus, when running, whatever
|
|
49
|
+
# `/json/version` reports (Browser, protocolVersion, …) — no
|
|
50
|
+
# HTTP request is ever made to anything but 127.0.0.1:`port`.
|
|
51
|
+
def status
|
|
52
|
+
version = @cdp.version(port: @port)
|
|
53
|
+
base = { running: !version.nil?, browser: @browser, dir: @dir, port: @port }
|
|
54
|
+
version ? base.merge(version) : base
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Launches the browser if it isn't already running on this
|
|
58
|
+
# profile's port, then either opens a new tab at `url` or hands
|
|
59
|
+
# back the first existing tab. Idempotent: calling this against an
|
|
60
|
+
# already-running profile just attaches.
|
|
61
|
+
def open!(url: nil)
|
|
62
|
+
launch! unless status[:running]
|
|
63
|
+
attach(url)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def launch!
|
|
69
|
+
binary = Browsers.binary_for(@browser)
|
|
70
|
+
raise BrowserNotFoundError, "#{@browser} isn't installed on this machine" unless binary
|
|
71
|
+
|
|
72
|
+
FileUtils.mkdir_p(@dir)
|
|
73
|
+
@launcher.launch(binary: binary, profile_dir: @dir, port: @port)
|
|
74
|
+
wait_until_running!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def wait_until_running!
|
|
78
|
+
@poll_attempts.times do
|
|
79
|
+
return true if status[:running]
|
|
80
|
+
|
|
81
|
+
@sleep_fn.call(@poll_interval)
|
|
82
|
+
end
|
|
83
|
+
raise LaunchError, "#{@browser} didn't answer its remote-debugging port (#{@port}) in time"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def attach(url)
|
|
87
|
+
target = url ? @cdp.new_tab(port: @port, url: url) : @cdp.list(port: @port).first
|
|
88
|
+
{ running: true, browser: @browser, dir: @dir, port: @port, target: target }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require_relative "browser_profile/errors"
|
|
2
|
+
require_relative "browser_profile/browsers"
|
|
3
|
+
require_relative "browser_profile/cdp"
|
|
4
|
+
require_relative "browser_profile/cdp_socket"
|
|
5
|
+
require_relative "browser_profile/allowlist"
|
|
6
|
+
require_relative "browser_profile/bridge"
|
|
7
|
+
require_relative "browser_profile/launcher"
|
|
8
|
+
require_relative "browser_profile/profile"
|
|
9
|
+
|
|
10
|
+
module Portage
|
|
11
|
+
module Cli
|
|
12
|
+
# `portage browser profile init|open|status` — the Portage browser
|
|
13
|
+
# profile (docs/plans/buy-skill-and-local-browser.md Phase 6, Tier B).
|
|
14
|
+
# A dedicated Chromium-family profile directory under
|
|
15
|
+
# ~/.portage/browser/, never the browser's own default profile, that
|
|
16
|
+
# `portage buy --handoff-target profile` drives as a WebMCP bridge
|
|
17
|
+
# (see Bridge) limited to a domain allowlist (Allowlist) — the store
|
|
18
|
+
# being bought from, plus its checkout host once known. The user signs
|
|
19
|
+
# into their shopping sites in it once; Portage never reads a
|
|
20
|
+
# credential/cookie/autofill store from it, never touches a payment
|
|
21
|
+
# field, and never clicks pay.
|
|
22
|
+
module BrowserProfile
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|