browserctl 0.8.0 → 0.8.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e63d2425e4bbd57beefebf31e3f616a3183823f467b08c89ba6a1e2bb32cca0b
4
- data.tar.gz: 8f12b646d805237a46b2bba2c194fb91c21834dc78648db97a4f74b0cc672de6
3
+ metadata.gz: c5145a9c65fc1dc92bd238aecd15c672286dfa41005333dbaa1e6a613834c9fc
4
+ data.tar.gz: 0f1c56d87a58b41fbdea2e3ec1abf211bcf08a3677348d34565301f15bb76e96
5
5
  SHA512:
6
- metadata.gz: 50521d3c938c009818d85c93b793c55f5ec2b6d7ae64ec0daae5bf337d793f795189f870d1451d5dca18b285c428a2dd48ec1c0b6af210e6e7c3b41a49c14989
7
- data.tar.gz: fe973906dbfe35134aaa32c2e3080196febaa99dc4c8858683ceb9a9e41579f9a40b7ca7865af67ba946a6b2acc68dd7bafa31036daafeebe96e23edbd3f53c6
6
+ metadata.gz: 162679d64a18bfb241ef1312a7ecfac12cc312407ccd598bc0741c2f985d1754dbdcbe00112ee2cc522a3987b88d8cd34dd4799b48651238efb548348123d58f
7
+ data.tar.gz: 710809a3eb707ad0f1f7300c77cb4b6cfdc4f631dc7f1e02b4b426262164b061d7008ba1901e501483e85a09ecb7be63fd60afa08dba9039b79670e711532fa4
data/CHANGELOG.md CHANGED
@@ -10,6 +10,21 @@ All notable changes to this project will be documented in this file.
10
10
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
11
11
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
+ ## [0.8.2](https://github.com/patrick204nqh/browserctl/compare/v0.8.1...v0.8.2) (2026-04-29)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * v0.8.2 UX polish — describe secret_ref, fallback hint, cross-platform encrypt guidance ([#65](https://github.com/patrick204nqh/browserctl/issues/65)) ([2fe5ea4](https://github.com/patrick204nqh/browserctl/commit/2fe5ea4da0997dc2ab2c3a0696fc819e3fffca40))
19
+
20
+ ## [0.8.1](https://github.com/patrick204nqh/browserctl/compare/v0.8.0...v0.8.1) (2026-04-29)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * fill test gaps and tighten DX for v0.8.1 encryption ([#59](https://github.com/patrick204nqh/browserctl/issues/59)) ([80ae8b3](https://github.com/patrick204nqh/browserctl/commit/80ae8b38620f182db02a971516cfb5295cfb4945))
26
+ * session encryption at rest and passphrase-protected export ([#57](https://github.com/patrick204nqh/browserctl/issues/57)) ([e3185d9](https://github.com/patrick204nqh/browserctl/commit/e3185d95dad3dd373659ce3c1e5781d9a06ed7f1))
27
+
13
28
  ## [0.8.0](https://github.com/patrick204nqh/browserctl/compare/v0.7.0...v0.8.0) (2026-04-29)
14
29
 
15
30
 
@@ -1,5 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "io/console"
5
+ require "json"
6
+ require "open3"
7
+ require "openssl"
8
+ require "securerandom"
9
+ require "tmpdir"
3
10
  require_relative "cli_output"
4
11
 
5
12
  module Browserctl
@@ -9,6 +16,13 @@ module Browserctl
9
16
 
10
17
  USAGE = "Usage: browserctl session <save|load|list|delete|export|import> [args]"
11
18
 
19
+ PBKDF2_ITERATIONS = 100_000
20
+ PBKDF2_KEY_LEN = 32
21
+ SALT_LEN = 16
22
+ NONCE_LEN = 12
23
+
24
+ SENSITIVE_BASENAMES = %w[cookies.json local_storage.json session_storage.json].freeze
25
+
12
26
  def self.run(client, args)
13
27
  sub = args.shift or abort USAGE
14
28
  case sub
@@ -23,8 +37,9 @@ module Browserctl
23
37
  end
24
38
 
25
39
  def self.run_save(client, args)
26
- name = args.shift or abort "usage: browserctl session save <name>"
27
- print_result(client.session_save(name))
40
+ encrypt = args.delete("--encrypt")
41
+ name = args.shift or abort "usage: browserctl session save <name> [--encrypt]"
42
+ print_result(client.session_save(name, encrypt: !!encrypt))
28
43
  end
29
44
 
30
45
  def self.run_load(client, args)
@@ -42,14 +57,23 @@ module Browserctl
42
57
  end
43
58
 
44
59
  def self.run_export(args)
45
- name = args.shift or abort "usage: browserctl session export <name> <path>"
46
- dest = args.shift or abort "usage: browserctl session export <name> <path>"
60
+ encrypt = args.delete("--encrypt")
61
+ name = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
62
+ dest = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
63
+
47
64
  session_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions", name)
48
65
  abort "session '#{name}' not found" unless Dir.exist?(session_dir)
49
66
 
50
67
  dest = File.expand_path(dest)
51
- pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
52
- Process.wait(pid)
68
+
69
+ if encrypt
70
+ passphrase = prompt_passphrase(confirm: true)
71
+ encrypt_export(session_dir, name, dest, passphrase)
72
+ else
73
+ pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
74
+ Process.wait(pid)
75
+ end
76
+
53
77
  puts({ ok: true, path: dest }.to_json)
54
78
  end
55
79
 
@@ -60,10 +84,154 @@ module Browserctl
60
84
 
61
85
  sessions_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions")
62
86
  FileUtils.mkdir_p(sessions_dir)
63
- pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
64
- Process.wait(pid)
87
+
88
+ if encrypted_zip?(zip_path)
89
+ passphrase = prompt_passphrase
90
+ decrypt_import(zip_path, sessions_dir, passphrase)
91
+ else
92
+ pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
93
+ Process.wait(pid)
94
+ end
95
+
65
96
  puts({ ok: true }.to_json)
66
97
  end
98
+
99
+ # --- private helpers ---
100
+
101
+ def self.prompt_passphrase(confirm: false)
102
+ return ENV["BROWSERCTL_EXPORT_PASSPHRASE"] if ENV["BROWSERCTL_EXPORT_PASSPHRASE"]
103
+
104
+ $stderr.print "Passphrase: "
105
+ pass = $stdin.noecho(&:gets).to_s.chomp
106
+ $stderr.puts
107
+
108
+ if confirm
109
+ $stderr.print "Confirm passphrase: "
110
+ confirm_pass = $stdin.noecho(&:gets).to_s.chomp
111
+ $stderr.puts
112
+ abort "Passphrases do not match." unless pass == confirm_pass
113
+ end
114
+
115
+ pass
116
+ end
117
+ private_class_method :prompt_passphrase
118
+
119
+ def self.derive_key(passphrase, salt)
120
+ OpenSSL::PKCS5.pbkdf2_hmac(
121
+ passphrase,
122
+ salt,
123
+ PBKDF2_ITERATIONS,
124
+ PBKDF2_KEY_LEN,
125
+ OpenSSL::Digest.new("SHA256")
126
+ )
127
+ end
128
+ private_class_method :derive_key
129
+
130
+ def self.encrypt_blob(plaintext, key)
131
+ nonce = SecureRandom.bytes(NONCE_LEN)
132
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
133
+ cipher.encrypt
134
+ cipher.key = key
135
+ cipher.iv = nonce
136
+ ct = cipher.update(plaintext) + cipher.final
137
+ tag = cipher.auth_tag
138
+ nonce + ct + tag
139
+ end
140
+ private_class_method :encrypt_blob
141
+
142
+ def self.decrypt_blob(blob, key)
143
+ nonce = blob.byteslice(0, NONCE_LEN)
144
+ tag = blob.byteslice(-16, 16)
145
+ ciphertext = blob.byteslice(NONCE_LEN, blob.bytesize - NONCE_LEN - 16)
146
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
147
+ cipher.decrypt
148
+ cipher.key = key
149
+ cipher.iv = nonce
150
+ cipher.auth_tag = tag
151
+ cipher.update(ciphertext) + cipher.final
152
+ rescue OpenSSL::Cipher::CipherError => e
153
+ raise Browserctl::Error, "export decryption failed: #{e.message}"
154
+ end
155
+ private_class_method :decrypt_blob
156
+
157
+ # Build an encrypted zip using the system zip command.
158
+ # Plaintext files are staged in a tmpdir; sensitive ones are encrypted
159
+ # before staging. An _encryption_manifest.json is added at the zip root.
160
+ def self.encrypt_export(session_dir, session_name, dest, passphrase)
161
+ salt = SecureRandom.bytes(SALT_LEN)
162
+ key = derive_key(passphrase, salt)
163
+ manifest = JSON.generate({
164
+ encrypted: true, kdf: "pbkdf2-sha256",
165
+ iterations: PBKDF2_ITERATIONS, salt: salt.unpack1("H*")
166
+ })
167
+ Dir.mktmpdir do |tmpdir|
168
+ File.write(File.join(tmpdir, "_encryption_manifest.json"), manifest)
169
+ stage_session_files(session_dir, session_name, tmpdir, key)
170
+ pid = Process.spawn("zip", "-r", dest, "_encryption_manifest.json", session_name, chdir: tmpdir)
171
+ Process.wait(pid)
172
+ end
173
+ end
174
+ private_class_method :encrypt_export
175
+
176
+ def self.stage_session_files(session_dir, session_name, tmpdir, key)
177
+ staged_session = File.join(tmpdir, session_name)
178
+ FileUtils.mkdir_p(staged_session)
179
+ Dir[File.join(session_dir, "**", "*")].each do |file|
180
+ next if File.directory?(file)
181
+
182
+ relative = file.delete_prefix("#{session_dir}/")
183
+ staged_path = File.join(staged_session, relative)
184
+ FileUtils.mkdir_p(File.dirname(staged_path))
185
+ raw = File.binread(file)
186
+ if SENSITIVE_BASENAMES.include?(File.basename(file))
187
+ File.binwrite("#{staged_path}.enc", encrypt_blob(raw, key))
188
+ else
189
+ File.binwrite(staged_path, raw)
190
+ end
191
+ end
192
+ end
193
+ private_class_method :stage_session_files
194
+
195
+ # Returns true if the zip contains _encryption_manifest.json.
196
+ def self.encrypted_zip?(zip_path)
197
+ out, status = Open3.capture2("unzip", "-l", zip_path)
198
+ status.success? && out.include?("_encryption_manifest.json")
199
+ end
200
+ private_class_method :encrypted_zip?
201
+
202
+ # Extract zip to tmpdir, read manifest, decrypt .enc files, copy to sessions_dir.
203
+ def self.decrypt_import(zip_path, sessions_dir, passphrase)
204
+ Dir.mktmpdir do |tmpdir|
205
+ pid = Process.spawn("unzip", "-o", zip_path, "-d", tmpdir)
206
+ Process.wait(pid)
207
+ manifest_path = File.join(tmpdir, "_encryption_manifest.json")
208
+ raise Browserctl::Error, "missing encryption manifest in zip" unless File.exist?(manifest_path)
209
+
210
+ manifest = JSON.parse(File.read(manifest_path), symbolize_names: true)
211
+ key = derive_key(passphrase, [manifest[:salt]].pack("H*"))
212
+ copy_decrypted_files(tmpdir, sessions_dir, key)
213
+ end
214
+ end
215
+ private_class_method :decrypt_import
216
+
217
+ def self.copy_decrypted_files(tmpdir, sessions_dir, key)
218
+ Dir[File.join(tmpdir, "**", "*")].each do |file|
219
+ next if File.directory?(file)
220
+ next if File.basename(file) == "_encryption_manifest.json"
221
+
222
+ relative = file.delete_prefix("#{tmpdir}/")
223
+ dest_path = File.join(sessions_dir, relative)
224
+ FileUtils.mkdir_p(File.dirname(dest_path))
225
+ if file.end_with?(".enc")
226
+ File.open(dest_path.delete_suffix(".enc"), "wb", 0o600) do |f|
227
+ f.write(decrypt_blob(File.binread(file), key))
228
+ end
229
+ else
230
+ FileUtils.cp(file, dest_path)
231
+ end
232
+ end
233
+ end
234
+ private_class_method :copy_decrypted_files
67
235
  end
68
236
  end
69
237
  end
@@ -109,7 +109,11 @@ module Browserctl
109
109
  end
110
110
 
111
111
  def format_params(defn)
112
- defn.param_defs.transform_values { |p| { required: p.required, secret: p.secret, default: p.default } }
112
+ defn.param_defs.transform_values do |p|
113
+ entry = { required: p.required, secret: p.secret, default: p.default }
114
+ entry[:secret_ref] = p.secret_ref if p.secret_ref
115
+ entry
116
+ end
113
117
  end
114
118
  end
115
119
  end
@@ -16,9 +16,13 @@ module Browserctl
16
16
  scheme, reference = secret_ref.split("://", 2)
17
17
  resolver = @mutex.synchronize { @registry[scheme] }
18
18
  raise SecretResolverError, "unknown secret resolver scheme '#{scheme}'" unless resolver
19
+
19
20
  unless resolver.available?
20
- raise SecretResolverError,
21
- "'#{scheme}://' resolver is not available in this environment"
21
+ msg = "'#{scheme}://' resolver is not available in this environment"
22
+ if scheme == "keychain"
23
+ msg += "\n Use env://YOUR_VAR_NAME to source secrets from environment variables instead."
24
+ end
25
+ raise SecretResolverError, msg
22
26
  end
23
27
 
24
28
  resolver.resolve(reference)
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "open3"
5
8
  require_relative "constants"
6
9
 
7
10
  module Browserctl
@@ -10,6 +13,8 @@ module Browserctl
10
13
 
11
14
  SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
12
15
 
16
+ SENSITIVE_FILES = %w[cookies.json local_storage.json session_storage.json].freeze
17
+
13
18
  def self.path(name) = File.join(BASE_DIR, name)
14
19
  def self.exist?(name) = Dir.exist?(path(name))
15
20
 
@@ -22,29 +27,70 @@ module Browserctl
22
27
  Dir[File.join(BASE_DIR, "*/metadata.json")].filter_map { |f| load_meta(f) }
23
28
  end
24
29
 
25
- def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:)
30
+ def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:, encrypt: false) # rubocop:disable Metrics/ParameterLists
26
31
  validate_name!(session_name)
32
+ key, metadata = prepare_encryption(session_name, metadata, encrypt)
27
33
  dir = path(session_name)
28
34
  FileUtils.mkdir_p(dir)
29
- write_json(File.join(dir, "metadata.json"), metadata)
30
- write_secret(File.join(dir, "cookies.json"), cookies)
31
- write_secret(File.join(dir, "local_storage.json"), local_storage)
32
- return if session_storage.empty?
35
+ write_json(File.join(dir, "metadata.json"), metadata)
36
+ write_session_files(dir, cookies, local_storage, session_storage, key)
37
+ end
38
+
39
+ def self.prepare_encryption(session_name, metadata, encrypt)
40
+ return [nil, metadata] unless encrypt
33
41
 
34
- write_secret(File.join(dir, "session_storage.json"), session_storage)
42
+ unless keychain_available?
43
+ raise Browserctl::Error,
44
+ "session encryption requires macOS Keychain (darwin only)\n " \
45
+ "For Linux/CI, omit --encrypt and rely on 0o600 file permissions,\n " \
46
+ "or use BROWSERCTL_EXPORT_PASSPHRASE with session export --encrypt for portable archives."
47
+ end
48
+
49
+ key = SecureRandom.bytes(32)
50
+ keychain_store(session_name, key)
51
+ [key, metadata.merge(encrypted: true)]
52
+ end
53
+ private_class_method :prepare_encryption
54
+
55
+ def self.write_session_files(dir, cookies, local_storage, session_storage, key)
56
+ if key
57
+ write_encrypted_secret(File.join(dir, "cookies.json.enc"), cookies, key)
58
+ write_encrypted_secret(File.join(dir, "local_storage.json.enc"), local_storage, key)
59
+ unless session_storage.empty?
60
+ write_encrypted_secret(File.join(dir, "session_storage.json.enc"), session_storage,
61
+ key)
62
+ end
63
+ else
64
+ write_secret(File.join(dir, "cookies.json"), cookies)
65
+ write_secret(File.join(dir, "local_storage.json"), local_storage)
66
+ write_secret(File.join(dir, "session_storage.json"), session_storage) unless session_storage.empty?
67
+ end
35
68
  end
69
+ private_class_method :write_session_files
36
70
 
37
71
  def self.load(session_name)
38
72
  validate_name!(session_name)
39
73
  dir = path(session_name)
40
74
  raise "session '#{session_name}' not found" unless Dir.exist?(dir)
41
75
 
42
- {
43
- metadata: JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true),
44
- cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
45
- local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
46
- session_storage: load_session_storage(dir)
47
- }
76
+ meta = JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true)
77
+
78
+ if meta[:encrypted]
79
+ key = keychain_fetch(session_name)
80
+ {
81
+ metadata: meta,
82
+ cookies: decrypt_json(File.join(dir, "cookies.json.enc"), key, symbolize_names: true),
83
+ local_storage: decrypt_json(File.join(dir, "local_storage.json.enc"), key, symbolize_names: false),
84
+ session_storage: load_session_storage_encrypted(dir, key)
85
+ }
86
+ else
87
+ {
88
+ metadata: meta,
89
+ cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
90
+ local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
91
+ session_storage: load_session_storage(dir)
92
+ }
93
+ end
48
94
  end
49
95
 
50
96
  def self.load_meta(path)
@@ -59,12 +105,87 @@ module Browserctl
59
105
  raise ArgumentError, "invalid session name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
60
106
  end
61
107
 
108
+ # Encrypt data (JSON) and return binary blob: [12-byte nonce][ciphertext+16-byte tag]
109
+ def self.encrypt_file(data, key)
110
+ nonce = SecureRandom.bytes(12)
111
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
112
+ cipher.encrypt
113
+ cipher.key = key
114
+ cipher.iv = nonce
115
+ ciphertext = cipher.update(data) + cipher.final
116
+ tag = cipher.auth_tag
117
+ nonce + ciphertext + tag
118
+ end
119
+ private_class_method :encrypt_file
120
+
121
+ # Decrypt binary blob produced by encrypt_file; returns original plaintext string.
122
+ def self.decrypt_file(blob, key)
123
+ nonce = blob.byteslice(0, 12)
124
+ tag = blob.byteslice(-16, 16)
125
+ ciphertext = blob.byteslice(12, blob.bytesize - 28)
126
+
127
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
128
+ cipher.decrypt
129
+ cipher.key = key
130
+ cipher.iv = nonce
131
+ cipher.auth_tag = tag
132
+ cipher.update(ciphertext) + cipher.final
133
+ rescue OpenSSL::Cipher::CipherError => e
134
+ raise Browserctl::Error, "decryption failed: #{e.message}"
135
+ end
136
+ private_class_method :decrypt_file
137
+
138
+ def self.keychain_store(name, key)
139
+ hex_key = key.unpack1("H*")
140
+ out, status = Open3.capture2(
141
+ "security", "add-generic-password",
142
+ "-a", name,
143
+ "-s", "browserctl",
144
+ "-w", hex_key,
145
+ "-U"
146
+ )
147
+ raise Browserctl::Error, "keychain store failed: #{out}" unless status.success?
148
+ end
149
+ private_class_method :keychain_store
150
+
151
+ def self.keychain_fetch(name)
152
+ hex_key, status = Open3.capture2(
153
+ "security", "find-generic-password",
154
+ "-a", name,
155
+ "-s", "browserctl",
156
+ "-w"
157
+ )
158
+ raise Browserctl::Error, "keychain fetch failed for session '#{name}'" unless status.success?
159
+
160
+ [hex_key.strip].pack("H*")
161
+ end
162
+ private_class_method :keychain_fetch
163
+
164
+ def self.keychain_available?
165
+ RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
166
+ end
167
+ private_class_method :keychain_available?
168
+
62
169
  def self.load_session_storage(dir)
63
170
  ss_path = File.join(dir, "session_storage.json")
64
171
  File.exist?(ss_path) ? JSON.parse(File.read(ss_path), symbolize_names: false) : {}
65
172
  end
66
173
  private_class_method :load_session_storage
67
174
 
175
+ def self.load_session_storage_encrypted(dir, key)
176
+ ss_path = File.join(dir, "session_storage.json.enc")
177
+ return {} unless File.exist?(ss_path)
178
+
179
+ decrypt_json(ss_path, key, symbolize_names: false)
180
+ end
181
+ private_class_method :load_session_storage_encrypted
182
+
183
+ def self.decrypt_json(path, key, symbolize_names:)
184
+ blob = File.binread(path)
185
+ JSON.parse(decrypt_file(blob, key), symbolize_names: symbolize_names)
186
+ end
187
+ private_class_method :decrypt_json
188
+
68
189
  def self.write_json(path, data)
69
190
  File.write(path, JSON.generate(data))
70
191
  end
@@ -75,5 +196,11 @@ module Browserctl
75
196
  File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
76
197
  end
77
198
  private_class_method :write_secret
199
+
200
+ def self.write_encrypted_secret(path, data, key)
201
+ blob = encrypt_file(JSON.generate(data), key)
202
+ File.open(path, "wb", 0o600) { |f| f.write(blob) }
203
+ end
204
+ private_class_method :write_encrypted_secret
78
205
  end
79
206
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.8.0"
4
+ VERSION = "0.8.2"
5
5
  end
@@ -4,6 +4,7 @@ require "timeout"
4
4
  require_relative "client"
5
5
  require_relative "errors"
6
6
  require_relative "secret_resolvers"
7
+ require_relative "session"
7
8
 
8
9
  module Browserctl
9
10
  ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
@@ -60,8 +61,8 @@ module Browserctl
60
61
  res
61
62
  end
62
63
 
63
- def save_session(session_name)
64
- res = @client.session_save(session_name)
64
+ def save_session(session_name, encrypt: false)
65
+ res = @client.session_save(session_name, encrypt: encrypt)
65
66
  raise WorkflowError, res[:error] if res[:error]
66
67
 
67
68
  res
@@ -76,8 +77,11 @@ module Browserctl
76
77
  invoke(fallback.to_s)
77
78
  res2 = @client.session_load(session_name)
78
79
  if res2[:error]
79
- raise WorkflowError,
80
- "session '#{session_name}' still unavailable after running fallback '#{fallback}'"
80
+ msg = "session '#{session_name}' still unavailable after running fallback '#{fallback}'"
81
+ unless Session.exist?(session_name)
82
+ msg += "\n Hint: '#{fallback}' did not call save_session(\"#{session_name}\") — add it as the last step."
83
+ end
84
+ raise WorkflowError, msg
81
85
  end
82
86
 
83
87
  res2
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick