ruby_everywhere 0.6.0 → 0.7.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +12 -1
  3. data/exe/rbe +12 -1
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +21 -4
  7. data/lib/everywhere/builders/android.rb +144 -30
  8. data/lib/everywhere/builders/desktop.rb +25 -17
  9. data/lib/everywhere/builders/ios.rb +244 -9
  10. data/lib/everywhere/cli.rb +2 -0
  11. data/lib/everywhere/commands/build.rb +113 -58
  12. data/lib/everywhere/commands/clean.rb +8 -3
  13. data/lib/everywhere/commands/dev.rb +85 -11
  14. data/lib/everywhere/commands/doctor.rb +138 -21
  15. data/lib/everywhere/commands/install.rb +48 -8
  16. data/lib/everywhere/commands/platform/build.rb +136 -27
  17. data/lib/everywhere/commands/platform/login.rb +16 -2
  18. data/lib/everywhere/commands/platform/runner.rb +113 -23
  19. data/lib/everywhere/commands/preview.rb +359 -0
  20. data/lib/everywhere/commands/publish.rb +20 -2
  21. data/lib/everywhere/commands/release.rb +145 -28
  22. data/lib/everywhere/commands/shell_dir.rb +2 -2
  23. data/lib/everywhere/config.rb +88 -3
  24. data/lib/everywhere/console.rb +2 -1
  25. data/lib/everywhere/engine.rb +24 -0
  26. data/lib/everywhere/framework.rb +21 -4
  27. data/lib/everywhere/ignore.rb +3 -1
  28. data/lib/everywhere/jump.rb +121 -0
  29. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  30. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  31. data/lib/everywhere/paths.rb +13 -6
  32. data/lib/everywhere/platform/client.rb +27 -6
  33. data/lib/everywhere/platform/credentials.rb +18 -8
  34. data/lib/everywhere/platform/snapshot.rb +10 -0
  35. data/lib/everywhere/receipt.rb +55 -10
  36. data/lib/everywhere/shellout.rb +19 -0
  37. data/lib/everywhere/simulator.rb +12 -1
  38. data/lib/everywhere/version.rb +1 -1
  39. data/support/mobile/android/app/build.gradle.kts +29 -1
  40. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  41. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  42. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
  43. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  44. data/support/mobile/ios/README.md +13 -6
  45. metadata +17 -1
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require_relative "ui"
4
5
 
5
6
  module Everywhere
7
+ # Also defined by everywhere.rb and framework.rb; repeated here so config.rb
8
+ # stays loadable on its own (the packaged runtime requires it piecemeal).
9
+ class Error < StandardError; end
10
+
6
11
  class << self
7
12
  # Register a per-request filter for the mobile tab bar. The block receives
8
13
  # the resolved tab list (`[{ "title" =>, "path" =>, "icon" => }, …]`) and
@@ -66,6 +71,50 @@ module Everywhere
66
71
  HTML
67
72
  end
68
73
 
74
+ # The page served at /everywhere/jump/leave — the way OUT of a Jump
75
+ # preview. Once Jump re-roots onto a previewed app (instance.set), every
76
+ # Jump surface — launcher, scanner, tabs — resolves against the previewed
77
+ # app, so the escape hatch has to be served by the previewed app itself:
78
+ # this gem is the one thing guaranteed to be there. Posts clearInstance on
79
+ # the shell's control channel; shells that aren't multi-instance ignore
80
+ # it, and a plain browser just goes home.
81
+ # Fires clearInstance only once VISIBLE. This page rides in the previewed
82
+ # app's tab bar; shells preload tabs eagerly, but a preloaded webview is
83
+ # offscreen and reports visibilityState "hidden" — firing on load would
84
+ # bounce every preview the instant it connects, and a tap on the tab
85
+ # never re-proposes a visit, so visibility flipping is the ONLY signal a
86
+ # tab tap gives the page. The button is a belt for browsers and any
87
+ # webview that lies about visibility.
88
+ def mobile_jump_leave_html
89
+ <<~HTML
90
+ <!DOCTYPE html><html><head><meta charset="utf-8"><title>Jump</title>
91
+ <meta name="viewport" content="width=device-width,initial-scale=1">
92
+ <style>
93
+ body{font-family:-apple-system,system-ui,sans-serif;margin:0;min-height:100vh;
94
+ display:flex;flex-direction:column;align-items:center;justify-content:center;
95
+ gap:12px;padding:24px;text-align:center;background:#fff;color:#171717}
96
+ @media(prefers-color-scheme:dark){body{background:#171717;color:#fafafa}}
97
+ p{margin:0;opacity:.6}
98
+ button{font:inherit;font-weight:600;font-size:18px;color:#fff;background:#dc2626;
99
+ border:0;border-radius:12px;padding:16px 32px}
100
+ </style></head>
101
+ <body>
102
+ <p>Returning to Jump…</p>
103
+ <button onclick="everywhereJumpLeave()">Return to Jump</button>
104
+ <script>
105
+ function everywhereJumpLeave(){var msg={action:"clearInstance"};
106
+ var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
107
+ var android=window.everywhereControl;
108
+ if(ios){ios.postMessage(msg);}
109
+ else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
110
+ else{window.location.replace("/");}}
111
+ if(document.visibilityState==="visible"){everywhereJumpLeave();}
112
+ else{document.addEventListener("visibilitychange",function(){
113
+ if(document.visibilityState==="visible"){everywhereJumpLeave();}});}
114
+ </script></body></html>
115
+ HTML
116
+ end
117
+
69
118
  # The page served at /everywhere/auth/native. The shell normally diverts a
70
119
  # provider path natively, before the request is ever made; this covers the
71
120
  # visits it can't see — a `data-turbo="false"` link, or the POST OmniAuth 2
@@ -150,10 +199,32 @@ module Everywhere
150
199
  class Config
151
200
  FILE = File.join("config", "everywhere.yml")
152
201
 
202
+ # Dates, times and symbols are permitted rather than fatal: an unquoted
203
+ # `version: 2026-07-25` or `mode: :remote` is a typo of a string, and every
204
+ # value here is read as one anyway — dying on it would be theatre.
205
+ PERMITTED_YAML_CLASSES = [Date, Time, Symbol].freeze
206
+
153
207
  def self.load(root = Dir.pwd)
154
208
  path = File.join(root, FILE)
155
- data = File.exist?(path) ? YAML.safe_load_file(path, aliases: true) || {} : {}
209
+ return new({}, root) unless File.exist?(path)
210
+
211
+ data = YAML.safe_load_file(path, aliases: true, permitted_classes: PERMITTED_YAML_CLASSES)
212
+ data = {} if data.nil? || data == false
213
+ unless data.is_a?(Hash)
214
+ # Raise, never die!: this also runs inside the packaged app's request
215
+ # middleware, where a SystemExit would take the whole server down —
216
+ # runtime callers rescue and keep the last good config, and the CLI's
217
+ # top-level handler renders an Error just as cleanly.
218
+ raise Everywhere::Error,
219
+ "#{UI.short_path(path)} must be a mapping of sections (app:, build:, …), not a #{data.class}"
220
+ end
221
+
156
222
  new(data, root)
223
+ rescue Psych::Exception => e
224
+ # Psych prefixes syntax errors with the absolute path; the line/column
225
+ # tail is the useful half, and we've already named the file.
226
+ detail = e.message.sub(/\A\(#{Regexp.escape(path)}\):\s*/, "")
227
+ raise Everywhere::Error, "#{UI.short_path(path)} isn't valid YAML: #{detail}"
157
228
  end
158
229
 
159
230
  attr_reader :root
@@ -169,6 +240,13 @@ module Everywhere
169
240
  # matching override; omit it for the shared app-level value.
170
241
  def self.os_of(target) = target.to_s.split("-", 2).first
171
242
 
243
+ # A target may carry a ":<channel>" suffix ("ios-arm64:testflight") that
244
+ # selects the distribution channel for THAT target. nil when it doesn't.
245
+ def self.channel_of(target)
246
+ suffix = target.to_s.split(":", 2)[1]
247
+ suffix unless suffix.nil? || suffix.empty?
248
+ end
249
+
172
250
  def name(target: nil)
173
251
  resolved(target)["name"] || File.basename(File.expand_path(root)).split(/[-_]/).map(&:capitalize).join(" ")
174
252
  end
@@ -229,8 +307,15 @@ module Everywhere
229
307
  # accessor was silently redefined by that one and could never be read.
230
308
  def build_capabilities = Array(build["capabilities"])
231
309
 
232
- # Build targets (os-arch strings)the CI matrix, expressed once.
233
- def targets = Array(build["targets"])
310
+ # Build targets as BARE os-arch strings — any ":<channel>" suffix is
311
+ # stripped. Callers treat these as filesystem/URL-safe identifiers
312
+ # (`every publish` derives S3 update-manifest key paths from the first one),
313
+ # so a suffix leaking through here would poison those paths. Use
314
+ # #build_target_specs when the channel matters.
315
+ def targets = build_target_specs.map { |t| t.to_s.split(":", 2).first }
316
+
317
+ # The raw `build.targets` entries, suffixes intact ("ios-arm64:testflight").
318
+ def build_target_specs = Array(build["targets"])
234
319
 
235
320
  # "local" — the app is tebako-pressed and runs on-device (default)
236
321
  # "remote" — thin shell around an already-deployed app: no press, no sidecar
@@ -36,7 +36,8 @@ module Everywhere
36
36
  desktop: ["desktop", :magenta],
37
37
  ios: ["ios", :blue],
38
38
  android: ["android", :green],
39
- logs: ["logs", :gray]
39
+ logs: ["logs", :gray],
40
+ tunnel: ["tunnel", :yellow]
40
41
  }.freeze
41
42
 
42
43
  TAG_WIDTH = TAGS.values.map { |label, _| label.length }.max
@@ -51,6 +51,14 @@ module Everywhere
51
51
  app.middleware.insert_before 0, Everywhere::AuthHandoff, root: app.root.to_s
52
52
  end
53
53
 
54
+ # `every preview` fronts the dev server with a tunnel whose hostname is
55
+ # only known at spawn time, so it arrives by env var; without this, Rails'
56
+ # host authorization greets every phone with a blocked-host page.
57
+ initializer "everywhere.preview_host" do |app|
58
+ host = ENV["EVERYWHERE_PREVIEW_HOST"].to_s
59
+ app.config.hosts << host if app.config.respond_to?(:hosts) && !host.empty?
60
+ end
61
+
54
62
  # /everywhere/ios_v1.json — the mobile shells' remote path configuration
55
63
  # (rules + settings.tabs), generated from config/everywhere.yml at request
56
64
  # time so tab changes ship with a normal deploy. A real controller (not
@@ -64,6 +72,22 @@ module Everywhere
64
72
  require_relative "mobile_configs_controller"
65
73
  get "/everywhere/reset", to: Everywhere::MobileConfigsController.action(:reset),
66
74
  as: :everywhere_reset
75
+ # Jump preview endpoints — dead (404) outside `every preview` runs.
76
+ # OPTIONS routes are the CORS preflights: the connect page calling
77
+ # these lives on the Jump site, another origin by definition.
78
+ get "/everywhere/jump_v1", to: Everywhere::MobileConfigsController.action(:jump_manifest),
79
+ defaults: { format: "json" },
80
+ as: :everywhere_jump_manifest
81
+ match "/everywhere/jump_v1", to: Everywhere::MobileConfigsController.action(:jump_manifest),
82
+ via: :options
83
+ post "/everywhere/jump/session", to: Everywhere::MobileConfigsController.action(:jump_session),
84
+ as: :everywhere_jump_session
85
+ match "/everywhere/jump/session", to: Everywhere::MobileConfigsController.action(:jump_session),
86
+ via: :options
87
+ # The way back out of a preview — served on EVERY app with this gem,
88
+ # because after instance.set the previewed app is all Jump can reach.
89
+ get "/everywhere/jump/leave", to: Everywhere::MobileConfigsController.action(:jump_leave),
90
+ as: :everywhere_jump_leave
67
91
  get "/everywhere/:platform_v1", to: Everywhere::MobileConfigsController.action(:show),
68
92
  constraints: { platform_v1: /(?:ios|android)_v1/ },
69
93
  defaults: { format: "json" },
@@ -32,9 +32,24 @@ module Everywhere
32
32
  File.exist?(File.join(root, "config.ru")) && gemfile_mentions?(root, "sinatra")
33
33
  end
34
34
 
35
+ # Bundler reads either name, and `gem "rails"` / `gem("rails")` are the same
36
+ # line to it. Anchored at the start of the line so a commented-out gem — the
37
+ # usual reason a Gemfile still mentions one — doesn't count as detection.
38
+ GEMFILE_NAMES = %w[Gemfile gems.rb].freeze
39
+
35
40
  def self.gemfile_mentions?(root, gem_name)
36
- gemfile = File.join(root, "Gemfile")
37
- File.exist?(gemfile) && File.read(gemfile).match?(/gem ["']#{Regexp.escape(gem_name)}/)
41
+ pattern = /^\s*gem[\s(]+["']#{Regexp.escape(gem_name)}/
42
+ GEMFILE_NAMES.any? do |name|
43
+ path = File.join(root, name)
44
+ File.exist?(path) && read_source(path).match?(pattern)
45
+ end
46
+ end
47
+
48
+ # Gemfiles are UTF-8, but the process may be running under LANG=C, where
49
+ # File.read tags the bytes as US-ASCII and any accented comment makes the
50
+ # match raise instead of answering.
51
+ def self.read_source(path)
52
+ File.read(path, encoding: "UTF-8").scrub
38
53
  end
39
54
 
40
55
  attr_reader :root
@@ -120,8 +135,10 @@ module Everywhere
120
135
  def name = "rails"
121
136
 
122
137
  # CLI
138
+ # executable?, not exist?: a repo downloaded as a zip loses the exec bit,
139
+ # and running bin/dev then dies with a bare "Permission denied".
123
140
  def dev_command
124
- File.exist?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rails server"
141
+ File.executable?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rails server"
125
142
  end
126
143
 
127
144
  def precompile_env
@@ -187,7 +204,7 @@ module Everywhere
187
204
  def name = "sinatra"
188
205
 
189
206
  def dev_command
190
- File.exist?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rackup --host 127.0.0.1"
207
+ File.executable?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rackup --host 127.0.0.1"
191
208
  end
192
209
 
193
210
  # No asset pipeline by default; nothing to precompile.
@@ -31,7 +31,9 @@ module Everywhere
31
31
 
32
32
  def self.for(root)
33
33
  path = File.join(root, FILE)
34
- source = File.exist?(path) ? File.read(path) : DEFAULTS.join("\n")
34
+ # UTF-8 explicitly (and scrubbed): under LANG=C the bytes come back
35
+ # US-ASCII and a non-ASCII path pattern makes the matching raise.
36
+ source = File.exist?(path) ? File.read(path, encoding: "UTF-8").scrub : DEFAULTS.join("\n")
35
37
  new(parse(source))
36
38
  end
37
39
 
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ module Everywhere
7
+ # Jump: the companion app that previews RubyEverywhere apps on a real
8
+ # device (spec: platform/docs/jump.md). The server side is two endpoints,
9
+ # live only while `every preview` runs (EVERYWHERE_JUMP_TOKEN set):
10
+ #
11
+ # POST /everywhere/jump/session {token, device_name} -> {bearer}
12
+ # GET /everywhere/jump_v1.json Authorization: Bearer ... -> manifest
13
+ #
14
+ # The pairing token is per-run and multi-use (a forwarded link must work
15
+ # for a second coworker's device); each device exchanges it once for a
16
+ # bearer. Bearers are stateless HMACs keyed on the token — no session
17
+ # store, and every bearer dies with the run because the token does.
18
+ module Jump
19
+ PROTOCOL = 1
20
+ SESSION_PATH = "/everywhere/jump/session"
21
+ MANIFEST_PATH = "/everywhere/jump_v1.json"
22
+
23
+ module_function
24
+
25
+ def token = ENV["EVERYWHERE_JUMP_TOKEN"]
26
+ def enabled? = !token.to_s.empty?
27
+
28
+ def manifest_hash(config)
29
+ {
30
+ "version" => 1,
31
+ "jump_protocol" => PROTOCOL,
32
+ "app" => { "name" => config.name, "identifier" => config.bundle_id },
33
+ "appearance" => {
34
+ "tint_color" => config.tint_color,
35
+ "background_color" => config.background_color
36
+ }.compact,
37
+ "entry_path" => config.entry_path,
38
+ "remote_instances" => (true if config.remote_instances?),
39
+ "path_configuration" => {
40
+ "ios" => "/everywhere/ios_v1.json",
41
+ "android" => "/everywhere/android_v1.json"
42
+ },
43
+ "native_extensions" => [
44
+ ("ios" if config.native_ios?),
45
+ ("android" if config.native_android?)
46
+ ].compact
47
+ }.compact
48
+ end
49
+
50
+ # nil on a bad token; the session hash otherwise. Logs to stderr so the
51
+ # connect shows up in the terminal `every preview` is pumping.
52
+ def exchange(candidate, device_name)
53
+ return nil unless valid_token?(candidate)
54
+
55
+ warn connect_log_line(device_name)
56
+ { "bearer" => mint_bearer, "jump_protocol" => PROTOCOL }
57
+ end
58
+
59
+ def mint_bearer(tok = token)
60
+ nonce = SecureRandom.hex(12)
61
+ "v1.#{nonce}.#{signature(tok, nonce)}"
62
+ end
63
+
64
+ def valid_bearer?(bearer, tok = token)
65
+ version, nonce, mac = bearer.to_s.split(".", 3)
66
+ return false unless version == "v1" && nonce && mac && !tok.to_s.empty?
67
+
68
+ expected = signature(tok, nonce)
69
+ mac.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(mac, expected)
70
+ end
71
+
72
+ def valid_token?(candidate, tok = token)
73
+ candidate = candidate.to_s
74
+ return false if tok.to_s.empty? || candidate.empty?
75
+
76
+ candidate.bytesize == tok.bytesize && OpenSSL.fixed_length_secure_compare(candidate, tok)
77
+ end
78
+
79
+ def signature(tok, nonce)
80
+ OpenSSL::HMAC.hexdigest("SHA256", tok, "jump-session-v1:#{nonce}")
81
+ end
82
+
83
+ LEAVE_PATH = "/everywhere/jump/leave"
84
+
85
+ # The connect page lives on the Jump site; the endpoints live on the
86
+ # previewed app — always cross-origin. `*` is safe here: nothing is
87
+ # cookie-authenticated, the token/bearer IS the credential.
88
+ CORS_HEADERS = {
89
+ "access-control-allow-origin" => "*",
90
+ "access-control-allow-methods" => "GET, POST, OPTIONS",
91
+ "access-control-allow-headers" => "authorization, content-type",
92
+ "access-control-max-age" => "600"
93
+ }.freeze
94
+
95
+ # Appended to the previewed app's resolved tabs while preview runs: after
96
+ # instance.set the previewed app's path configuration is the ONLY chrome
97
+ # Jump has, so without this tab there is no way back to the launcher. A
98
+ # tabless app gets a pair — its own content as tab one, the exit as tab
99
+ # two — because a lone exit tab would replace the app's content outright.
100
+ def exit_tabs(config, os, tabs)
101
+ return tabs + [exit_tab(os)] unless tabs.empty?
102
+
103
+ app_tab = { "title" => config.name,
104
+ "path" => config.entry_path || "/",
105
+ "icon" => os.to_s == "android" ? "apps" : "app" }
106
+ [app_tab, exit_tab(os)]
107
+ end
108
+
109
+ def exit_tab(os)
110
+ { "title" => "Jump",
111
+ "path" => LEAVE_PATH,
112
+ "icon" => os.to_s == "android" ? "u_turn_left" : "arrow.uturn.backward.circle" }
113
+ end
114
+
115
+ def connect_log_line(device_name)
116
+ name = device_name.to_s.gsub(/[[:cntrl:]]/, "").strip[0, 64]
117
+ name = "A device" if name.empty?
118
+ "[everywhere] Jump connected: #{name}"
119
+ end
120
+ end
121
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "jump"
4
5
 
5
6
  module Everywhere
6
7
  # Rack middleware serving the mobile shells' remote path configuration:
@@ -32,6 +33,26 @@ module Everywhere
32
33
  end
33
34
 
34
35
  def call(env)
36
+ # Jump endpoints exist only while `every preview` runs (pass through
37
+ # otherwise, so nothing is reachable on a normal deploy). The leave page
38
+ # is the exception — it's how Jump escapes a previewed app, so it must
39
+ # answer whenever this gem is present.
40
+ if Jump.enabled?
41
+ if env["REQUEST_METHOD"] == "OPTIONS" &&
42
+ [Jump::SESSION_PATH, Jump::MANIFEST_PATH].include?(env["PATH_INFO"])
43
+ return [204, Jump::CORS_HEADERS.dup, []]
44
+ end
45
+ if env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == Jump::SESSION_PATH
46
+ return jump_session(env)
47
+ end
48
+ if env["REQUEST_METHOD"] == "GET" && env["PATH_INFO"] == Jump::MANIFEST_PATH
49
+ return jump_manifest(env)
50
+ end
51
+ end
52
+ if env["REQUEST_METHOD"] == "GET" && env["PATH_INFO"] == Jump::LEAVE_PATH
53
+ return html(Everywhere.mobile_jump_leave_html)
54
+ end
55
+
35
56
  return @app.call(env) unless env["REQUEST_METHOD"] == "GET"
36
57
 
37
58
  if env["PATH_INFO"] == RESET
@@ -56,11 +77,37 @@ module Everywhere
56
77
  os = match[1]
57
78
  config = Config.load(@root)
58
79
  tabs = Everywhere.resolve_tabs(config.tabs_for(os), request_for(env))
80
+ tabs = Jump.exit_tabs(config, os, tabs) if Jump.enabled?
59
81
  json(config.path_configuration_json(os, tabs: tabs))
60
82
  end
61
83
 
62
84
  private
63
85
 
86
+ def jump_session(env)
87
+ require "json"
88
+ body = JSON.parse(env["rack.input"].read.to_s) rescue {}
89
+ result = Jump.exchange(body["token"], body["device_name"])
90
+ return forbidden unless result
91
+
92
+ [201, jump_json_headers, [JSON.generate(result)]]
93
+ end
94
+
95
+ def jump_manifest(env)
96
+ require "json"
97
+ bearer = env["HTTP_AUTHORIZATION"].to_s.delete_prefix("Bearer ").strip
98
+ return forbidden unless Jump.valid_bearer?(bearer)
99
+
100
+ [200, jump_json_headers, [JSON.generate(Jump.manifest_hash(Config.load(@root)))]]
101
+ end
102
+
103
+ def forbidden
104
+ [403, jump_json_headers, ['{"error":"invalid or expired preview token"}']]
105
+ end
106
+
107
+ def jump_json_headers
108
+ { "content-type" => "application/json", "cache-control" => "no-store" }.merge(Jump::CORS_HEADERS)
109
+ end
110
+
64
111
  def json(body)
65
112
  [200, { "content-type" => "application/json", "cache-control" => cache_control }, [body]]
66
113
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "jump"
4
5
 
5
6
  module Everywhere
6
7
  # Serves the mobile shells' remote path configuration in Rails apps:
@@ -31,6 +32,7 @@ module Everywhere
31
32
 
32
33
  os = params[:platform_v1].delete_suffix("_v1")
33
34
  tabs = Everywhere.resolve_tabs(config.tabs_for(os), request)
35
+ tabs = Jump.exit_tabs(config, os, tabs) if Jump.enabled?
34
36
  render json: config.path_configuration_hash(os, tabs: tabs)
35
37
  end
36
38
 
@@ -52,6 +54,43 @@ module Everywhere
52
54
  render json: json
53
55
  end
54
56
 
57
+ # Jump preview endpoints (spec: platform/docs/jump.md §3). 404 unless
58
+ # `every preview` is running (EVERYWHERE_JUMP_TOKEN set), so nothing is
59
+ # reachable on a normal deploy. Always cross-origin (the connect page
60
+ # lives on the Jump site), hence the CORS headers + preflight.
61
+ def jump_session
62
+ return head(:not_found) unless Jump.enabled?
63
+
64
+ apply_jump_headers
65
+ return head(:no_content) if request.method == "OPTIONS"
66
+
67
+ result = Jump.exchange(params[:token], params[:device_name])
68
+ return head(:forbidden) unless result
69
+
70
+ render json: result, status: :created
71
+ end
72
+
73
+ def jump_manifest
74
+ return head(:not_found) unless Jump.enabled?
75
+
76
+ apply_jump_headers
77
+ return head(:no_content) if request.method == "OPTIONS"
78
+
79
+ bearer = request.headers["Authorization"].to_s.delete_prefix("Bearer ").strip
80
+ return head(:forbidden) unless Jump.valid_bearer?(bearer)
81
+
82
+ render json: Jump.manifest_hash(Config.load(::Rails.root.to_s))
83
+ end
84
+
85
+ # GET /everywhere/jump/leave — the way back OUT of a Jump preview. Served
86
+ # unconditionally (unlike the endpoints above): once Jump re-roots onto a
87
+ # previewed app, this page is its only path home, preview session or not.
88
+ def jump_leave
89
+ response.headers["cache-control"] = "no-store"
90
+ render html: Everywhere.mobile_jump_leave_html.html_safe,
91
+ layout: false, content_type: "text/html"
92
+ end
93
+
55
94
  # GET /everywhere/reset?to=/path — the native "reset the app" page. Auth
56
95
  # flows redirect the shell here so it rebuilds cleanly before landing on
57
96
  # `to`. Never cached (it must run every time).
@@ -60,5 +99,12 @@ module Everywhere
60
99
  render html: Everywhere.mobile_reset_html(params[:to]).html_safe,
61
100
  layout: false, content_type: "text/html"
62
101
  end
102
+
103
+ private
104
+
105
+ def apply_jump_headers
106
+ response.headers["cache-control"] = "no-store"
107
+ Jump::CORS_HEADERS.each { |key, value| response.headers[key] = value }
108
+ end
63
109
  end
64
110
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "ui"
4
+
3
5
  module Everywhere
4
6
  # Filesystem locations the CLI resolves relative to the gem itself, so
5
7
  # `every dev`/`build`/`release` work in any project the gem is installed into.
@@ -83,8 +85,12 @@ module Everywhere
83
85
  end
84
86
 
85
87
  # Per-user cache dir. Keeps heavy build state out of the gem and the app.
88
+ # `~` needs a home to expand against, and a container run with --user has
89
+ # neither HOME nor a passwd entry.
86
90
  def cache_dir
87
91
  File.expand_path(ENV["RUBYEVERYWHERE_HOME"] || "~/.rubyeverywhere")
92
+ rescue ArgumentError
93
+ UI.die!("couldn't resolve your home directory — set HOME or RUBYEVERYWHERE_HOME")
88
94
  end
89
95
 
90
96
  # The stamped copy of the iOS template for one app. Persistent (not a
@@ -130,12 +136,13 @@ module Everywhere
130
136
  File.join(cache_dir, "android-fonts")
131
137
  end
132
138
 
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.
139
+ # The staged copy of the Tauri shell for one app, keyed by bundle id.
140
+ # Every app builds from one of thesecargo never runs inside the
141
+ # installed gem, which may be root-owned but only apps that declare
142
+ # `native.desktop` get their Rust stamped in; everyone else's copy is
143
+ # identical to the template and shares cargo_target_dir so the compile
144
+ # stays warm across projects. Persistent for the same reason the mobile
145
+ # work dirs are: a cold Tauri build is minutes where a warm one is seconds.
139
146
  def desktop_work_dir(bundle_id)
140
147
  File.join(cache_dir, "desktop", bundle_id)
141
148
  end
@@ -3,6 +3,11 @@
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
6
11
  require_relative "../version"
7
12
 
8
13
  module Everywhere
@@ -27,6 +32,19 @@ module Everywhere
27
32
  end
28
33
 
29
34
  class Client
35
+ # Everything a flaky link, a hung host or a TLS-intercepting proxy can
36
+ # throw at us. OpenSSL is an optional stdlib (net/http requires it
37
+ # best-effort), so its error class is only listed when it loaded.
38
+ NETWORK_ERRORS = [
39
+ SocketError, EOFError,
40
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
41
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EPIPE,
42
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
43
+ *(defined?(OpenSSL::SSL::SSLError) ? [OpenSSL::SSL::SSLError] : [])
44
+ ].freeze
45
+
46
+ OPEN_TIMEOUT = 15
47
+
30
48
  def self.base_url(explicit = nil)
31
49
  (explicit || ENV["EVERYWHERE_PLATFORM_URL"] || DEFAULT_URL).chomp("/")
32
50
  end
@@ -56,8 +74,8 @@ module Everywhere
56
74
  req.body_stream = io
57
75
  http(uri) { |conn| conn.request(req) }
58
76
  end
59
- rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
60
- raise Error, "upload to #{uri.host} failed (#{e.class})"
77
+ rescue *NETWORK_ERRORS => e
78
+ raise Error, "upload to #{uri.host} failed (#{e.class}: #{e.message})"
61
79
  end
62
80
 
63
81
  # GET a (possibly redirecting, signed) URL and stream it to dest. Sends the
@@ -83,12 +101,15 @@ module Everywhere
83
101
  end
84
102
  end
85
103
  dest
104
+ rescue *NETWORK_ERRORS => e
105
+ raise Error, "download from #{uri.host} failed (#{e.class}: #{e.message})"
86
106
  end
87
107
 
88
108
  private
89
109
 
90
110
  def http(uri, &block)
91
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", &block)
111
+ Net::HTTP.start(uri.hostname, uri.port,
112
+ use_ssl: uri.scheme == "https", open_timeout: OPEN_TIMEOUT, &block)
92
113
  end
93
114
 
94
115
  def request(klass, path, body = nil)
@@ -102,10 +123,10 @@ module Everywhere
102
123
  req.body = JSON.generate(body)
103
124
  end
104
125
 
105
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
126
+ res = http(uri) { |conn| conn.request(req) }
106
127
  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})"
128
+ rescue *NETWORK_ERRORS => e
129
+ raise Error, "couldn't reach the Platform at #{@base_url} (#{e.class}: #{e.message})"
109
130
  end
110
131
 
111
132
  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