camoufox 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c391cb28a74732f550d94ffc69602383111cf3b99673d2fbcd6d451945db1c3f
4
- data.tar.gz: f8a4e392dbf8e240932643bcdf9cba80726cec1c2d3146d9c0ae03141b389e9b
3
+ metadata.gz: 5fed67395114c5d9ecb19c0c4568266f4870f16927b9fbb7dbe20419bade6978
4
+ data.tar.gz: 45a0a6c1d40ca0988061d80f5fccdb8b2dbc711c7249971551355b8e5ab66655
5
5
  SHA512:
6
- metadata.gz: ea6e830b939932a3b906c77bedc5e2a28b5328c5194bafe420f5fbd2dedf0a96e3c631ea0f617389d52c6717970b2c192ca062656f92e6db2c0655276f0d1fa5
7
- data.tar.gz: b7aa87886a1a0312d8ecc77868e13f5b9748055e429131c091f8e95583290ed9c97763809953f5abe9615694a160d0180af4408ccdb3db0959b940c61e68ad9f
6
+ metadata.gz: 37b227afb8200918e51019dae5f58cbf086729ac15e76bbecfe52f6f1c4a5f1a59a5127dd62368caaead14e7493124ac76e3c794a35904b8be94cb3fd1736dc4
7
+ data.tar.gz: ae7e5b475c690f3b646a1e78a50134c2d27830e8bf63d4fff35f5aec328327a23f89703d1028f0fd1f6b2d5376067526fbe33edccb8c02d7f094f33af1cab73f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+ - Add `user_data_dir` support to the synchronous Playwright bridge so Firefox can reuse persistent
5
+ profiles via `launchPersistentContext`.
6
+
7
+ ## 0.4.2
8
+ - Default the native stub's `executable_path` to `File.join(Camoufox::Pkgman.install_dir, "camoufox")`
9
+ while still honoring kwargs or `CAMOUFOX_EXECUTABLE_PATH`, so Playwright can launch whichever
10
+ Camoufox binary the gem installed.
11
+
12
+ ## 0.4.0
13
+ - Rework the synchronous Playwright bridge to keep a persistent Firefox page session so multiple
14
+ commands run against the same browser instance.
15
+ - Add `Camoufox::SyncAPI::Page#wait_for_selector` and expose live `title`/`content` reads through
16
+ the new bridge.
17
+ - Allow the native stub to honor `executable_path` kwargs or `CAMOUFOX_EXECUTABLE_PATH` so the
18
+ Playwright bridge can launch real binaries without patching the extension.
19
+
3
20
  ## 0.3.0
4
21
  - Improve Playwright bridge: unwrap driver bundles that only expose `createPlaywright`/`default`
5
22
  exports so `Camoufox::SyncAPI` can always reach `playwright.firefox`.
data/README.md CHANGED
@@ -48,7 +48,9 @@ end
48
48
  Camoufox::SyncAPI::Camoufox.open(headless: true) do |browser|
49
49
  page = browser.new_page
50
50
  page.goto("https://example.com")
51
+ page.wait_for_selector('h1')
51
52
  puts page.title
53
+ puts page.content.include?('Example Domain')
52
54
  end
53
55
  ```
54
56
 
@@ -56,6 +58,27 @@ Behind the scenes the Ruby port encodes the Camoufox launch options, hands them
56
58
  bridge, and lets Playwright do the heavy lifting. You must supply a Playwright driver bundle or
57
59
  installation so the Node script can `require('playwright')`.
58
60
 
61
+ The synchronous helper keeps a Firefox page alive for the lifetime of the Ruby object, so follow-up
62
+ calls like `wait_for_selector`, `content`, or `title` reuse the same DOM state without re-launching
63
+ the browser for every method.
64
+
65
+ ### Reusing Firefox profiles
66
+
67
+ Playwright's `launchPersistentContext` API can now be toggled by passing a `user_data_dir` when
68
+ opening a Camoufox session. The directory stores cookies, history, and other profile data between
69
+ runs, mirroring how Playwright persists Chromium profiles:
70
+
71
+ ```ruby
72
+ Camoufox::SyncAPI::Camoufox.open(headless: false, user_data_dir: "/tmp/camoufox-profile") do |browser|
73
+ page = browser.new_page
74
+ page.goto("https://example.com")
75
+ puts page.title
76
+ end
77
+ ```
78
+
79
+ When `user_data_dir` is provided the Node bridge launches Firefox through
80
+ `browserType.launchPersistentContext`, so every browser command reuses the same profile directory.
81
+
59
82
  ### Launching the Playwright server (experimental)
60
83
 
61
84
  To mirror the Python helper that spins up a Playwright websocket endpoint, the Ruby port can invoke
@@ -138,6 +161,7 @@ Environment overrides:
138
161
 
139
162
  - `CAMOUFOX_DATA_DIR` – override where Camoufox assets are stored (planned use)
140
163
  - `CAMOUFOX_CACHE_DIR` – override the cache directory (planned use)
164
+ - `CAMOUFOX_EXECUTABLE_PATH` – path to the Camoufox Firefox binary returned by the native stub (defaults to `File.join(Camoufox::Pkgman.install_dir, "camoufox")`, but override it if you place the browser elsewhere)
141
165
  - `CAMOUFOX_NODE_PATH` – path to the Node.js binary used when spawning the Playwright server (defaults to `node`)
142
166
  - `CAMOUFOX_PLAYWRIGHT_DRIVER_DIR` – directory containing `lib/browserServerImpl.js` (defaults to `node_modules/playwright` if present)
143
167
  - `CAMOUFOX_PLAYWRIGHT_JS_REQUIRE` – optional module identifier passed to Node's `require()` when
@@ -0,0 +1,267 @@
1
+
2
+ SHELL = /bin/sh
3
+
4
+ # V=0 quiet, V=1 verbose. other values don't work.
5
+ V = 0
6
+ Q1 = $(V:1=)
7
+ Q = $(Q1:0=@)
8
+ ECHO1 = $(V:1=@ :)
9
+ ECHO = $(ECHO1:0=@ echo)
10
+ NULLCMD = :
11
+
12
+ #### Start of system configuration section. ####
13
+
14
+ srcdir = .
15
+ topdir = /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.1.sdk/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/include/ruby-2.6.0
16
+ hdrdir = $(topdir)
17
+ arch_hdrdir = /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.1.sdk/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/include/ruby-2.6.0/universal-darwin24
18
+ PATH_SEPARATOR = :
19
+ VPATH = $(srcdir):$(arch_hdrdir)/ruby:$(hdrdir)/ruby
20
+ prefix = $(DESTDIR)/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr
21
+ rubysitearchprefix = $(rubylibprefix)/$(sitearch)
22
+ rubyarchprefix = $(rubylibprefix)/$(arch)
23
+ rubylibprefix = $(libdir)/$(RUBY_BASE_NAME)
24
+ exec_prefix = $(prefix)
25
+ vendorarchhdrdir = $(vendorhdrdir)/$(sitearch)
26
+ sitearchhdrdir = $(sitehdrdir)/$(sitearch)
27
+ rubyarchhdrdir = $(rubyhdrdir)/$(arch)
28
+ vendorhdrdir = $(rubyhdrdir)/vendor_ruby
29
+ sitehdrdir = $(rubyhdrdir)/site_ruby
30
+ rubyhdrdir = $(includedir)/$(RUBY_VERSION_NAME)
31
+ vendorarchdir = $(vendorlibdir)/$(sitearch)
32
+ vendorlibdir = $(vendordir)/$(ruby_version)
33
+ vendordir = $(rubylibprefix)/vendor_ruby
34
+ sitearchdir = $(sitelibdir)/$(sitearch)
35
+ sitelibdir = $(sitedir)/$(ruby_version)
36
+ sitedir = $(DESTDIR)/Library/Ruby/Site
37
+ rubyarchdir = $(rubylibdir)/$(arch)
38
+ rubylibdir = $(rubylibprefix)/$(ruby_version)
39
+ sitearchincludedir = $(includedir)/$(sitearch)
40
+ archincludedir = $(includedir)/$(arch)
41
+ sitearchlibdir = $(libdir)/$(sitearch)
42
+ archlibdir = $(libdir)/$(arch)
43
+ ridir = $(datarootdir)/$(RI_BASE_NAME)
44
+ mandir = $(DESTDIR)/usr/share/man
45
+ localedir = $(datarootdir)/locale
46
+ libdir = $(exec_prefix)/lib
47
+ psdir = $(docdir)
48
+ pdfdir = $(docdir)
49
+ dvidir = $(docdir)
50
+ htmldir = $(docdir)
51
+ infodir = $(DESTDIR)/usr/share/info
52
+ docdir = $(datarootdir)/doc/$(PACKAGE)
53
+ oldincludedir = $(DESTDIR)/usr/include
54
+ includedir = $(DESTDIR)/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.1.sdk$(prefix)/include
55
+ runstatedir = $(localstatedir)/run
56
+ localstatedir = $(prefix)/var
57
+ sharedstatedir = $(prefix)/com
58
+ sysconfdir = $(DESTDIR)/Library/Ruby/Site
59
+ datadir = $(datarootdir)
60
+ datarootdir = $(prefix)/share
61
+ libexecdir = $(exec_prefix)/libexec
62
+ sbindir = $(exec_prefix)/sbin
63
+ bindir = $(exec_prefix)/bin
64
+ archdir = $(rubyarchdir)
65
+
66
+
67
+ CC_WRAPPER =
68
+ CC = xcrun clang
69
+ CXX = xcrun clang++
70
+ LIBRUBY = $(LIBRUBY_SO)
71
+ LIBRUBY_A = lib$(RUBY_SO_NAME)-static.a
72
+ LIBRUBYARG_SHARED = -l$(RUBY_SO_NAME)
73
+ LIBRUBYARG_STATIC = -l$(RUBY_SO_NAME)-static -framework Security -framework Foundation $(MAINLIBS)
74
+ empty =
75
+ OUTFLAG = -o $(empty)
76
+ COUTFLAG = -o $(empty)
77
+ CSRCFLAG = $(empty)
78
+
79
+ RUBY_EXTCONF_H =
80
+ cflags = $(optflags) $(debugflags) $(warnflags)
81
+ cxxflags = $(optflags) $(debugflags) $(warnflags)
82
+ optflags =
83
+ debugflags = -g
84
+ warnflags =
85
+ cppflags =
86
+ CCDLFLAGS =
87
+ CFLAGS = $(CCDLFLAGS) -g -Os -pipe -DHAVE_GCC_ATOMIC_BUILTINS -fno-typed-memory-operations -fno-typed-cxx-new-delete -DUSE_FFI_CLOSURE_ALLOC -std=c99 $(ARCH_FLAG)
88
+ INCFLAGS = -I. -I$(arch_hdrdir) -I$(hdrdir)/ruby/backward -I$(hdrdir) -I$(srcdir)
89
+ DEFS =
90
+ CPPFLAGS = -D_XOPEN_SOURCE -D_DARWIN_C_SOURCE -D_DARWIN_UNLIMITED_SELECT -D_REENTRANT $(DEFS) $(cppflags)
91
+ CXXFLAGS = $(CCDLFLAGS) -g -Os -pipe -std=c++20 -Wall -Wextra $(ARCH_FLAG)
92
+ ldflags = -L. -L/AppleInternal/Library/BuildRoots/4~B5FAugAunRW9OK7YcFNXDBTTVy6DXTYoxzE3ORQ/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.6.Internal.sdk/usr/local/lib
93
+ dldflags = $(ARCH_FLAG) -undefined dynamic_lookup
94
+ ARCH_FLAG =
95
+ DLDFLAGS = $(ldflags) $(dldflags) $(ARCH_FLAG)
96
+ LDSHARED = $(CC) -dynamic -bundle
97
+ LDSHAREDXX = $(CXX) -dynamic -bundle
98
+ AR = libtool -static
99
+ EXEEXT =
100
+
101
+ RUBY_INSTALL_NAME = $(RUBY_BASE_NAME)
102
+ RUBY_SO_NAME = ruby.2.6
103
+ RUBYW_INSTALL_NAME =
104
+ RUBY_VERSION_NAME = $(RUBY_BASE_NAME)-$(ruby_version)
105
+ RUBYW_BASE_NAME = rubyw
106
+ RUBY_BASE_NAME = ruby
107
+
108
+ arch = universal-darwin24
109
+ sitearch = $(arch)
110
+ ruby_version = 2.6.0
111
+ ruby = $(bindir)/$(RUBY_BASE_NAME)
112
+ RUBY = $(ruby)
113
+ ruby_headers = $(hdrdir)/ruby.h $(hdrdir)/ruby/backward.h $(hdrdir)/ruby/ruby.h $(hdrdir)/ruby/defines.h $(hdrdir)/ruby/missing.h $(hdrdir)/ruby/intern.h $(hdrdir)/ruby/st.h $(hdrdir)/ruby/subst.h $(arch_hdrdir)/ruby/config.h
114
+
115
+ RM = rm -f
116
+ RM_RF = $(RUBY) -run -e rm -- -rf
117
+ RMDIRS = rmdir -p
118
+ MAKEDIRS = mkdir -p
119
+ INSTALL = /usr/bin/install -c
120
+ INSTALL_PROG = $(INSTALL) -m 0755
121
+ INSTALL_DATA = $(INSTALL) -m 644
122
+ COPY = cp
123
+ TOUCH = exit >
124
+
125
+ #### End of system configuration section. ####
126
+
127
+ preload =
128
+ libpath = . $(libdir)
129
+ LIBPATH = -L. -L$(libdir)
130
+ DEFFILE =
131
+
132
+ CLEANFILES = mkmf.log
133
+ DISTCLEANFILES =
134
+ DISTCLEANDIRS =
135
+
136
+ extout =
137
+ extout_prefix =
138
+ target_prefix =
139
+ LOCAL_LIBS =
140
+ LIBS = $(LIBRUBYARG_SHARED)
141
+ ORIG_SRCS = camoufox_native.cpp
142
+ SRCS = $(ORIG_SRCS)
143
+ OBJS = camoufox_native.o
144
+ HDRS =
145
+ LOCAL_HDRS =
146
+ TARGET = camoufox_native
147
+ TARGET_NAME = camoufox_native
148
+ TARGET_ENTRY = Init_$(TARGET_NAME)
149
+ DLLIB = $(TARGET).bundle
150
+ EXTSTATIC =
151
+ STATIC_LIB =
152
+
153
+ TIMESTAMP_DIR = .
154
+ BINDIR = $(bindir)
155
+ RUBYCOMMONDIR = $(sitedir)$(target_prefix)
156
+ RUBYLIBDIR = $(sitelibdir)$(target_prefix)
157
+ RUBYARCHDIR = $(sitearchdir)$(target_prefix)
158
+ HDRDIR = $(rubyhdrdir)/ruby$(target_prefix)
159
+ ARCHHDRDIR = $(rubyhdrdir)/$(arch)/ruby$(target_prefix)
160
+ TARGET_SO_DIR =
161
+ TARGET_SO = $(TARGET_SO_DIR)$(DLLIB)
162
+ CLEANLIBS = $(TARGET_SO)
163
+ CLEANOBJS = *.o *.bak
164
+
165
+ all: $(DLLIB)
166
+ static: $(STATIC_LIB)
167
+ .PHONY: all install static install-so install-rb
168
+ .PHONY: clean clean-so clean-static clean-rb
169
+
170
+ clean-static::
171
+ clean-rb-default::
172
+ clean-rb::
173
+ clean-so::
174
+ clean: clean-so clean-static clean-rb-default clean-rb
175
+ -$(Q)$(RM) $(CLEANLIBS) $(CLEANOBJS) $(CLEANFILES) .*.time
176
+
177
+ distclean-rb-default::
178
+ distclean-rb::
179
+ distclean-so::
180
+ distclean-static::
181
+ distclean: clean distclean-so distclean-static distclean-rb-default distclean-rb
182
+ -$(Q)$(RM) Makefile $(RUBY_EXTCONF_H) conftest.* mkmf.log
183
+ -$(Q)$(RM) core ruby$(EXEEXT) *~ $(DISTCLEANFILES)
184
+ -$(Q)$(RMDIRS) $(DISTCLEANDIRS) 2> /dev/null || true
185
+
186
+ realclean: distclean
187
+ install: install-so install-rb
188
+
189
+ install-so: $(DLLIB) $(TIMESTAMP_DIR)/.sitearchdir.time
190
+ $(INSTALL_PROG) $(DLLIB) $(RUBYARCHDIR)
191
+ clean-static::
192
+ -$(Q)$(RM) $(STATIC_LIB)
193
+ install-rb: pre-install-rb do-install-rb install-rb-default
194
+ install-rb-default: pre-install-rb-default do-install-rb-default
195
+ pre-install-rb: Makefile
196
+ pre-install-rb-default: Makefile
197
+ do-install-rb:
198
+ do-install-rb-default:
199
+ pre-install-rb-default:
200
+ @$(NULLCMD)
201
+ $(TIMESTAMP_DIR)/.sitearchdir.time:
202
+ $(Q) $(MAKEDIRS) $(@D) $(RUBYARCHDIR)
203
+ $(Q) $(TOUCH) $@
204
+
205
+ site-install: site-install-so site-install-rb
206
+ site-install-so: install-so
207
+ site-install-rb: install-rb
208
+
209
+ .SUFFIXES: .c .m .cc .mm .cxx .cpp .o .S
210
+
211
+ .cc.o:
212
+ $(ECHO) compiling $(<)
213
+ $(Q) $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) $(COUTFLAG)$@ -c $(CSRCFLAG)$<
214
+
215
+ .cc.S:
216
+ $(ECHO) translating $(<)
217
+ $(Q) $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) $(COUTFLAG)$@ -S $(CSRCFLAG)$<
218
+
219
+ .mm.o:
220
+ $(ECHO) compiling $(<)
221
+ $(Q) $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) $(COUTFLAG)$@ -c $(CSRCFLAG)$<
222
+
223
+ .mm.S:
224
+ $(ECHO) translating $(<)
225
+ $(Q) $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) $(COUTFLAG)$@ -S $(CSRCFLAG)$<
226
+
227
+ .cxx.o:
228
+ $(ECHO) compiling $(<)
229
+ $(Q) $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) $(COUTFLAG)$@ -c $(CSRCFLAG)$<
230
+
231
+ .cxx.S:
232
+ $(ECHO) translating $(<)
233
+ $(Q) $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) $(COUTFLAG)$@ -S $(CSRCFLAG)$<
234
+
235
+ .cpp.o:
236
+ $(ECHO) compiling $(<)
237
+ $(Q) $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) $(COUTFLAG)$@ -c $(CSRCFLAG)$<
238
+
239
+ .cpp.S:
240
+ $(ECHO) translating $(<)
241
+ $(Q) $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) $(COUTFLAG)$@ -S $(CSRCFLAG)$<
242
+
243
+ .c.o:
244
+ $(ECHO) compiling $(<)
245
+ $(Q) $(CC) $(INCFLAGS) $(CPPFLAGS) $(CFLAGS) $(COUTFLAG)$@ -c $(CSRCFLAG)$<
246
+
247
+ .c.S:
248
+ $(ECHO) translating $(<)
249
+ $(Q) $(CC) $(INCFLAGS) $(CPPFLAGS) $(CFLAGS) $(COUTFLAG)$@ -S $(CSRCFLAG)$<
250
+
251
+ .m.o:
252
+ $(ECHO) compiling $(<)
253
+ $(Q) $(CC) $(INCFLAGS) $(CPPFLAGS) $(CFLAGS) $(COUTFLAG)$@ -c $(CSRCFLAG)$<
254
+
255
+ .m.S:
256
+ $(ECHO) translating $(<)
257
+ $(Q) $(CC) $(INCFLAGS) $(CPPFLAGS) $(CFLAGS) $(COUTFLAG)$@ -S $(CSRCFLAG)$<
258
+
259
+ $(TARGET_SO): $(OBJS) Makefile
260
+ $(ECHO) linking shared-object $(DLLIB)
261
+ -$(Q)$(RM) $(@)
262
+ $(Q) $(LDSHAREDXX) -o $@ $(OBJS) $(LIBPATH) $(DLDFLAGS) $(LOCAL_LIBS) $(LIBS)
263
+ $(Q) $(POSTLINK)
264
+
265
+
266
+
267
+ $(OBJS): $(HDRS) $(ruby_headers)
@@ -1,13 +1,61 @@
1
1
  #include <ruby.h>
2
2
  #include <cstring>
3
+ #include <cstdlib>
3
4
 
4
5
  namespace {
5
6
 
7
+ VALUE pkgman_default_executable() {
8
+ ID camoufox_id = rb_intern("Camoufox");
9
+ if (!rb_const_defined(rb_cObject, camoufox_id)) {
10
+ return Qnil;
11
+ }
12
+
13
+ VALUE camoufox_module = rb_const_get(rb_cObject, camoufox_id);
14
+ ID pkgman_id = rb_intern("Pkgman");
15
+ if (!rb_const_defined(camoufox_module, pkgman_id)) {
16
+ return Qnil;
17
+ }
18
+
19
+ VALUE pkgman_module = rb_const_get(camoufox_module, pkgman_id);
20
+ VALUE install_dir = rb_funcall(pkgman_module, rb_intern("install_dir"), 0);
21
+ if (NIL_P(install_dir)) {
22
+ return Qnil;
23
+ }
24
+
25
+ VALUE file_class = rb_const_get(rb_cObject, rb_intern("File"));
26
+ VALUE executable_name = rb_str_new_cstr("camoufox");
27
+ return rb_funcall(file_class, rb_intern("join"), 2, install_dir, executable_name);
28
+ }
29
+
30
+ VALUE fetch_executable_path(VALUE rb_options) {
31
+ ID executable_id = rb_intern("executable_path");
32
+ VALUE executable_key = ID2SYM(executable_id);
33
+ VALUE explicit_value = rb_hash_lookup(rb_options, executable_key);
34
+
35
+ if (!NIL_P(explicit_value)) {
36
+ return explicit_value;
37
+ }
38
+
39
+ const char* env_value = std::getenv("CAMOUFOX_EXECUTABLE_PATH");
40
+ if (env_value && env_value[0] != '\0') {
41
+ return rb_str_new_cstr(env_value);
42
+ }
43
+
44
+ VALUE pkgman_path = pkgman_default_executable();
45
+ if (!NIL_P(pkgman_path)) {
46
+ return pkgman_path;
47
+ }
48
+
49
+ return Qnil;
50
+ }
51
+
6
52
  VALUE build_stub_launch_options(VALUE rb_options) {
53
+ Check_Type(rb_options, T_HASH);
7
54
  VALUE result = rb_hash_new();
8
55
 
9
- VALUE executable_path = rb_str_new_cstr("/usr/local/bin/camoufox");
10
- rb_hash_aset(result, ID2SYM(rb_intern("executable_path")), executable_path);
56
+ VALUE executable_key = ID2SYM(rb_intern("executable_path"));
57
+ VALUE executable_path = fetch_executable_path(rb_options);
58
+ rb_hash_aset(result, executable_key, executable_path);
11
59
 
12
60
  VALUE args = rb_ary_new();
13
61
  rb_hash_aset(result, ID2SYM(rb_intern("args")), args);
@@ -28,6 +76,13 @@ VALUE build_stub_launch_options(VALUE rb_options) {
28
76
 
29
77
  rb_hash_aset(result, headless_key, headless_value);
30
78
 
79
+ ID user_data_dir_id = rb_intern("user_data_dir");
80
+ VALUE user_data_dir_key = ID2SYM(user_data_dir_id);
81
+ VALUE user_data_dir_value = rb_hash_lookup(rb_options, user_data_dir_key);
82
+ if (!NIL_P(user_data_dir_value)) {
83
+ rb_hash_aset(result, user_data_dir_key, user_data_dir_value);
84
+ }
85
+
31
86
  return result;
32
87
  }
33
88
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Camoufox
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -15,7 +15,12 @@ module Camoufox
15
15
 
16
16
  def launch_options(**kwargs)
17
17
  ensure_loaded!
18
- CamoufoxNative.launch_options(kwargs)
18
+ options = CamoufoxNative.launch_options(kwargs)
19
+ if kwargs.key?(:user_data_dir)
20
+ options = options.dup
21
+ options[:user_data_dir] = kwargs[:user_data_dir]
22
+ end
23
+ options
19
24
  end
20
25
 
21
26
  def run_cli(command, args = [])
@@ -0,0 +1,251 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const readline = require('readline');
4
+
5
+ function withDefault(value, extractor) {
6
+ if (!value) {
7
+ return null;
8
+ }
9
+
10
+ try {
11
+ return extractor(value);
12
+ } catch (error) {
13
+ console.warn(`camoufox: failed to initialize Playwright shim (${error.message || error})`);
14
+ return null;
15
+ }
16
+ }
17
+
18
+ function resolvePlaywrightApi(candidate) {
19
+ if (!candidate) {
20
+ return null;
21
+ }
22
+
23
+ if (candidate.firefox) {
24
+ return candidate;
25
+ }
26
+
27
+ if (candidate.default) {
28
+ const resolved = resolvePlaywrightApi(candidate.default);
29
+ if (resolved) {
30
+ return resolved;
31
+ }
32
+ }
33
+
34
+ if (typeof candidate.createInProcessPlaywright === 'function') {
35
+ const resolved = withDefault(candidate, (mod) => mod.createInProcessPlaywright());
36
+ if (resolved) {
37
+ return resolved;
38
+ }
39
+ }
40
+
41
+ if (typeof candidate.createPlaywright === 'function') {
42
+ const resolved = withDefault(candidate, (mod) => mod.createPlaywright({ sdkLanguage: process.env.PW_LANG_NAME || 'javascript' }));
43
+ if (resolved && resolved.firefox) {
44
+ return resolved;
45
+ }
46
+ }
47
+
48
+ if (candidate.playwright) {
49
+ return resolvePlaywrightApi(candidate.playwright);
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ function loadPlaywright() {
56
+ const override = process.env.CAMOUFOX_PLAYWRIGHT_JS_REQUIRE;
57
+ if (override) {
58
+ // eslint-disable-next-line global-require, import/no-dynamic-require
59
+ return require(override);
60
+ }
61
+
62
+ try {
63
+ // eslint-disable-next-line global-require, import/no-dynamic-require
64
+ return require('playwright');
65
+ } catch (error) {
66
+ // fall through
67
+ }
68
+
69
+ const driverDir = process.env.CAMOUFOX_PLAYWRIGHT_DRIVER_DIR;
70
+ if (driverDir) {
71
+ try {
72
+ // eslint-disable-next-line global-require, import/no-dynamic-require
73
+ return require(path.join(driverDir, 'package'));
74
+ } catch (error) {
75
+ // fall through
76
+ }
77
+ }
78
+
79
+ console.error('Unable to require Playwright. Install the `playwright` npm package or set CAMOUFOX_PLAYWRIGHT_JS_REQUIRE.');
80
+ process.exit(1);
81
+ }
82
+
83
+ function decodePayload(line) {
84
+ const trimmed = (line || '').trim();
85
+ if (!trimmed) {
86
+ return null;
87
+ }
88
+ const buffer = Buffer.from(trimmed, 'base64');
89
+ return JSON.parse(buffer.toString());
90
+ }
91
+
92
+ function encodePayload(message) {
93
+ const json = JSON.stringify(message);
94
+ return Buffer.from(json).toString('base64');
95
+ }
96
+
97
+ function writeMessage(message) {
98
+ process.stdout.write(`${encodePayload(message)}\n`);
99
+ }
100
+
101
+ async function waitForNetworkIdle(page, timeout) {
102
+ try {
103
+ await page.waitForLoadState('networkidle', { timeout });
104
+ } catch (error) {
105
+ console.warn(`camoufox: waitForLoadState(networkidle) warning: ${error.message || error}`);
106
+ }
107
+ }
108
+
109
+ async function run() {
110
+ const rl = readline.createInterface({
111
+ input: process.stdin,
112
+ crlfDelay: Infinity,
113
+ terminal: false,
114
+ });
115
+
116
+ let initialized = false;
117
+ let browser;
118
+ let context;
119
+ let page;
120
+
121
+ rl.on('line', async (line) => {
122
+ try {
123
+ if (!initialized) {
124
+ const payload = decodePayload(line);
125
+ if (!payload || !payload.options) {
126
+ throw new Error('Missing launch options');
127
+ }
128
+
129
+ const options = payload.options;
130
+ if (options.executablePath && !fs.existsSync(options.executablePath)) {
131
+ delete options.executablePath;
132
+ }
133
+
134
+ const playwrightModule = loadPlaywright();
135
+ const playwright = resolvePlaywrightApi(playwrightModule);
136
+ const browserType = playwright && playwright.firefox;
137
+ if (!browserType) {
138
+ throw new Error('Playwright module does not expose `firefox` browser type');
139
+ }
140
+
141
+ const userDataDir = options.userDataDir;
142
+ if (userDataDir) {
143
+ delete options.userDataDir;
144
+ context = await browserType.launchPersistentContext(userDataDir, options);
145
+ const pages = context.pages();
146
+ page = pages.length ? pages[0] : await context.newPage();
147
+ } else {
148
+ browser = await browserType.launch(options);
149
+ page = await browser.newPage();
150
+ }
151
+ initialized = true;
152
+ writeMessage({ event: 'ready' });
153
+ return;
154
+ }
155
+
156
+ const payload = decodePayload(line);
157
+ if (!payload || typeof payload !== 'object') {
158
+ throw new Error('Invalid command payload');
159
+ }
160
+
161
+ const { id, action, params = {} } = payload;
162
+ if (typeof id === 'undefined') {
163
+ throw new Error('Command is missing id');
164
+ }
165
+
166
+ const respond = (body) => writeMessage({ id, ...body });
167
+
168
+ try {
169
+ let result;
170
+ switch (action) {
171
+ case 'goto': {
172
+ const { url, waitUntil = 'domcontentloaded', waitForNetworkIdleTimeout = 15000 } = params;
173
+ if (!url) {
174
+ throw new Error('goto requires a url');
175
+ }
176
+ await page.goto(url, { waitUntil });
177
+ await waitForNetworkIdle(page, waitForNetworkIdleTimeout);
178
+ const [title, content] = await Promise.all([page.title(), page.content()]);
179
+ result = { title, content };
180
+ break;
181
+ }
182
+ case 'wait_for_selector': {
183
+ const { selector, options = {} } = params;
184
+ if (!selector) {
185
+ throw new Error('wait_for_selector requires a selector');
186
+ }
187
+ const handle = await page.waitForSelector(selector, options);
188
+ if (handle) {
189
+ await handle.dispose();
190
+ }
191
+ result = { resolved: true };
192
+ break;
193
+ }
194
+ case 'content': {
195
+ const content = await page.content();
196
+ result = { content };
197
+ break;
198
+ }
199
+ case 'title': {
200
+ const title = await page.title();
201
+ result = { title };
202
+ break;
203
+ }
204
+ case 'close': {
205
+ if (context) {
206
+ await context.close();
207
+ context = null;
208
+ } else if (browser) {
209
+ await browser.close();
210
+ browser = null;
211
+ }
212
+ result = { closed: true };
213
+ respond({ result });
214
+ rl.close();
215
+ return process.exit(0);
216
+ }
217
+ default:
218
+ throw new Error(`Unknown action: ${action}`);
219
+ }
220
+
221
+ respond({ result });
222
+ } catch (error) {
223
+ respond({ error: { message: error.message || String(error), name: error.name } });
224
+ }
225
+ } catch (fatalError) {
226
+ console.error(fatalError.message || fatalError);
227
+ process.exit(1);
228
+ }
229
+ });
230
+
231
+ rl.on('close', async () => {
232
+ if (context) {
233
+ try {
234
+ await context.close();
235
+ } catch (error) {
236
+ // swallow
237
+ }
238
+ } else if (browser) {
239
+ try {
240
+ await browser.close();
241
+ } catch (error) {
242
+ // swallow
243
+ }
244
+ }
245
+ });
246
+ }
247
+
248
+ run().catch((error) => {
249
+ console.error(error.message || error);
250
+ process.exit(1);
251
+ });
@@ -20,67 +20,228 @@ module Camoufox
20
20
 
21
21
  def initialize(**kwargs)
22
22
  @launch_options = Utils.launch_options(**kwargs).to_h
23
+ @pages = []
23
24
  end
24
25
 
25
26
  def new_page
26
- Page.new(@launch_options)
27
+ page = Page.new(@launch_options)
28
+ @pages << page
29
+ page.on_close { @pages.delete(page) }
30
+ page
27
31
  end
28
32
 
29
33
  def close
30
- # Nothing to cleanup yet – placeholder for future native resources.
34
+ @pages.each(&:close)
35
+ @pages.clear
31
36
  nil
32
37
  end
33
38
  end
34
39
 
35
40
  class Page
36
- attr_reader :title, :content
37
-
38
41
  def initialize(launch_options)
39
42
  @launch_options = launch_options
43
+ @session = NodeRunner::Session.new(@launch_options)
40
44
  @title = nil
41
45
  @content = nil
46
+ @closed = false
47
+ @on_close = nil
48
+ end
49
+
50
+ def on_close(&block)
51
+ @on_close = block
42
52
  end
43
53
 
44
54
  def goto(url)
45
- result = NodeRunner.visit(@launch_options, url)
55
+ ensure_open!
56
+ result = @session.request('goto', 'url' => url)
46
57
  @title = result['title']
47
58
  @content = result['content']&.to_s
48
59
  self
49
60
  end
61
+
62
+ def wait_for_selector(selector, **options)
63
+ ensure_open!
64
+ raise ArgumentError, "selector must be provided" if selector.to_s.empty?
65
+
66
+ wait_options = options.compact
67
+ params = { 'selector' => selector }
68
+ params['options'] = Utils.camelize_hash(wait_options) unless wait_options.empty?
69
+ @session.request('wait_for_selector', params)
70
+
71
+ @title = nil
72
+ @content = nil
73
+ self
74
+ end
75
+
76
+ def content
77
+ ensure_open!
78
+ @content ||= begin
79
+ result = @session.request('content')
80
+ result['content']&.to_s
81
+ end
82
+ end
83
+
84
+ def title
85
+ ensure_open!
86
+ @title ||= begin
87
+ result = @session.request('title')
88
+ result['title']
89
+ end
90
+ end
91
+
92
+ def close
93
+ return if closed?
94
+
95
+ @session.close
96
+ @closed = true
97
+ @on_close&.call(self)
98
+ nil
99
+ end
100
+
101
+ def closed?
102
+ @closed
103
+ end
104
+
105
+ private
106
+
107
+ def ensure_open!
108
+ raise Camoufox::Error, "Page is closed" if closed?
109
+ end
50
110
  end
51
111
 
52
112
  module NodeRunner
53
- module_function
54
-
55
- def visit(launch_options, url)
56
- node_path = ::Camoufox.configuration.node_path || 'node'
57
- script_path = File.expand_path('visit.js', __dir__)
58
-
59
- payload = Base64.strict_encode64(
60
- JSON.generate(
61
- options: Utils.camelize_hash(launch_options),
62
- url: url,
63
- ),
64
- )
65
-
66
- env = {}
67
- if (driver_dir = ::Camoufox.configuration.playwright_driver_dir)
68
- env['NODE_PATH'] = [driver_dir, ENV['NODE_PATH']].compact.join(File::PATH_SEPARATOR)
69
- env['CAMOUFOX_PLAYWRIGHT_DRIVER_DIR'] = driver_dir
113
+ class Session
114
+ def initialize(launch_options)
115
+ @launch_options = launch_options
116
+ @command_id = 0
117
+ @closed = false
118
+ spawn_session
119
+ wait_for_ready
120
+ end
121
+
122
+ def request(action, params = {})
123
+ raise Camoufox::Error, "Page session is closed" if @closed
124
+
125
+ @command_id += 1
126
+ payload = {
127
+ 'id' => @command_id,
128
+ 'action' => action,
129
+ 'params' => params,
130
+ }
131
+ write_message(payload)
132
+ handle_response(@command_id)
133
+ end
134
+
135
+ def close
136
+ return if @closed
137
+
138
+ begin
139
+ request('close')
140
+ rescue NodeExecutionFailed
141
+ # swallow shutdown errors
142
+ ensure
143
+ @closed = true
144
+ cleanup
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def spawn_session
151
+ node_path = ::Camoufox.configuration.node_path || 'node'
152
+ script_path = File.expand_path('syncSession.js', __dir__)
153
+ env = {}
154
+
155
+ if (driver_dir = ::Camoufox.configuration.playwright_driver_dir)
156
+ env['NODE_PATH'] = [driver_dir, ENV['NODE_PATH']].compact.join(File::PATH_SEPARATOR)
157
+ env['CAMOUFOX_PLAYWRIGHT_DRIVER_DIR'] = driver_dir
158
+ end
159
+
160
+ @stdin, @stdout, stderr, @wait_thr = Open3.popen3(env, node_path, script_path)
161
+ @stdin.sync = true
162
+ @stdout.sync = true
163
+
164
+ @stderr_thread = Thread.new do
165
+ begin
166
+ stderr.each_line { |line| warn(line.chomp) }
167
+ rescue IOError
168
+ nil
169
+ ensure
170
+ stderr.close unless stderr.closed?
171
+ end
172
+ end
173
+
174
+ payload = Base64.strict_encode64(
175
+ JSON.generate(
176
+ options: Utils.camelize_hash(@launch_options),
177
+ ),
178
+ )
179
+ @stdin.puts(payload)
180
+ rescue Errno::ENOENT => e
181
+ raise NodeExecutionFailed.new("Failed to execute #{node_path}: #{e.message}", nil)
182
+ end
183
+
184
+ def wait_for_ready
185
+ message = read_message
186
+ return if message['event'] == 'ready'
187
+
188
+ raise NodeExecutionFailed.new("Invalid handshake from Playwright bridge", nil)
189
+ end
190
+
191
+ def handle_response(expected_id)
192
+ message = read_message
193
+ unless message['id'] == expected_id
194
+ raise NodeExecutionFailed.new("Mismatched response id from Playwright bridge", nil)
195
+ end
196
+
197
+ if (error = message['error'])
198
+ raise NodeExecutionFailed.new("Playwright bridge error: #{error['message']}", nil)
199
+ end
200
+
201
+ message['result']
70
202
  end
71
203
 
72
- stdout, stderr, status = Open3.capture3(env, node_path, script_path, stdin_data: payload)
204
+ def write_message(payload)
205
+ encoded = Base64.strict_encode64(JSON.generate(payload))
206
+ @stdin.puts(encoded)
207
+ rescue IOError => e
208
+ raise NodeExecutionFailed.new("Failed to talk to Playwright bridge: #{e.message}", nil)
209
+ end
73
210
 
74
- unless status.success?
75
- message = stderr.empty? ? stdout : stderr
76
- raise NodeExecutionFailed.new("Playwright visit failed: #{message.strip}", status)
211
+ def read_message
212
+ line = nil
213
+ loop do
214
+ line = @stdout.gets
215
+ raise NodeExecutionFailed.new("Playwright bridge closed unexpectedly", nil) if line.nil?
216
+
217
+ stripped = line.strip
218
+ next if stripped.empty?
219
+
220
+ line = stripped
221
+ break
222
+ end
223
+
224
+ decoded = Base64.strict_decode64(line)
225
+ JSON.parse(decoded)
226
+ rescue ArgumentError, JSON::ParserError => e
227
+ raise NodeExecutionFailed.new("Invalid response from Playwright bridge: #{e.message}", nil)
228
+ rescue IOError => e
229
+ raise NodeExecutionFailed.new("Failed to read from Playwright bridge: #{e.message}", nil)
77
230
  end
78
231
 
79
- JSON.parse(stdout)
80
- rescue Errno::ENOENT => e
81
- raise NodeExecutionFailed.new("Failed to execute #{node_path}: #{e.message}", nil)
82
- rescue JSON::ParserError => e
83
- raise NodeExecutionFailed.new("Invalid response from Playwright visit: #{e.message}", nil)
232
+ def cleanup
233
+ @stdin.close unless @stdin.closed?
234
+ @stdout.close unless @stdout.closed?
235
+ if @wait_thr&.alive?
236
+ Process.kill('TERM', @wait_thr.pid)
237
+ @wait_thr.join
238
+ else
239
+ @wait_thr&.value
240
+ end
241
+ @stderr_thread&.join
242
+ rescue Errno::ESRCH, IOError
243
+ nil
244
+ end
84
245
  end
85
246
  end
86
247
  end
Binary file
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: camoufox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Camoufox contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-11-10 00:00:00.000000000 Z
10
+ date: 2025-11-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -81,6 +81,7 @@ files:
81
81
  - README.md
82
82
  - bin/camoufox
83
83
  - docs/native_port.md
84
+ - ext/camoufox_native/Makefile
84
85
  - ext/camoufox_native/camoufox_native.cpp
85
86
  - ext/camoufox_native/extconf.rb
86
87
  - lib/camoufox.rb
@@ -99,10 +100,10 @@ files:
99
100
  - lib/camoufox/native_bridge.rb
100
101
  - lib/camoufox/pkgman.rb
101
102
  - lib/camoufox/server.rb
103
+ - lib/camoufox/syncSession.js
102
104
  - lib/camoufox/sync_api.rb
103
105
  - lib/camoufox/utils.rb
104
106
  - lib/camoufox/virtdisplay.rb
105
- - lib/camoufox/visit.js
106
107
  - lib/camoufox/warnings.rb
107
108
  - lib/camoufox/warnings.yml
108
109
  - lib/camoufox/webgl/README.md
@@ -1,129 +0,0 @@
1
- const path = require('path');
2
- const fs = require('fs');
3
-
4
- function readStdinAsBase64() {
5
- return new Promise((resolve, reject) => {
6
- const chunks = [];
7
- process.stdin.setEncoding('utf8');
8
- process.stdin.on('data', (chunk) => chunks.push(chunk));
9
- process.stdin.on('end', () => resolve(chunks.join('')));
10
- process.stdin.on('error', (error) => reject(error));
11
- });
12
- }
13
-
14
- function withDefault(value, extractor) {
15
- if (!value) {
16
- return null;
17
- }
18
-
19
- try {
20
- return extractor(value);
21
- } catch (error) {
22
- // Surface the original failure to the caller.
23
- console.warn(`camoufox: failed to initialize Playwright shim (${error.message || error})`);
24
- return null;
25
- }
26
- }
27
-
28
- function resolvePlaywrightApi(candidate) {
29
- if (!candidate) {
30
- return null;
31
- }
32
-
33
- if (candidate.firefox) {
34
- return candidate;
35
- }
36
-
37
- if (candidate.default) {
38
- const resolved = resolvePlaywrightApi(candidate.default);
39
- if (resolved) {
40
- return resolved;
41
- }
42
- }
43
-
44
- if (typeof candidate.createInProcessPlaywright === 'function') {
45
- const resolved = withDefault(candidate, (mod) => mod.createInProcessPlaywright());
46
- if (resolved) {
47
- return resolved;
48
- }
49
- }
50
-
51
- if (typeof candidate.createPlaywright === 'function') {
52
- const resolved = withDefault(candidate, (mod) => mod.createPlaywright({ sdkLanguage: process.env.PW_LANG_NAME || 'javascript' }));
53
- if (resolved && resolved.firefox) {
54
- return resolved;
55
- }
56
- }
57
-
58
- if (candidate.playwright) {
59
- return resolvePlaywrightApi(candidate.playwright);
60
- }
61
-
62
- return null;
63
- }
64
-
65
- function loadPlaywright() {
66
- const override = process.env.CAMOUFOX_PLAYWRIGHT_JS_REQUIRE;
67
- if (override) {
68
- return require(override);
69
- }
70
-
71
- try {
72
- return require('playwright');
73
- } catch (error) {
74
- // fall through
75
- }
76
-
77
- const driverDir = process.env.CAMOUFOX_PLAYWRIGHT_DRIVER_DIR;
78
- if (driverDir) {
79
- try {
80
- return require(path.join(driverDir, 'package'));
81
- } catch (error) {
82
- // fall through
83
- }
84
- }
85
-
86
- console.error('Unable to require Playwright. Install the `playwright` npm package or set CAMOUFOX_PLAYWRIGHT_JS_REQUIRE.');
87
- process.exit(1);
88
- }
89
-
90
- async function main() {
91
- const payloadB64 = await readStdinAsBase64();
92
- const payload = JSON.parse(Buffer.from(payloadB64, 'base64').toString());
93
- const { options, url } = payload;
94
-
95
- if (options.executablePath && !fs.existsSync(options.executablePath)) {
96
- console.warn(`camoufox: executable ${options.executablePath} not found, falling back to Playwright default`);
97
- delete options.executablePath;
98
- }
99
-
100
- const playwrightModule = loadPlaywright();
101
- const playwright = resolvePlaywrightApi(playwrightModule);
102
- const browserType = playwright && playwright.firefox;
103
- if (!browserType) {
104
- console.error('Playwright module does not expose `firefox`. Provide a driver bundle or module that exports Playwright.firefox.');
105
- process.exit(1);
106
- }
107
-
108
- const browser = await browserType.launch(options);
109
- const page = await browser.newPage();
110
- await page.goto(url, { waitUntil: 'domcontentloaded' });
111
- try {
112
- await page.waitForLoadState('networkidle', { timeout: 15000 });
113
- } catch (error) {
114
- console.warn(`camoufox: waitForLoadState(networkidle) warning: ${error.message || error}`);
115
- }
116
-
117
- const [title, content] = await Promise.all([
118
- page.title(),
119
- page.content(),
120
- ]);
121
-
122
- console.log(JSON.stringify({ title, content }));
123
- await browser.close();
124
- }
125
-
126
- main().catch((error) => {
127
- console.error(error.message || error);
128
- process.exit(1);
129
- });