Skip to content

Commit 730b888

Browse files
yashdsarafclaude
andcommitted
1.5.0: arm64 + dynamic CloudFront source URL
- New FetchDownloadSourceUrl module POSTs to /binary/api/v1/endpoint to discover the current CDN base URL (CloudFront primary; CloudFlare requested as fallback via X-Local-Fallback-Cloudflare header on retry). - Linux arm64 host now downloads BrowserStackLocal-linux-arm64. Branch order mirrors Node SDK: arm64 wins over alpine on musl. - proxyHost/proxyPort from Local#start now also flow into the binary download (previously ignored for download, used only for running binary). - User-Agent: browserstack-local-ruby/<version> on endpoint POST + GET. - TLS verification now enforced (VERIFY_PEER); was VERIFY_NONE. CHANGELOG calls this out for users on broken trust stores. Tracks LOC-6563. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 80c8d66 commit 730b888

7 files changed

Lines changed: 278 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
66

77
## [Unreleased] - yyyy-mm-dd
88

9-
## [
10-
1.4.3] - 2023-08-24
9+
## [1.5.0] - 2026-06-01
10+
11+
### Added
12+
- Linux arm64 support — downloads `BrowserStackLocal-linux-arm64` on aarch64/arm64 hosts.
13+
- Dynamic binary source URL via `POST local.browserstack.com/binary/api/v1/endpoint`. Primary downloads now come from CloudFront; CloudFlare is used as fallback after repeated failures.
14+
- Proxy passthrough for binary download — `proxyHost` and `proxyPort` passed to `Local#start` are now also used when downloading the binary itself.
15+
- `User-Agent: browserstack-local-ruby/<version>` header on the endpoint POST and on the binary download GET.
16+
17+
### Changed
18+
- TLS certificate verification is now enforced (`OpenSSL::SSL::VERIFY_PEER`) on all binary-download HTTPS traffic. Previous releases used `VERIFY_NONE`. If you were relying on disabled verification (e.g., behind a MITM proxy without the corporate CA installed in your system trust store), pin to 1.4.3 and open an issue.
19+
20+
## [1.4.3] - 2023-08-24
1121

1222
### Changed
1323
Ruby 3 exists? deprecation fix

browserstack-local.gemspec

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
1+
require File.expand_path('../lib/browserstack/version', __FILE__)
2+
13
Gem::Specification.new do |s|
24
s.name = 'browserstack-local'
3-
s.version = '1.4.3'
4-
s.date = '2023-08-24'
5+
s.version = BrowserStack::VERSION
6+
s.date = '2026-06-01'
57
s.summary = "BrowserStack Local"
68
s.description = "Ruby bindings for BrowserStack Local"
79
s.authors = ["BrowserStack"]
810
s.email = 'support@browserstack.com'
9-
s.files = ["lib/browserstack/local.rb", "lib/browserstack/localbinary.rb", "lib/browserstack/localexception.rb"]
11+
s.files = [
12+
"lib/browserstack/local.rb",
13+
"lib/browserstack/localbinary.rb",
14+
"lib/browserstack/localexception.rb",
15+
"lib/browserstack/fetch_download_source_url.rb",
16+
"lib/browserstack/version.rb"
17+
]
1018
s.homepage =
1119
'http://rubygems.org/gems/browserstack-local'
1220
s.license = 'MIT'
1321
end
14-
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
require 'net/http'
2+
require 'net/https'
3+
require 'json'
4+
require 'openssl'
5+
require 'browserstack/localexception'
6+
7+
module BrowserStack
8+
module FetchDownloadSourceUrl
9+
BS_HOST = 'local.browserstack.com'.freeze
10+
ENDPOINT_PATH = '/binary/api/v1/endpoint'.freeze
11+
12+
def self.call(auth_token:, user_agent:, fallback: false, error_message: nil,
13+
proxy_host: nil, proxy_port: nil)
14+
uri = URI::HTTPS.build(host: BS_HOST, path: ENDPOINT_PATH)
15+
16+
body = { 'auth_token' => auth_token }
17+
body['error_message'] = error_message if fallback && error_message
18+
19+
http_class = if proxy_host && proxy_port
20+
Net::HTTP::Proxy(proxy_host, proxy_port.to_i)
21+
else
22+
Net::HTTP
23+
end
24+
25+
http = http_class.new(uri.host, uri.port)
26+
http.use_ssl = true
27+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
28+
29+
req = Net::HTTP::Post.new(uri.request_uri)
30+
req['Content-Type'] = 'application/json'
31+
req['User-Agent'] = user_agent
32+
req['X-Local-Fallback-Cloudflare'] = 'true' if fallback
33+
req.body = JSON.dump(body)
34+
35+
res = http.request(req)
36+
37+
begin
38+
parsed = JSON.parse(res.body.to_s)
39+
rescue JSON::ParserError => e
40+
raise BrowserStack::LocalException.new(
41+
"Failed to parse binary endpoint API response (HTTP #{res.code}): #{e.message}"
42+
)
43+
end
44+
45+
if parsed.is_a?(Hash) && parsed['error']
46+
raise BrowserStack::LocalException.new(
47+
"Binary endpoint API returned error: #{parsed['error']}"
48+
)
49+
end
50+
51+
endpoint = parsed.is_a?(Hash) ? parsed.dig('data', 'endpoint') : nil
52+
if endpoint.nil? || endpoint.to_s.empty?
53+
raise BrowserStack::LocalException.new(
54+
"Binary endpoint API returned no endpoint (HTTP #{res.code})"
55+
)
56+
end
57+
58+
endpoint
59+
end
60+
end
61+
end

lib/browserstack/local.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ def start(options = {})
6464
end
6565

6666
@binary_path = if @binary_path.nil?
67-
BrowserStack::LocalBinary.new.binary_path
67+
BrowserStack::LocalBinary.new(
68+
auth_token: @key,
69+
proxy_host: @proxy_host,
70+
proxy_port: @proxy_port
71+
).binary_path
6872
else
6973
@binary_path
7074
end

lib/browserstack/localbinary.rb

Lines changed: 116 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,27 @@
33
require 'rbconfig'
44
require 'openssl'
55
require 'tmpdir'
6+
require 'fileutils'
67
require 'browserstack/localexception'
8+
require 'browserstack/fetch_download_source_url'
9+
require 'browserstack/version'
710

811
module BrowserStack
9-
12+
1013
class LocalBinary
11-
def initialize
12-
host_os = RbConfig::CONFIG['host_os']
13-
@http_path = case host_os
14-
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
15-
@windows = true
16-
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal.exe"
17-
when /darwin|mac os/
18-
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-darwin-x64"
19-
when /linux-musl/
20-
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-alpine"
21-
when /linux/
22-
if 1.size == 8
23-
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-linux-x64"
24-
else
25-
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-linux-ia32"
26-
end
27-
end
14+
BASE_RETRIES = 9
15+
FALLBACK_TRIGGER_RETRY = 4
16+
17+
def initialize(conf = {})
18+
@auth_token = conf[:auth_token] || ENV['BROWSERSTACK_ACCESS_KEY']
19+
@proxy_host = conf[:proxy_host]
20+
@proxy_port = conf[:proxy_port]
21+
@user_agent = conf[:user_agent] || "browserstack-local-ruby/#{BrowserStack::VERSION}"
22+
23+
@windows = !!(RbConfig::CONFIG['host_os'] =~ /mswin|msys|mingw|cygwin|bccwin|wince|emc/)
24+
@binary_filename = compute_binary_filename
25+
@source_url = nil
26+
@download_error_message = nil
2827

2928
@ordered_paths = [
3029
File.join(File.expand_path('~'), '.browserstack'),
@@ -33,79 +32,123 @@ def initialize
3332
]
3433
end
3534

36-
def download(dest_parent_dir)
37-
unless File.exist? dest_parent_dir
38-
Dir.mkdir dest_parent_dir
39-
end
40-
uri = URI.parse(@http_path)
41-
binary_path = File.join(dest_parent_dir, "BrowserStackLocal#{".exe" if @windows}")
42-
http = Net::HTTP.new(uri.host, uri.port)
43-
http.use_ssl = true
44-
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
45-
46-
res = http.get(uri.path)
47-
file = open(binary_path, 'wb')
48-
file.write(res.body)
49-
file.close
50-
FileUtils.chmod 0755, binary_path
51-
52-
binary_path
53-
end
35+
def binary_path
36+
dest_parent_dir = get_available_dirs
37+
bin_path = File.join(dest_parent_dir, dest_binary_name)
5438

55-
def verify_binary(binary_path)
56-
binary_response = IO.popen(binary_path + " --version").readline
57-
binary_response =~ /BrowserStack Local version \d+\.\d+/
58-
rescue Exception => e
59-
false
39+
return bin_path if File.exist?(bin_path) && verify_binary(bin_path)
40+
41+
File.delete(bin_path) if File.exist?(bin_path)
42+
download_with_retries(bin_path)
6043
end
6144

62-
def binary_path
63-
dest_parent_dir = get_available_dirs
64-
binary_path = File.join(dest_parent_dir, "BrowserStackLocal#{".exe" if @windows}")
45+
private
6546

66-
if File.exist? binary_path
67-
binary_path
47+
def dest_binary_name
48+
@windows ? 'BrowserStackLocal.exe' : 'BrowserStackLocal'
49+
end
50+
51+
def compute_binary_filename
52+
host_os = RbConfig::CONFIG['host_os']
53+
host_cpu = RbConfig::CONFIG['host_cpu']
54+
case host_os
55+
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
56+
'BrowserStackLocal.exe'
57+
when /darwin|mac os/
58+
'BrowserStackLocal-darwin-x64'
59+
when /linux/
60+
if host_cpu =~ /arm64|aarch64/
61+
'BrowserStackLocal-linux-arm64'
62+
elsif host_os =~ /linux-musl/
63+
'BrowserStackLocal-alpine'
64+
elsif 1.size == 8
65+
'BrowserStackLocal-linux-x64'
66+
else
67+
'BrowserStackLocal-linux-ia32'
68+
end
6869
else
69-
binary_path = download(dest_parent_dir)
70+
raise BrowserStack::LocalException.new("Unsupported host OS: #{host_os}")
7071
end
72+
end
7173

72-
valid_binary = verify_binary(binary_path)
73-
74-
if valid_binary
75-
binary_path
76-
else
77-
binary_path = download(dest_parent_dir)
78-
valid_binary = verify_binary(binary_path)
79-
if valid_binary
80-
binary_path
81-
else
82-
raise BrowserStack::LocalException.new('BrowserStack Local binary is corrupt')
74+
def download_with_retries(bin_path)
75+
retries = BASE_RETRIES
76+
while retries > 0
77+
refresh_source_url(retries) if retries == BASE_RETRIES || retries == FALLBACK_TRIGGER_RETRY
78+
begin
79+
download_to(@source_url + '/' + @binary_filename, bin_path)
80+
return bin_path if verify_binary(bin_path)
81+
@download_error_message = 'Downloaded binary failed verification'
82+
rescue StandardError => e
83+
@download_error_message = "Download failed: #{e.message}"
8384
end
85+
File.delete(bin_path) if File.exist?(bin_path)
86+
retries -= 1
8487
end
88+
89+
raise BrowserStack::LocalException.new(
90+
"Failed to download BrowserStack Local binary after #{BASE_RETRIES} attempts. " \
91+
"Last error: #{@download_error_message}"
92+
)
8593
end
8694

87-
private
95+
def refresh_source_url(retries)
96+
is_fallback = (retries == FALLBACK_TRIGGER_RETRY) && !@download_error_message.nil?
97+
begin
98+
@source_url = BrowserStack::FetchDownloadSourceUrl.call(
99+
auth_token: @auth_token,
100+
user_agent: @user_agent,
101+
fallback: is_fallback,
102+
error_message: @download_error_message,
103+
proxy_host: @proxy_host,
104+
proxy_port: @proxy_port
105+
)
106+
rescue StandardError => e
107+
raise if @source_url.nil?
108+
@download_error_message = "Source URL refresh failed: #{e.message}"
109+
end
110+
end
111+
112+
def download_to(url, bin_path)
113+
uri = URI.parse(url)
114+
http_class = if @proxy_host && @proxy_port
115+
Net::HTTP::Proxy(@proxy_host, @proxy_port.to_i)
116+
else
117+
Net::HTTP
118+
end
119+
http = http_class.new(uri.host, uri.port)
120+
http.use_ssl = (uri.scheme == 'https')
121+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
122+
123+
req = Net::HTTP::Get.new(uri.request_uri)
124+
req['User-Agent'] = @user_agent
125+
126+
res = http.request(req)
127+
raise "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
128+
129+
File.open(bin_path, 'wb') { |f| f.write(res.body) }
130+
FileUtils.chmod 0755, bin_path
131+
end
132+
133+
def verify_binary(bin_path)
134+
binary_response = IO.popen(bin_path + " --version").readline
135+
!!(binary_response =~ /BrowserStack Local version \d+\.\d+/)
136+
rescue StandardError
137+
false
138+
end
88139

89140
def get_available_dirs
90-
i = 0
91-
while i < @ordered_paths.size
92-
path = @ordered_paths[i]
93-
if make_path(path)
94-
return path
95-
else
96-
i += 1
97-
end
141+
@ordered_paths.each do |path|
142+
return path if make_path(path)
98143
end
99144
raise BrowserStack::LocalException.new('Error trying to download BrowserStack Local binary')
100145
end
101146

102147
def make_path(path)
103-
begin
104-
FileUtils.mkdir_p path if !File.directory?(path)
105-
return true
106-
rescue Exception
107-
return false
108-
end
148+
FileUtils.mkdir_p(path) unless File.directory?(path)
149+
true
150+
rescue StandardError
151+
false
109152
end
110153
end
111154

lib/browserstack/version.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module BrowserStack
2+
VERSION = '1.5.0'.freeze
3+
end

0 commit comments

Comments
 (0)