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.
- checksums.yaml +4 -4
- data/exe/every +12 -1
- data/exe/rbe +12 -1
- data/lib/everywhere/agents_guide.rb +3 -1
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +21 -4
- data/lib/everywhere/builders/android.rb +144 -30
- data/lib/everywhere/builders/desktop.rb +25 -17
- data/lib/everywhere/builders/ios.rb +244 -9
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +113 -58
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +85 -11
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +48 -8
- data/lib/everywhere/commands/platform/build.rb +136 -27
- data/lib/everywhere/commands/platform/login.rb +16 -2
- data/lib/everywhere/commands/platform/runner.rb +113 -23
- data/lib/everywhere/commands/preview.rb +359 -0
- data/lib/everywhere/commands/publish.rb +20 -2
- data/lib/everywhere/commands/release.rb +145 -28
- data/lib/everywhere/commands/shell_dir.rb +2 -2
- data/lib/everywhere/config.rb +88 -3
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/framework.rb +21 -4
- data/lib/everywhere/ignore.rb +3 -1
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/paths.rb +13 -6
- data/lib/everywhere/platform/client.rb +27 -6
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +10 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- metadata +17 -1
data/lib/everywhere/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
233
|
-
|
|
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
|
data/lib/everywhere/console.rb
CHANGED
|
@@ -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
|
data/lib/everywhere/engine.rb
CHANGED
|
@@ -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" },
|
data/lib/everywhere/framework.rb
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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.
|
|
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.
|
|
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.
|
data/lib/everywhere/ignore.rb
CHANGED
|
@@ -31,7 +31,9 @@ module Everywhere
|
|
|
31
31
|
|
|
32
32
|
def self.for(root)
|
|
33
33
|
path = File.join(root, FILE)
|
|
34
|
-
|
|
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
|
data/lib/everywhere/paths.rb
CHANGED
|
@@ -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
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
+
# The staged copy of the Tauri shell for one app, keyed by bundle id.
|
|
140
|
+
# Every app builds from one of these — cargo 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
|
|
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,
|
|
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 =
|
|
126
|
+
res = http(uri) { |conn| conn.request(req) }
|
|
106
127
|
Response.new(res.code.to_i, parse(res.body))
|
|
107
|
-
rescue
|
|
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
|
-
|
|
67
|
-
|
|
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
|