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
@@ -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
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require_relative "desktop_assets"
6
+ require_relative "host"
7
+ require_relative "icon"
8
+ require_relative "paths"
9
+ require_relative "plist"
10
+ require_relative "ui"
11
+
12
+ module Everywhere
13
+ # What `every dev` stages under ~/.rubyeverywhere so a live `cargo run` shell
14
+ # looks like the app being built: the flat assets folder, the Dock icon, and
15
+ # on macOS a throwaway .app around the dev binary — plus the command that
16
+ # links the two together and launches it.
17
+ #
18
+ # None of this is packaging. `every build` writes the real, complete bundle;
19
+ # this is the subset a dev session needs, restaged on every launch so
20
+ # editing an asset and pressing `d` is enough to see it.
21
+ class DesktopDevApp
22
+ def initialize(config:)
23
+ @config = config
24
+ end
25
+
26
+ # native/desktop/assets/** → the staged flat folder the running shell
27
+ # reads. Runs the same compiler `every build` does, so an asset that
28
+ # resolves in dev resolves in the packaged app too. Returns
29
+ # [dir, errors]; the dir is returned even when the app ships no assets,
30
+ # because the shell only has to cope with it not existing.
31
+ def stage_desktop_assets
32
+ dest = Everywhere::Paths.desktop_assets_dir(@config.bundle_id)
33
+ source = File.join(@config.root, "native", "desktop", "assets")
34
+ _names, errors = Everywhere::DesktopAssets.write!(source, dest)
35
+ [dest, errors]
36
+ end
37
+
38
+ # A minimal .app around the dev binary, so macOS has a CFBundleName to
39
+ # read. Without it every menu that names the app says "example-shell" —
40
+ # the crate name — because an unbundled process has nothing else to go on.
41
+ # (Setting NSProcessInfo's processName at runtime does NOT fix this: the
42
+ # menu bar and the predefined About/Hide/Quit items are built from the
43
+ # bundle, and macOS has already decided by the time any Rust runs.)
44
+ #
45
+ # Only the plist matters here. No icon is stamped: the shell sets the Dock
46
+ # icon at runtime from NATIVE_ICON_PATH, because Tauri applies its own
47
+ # compiled-in icon after launch and would win over a bundle icns anyway.
48
+ # `every build` writes the real, complete plist — this is the dev subset.
49
+ def stage_dev_bundle
50
+ app = File.join(Everywhere::Paths.desktop_dev_app_dir(@config.bundle_id),
51
+ "#{dev_bundle_name}.app")
52
+ macos = File.join(app, "Contents", "MacOS")
53
+ FileUtils.mkdir_p(macos)
54
+ File.write(File.join(app, "Contents", "Info.plist"), dev_info_plist)
55
+ [app, File.join(macos, dev_exe_name)]
56
+ end
57
+
58
+ # Display names are free-form (spaces, punctuation, emoji all fine), but
59
+ # this one becomes a directory name — so the separators, and a leading dot
60
+ # that would hide or escape the bundle, are the only things taken out.
61
+ def dev_bundle_name
62
+ cleaned = @config.name.to_s.gsub(%r{[/\\]}, "-").sub(/\A\.+/, "").strip
63
+ cleaned.empty? ? "App" : cleaned
64
+ end
65
+
66
+ # Spaces are legal in CFBundleExecutable but a nuisance in every shell
67
+ # command that names it, so the executable follows the packaged app's
68
+ # convention and drops them.
69
+ def dev_exe_name = dev_bundle_name.gsub(/\s+/, "")
70
+
71
+ def dev_info_plist
72
+ name = Everywhere::Plist.escape(@config.name)
73
+ <<~PLIST
74
+ <?xml version="1.0" encoding="UTF-8"?>
75
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
76
+ <plist version="1.0">
77
+ <dict>
78
+ <key>CFBundleName</key><string>#{name}</string>
79
+ <key>CFBundleDisplayName</key><string>#{name}</string>
80
+ <key>CFBundleExecutable</key><string>#{Everywhere::Plist.escape(dev_exe_name)}</string>
81
+ <key>CFBundleIdentifier</key><string>#{Everywhere::Plist.escape(@config.bundle_id)}</string>
82
+ <key>CFBundlePackageType</key><string>APPL</string>
83
+ <key>CFBundleShortVersionString</key><string>#{Everywhere::Plist.escape(@config.version)}</string>
84
+ <key>NSHighResolutionCapable</key><true/>
85
+ </dict>
86
+ </plist>
87
+ PLIST
88
+ end
89
+
90
+ # The Dock icon for the running shell. `cargo run` produces a bare binary
91
+ # with no .app around it, so macOS has nothing to read an icon from and
92
+ # Tauri's compiled-in placeholder wins — which is how you end up staring
93
+ # at the gem's icon while testing your own app.
94
+ #
95
+ # Shaped with the same Big Sur inset + squircle the packaged .icns gets
96
+ # (Icon.macos_master), so the Dock looks identical in dev and in a release
97
+ # build rather than subtly rounder in one of them. Returns "" when the app
98
+ # has no icon — the shell reads that as "leave it alone".
99
+ def stage_desktop_icon
100
+ source = @config.icon
101
+ return "" unless source && File.exist?(source)
102
+
103
+ dest = File.join(Everywhere::Paths.desktop_icon_dir(@config.bundle_id), "icon.png")
104
+ FileUtils.mkdir_p(File.dirname(dest))
105
+ FileUtils.cp(source, dest) unless Everywhere::Icon.macos_master(source, dest)
106
+ dest
107
+ rescue StandardError => e
108
+ # A Dock icon is never worth failing a dev session over.
109
+ UI.warn("couldn't prepare the Dock icon (#{e.class}) — using the default")
110
+ ""
111
+ end
112
+
113
+ # macOS runs the shell from inside a throwaway .app so it has a name;
114
+ # everywhere else `cargo run` is still the whole story.
115
+ #
116
+ # Three steps in one command rather than three Ruby calls, because the
117
+ # link has to happen between the compile and the launch and we want ONE
118
+ # supervised child: cargo's progress relays into the dock, and exec then
119
+ # replaces it with the app, so the pid we're tracking is the window.
120
+ #
121
+ # A hard link, not a copy: same volume (both under ~/.rubyeverywhere), so
122
+ # it's free and instant, where copying a debug Tauri binary is ~100MB on
123
+ # every launch. cargo replaces the file on rebuild rather than writing
124
+ # through it, so the link is remade each time — hence `ln -f`.
125
+ def desktop_command(prepared)
126
+ return "cargo run" unless Host.darwin?
127
+
128
+ _app, exe = stage_dev_bundle
129
+ built = File.join(prepared.target_dir, "debug", "example-shell")
130
+ [
131
+ "cargo build",
132
+ "{ ln -f #{built.shellescape} #{exe.shellescape} 2>/dev/null || " \
133
+ "cp -f #{built.shellescape} #{exe.shellescape}; }",
134
+ "exec #{exe.shellescape}"
135
+ ].join(" && ")
136
+ end
137
+ end
138
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../clock"
4
+
3
5
  module Everywhere
4
6
  class Dock
5
7
  # What the dock knows about each thing `every dev` is running. Pure data
@@ -22,7 +24,7 @@ module Everywhere
22
24
  def busy? = BUSY.include?(state)
23
25
  end
24
26
 
25
- def initialize(keys, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
27
+ def initialize(keys, clock: -> { Clock.monotonic })
26
28
  @clock = clock
27
29
  @lock = Mutex.new
28
30
  ordered = ORDER & keys
@@ -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" },
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli"
4
+ require_relative "ui"
5
+
6
+ module Everywhere
7
+ # The body of both executables — `every` and `rbe` are the same CLI under two
8
+ # names, so exe/* stay one-liners that call in here.
9
+ module Entrypoint
10
+ module_function
11
+
12
+ def run(argv = ARGV)
13
+ Dry::CLI.new(Everywhere::CLI).call(arguments: argv)
14
+ rescue Interrupt
15
+ # ^C is an answer, not a crash: no backtrace, and the shell's own convention
16
+ # for "killed by SIGINT".
17
+ exit 130
18
+ rescue Everywhere::Error => e
19
+ # Everywhere::Fatal is a SystemExit, not an Error, so a die! that already
20
+ # printed its own line passes straight through here.
21
+ Everywhere::UI.die!(e.message)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Required on its own by every file that raises it, so each stays loadable
5
+ # standalone (the packaged runtime requires them piecemeal, without going
6
+ # through everywhere.rb).
7
+ class Error < StandardError; end
8
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error"
4
+
3
5
  module Everywhere
4
6
  # Framework adapters keep both the CLI (dev/build) and the packaged runtime
5
7
  # (boot) framework-agnostic. Each adapter owns two sides of one framework:
@@ -32,9 +34,24 @@ module Everywhere
32
34
  File.exist?(File.join(root, "config.ru")) && gemfile_mentions?(root, "sinatra")
33
35
  end
34
36
 
37
+ # Bundler reads either name, and `gem "rails"` / `gem("rails")` are the same
38
+ # line to it. Anchored at the start of the line so a commented-out gem — the
39
+ # usual reason a Gemfile still mentions one — doesn't count as detection.
40
+ GEMFILE_NAMES = %w[Gemfile gems.rb].freeze
41
+
35
42
  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)}/)
43
+ pattern = /^\s*gem[\s(]+["']#{Regexp.escape(gem_name)}/
44
+ GEMFILE_NAMES.any? do |name|
45
+ path = File.join(root, name)
46
+ File.exist?(path) && read_source(path).match?(pattern)
47
+ end
48
+ end
49
+
50
+ # Gemfiles are UTF-8, but the process may be running under LANG=C, where
51
+ # File.read tags the bytes as US-ASCII and any accented comment makes the
52
+ # match raise instead of answering.
53
+ def self.read_source(path)
54
+ File.read(path, encoding: "UTF-8").scrub
38
55
  end
39
56
 
40
57
  attr_reader :root
@@ -120,8 +137,10 @@ module Everywhere
120
137
  def name = "rails"
121
138
 
122
139
  # CLI
140
+ # executable?, not exist?: a repo downloaded as a zip loses the exec bit,
141
+ # and running bin/dev then dies with a bare "Permission denied".
123
142
  def dev_command
124
- File.exist?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rails server"
143
+ File.executable?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rails server"
125
144
  end
126
145
 
127
146
  def precompile_env
@@ -187,7 +206,7 @@ module Everywhere
187
206
  def name = "sinatra"
188
207
 
189
208
  def dev_command
190
- File.exist?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rackup --host 127.0.0.1"
209
+ File.executable?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rackup --host 127.0.0.1"
191
210
  end
192
211
 
193
212
  # No asset pipeline by default; nothing to precompile.
@@ -206,6 +225,4 @@ module Everywhere
206
225
  def env_exports = { "HANAMI_ENV" => "production", "RACK_ENV" => "production" }
207
226
  end
208
227
  end
209
-
210
- class Error < StandardError; end
211
228
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # The machine the CLI is running on — never the machine it's building for
5
+ # (that's the --target, see Config.os_of and the receipt).
6
+ module Host
7
+ module_function
8
+
9
+ def darwin? = RUBY_PLATFORM.include?("darwin")
10
+ end
11
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- # Shared source-exclusion rules, read from `.everywhereignore` (dockerignore
5
- # style) when present, else a sensible default. ONE source of truth so the
4
+ # Shared source-exclusion rules: the built-in DEFAULTS, plus `.everywhereignore`
5
+ # (dockerignore style) appended when the app has one. ONE source of truth so the
6
6
  # build receipt's source checksum (Receipt) and the Platform snapshot
7
7
  # (`every platform build`, later) describe exactly the same set of files — a
8
8
  # checksum that didn't match what actually ships would be worse than none.
@@ -15,7 +15,8 @@ module Everywhere
15
15
  #
16
16
  # Rules are evaluated top to bottom and the LAST one that matches a path wins
17
17
  # (dockerignore semantics), so ordering matters: put `!` re-includes after the
18
- # broad exclude they carve out of.
18
+ # broad exclude they carve out of — and the app's file, coming last, can
19
+ # re-include anything DEFAULTS excluded.
19
20
  class Ignore
20
21
  FILE = ".everywhereignore"
21
22
  FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH
@@ -29,10 +30,17 @@ module Everywhere
29
30
  *.key *.pem *.crt *.p12 *.mobileprovision
30
31
  ].freeze
31
32
 
33
+ # The app's file is layered ON TOP of DEFAULTS rather than replacing them:
34
+ # a project that adds one line to skip a fixture dir must not thereby start
35
+ # shipping .env and *.p12. Last match wins, so a user who really wants a
36
+ # defaulted-out file back can still `!` it in.
32
37
  def self.for(root)
33
38
  path = File.join(root, FILE)
34
- source = File.exist?(path) ? File.read(path) : DEFAULTS.join("\n")
35
- new(parse(source))
39
+ return new(parse(DEFAULTS.join("\n"))) unless File.exist?(path)
40
+
41
+ # UTF-8 explicitly (and scrubbed): under LANG=C the bytes come back
42
+ # US-ASCII and a non-ASCII path pattern makes the matching raise.
43
+ new(parse(DEFAULTS.join("\n")) + parse(File.read(path, encoding: "UTF-8").scrub))
36
44
  end
37
45
 
38
46
  # → [{ negated:, pattern: }, ...] in file order. Blank lines and whole-line
@@ -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,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "clock"
4
+
3
5
  module Everywhere
4
6
  # Turns the raw byte stream off a pty master into whole lines.
5
7
  #
@@ -25,7 +27,7 @@ module Everywhere
25
27
  # "Overwrite? [Yn]" — would be invisible for as long as it mattered.
26
28
  IDLE_FLUSH = 0.2
27
29
 
28
- def initialize(idle_flush: IDLE_FLUSH, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
30
+ def initialize(idle_flush: IDLE_FLUSH, clock: -> { Clock.monotonic })
29
31
  # Bytes in, text out. A pty hands over BINARY, and a chunk boundary can
30
32
  # land in the middle of a multi-byte character, so buffering and splitting
31
33
  # happen on bytes — UTF-8 is self-synchronizing, so a newline is never
@@ -4,6 +4,7 @@ require "openssl"
4
4
  require "base64"
5
5
  require "securerandom"
6
6
  require_relative "blake2b"
7
+ require_relative "error"
7
8
 
8
9
  module Everywhere
9
10
  # Minisign-compatible Ed25519 signing, stdlib-only (OpenSSL + our Blake2b).
@@ -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