Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased] - yyyy-mm-dd

## [
1.4.3] - 2023-08-24
## [1.5.0] - 2026-06-01

### Added
- Linux arm64 support — downloads `BrowserStackLocal-linux-arm64` on aarch64/arm64 hosts.
- 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.
- Proxy passthrough for binary download — `proxyHost` and `proxyPort` passed to `Local#start` are now also used when downloading the binary itself.
- `User-Agent: browserstack-local-ruby/<version>` header on the endpoint POST and on the binary download GET.

### Changed
- 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.

## [1.4.3] - 2023-08-24

### Changed
Ruby 3 exists? deprecation fix
15 changes: 11 additions & 4 deletions browserstack-local.gemspec
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
require File.expand_path('../lib/browserstack/version', __FILE__)

Gem::Specification.new do |s|
s.name = 'browserstack-local'
s.version = '1.4.3'
s.date = '2023-08-24'
s.version = BrowserStack::VERSION
s.date = '2026-06-01'
s.summary = "BrowserStack Local"
s.description = "Ruby bindings for BrowserStack Local"
s.authors = ["BrowserStack"]
s.email = 'support@browserstack.com'
s.files = ["lib/browserstack/local.rb", "lib/browserstack/localbinary.rb", "lib/browserstack/localexception.rb"]
s.files = [
"lib/browserstack/local.rb",
"lib/browserstack/localbinary.rb",
"lib/browserstack/localexception.rb",
"lib/browserstack/fetch_download_source_url.rb",
"lib/browserstack/version.rb"
]
s.homepage =
'http://rubygems.org/gems/browserstack-local'
s.license = 'MIT'
end

63 changes: 63 additions & 0 deletions lib/browserstack/fetch_download_source_url.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
require 'net/http'
require 'net/https'
require 'json'
require 'openssl'
require 'browserstack/localexception'

module BrowserStack
module FetchDownloadSourceUrl
BS_HOST = 'local.browserstack.com'.freeze
ENDPOINT_PATH = '/binary/api/v1/endpoint'.freeze

def self.call(auth_token:, user_agent:, fallback: false, error_message: nil,
proxy_host: nil, proxy_port: nil)
uri = URI::HTTPS.build(host: BS_HOST, path: ENDPOINT_PATH)

body = { 'auth_token' => auth_token }
body['error_message'] = error_message if fallback && error_message

http_class = if proxy_host && proxy_port
Net::HTTP::Proxy(proxy_host, proxy_port.to_i)
else
Net::HTTP
end

http = http_class.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
http.open_timeout = 10
http.read_timeout = 15

req = Net::HTTP::Post.new(uri.request_uri)
req['Content-Type'] = 'application/json'
req['User-Agent'] = user_agent
req['X-Local-Fallback-Cloudflare'] = 'true' if fallback
req.body = JSON.dump(body)

res = http.request(req)

begin
parsed = JSON.parse(res.body.to_s)
rescue JSON::ParserError => e
raise BrowserStack::LocalException.new(
"Failed to parse binary endpoint API response (HTTP #{res.code}): #{e.message}"
)
end

if parsed.is_a?(Hash) && parsed['error']
raise BrowserStack::LocalException.new(
"Binary endpoint API returned error: #{parsed['error']}"
)
end

endpoint = parsed.is_a?(Hash) ? parsed.dig('data', 'endpoint') : nil
if endpoint.nil? || endpoint.to_s.empty?
raise BrowserStack::LocalException.new(
"Binary endpoint API returned no endpoint (HTTP #{res.code})"
)
end

endpoint
end
end
end
6 changes: 5 additions & 1 deletion lib/browserstack/local.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ def start(options = {})
end

@binary_path = if @binary_path.nil?
BrowserStack::LocalBinary.new.binary_path
BrowserStack::LocalBinary.new(
auth_token: @key,
proxy_host: @proxy_host,
proxy_port: @proxy_port
).binary_path
else
@binary_path
end
Expand Down
191 changes: 118 additions & 73 deletions lib/browserstack/localbinary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,27 @@
require 'rbconfig'
require 'openssl'
require 'tmpdir'
require 'fileutils'
require 'browserstack/localexception'
require 'browserstack/fetch_download_source_url'
require 'browserstack/version'

module BrowserStack

class LocalBinary
def initialize
host_os = RbConfig::CONFIG['host_os']
@http_path = case host_os
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
@windows = true
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal.exe"
when /darwin|mac os/
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-darwin-x64"
when /linux-musl/
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-alpine"
when /linux/
if 1.size == 8
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-linux-x64"
else
"https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-linux-ia32"
end
end
BASE_RETRIES = 9
FALLBACK_TRIGGER_RETRY = 4

def initialize(conf = {})
@auth_token = conf[:auth_token] || ENV['BROWSERSTACK_ACCESS_KEY']
@proxy_host = conf[:proxy_host]
@proxy_port = conf[:proxy_port]
@user_agent = conf[:user_agent] || "browserstack-local-ruby/#{BrowserStack::VERSION}"

@windows = !!(RbConfig::CONFIG['host_os'] =~ /mswin|msys|mingw|cygwin|bccwin|wince|emc/)
@binary_filename = compute_binary_filename
@source_url = nil
@download_error_message = nil

@ordered_paths = [
File.join(File.expand_path('~'), '.browserstack'),
Expand All @@ -33,79 +32,125 @@ def initialize
]
end

def download(dest_parent_dir)
unless File.exist? dest_parent_dir
Dir.mkdir dest_parent_dir
end
uri = URI.parse(@http_path)
binary_path = File.join(dest_parent_dir, "BrowserStackLocal#{".exe" if @windows}")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE

res = http.get(uri.path)
file = open(binary_path, 'wb')
file.write(res.body)
file.close
FileUtils.chmod 0755, binary_path

binary_path
end
def binary_path
dest_parent_dir = get_available_dirs
bin_path = File.join(dest_parent_dir, dest_binary_name)

def verify_binary(binary_path)
binary_response = IO.popen(binary_path + " --version").readline
binary_response =~ /BrowserStack Local version \d+\.\d+/
rescue Exception => e
false
return bin_path if File.exist?(bin_path) && verify_binary(bin_path)

File.delete(bin_path) if File.exist?(bin_path)
download_with_retries(bin_path)
end

def binary_path
dest_parent_dir = get_available_dirs
binary_path = File.join(dest_parent_dir, "BrowserStackLocal#{".exe" if @windows}")
private

if File.exist? binary_path
binary_path
def dest_binary_name
@windows ? 'BrowserStackLocal.exe' : 'BrowserStackLocal'
end

def compute_binary_filename
host_os = RbConfig::CONFIG['host_os']
host_cpu = RbConfig::CONFIG['host_cpu']
case host_os
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
'BrowserStackLocal.exe'
when /darwin|mac os/
'BrowserStackLocal-darwin-x64'
when /linux/
if host_cpu =~ /arm64|aarch64/
'BrowserStackLocal-linux-arm64'
elsif host_os =~ /linux-musl/
'BrowserStackLocal-alpine'
elsif 1.size == 8
'BrowserStackLocal-linux-x64'
else
'BrowserStackLocal-linux-ia32'
end
else
binary_path = download(dest_parent_dir)
raise BrowserStack::LocalException.new("Unsupported host OS: #{host_os}")
end
end

valid_binary = verify_binary(binary_path)

if valid_binary
binary_path
else
binary_path = download(dest_parent_dir)
valid_binary = verify_binary(binary_path)
if valid_binary
binary_path
else
raise BrowserStack::LocalException.new('BrowserStack Local binary is corrupt')
def download_with_retries(bin_path)
retries = BASE_RETRIES
while retries > 0
refresh_source_url(retries) if retries == BASE_RETRIES || retries == FALLBACK_TRIGGER_RETRY
begin
download_to(@source_url + '/' + @binary_filename, bin_path)
return bin_path if verify_binary(bin_path)
@download_error_message = 'Downloaded binary failed verification'
rescue StandardError => e
@download_error_message = "Download failed: #{e.message}"
end
File.delete(bin_path) if File.exist?(bin_path)
retries -= 1
end

raise BrowserStack::LocalException.new(
"Failed to download BrowserStack Local binary after #{BASE_RETRIES} attempts. " \
"Last error: #{@download_error_message}"
)
end

private
def refresh_source_url(retries)
is_fallback = (retries == FALLBACK_TRIGGER_RETRY) && !@download_error_message.nil?
begin
@source_url = BrowserStack::FetchDownloadSourceUrl.call(
auth_token: @auth_token,
user_agent: @user_agent,
fallback: is_fallback,
error_message: @download_error_message,
proxy_host: @proxy_host,
proxy_port: @proxy_port
)
rescue StandardError => e
raise if @source_url.nil?
@download_error_message = "Source URL refresh failed: #{e.message}"
end
end

def download_to(url, bin_path)
uri = URI.parse(url)
http_class = if @proxy_host && @proxy_port
Net::HTTP::Proxy(@proxy_host, @proxy_port.to_i)
else
Net::HTTP
end
http = http_class.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
http.open_timeout = 10
http.read_timeout = 30

req = Net::HTTP::Get.new(uri.request_uri)
req['User-Agent'] = @user_agent

res = http.request(req)
raise "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)

File.open(bin_path, 'wb') { |f| f.write(res.body) }
FileUtils.chmod 0755, bin_path
end

def verify_binary(bin_path)
binary_response = IO.popen(bin_path + " --version").readline
!!(binary_response =~ /BrowserStack Local version \d+\.\d+/)
rescue StandardError
false
end

def get_available_dirs
i = 0
while i < @ordered_paths.size
path = @ordered_paths[i]
if make_path(path)
return path
else
i += 1
end
@ordered_paths.each do |path|
return path if make_path(path)
end
raise BrowserStack::LocalException.new('Error trying to download BrowserStack Local binary')
end

def make_path(path)
begin
FileUtils.mkdir_p path if !File.directory?(path)
return true
rescue Exception
return false
end
FileUtils.mkdir_p(path) unless File.directory?(path)
true
rescue StandardError
false
end
end

Expand Down
3 changes: 3 additions & 0 deletions lib/browserstack/version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module BrowserStack
VERSION = '1.5.0'.freeze
end
Loading
Loading