ruby_everywhere 0.6.0 → 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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +2 -2
  3. data/exe/rbe +2 -2
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +30 -6
  7. data/lib/everywhere/builders/android.rb +152 -64
  8. data/lib/everywhere/builders/base.rb +53 -0
  9. data/lib/everywhere/builders/desktop.rb +32 -40
  10. data/lib/everywhere/builders/ios.rb +268 -36
  11. data/lib/everywhere/builders/native_sources.rb +38 -0
  12. data/lib/everywhere/child_processes.rb +4 -4
  13. data/lib/everywhere/child_supervision.rb +172 -0
  14. data/lib/everywhere/cli.rb +2 -0
  15. data/lib/everywhere/clock.rb +12 -0
  16. data/lib/everywhere/commands/build.rb +132 -66
  17. data/lib/everywhere/commands/clean.rb +8 -3
  18. data/lib/everywhere/commands/dev.rb +91 -242
  19. data/lib/everywhere/commands/doctor.rb +138 -21
  20. data/lib/everywhere/commands/install.rb +49 -8
  21. data/lib/everywhere/commands/platform/auth_status.rb +1 -0
  22. data/lib/everywhere/commands/platform/build.rb +141 -33
  23. data/lib/everywhere/commands/platform/login.rb +20 -6
  24. data/lib/everywhere/commands/platform/logout.rb +1 -0
  25. data/lib/everywhere/commands/platform/runner.rb +170 -25
  26. data/lib/everywhere/commands/preview.rb +286 -0
  27. data/lib/everywhere/commands/publish.rb +22 -2
  28. data/lib/everywhere/commands/release.rb +155 -35
  29. data/lib/everywhere/commands/shell_dir.rb +4 -2
  30. data/lib/everywhere/commands/updates_keygen.rb +25 -1
  31. data/lib/everywhere/config/app.rb +126 -0
  32. data/lib/everywhere/config/auth.rb +108 -0
  33. data/lib/everywhere/config/data.rb +50 -0
  34. data/lib/everywhere/config/deep_linking.rb +107 -0
  35. data/lib/everywhere/config/desktop_ui.rb +153 -0
  36. data/lib/everywhere/config/mobile.rb +211 -0
  37. data/lib/everywhere/config/native_desktop.rb +168 -0
  38. data/lib/everywhere/config/native_mobile.rb +337 -0
  39. data/lib/everywhere/config/shell.rb +57 -0
  40. data/lib/everywhere/config/updates.rb +63 -0
  41. data/lib/everywhere/config.rb +59 -1367
  42. data/lib/everywhere/console.rb +2 -1
  43. data/lib/everywhere/desktop_dev_app.rb +138 -0
  44. data/lib/everywhere/dock/state.rb +3 -1
  45. data/lib/everywhere/engine.rb +24 -0
  46. data/lib/everywhere/entrypoint.rb +24 -0
  47. data/lib/everywhere/error.rb +8 -0
  48. data/lib/everywhere/framework.rb +23 -6
  49. data/lib/everywhere/host.rb +11 -0
  50. data/lib/everywhere/ignore.rb +13 -5
  51. data/lib/everywhere/jump.rb +121 -0
  52. data/lib/everywhere/line_pump.rb +3 -1
  53. data/lib/everywhere/minisign.rb +1 -0
  54. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  55. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  56. data/lib/everywhere/native_platform.rb +44 -0
  57. data/lib/everywhere/paths.rb +36 -13
  58. data/lib/everywhere/platform/client.rb +46 -7
  59. data/lib/everywhere/platform/credentials.rb +18 -8
  60. data/lib/everywhere/platform/snapshot.rb +12 -0
  61. data/lib/everywhere/plist.rb +17 -0
  62. data/lib/everywhere/png.rb +1 -0
  63. data/lib/everywhere/raw_tty.rb +51 -0
  64. data/lib/everywhere/receipt.rb +55 -10
  65. data/lib/everywhere/s3.rb +1 -0
  66. data/lib/everywhere/shell_pages.rb +109 -0
  67. data/lib/everywhere/shellout.rb +19 -0
  68. data/lib/everywhere/simulator.rb +12 -1
  69. data/lib/everywhere/tab_filter.rb +36 -0
  70. data/lib/everywhere/task_pool.rb +3 -4
  71. data/lib/everywhere/ui.rb +6 -1
  72. data/lib/everywhere/version.rb +6 -4
  73. data/lib/everywhere.rb +1 -2
  74. data/support/mobile/android/app/build.gradle.kts +29 -1
  75. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  76. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  77. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
  78. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  79. data/support/mobile/ios/README.md +13 -6
  80. data/support/release/macos/notarize.sh +3 -0
  81. metadata +40 -1
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class << self
5
+ # The desktop shell's marker. WKWebView APPENDS
6
+ # `applicationNameForUserAgent` to the real system UA, so this arrives
7
+ # alongside a normal Safari UA rather than replacing it — hence a pattern
8
+ # rather than a prefix check. The version is absent when everywhere.yml
9
+ # declares none.
10
+ #
11
+ # macOS only for now: the marker is set in macos_webview_configuration, and
12
+ # Windows/Linux have no append-only equivalent (their user_agent() REPLACES
13
+ # the string, and fabricating a whole UA is worse than not marking it).
14
+ # Desktop only ships macOS today; revisit when it doesn't.
15
+ DESKTOP_UA = %r{\bRubyEverywhere(?:/[\w.\-]+)? \((?:macos|windows|linux)\)}
16
+
17
+ MOBILE_PLATFORMS = %i[ios android].freeze
18
+
19
+ # :ios, :android, :desktop or nil, from a User-Agent string. Present from
20
+ # the very first request, before any JS has run. Shared by the view helpers
21
+ # and the auth middleware.
22
+ #
23
+ # Mobile is checked FIRST and the order matters: the mobile shells prepend
24
+ # the same "RubyEverywhere/<version> (<os>)" marker to Hotwire Native's own,
25
+ # so a mobile UA matches DESKTOP_UA too.
26
+ def native_platform_of(user_agent)
27
+ ua = user_agent.to_s
28
+ return :ios if ua.include?("Hotwire Native iOS")
29
+ return :android if ua.include?("Hotwire Native Android")
30
+ return :desktop if ua.match?(DESKTOP_UA)
31
+
32
+ nil
33
+ end
34
+
35
+ # Just the phone shells. Anything that leans on a mobile-only affordance —
36
+ # the OAuth handoff to ASWebAuthenticationSession / Custom Tabs, biometric
37
+ # gating — has to ask for this rather than "is this a native shell", because
38
+ # the desktop shell answers yes to that and has neither.
39
+ def mobile_platform_of(user_agent)
40
+ platform = native_platform_of(user_agent)
41
+ platform if MOBILE_PLATFORMS.include?(platform)
42
+ end
43
+ end
44
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error"
4
+ require_relative "ui"
5
+
3
6
  module Everywhere
4
7
  # Filesystem locations the CLI resolves relative to the gem itself, so
5
8
  # `every dev`/`build`/`release` work in any project the gem is installed into.
@@ -83,15 +86,19 @@ module Everywhere
83
86
  end
84
87
 
85
88
  # Per-user cache dir. Keeps heavy build state out of the gem and the app.
89
+ # `~` needs a home to expand against, and a container run with --user has
90
+ # neither HOME nor a passwd entry.
86
91
  def cache_dir
87
92
  File.expand_path(ENV["RUBYEVERYWHERE_HOME"] || "~/.rubyeverywhere")
93
+ rescue ArgumentError
94
+ UI.die!("couldn't resolve your home directory — set HOME or RUBYEVERYWHERE_HOME")
88
95
  end
89
96
 
90
97
  # The stamped copy of the iOS template for one app. Persistent (not a
91
98
  # tmpdir) on purpose: a stable project path keeps Xcode's incremental state
92
99
  # valid across builds, and users can open the stamped project in Xcode.
93
100
  def ios_work_dir(bundle_id)
94
- File.join(cache_dir, "ios", bundle_id)
101
+ File.join(cache_dir, "ios", segment!(bundle_id))
95
102
  end
96
103
 
97
104
  # Where xcodebuild writes DerivedData for iOS shell builds. Shared across
@@ -112,7 +119,7 @@ module Everywhere
112
119
  # incremental state is path-sensitive, and the stamped project opens in
113
120
  # Android Studio unmodified.
114
121
  def android_work_dir(application_id)
115
- File.join(cache_dir, "android", application_id)
122
+ File.join(cache_dir, "android", segment!(application_id))
116
123
  end
117
124
 
118
125
  # GRADLE_USER_HOME for shell builds. Redirected here so the multi-GB Gradle
@@ -130,14 +137,15 @@ module Everywhere
130
137
  File.join(cache_dir, "android-fonts")
131
138
  end
132
139
 
133
- # The stamped copy of the Tauri shell for one app, keyed by bundle id.
134
- # Only apps that declare `native.desktop` get oneeveryone else compiles
135
- # the gem's own copy, which is identical for them and stays warm across
136
- # projects (see cargo_target_dir). Persistent for the same reason the mobile
137
- # work dirs are: cargo's incremental state lives inside it, and a cold Tauri
138
- # build is minutes where a warm one is seconds.
140
+ # The staged copy of the Tauri shell for one app, keyed by bundle id.
141
+ # Every app builds from one of thesecargo never runs inside the
142
+ # installed gem, which may be root-owned but only apps that declare
143
+ # `native.desktop` get their Rust stamped in; everyone else's copy is
144
+ # identical to the template and shares cargo_target_dir so the compile
145
+ # stays warm across projects. Persistent for the same reason the mobile
146
+ # work dirs are: a cold Tauri build is minutes where a warm one is seconds.
139
147
  def desktop_work_dir(bundle_id)
140
- File.join(cache_dir, "desktop", bundle_id)
148
+ File.join(cache_dir, "desktop", segment!(bundle_id))
141
149
  end
142
150
 
143
151
  # CARGO_TARGET_DIR for a stamped shell. Per-app rather than shared, because
@@ -145,7 +153,7 @@ module Everywhere
145
153
  # every other app's — pointing them at one target dir would make cargo
146
154
  # rebuild the world on every alternating build.
147
155
  def desktop_target_dir(bundle_id)
148
- File.join(cache_dir, "desktop-target", bundle_id)
156
+ File.join(cache_dir, "desktop-target", segment!(bundle_id))
149
157
  end
150
158
 
151
159
  # Where `every dev` keeps the throwaway .app wrapper it runs the shell
@@ -154,14 +162,14 @@ module Everywhere
154
162
  # menu bar, the About/Hide/Quit items and the Dock. The wrapper exists only
155
163
  # to carry an Info.plist; the binary inside it is a hard link to cargo's.
156
164
  def desktop_dev_app_dir(bundle_id)
157
- File.join(cache_dir, "desktop-app", bundle_id)
165
+ File.join(cache_dir, "desktop-app", segment!(bundle_id))
158
166
  end
159
167
 
160
168
  # Where `every dev` stages the app's Dock icon for the running shell. The
161
169
  # shaped master is derived from app.icon, so it's a build product, not the
162
170
  # app's own file — it belongs in the cache next to the rest of them.
163
171
  def desktop_icon_dir(bundle_id)
164
- File.join(cache_dir, "desktop-icon", bundle_id)
172
+ File.join(cache_dir, "desktop-icon", segment!(bundle_id))
165
173
  end
166
174
 
167
175
  # Where `every dev` stages native/desktop/assets for the running shell.
@@ -171,7 +179,7 @@ module Everywhere
171
179
  # in dev. Staging runs the same DesktopAssets compiler both ways, so the two
172
180
  # can't drift. Keyed by bundle id like the mobile work dirs.
173
181
  def desktop_assets_dir(bundle_id)
174
- File.join(cache_dir, "desktop-assets", bundle_id)
182
+ File.join(cache_dir, "desktop-assets", segment!(bundle_id))
175
183
  end
176
184
 
177
185
  # Where cargo writes the shell's build output (CARGO_TARGET_DIR) for apps on
@@ -192,5 +200,20 @@ module Everywhere
192
200
  def app_bundle_dir
193
201
  File.join(cache_dir, "bundle")
194
202
  end
203
+
204
+ # Bundle ids and application ids come from the app's everywhere.yml and are
205
+ # used as ONE path segment under the cache — directories the builders rm_rf
206
+ # wholesale. Config#identity_errors rejects a malformed id before a build
207
+ # starts; this is the backstop for every other caller. Raise, not die!:
208
+ # Paths is reachable from the packaged app's runtime.
209
+ def segment!(value)
210
+ segment = value.to_s
211
+ if segment.empty? || segment.include?("/") || segment.include?("\\") || segment.include?("..")
212
+ raise Everywhere::Error, "#{segment.inspect} can't be a directory name (app.bundle_id in " \
213
+ "config/everywhere.yml must be reverse-DNS, like com.example.app)"
214
+ end
215
+
216
+ segment
217
+ end
195
218
  end
196
219
  end
@@ -3,6 +3,12 @@
3
3
  require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
+ begin
7
+ require "openssl" # optional stdlib; NETWORK_ERRORS below adapts if it's absent
8
+ rescue LoadError
9
+ nil
10
+ end
11
+ require_relative "../error"
6
12
  require_relative "../version"
7
13
 
8
14
  module Everywhere
@@ -27,6 +33,19 @@ module Everywhere
27
33
  end
28
34
 
29
35
  class Client
36
+ # Everything a flaky link, a hung host or a TLS-intercepting proxy can
37
+ # throw at us. OpenSSL is an optional stdlib (net/http requires it
38
+ # best-effort), so its error class is only listed when it loaded.
39
+ NETWORK_ERRORS = [
40
+ SocketError, EOFError,
41
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
42
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EPIPE,
43
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
44
+ *(defined?(OpenSSL::SSL::SSLError) ? [OpenSSL::SSL::SSLError] : [])
45
+ ].freeze
46
+
47
+ OPEN_TIMEOUT = 15
48
+
30
49
  def self.base_url(explicit = nil)
31
50
  (explicit || ENV["EVERYWHERE_PLATFORM_URL"] || DEFAULT_URL).chomp("/")
32
51
  end
@@ -56,8 +75,8 @@ module Everywhere
56
75
  req.body_stream = io
57
76
  http(uri) { |conn| conn.request(req) }
58
77
  end
59
- rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
60
- raise Error, "upload to #{uri.host} failed (#{e.class})"
78
+ rescue *NETWORK_ERRORS => e
79
+ raise Error, "upload to #{uri.host} failed (#{e.class}: #{e.message})"
61
80
  end
62
81
 
63
82
  # GET a (possibly redirecting, signed) URL and stream it to dest. Sends the
@@ -74,7 +93,7 @@ module Everywhere
74
93
  when Net::HTTPRedirection
75
94
  raise Error, "too many redirects downloading #{url}" if redirects <= 0
76
95
 
77
- return download(res["location"], dest, redirects: redirects - 1)
96
+ return download(redirect_target(uri, res["location"]), dest, redirects: redirects - 1)
78
97
  when Net::HTTPSuccess
79
98
  File.open(dest, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
80
99
  else
@@ -83,12 +102,32 @@ module Everywhere
83
102
  end
84
103
  end
85
104
  dest
105
+ rescue *NETWORK_ERRORS => e
106
+ raise Error, "download from #{uri.host} failed (#{e.class}: #{e.message})"
86
107
  end
87
108
 
88
109
  private
89
110
 
111
+ # A signed-blob redirect is expected to leave the platform host, but it
112
+ # may never leave TLS: an http:// hop would put the artifact — and the
113
+ # snapshot or credentials inside it — on the wire in the clear. Only a
114
+ # base that was already http (`every platform --url http://localhost`)
115
+ # has nothing to downgrade from. Location is resolved against the current
116
+ # hop, which is also what makes a relative one work.
117
+ def redirect_target(from, location)
118
+ target = URI.join(from, location.to_s)
119
+ if from.scheme == "https" && target.scheme != "https"
120
+ raise Error, "refusing an https → #{target.scheme} redirect to #{target.host}"
121
+ end
122
+
123
+ target.to_s
124
+ rescue URI::Error => e
125
+ raise Error, "bad redirect from #{from.host} (#{e.message})"
126
+ end
127
+
90
128
  def http(uri, &block)
91
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", &block)
129
+ Net::HTTP.start(uri.hostname, uri.port,
130
+ use_ssl: uri.scheme == "https", open_timeout: OPEN_TIMEOUT, &block)
92
131
  end
93
132
 
94
133
  def request(klass, path, body = nil)
@@ -102,10 +141,10 @@ module Everywhere
102
141
  req.body = JSON.generate(body)
103
142
  end
104
143
 
105
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
144
+ res = http(uri) { |conn| conn.request(req) }
106
145
  Response.new(res.code.to_i, parse(res.body))
107
- rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
108
- raise Error, "couldn't reach the Platform at #{@base_url} (#{e.class})"
146
+ rescue *NETWORK_ERRORS => e
147
+ raise Error, "couldn't reach the Platform at #{@base_url} (#{e.class}: #{e.message})"
109
148
  end
110
149
 
111
150
  def parse(body)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require_relative "../ui"
5
6
 
6
7
  module Everywhere
7
8
  module Platform
@@ -22,15 +23,20 @@ module Everywhere
22
23
  end
23
24
 
24
25
  def self.load
25
- data =
26
- if File.exist?(path)
27
- JSON.parse(File.read(path)) rescue {}
28
- else
29
- {}
30
- end
26
+ data = File.exist?(path) ? read_or_warn(path) : {}
31
27
  new(data.is_a?(Hash) ? data : {})
32
28
  end
33
29
 
30
+ # A corrupt store used to look identical to "logged out", which sends
31
+ # people re-running login forever. Say which file to delete.
32
+ def self.read_or_warn(path)
33
+ JSON.parse(File.read(path))
34
+ rescue JSON::ParserError, SystemCallError, IOError => e
35
+ Everywhere::UI.warn("ignoring unreadable credentials at #{path} (#{e.class}) — " \
36
+ "delete it and run `every platform login`")
37
+ {}
38
+ end
39
+
34
40
  def initialize(data)
35
41
  @data = data
36
42
  end
@@ -63,8 +69,12 @@ module Everywhere
63
69
  dir = File.dirname(path)
64
70
  FileUtils.mkdir_p(dir)
65
71
  File.chmod(0o700, dir)
66
- File.write(path, "#{JSON.pretty_generate(@data)}\n")
67
- File.chmod(0o600, path)
72
+ # Create it 0600 rather than write-then-chmod: the token must never exist
73
+ # on disk world-readable, even for an instant.
74
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
75
+ f.write("#{JSON.pretty_generate(@data)}\n")
76
+ end
77
+ File.chmod(0o600, path) # the mode above only applies on create; tighten stores from older versions
68
78
  end
69
79
  end
70
80
  end
@@ -4,6 +4,7 @@ require "rubygems/package"
4
4
  require "zlib"
5
5
  require "digest"
6
6
  require_relative "../ignore"
7
+ require_relative "../ui"
7
8
 
8
9
  module Everywhere
9
10
  module Platform
@@ -38,10 +39,21 @@ module Everywhere
38
39
 
39
40
  def add(tar, root, rel)
40
41
  abs = File.join(root, rel)
42
+ # lstat, and never follow: File.stat would read through the link, so a
43
+ # dangling one (a deleted bundle path, a checked-in link to an absent
44
+ # sibling repo) raises Errno::ENOENT and aborts the whole snapshot —
45
+ # and a LIVE one pointing outside the tree (link → ~/.ssh/id_rsa) would
46
+ # pack and upload its target's contents under an innocent name.
47
+ return Everywhere::UI.warn("skipping symlink #{rel}") if File.lstat(abs).symlink?
48
+
41
49
  stat = File.stat(abs)
42
50
  tar.add_file_simple(rel, stat.mode & 0o777, stat.size) do |io|
43
51
  File.open(abs, "rb") { |f| IO.copy_stream(f, io) }
44
52
  end
53
+ rescue Errno::ENOENT, Errno::EACCES => e
54
+ # The listing is a snapshot in time; a dev server or watcher can delete a
55
+ # tmp/ file between the glob and here. Losing it beats losing the build.
56
+ Everywhere::UI.warn("skipping #{rel} (#{e.class.name.split("::").last})")
45
57
  end
46
58
  end
47
59
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Escaping for the plists and entitlements the CLI writes by hand (there are
5
+ # four: `every build`'s Info.plist, `every dev`'s dev bundle, and the iOS
6
+ # builder's entitlements + export options). Every value interpolated into them
7
+ # comes from everywhere.yml or from Apple, so an unescaped `&` or `<` is at
8
+ # best a plist macOS refuses to parse and at worst extra keys of someone
9
+ # else's choosing.
10
+ module Plist
11
+ module_function
12
+
13
+ def escape(value)
14
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
15
+ end
16
+ end
17
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "zlib"
4
+ require_relative "error"
4
5
 
5
6
  module Everywhere
6
7
  # Minimal PNG surgery: Tauri requires RGBA icons, users hand us whatever
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shellout"
4
+
5
+ module Everywhere
6
+ # A terminal that reports keys as they're pressed, put back the way it was
7
+ # found on the way out.
8
+ #
9
+ # io/console's getch holds full raw mode while blocked, which clears OPOST
10
+ # and stair-steps every log line we relay; stty -icanon -echo disables only
11
+ # line buffering and echo, leaving output processing (and Ctrl-C as SIGINT)
12
+ # intact.
13
+ module RawTty
14
+ module_function
15
+
16
+ def with
17
+ # stty is POSIX-only, and the non-pty spawn path otherwise works on
18
+ # Windows: without it the menu simply stays line-buffered (press d,
19
+ # then Enter) rather than the session dying on Errno::ENOENT.
20
+ saved_tty = saved_tty_state
21
+ system("stty", "-icanon", "-echo") if saved_tty
22
+ trap_exit_signals
23
+ yield
24
+ ensure
25
+ system("stty", saved_tty) if saved_tty
26
+ end
27
+
28
+ # The tty settings to put back on the way out, or nil when there's no
29
+ # usable stty to read them with — the one signal both the raw-mode setup
30
+ # and its restore key off.
31
+ def saved_tty_state
32
+ return nil unless Shellout.tool?("stty")
33
+
34
+ state = `stty -g`.chomp
35
+ state.empty? ? nil : state
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # The dock hides the cursor and the key loop puts the tty in -icanon
41
+ # -echo; a SIGTERM that skipped our ensure blocks would leave the user's
42
+ # shell in that state. exit(1) from a trap unwinds normally, which is all
43
+ # this needs to do — a handler must stay this small, because anything that
44
+ # takes a lock (Console, the dock) raises ThreadError in trap context.
45
+ def trap_exit_signals
46
+ %w[TERM HUP].each { |sig| Signal.trap(sig) { exit(1) } }
47
+ rescue ArgumentError
48
+ nil
49
+ end
50
+ end
51
+ end
@@ -5,6 +5,7 @@ require "digest"
5
5
  require_relative "version"
6
6
  require_relative "shellout"
7
7
  require_relative "ignore"
8
+ require_relative "paths"
8
9
 
9
10
  module Everywhere
10
11
  # Builds the machine-readable build receipt (release.json) — the same shape
@@ -22,14 +23,20 @@ module Everywhere
22
23
  class Receipt
23
24
  SCHEMA = 1
24
25
 
25
- def initialize(root:, config:, framework:, ruby:, target:, channel:, shell_dir: nil)
26
+ # source_checksum/lockfile_checksum are optional so a caller building one
27
+ # receipt per target hashes the tree ONCE and threads the digest through the
28
+ # rest — they describe the source, which is identical for every target.
29
+ def initialize(root:, config:, framework:, ruby:, target:, channel:, shell_dir: nil,
30
+ source_checksum: nil, lockfile_checksum: nil)
26
31
  @root = File.expand_path(root)
27
32
  @config = config
28
33
  @framework = framework
29
34
  @ruby = ruby
30
- @target_str = target # raw "os-arch" — selects platforms: overrides
35
+ @target_str = target # bare "os-arch" — selects platforms: overrides
31
36
  @target = parse_target(target, channel)
32
37
  @shell_dir = shell_dir
38
+ @source_checksum = source_checksum
39
+ @lockfile_checksum = lockfile_checksum
33
40
  end
34
41
 
35
42
  # The deterministic build request.
@@ -48,7 +55,9 @@ module Everywhere
48
55
  "lockfile_checksum" => lockfile_checksum
49
56
  },
50
57
  "target" => @target,
51
- "permissions" => @config.permissions
58
+ "permissions" => @config.permissions,
59
+ "mode" => @config.mode,
60
+ "remote" => { "url" => @config.remote_url }.compact
52
61
  }
53
62
  end
54
63
 
@@ -105,10 +114,15 @@ module Everywhere
105
114
  "cli" => Everywhere::VERSION,
106
115
  "bridge" => bridge_version,
107
116
  "shell" => shell_version,
108
- "tebako" => tebako_version
117
+ # Only macOS targets are tebako-pressed; spawning rbe-tebako for a
118
+ # mobile receipt would cost a process to learn nothing.
119
+ "tebako" => macos? ? tebako_version : nil,
120
+ "hotwire_native" => hotwire_native_version
109
121
  }
110
122
  end
111
123
 
124
+ def macos? = @target["os"] == "macos"
125
+
112
126
  def bridge_version
113
127
  # Package installs: read the version straight from package.json.
114
128
  [
@@ -145,11 +159,40 @@ module Everywhere
145
159
  out[/version\s+([\d.]+)/i, 1]
146
160
  end
147
161
 
162
+ # Which Hotwire Native the bundled mobile template pins. Read straight off
163
+ # the frozen templates in the gem (no spawn): iOS from the SPM lockfile,
164
+ # Android from the Gradle dependency line. Best-effort — a receipt is worth
165
+ # writing even when the template is missing or reshaped.
166
+ def hotwire_native_version
167
+ case @target["os"]
168
+ when "ios" then hotwire_native_ios_revision
169
+ when "android" then hotwire_native_android_version
170
+ end
171
+ end
172
+
173
+ def hotwire_native_ios_revision
174
+ dir = Paths.ios_dir or return nil
175
+ resolved = File.join(dir, "App.xcodeproj", "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved")
176
+ pin = JSON.parse(File.read(resolved))["pins"].find { |p| p["identity"].to_s.include?("hotwire-native-ios") }
177
+ pin&.dig("state", "revision")&.slice(0, 12)
178
+ rescue StandardError
179
+ nil
180
+ end
181
+
182
+ def hotwire_native_android_version
183
+ dir = Paths.android_dir or return nil
184
+ File.read(File.join(dir, "app", "build.gradle.kts"))[/dev\.hotwire:core:([\d.]+)/, 1]
185
+ rescue StandardError
186
+ nil
187
+ end
188
+
148
189
  # ---- checksums -----------------------------------------------------------
149
190
 
150
191
  def lockfile_checksum
151
- lock = File.join(@root, "Gemfile.lock")
152
- File.exist?(lock) ? "sha256:#{Digest::SHA256.file(lock).hexdigest}" : nil
192
+ @lockfile_checksum ||= begin
193
+ lock = File.join(@root, "Gemfile.lock")
194
+ File.exist?(lock) ? "sha256:#{Digest::SHA256.file(lock).hexdigest}" : nil
195
+ end
153
196
  end
154
197
 
155
198
  def lockfile_gem_version(gem_name)
@@ -164,11 +207,13 @@ module Everywhere
164
207
  # Uses the SAME ignore rules as the (future) snapshot, so the checksum
165
208
  # describes exactly what ships.
166
209
  def source_checksum
167
- digest = Digest::SHA256.new
168
- Ignore.for(@root).files(@root).each do |rel|
169
- digest << rel << "\0" << Digest::SHA256.file(File.join(@root, rel)).hexdigest << "\n"
210
+ @source_checksum ||= begin
211
+ digest = Digest::SHA256.new
212
+ Ignore.for(@root).files(@root).each do |rel|
213
+ digest << rel << "\0" << Digest::SHA256.file(File.join(@root, rel)).hexdigest << "\n"
214
+ end
215
+ "sha256:#{digest.hexdigest}"
170
216
  end
171
- "sha256:#{digest.hexdigest}"
172
217
  end
173
218
 
174
219
  # ---- helpers -------------------------------------------------------------
data/lib/everywhere/s3.rb CHANGED
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "openssl"
5
5
  require "uri"
6
6
  require "time"
7
+ require_relative "error"
7
8
 
8
9
  module Everywhere
9
10
  # Minimal S3 client — just the four operations `every publish` needs
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class << self
5
+ # The tiny page served at /everywhere/reset. Auth flows redirect the native
6
+ # app here so it resets cleanly (fresh web views, re-fetched tabs) before
7
+ # continuing — the standard Hotwire Native "reset the app" pattern. It talks
8
+ # to the shell's control channel directly (no bridge/importmap dependency);
9
+ # in a plain browser it just forwards to the target. `to` is constrained to
10
+ # a same-origin path.
11
+ #
12
+ # Both shells are addressed inline, and differently, for the same reason
13
+ # the bridge normalizes them: WKWebView's message handler takes an object,
14
+ # while Android's WebMessageListener channel takes a string. Reaching for
15
+ # the bridge here instead would trade that one line for an importmap
16
+ # dependency on a page whose whole job is to work before anything loads.
17
+ def mobile_reset_html(to)
18
+ require "json"
19
+ target = to.to_s
20
+ target = "/" unless target.start_with?("/") && !target.start_with?("//")
21
+ # script_safe, not to_json: this lands inside <script>, and outside Rails
22
+ # nothing escapes "</script>" in a plain JSON string.
23
+ encoded = JSON.generate(target, script_safe: true)
24
+
25
+ <<~HTML
26
+ <!DOCTYPE html><html><head><meta charset="utf-8"><title>One moment…</title>
27
+ <meta name="viewport" content="width=device-width,initial-scale=1"></head>
28
+ <body><script>
29
+ (function(){var to=#{encoded};var msg={action:"reset",to:to};
30
+ var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
31
+ var android=window.everywhereControl;
32
+ if(ios){ios.postMessage(msg);}
33
+ else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
34
+ else{window.location.replace(to);}})();
35
+ </script></body></html>
36
+ HTML
37
+ end
38
+
39
+ # The page served at /everywhere/jump/leave — the way OUT of a Jump
40
+ # preview. Once Jump re-roots onto a previewed app (instance.set), every
41
+ # Jump surface — launcher, scanner, tabs — resolves against the previewed
42
+ # app, so the escape hatch has to be served by the previewed app itself:
43
+ # this gem is the one thing guaranteed to be there. Posts clearInstance on
44
+ # the shell's control channel; shells that aren't multi-instance ignore
45
+ # it, and a plain browser just goes home.
46
+ # Fires clearInstance only once VISIBLE. This page rides in the previewed
47
+ # app's tab bar; shells preload tabs eagerly, but a preloaded webview is
48
+ # offscreen and reports visibilityState "hidden" — firing on load would
49
+ # bounce every preview the instant it connects, and a tap on the tab
50
+ # never re-proposes a visit, so visibility flipping is the ONLY signal a
51
+ # tab tap gives the page. The button is a belt for browsers and any
52
+ # webview that lies about visibility.
53
+ def mobile_jump_leave_html
54
+ <<~HTML
55
+ <!DOCTYPE html><html><head><meta charset="utf-8"><title>Jump</title>
56
+ <meta name="viewport" content="width=device-width,initial-scale=1">
57
+ <style>
58
+ body{font-family:-apple-system,system-ui,sans-serif;margin:0;min-height:100vh;
59
+ display:flex;flex-direction:column;align-items:center;justify-content:center;
60
+ gap:12px;padding:24px;text-align:center;background:#fff;color:#171717}
61
+ @media(prefers-color-scheme:dark){body{background:#171717;color:#fafafa}}
62
+ p{margin:0;opacity:.6}
63
+ button{font:inherit;font-weight:600;font-size:18px;color:#fff;background:#dc2626;
64
+ border:0;border-radius:12px;padding:16px 32px}
65
+ </style></head>
66
+ <body>
67
+ <p>Returning to Jump…</p>
68
+ <button onclick="everywhereJumpLeave()">Return to Jump</button>
69
+ <script>
70
+ function everywhereJumpLeave(){var msg={action:"clearInstance"};
71
+ var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
72
+ var android=window.everywhereControl;
73
+ if(ios){ios.postMessage(msg);}
74
+ else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
75
+ else{window.location.replace("/");}}
76
+ if(document.visibilityState==="visible"){everywhereJumpLeave();}
77
+ else{document.addEventListener("visibilitychange",function(){
78
+ if(document.visibilityState==="visible"){everywhereJumpLeave();}});}
79
+ </script></body></html>
80
+ HTML
81
+ end
82
+
83
+ # The page served at /everywhere/auth/native. The shell normally diverts a
84
+ # provider path natively, before the request is ever made; this covers the
85
+ # visits it can't see — a `data-turbo="false"` link, or the POST OmniAuth 2
86
+ # requires — by asking the shell, from the page, to open the auth session.
87
+ # In a browser it just continues to the provider.
88
+ def mobile_auth_html(to)
89
+ require "json"
90
+ target = to.to_s
91
+ target = "/" unless target.start_with?("/") && !target.start_with?("//")
92
+ # script_safe, as in mobile_reset_html: `to` is request input.
93
+ encoded = JSON.generate(target, script_safe: true)
94
+
95
+ <<~HTML
96
+ <!DOCTYPE html><html><head><meta charset="utf-8"><title>Signing in…</title>
97
+ <meta name="viewport" content="width=device-width,initial-scale=1"></head>
98
+ <body><p>Opening secure sign-in…</p><script>
99
+ (function(){var to=#{encoded};var msg={action:"authFlow",to:to};
100
+ var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
101
+ var android=window.everywhereControl;
102
+ if(ios){ios.postMessage(msg);}
103
+ else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
104
+ else{window.location.replace(to);}})();
105
+ </script></body></html>
106
+ HTML
107
+ end
108
+ end
109
+ end